diff --git a/EIPS/eip-1.md b/EIPS/eip-1.md index b3f77918ba4bab..9c0b2a5d16077b 100644 --- a/EIPS/eip-1.md +++ b/EIPS/eip-1.md @@ -218,6 +218,24 @@ Permitted Execution Client Specifications URLs must anchor to a specific commit, The Ethereum Execution Client Specifications repository also contains the Ethereum Execution Specification Tests, under its `tests/` directory. Links to specific commits of those test files are permitted under the same rule. +### Ethereum System Contract Implementations + +Links to the Ethereum System Contract Implementations repository may be included using normal markdown syntax, such as: + +```markdown +[Ethereum System Contract Implementations](https://github.com/ethereum/sys-asm/blob/83f9801245ff56878a450b5625801101b9a225a1/README.md) +``` + +Which renders to: + +[Ethereum System Contract Implementations](https://github.com/ethereum/sys-asm/blob/83f9801245ff56878a450b5625801101b9a225a1/README.md) + +Permitted URLs must anchor to a specific commit, and so must match this regular expression: + +```regex +^(https://github.com/ethereum/sys-asm/(blob|commit)/[0-9a-f]{40}/.*|https://github.com/ethereum/sys-asm/tree/[0-9a-f]{40}/.*)$ +``` + ### Consensus Layer Specifications Links to specific commits of files within the Ethereum Consensus Layer Specifications may be included using normal markdown syntax, such as: diff --git a/EIPS/eip-7666.md b/EIPS/eip-7666.md index ec776aa2607011..6b0b4a6d131d9a 100644 --- a/EIPS/eip-7666.md +++ b/EIPS/eip-7666.md @@ -2,12 +2,13 @@ eip: 7666 title: EVM-ify the identity precompile description: Remove the identity precompile, and put into place a piece of EVM code that has equivalent functionality -author: Vitalik Buterin (@vbuterin) +author: Vitalik Buterin (@vbuterin), Kevaundray Wedderburn (@kevaundray) discussions-to: https://ethereum-magicians.org/t/eip-7561-evm-ify-the-identity-precompile/19445 -status: Stagnant +status: Draft type: Standards Track category: Core created: 2024-03-31 +requires: 3855 --- ## Abstract diff --git a/EIPS/eip-7709.md b/EIPS/eip-7709.md index 785247491eacf6..0c7c4c625efdd4 100644 --- a/EIPS/eip-7709.md +++ b/EIPS/eip-7709.md @@ -1,10 +1,10 @@ --- eip: 7709 -title: Read BLOCKHASH from storage and update cost +title: Read BLOCKHASH from Storage and Update Cost description: Read the `BLOCKHASH (0x40)` opcode from the EIP-2935 system contract storage and adjust its gas cost to reflect storage access. author: Vitalik Buterin (@vbuterin), Tomasz Stanczak (@tkstanczak), Guillaume Ballet (@gballet), Gajinder Singh (@g11tech), Tanishq Jasoria (@tanishqjasoria), Ignacio Hagopian (@jsign), Jochem Brouwer (@jochem-brouwer), Gabriel Rocheleau (@gabrocheleau) discussions-to: https://ethereum-magicians.org/t/eip-7709-read-blockhash-opcode-from-storage-and-adjust-gas-cost/20052 -status: Stagnant +status: Draft type: Standards Track category: Core created: 2024-05-18 @@ -17,15 +17,20 @@ Update the `BLOCKHASH (0x40)` opcode to read and serve from the system contract ## Motivation -The `BLOCKHASH (0x40)` opcode currently assumes that the client has knowledge of the previous blocks, which in Verkle [EIP-6800](./eip-6800.md) would prevent stateless execution. However with [EIP-2935](./eip-2935.md) blockhashes can be retrieved and served from its system contract storage which allows Verkle blocks to include a storage access witness for stateless execution. +The `BLOCKHASH (0x40)` opcode currently assumes that the client has access to recent block history. This makes it a protocol special case: it depends on historical chain data, but it is not modeled like other state-backed reads. + +With [EIP-2935](./eip-2935.md), recent block hashes are stored in the system contract storage. This allows in-window `BLOCKHASH` lookups to be modeled as storage-backed accesses while preserving the existing `BLOCKHASH` return-value semantics. ## Specification +The key words "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](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174). + | Parameter | Value | | ------------------------- | ------ | | `FORK_TIMESTAMP` | TBD | | `HISTORY_STORAGE_ADDRESS` | `0x0000F90827F1C53a10cb7A02335B175320002935` | | `BLOCKHASH_SERVE_WINDOW` | `256` | +| `HISTORY_SERVE_WINDOW` | `8191` | The `BLOCKHASH` opcode semantics remains the same as before. From the `fork_block` (defined as `fork_block.timestamp >= FORK_TIMESTAMP and fork_block.parent.timestamp < FORK_TIMESTAMP`), the `BLOCKHASH` instruction should be updated to resolve block hash in the following manner: @@ -33,37 +38,35 @@ The `BLOCKHASH` opcode semantics remains the same as before. From the `fork_bloc def resolve_blockhash(block: Block, state: State, arg: uint64): # note that outside the BLOCKHASH_SERVE_WINDOW we continue to return 0 # despite the 2935 history contract being able to serve more hashes - if arg >= block.number or (arg + BLOCKHASH_SERVE_WINDOW) < block.number + if arg >= block.number or (arg + BLOCKHASH_SERVE_WINDOW) < block.number: return 0 # performs an sload on arg % HISTORY_SERVE_WINDOW including gas charges, - # warming effects as well as execution accesses + # warming effects as well as state-access recording # # note that the `BLOCKHASH_SERVE_WINDOW` and the 2935 ring buffer window # `HISTORY_SERVE_WINDOW` for slot calculation are different return state.load_slot(HISTORY_STORAGE_ADDRESS, arg % HISTORY_SERVE_WINDOW) ``` -ONLY if the `arg` is within the correct `BLOCKHASH` window, clients can choose to either +If the `arg` is within the correct `BLOCKHASH` window, clients MAY choose to either * do a direct `SLOAD` from state, or * do a system call to [EIP-2935](./eip-2935.md) contract via its `get` mechanism (caller other than `SYSTEM_ADDRESS`) or * serve from memory or as per current designs if maintaining requisite history (full clients for e.g.) -However the entire semantics and after effects of the `SLOAD` operation needs to be applied as per the current fork if the `arg` is within the correct `BLOCKHASH` window: +Regardless of the chosen resolution method, clients MUST apply the entire semantics and effects of the `SLOAD` operation as defined by the active fork if the `arg` is within the correct `BLOCKHASH` window: * `SLOAD` gas costs (cold or warm) for the `arg % HISTORY_SERVE_WINDOW` slot. * `SLOAD` after effects on the slot (warming the slot) -* `SLOAD` accesses added to execution witnesses if Verkle ([EIP-6800](./eip-6800.md) and [EIP-4762](./eip-4762.md)) is activated +* Any state-access recording required by the active fork for the corresponding `SLOAD`. ### Activation This EIP specifies the transition to the new logic assuming that [EIP-2935](./eip-2935.md) has been activated: * sufficiently ahead of this EIP's activation (>= `BLOCKHASH_SERVE_WINDOW`) or -* at genesis for testnets/devnets where this EIP could also be activated at genesis - -The current proposal is to activate this EIP with Verkle to allow for stateless execution of the block. +* at genesis for testnets/devnets where this EIP could also be activated at genesis. ### Gas costs @@ -71,26 +74,32 @@ As described above, if the `arg` to be resolved is within the correct window, th ### Reading from the System contract -Even if the clients choose to resolve `BLOCKHASH` through system call to [EIP-2935](./eip-2935.md) contract, the gas cost for the system code execution (and also the code witnesses if Verkle activated) is not applied. Only the effect of `SLOAD` is applied as described above. +Even if the clients choose to resolve `BLOCKHASH` through system call to [EIP-2935](./eip-2935.md) contract, the gas cost for the system code execution is not applied. Only the effect of `SLOAD` is applied as described above. ## Rationale -* The reason behind the updated gas cost is to match the real operation, which is equivalent to an `SLOAD`. -* The [EIP-2935](./eip-2935.md) system contract execution charges (and accesses) are not applied to keep the gas low and to keep things simple for clients which choose to resolve `BLOCKHASH` in other ways (directly or though memory/maintained history) +* The updated gas cost matches the accessed resource, which is equivalent to reading from storage. +* Charging normal `SLOAD`-like cold and warm costs keeps `BLOCKHASH` aligned with existing state-access pricing instead of adding a separate gas rule for recent block hashes. +* A client can alternatively prove recent block hashes through a chain of parent headers, but that keeps `BLOCKHASH` as a special case in witness construction rather than using the state access machinery provided by [EIP-2935](./eip-2935.md). +* Always charging the warm `SLOAD` cost, or introducing a custom `BLOCKHASH` price, could reduce compatibility risk but would introduce a new special-case gas rule. +* The [EIP-2935](./eip-2935.md) system contract execution charges and accesses are not applied to keep the gas low and to keep things simple for clients which choose to resolve `BLOCKHASH` in other ways (directly or through memory/maintained history). -Note that `BLOCKHASH` opcode only serves a limited `BLOCKHASH_SERVE_WINDOW` to be backward compatible (and to not extend the above exemptions). For deeper accesses one will need to directly call [EIP-2935](./eip-2935.md) system contract which will lead to a normal contract execution (as well as charges and accesses) +Note that `BLOCKHASH` opcode only serves a limited `BLOCKHASH_SERVE_WINDOW` to be backward compatible (and to not extend the above exemptions). For deeper accesses one will need to directly call [EIP-2935](./eip-2935.md) system contract which will lead to a normal contract execution (as well as charges and accesses). ## Backwards Compatibility -This EIP introduces a significant increase in the cost of `BLOCKHASH`, which could break use-cases that rely on the previous gas cost. Also, this EIP introduces a breaking change in the case where less than `BLOCKHASH_SERVE_WINDOW` elapse between the [EIP-2935](./eip-2935.md) fork and this EIP's fork (unless [EIP-2935](./eip-2935.md) is activated in genesis for e.g. in testnets/devnets) as the [EIP-2935](./eip-2935.md) system contract would not have saved the required history. +This EIP does not change the return-value semantics of `BLOCKHASH`. + +This EIP introduces a significant increase in the cost of in-window `BLOCKHASH` queries, which could break use-cases that rely on the previous gas cost. Also, this EIP introduces a breaking change in the case where less than `BLOCKHASH_SERVE_WINDOW` elapse between the [EIP-2935](./eip-2935.md) fork and this EIP's fork (unless [EIP-2935](./eip-2935.md) is activated in genesis for e.g. in testnets/devnets) as the [EIP-2935](./eip-2935.md) system contract would not have saved the required history. ## Test Cases -* If `BLOCKHASH` is not called or there is no [EIP-2935](./eip-2935.md) contract call by any transaction, only the [EIP-2935](./eip-2935.md) system update of the parent hash shows up in witnesses if Verkle is activated. -* If `BLOCKHASH` is called, there MUST be a storage access gas charge (and corresponding access witness if Verkle is activated) for the storage slot but ONLY if the `BLOCKHASH` query is for the last `BLOCKHASH_SERVE_WINDOW` ancestors. This is irrespective of how the client chooses to resolve the `BLOCKHASH` (directly, via system contract or via memory) -* The gas cost for each `BLOCKHASH` operation should still be charged, in addition to the `SLOAD` cost of each lookup (if performed) -* If the [EIP-2935](./eip-2935.md) contract is called directly (i.e. not through `BLOCKHASH`), then the witness and gas costs (including those related to contract code) are applied as per normal contract execution of the current fork. -* `BLOCKHASH` should be consistently resolved if this EIP is activated correctly `>= BLOCKHASH_SERVE_WINDOW` after [EIP-2935](./eip-2935.md) +* If `BLOCKHASH` is called with an argument outside the last `BLOCKHASH_SERVE_WINDOW` ancestors, or with an argument greater than or equal to the current block number, it returns `0` without applying additional storage access effects. +* If `BLOCKHASH` is called for an in-window ancestor and the corresponding storage slot is cold, the opcode charges the base `BLOCKHASH` cost plus the cold `SLOAD` cost. +* If `BLOCKHASH` is called more than once in the same transaction for a block number that maps to the same storage slot, the later lookup charges the warm `SLOAD` cost. +* The gas cost for each `BLOCKHASH` operation should still be charged, in addition to the `SLOAD` cost of each lookup (if performed). +* If the [EIP-2935](./eip-2935.md) contract is called directly (i.e. not through `BLOCKHASH`), then the gas costs, state-access recording, and any other effects are applied as per normal contract execution of the current fork. +* `BLOCKHASH` should be consistently resolved if this EIP is activated correctly `>= BLOCKHASH_SERVE_WINDOW` after [EIP-2935](./eip-2935.md). ## Security Considerations diff --git a/EIPS/eip-7773.md b/EIPS/eip-7773.md index 9139edbe22ee3c..2eb572dd4418a1 100644 --- a/EIPS/eip-7773.md +++ b/EIPS/eip-7773.md @@ -20,6 +20,9 @@ Definitions for `Scheduled for Inclusion`, `Considered for Inclusion`, `Declined ### EIPs Scheduled for Inclusion +* [EIP-2780](./eip-2780.md): Reduce intrinsic transaction gas +* [EIP-7610](./eip-7610.md): Revert creation in case of non-empty storage +* [EIP-7688](./eip-7688.md): Forward compatible consensus data structures * [EIP-7708](./eip-7708.md): ETH transfers emit a log * [EIP-7732](./eip-7732.md): Enshrined Proposer-Builder Separation * [EIP-7778](./eip-7778.md): Block Gas Accounting without Refunds @@ -28,12 +31,9 @@ Definitions for `Scheduled for Inclusion`, `Considered for Inclusion`, `Declined * [EIP-7954](./eip-7954.md): Increase Maximum Contract Size * [EIP-7976](./eip-7976.md): Increase Calldata Floor Cost * [EIP-7981](./eip-7981.md): Increase Access List Cost +* [EIP-7997](./eip-7997.md): Deterministic Factory Predeploy * [EIP-8024](./eip-8024.md): Backward compatible SWAPN, DUPN, EXCHANGE * [EIP-8037](./eip-8037.md): State Creation Gas Cost Increase -* [EIP-2780](./eip-2780.md): Reduce intrinsic transaction gas -* [EIP-7610](./eip-7610.md): Revert creation in case of non-empty storage -* [EIP-7688](./eip-7688.md): Forward compatible consensus data structures -* [EIP-7997](./eip-7997.md): Deterministic Factory Predeploy * [EIP-8038](./eip-8038.md): State-access gas cost increase * [EIP-8045](./eip-8045.md): Exclude slashed validators from proposing * [EIP-8061](./eip-8061.md): Increase exit and consolidation churn @@ -53,10 +53,11 @@ Definitions for `Scheduled for Inclusion`, `Considered for Inclusion`, `Declined #### Informational EIP * [EIP-7904](./eip-7904.md): Compute Gas Cost Analysis +* [EIP-8261](./eip-8261.md): Gas Limit Schedule ### Declined for Inclusion -* [EIP-2926](./eip-2926.md): Chunk-based code merkelization +* [EIP-2926](./eip-2926.md): Chunk-Based Code Merkleization * [EIP-5920](./eip-5920.md): PAY opcode * [EIP-6404](./eip-6404.md): SSZ transactions * [EIP-6466](./eip-6466.md): SSZ receipts @@ -98,6 +99,10 @@ Definitions for `Scheduled for Inclusion`, `Considered for Inclusion`, `Declined * [EIP-8080](./eip-8080.md): Let exits use the consolidation queue * [EIP-8254](./eip-8254.md): Cap Deposit Requests Per Block +### Mascot + +Polar bear πŸ»β€β„οΈ is the mascot for the Glamsterdam network upgrade, as per the [EIP-8066](./eip-8066.md) process. + ### Activation | Network Name | Activation Epoch | Activation Timestamp | diff --git a/EIPS/eip-7906.md b/EIPS/eip-7906.md index a8625c9aaaaa96..690fc1d2f7a765 100644 --- a/EIPS/eip-7906.md +++ b/EIPS/eip-7906.md @@ -2,7 +2,7 @@ eip: 7906 title: Transaction Assertions via State Diff Opcode description: An opcode that provides a mechanism to restrict the outcomes of transaction execution -author: Alex Forshtat (@forshtat), Shahaf Nacson (@shahafn), Dror Tirosh (@drortirosh), Yoav Weiss (@yoavw), Fredrik Svantes (@fredrik0x) +author: Alex Forshtat (@forshtat), Shahaf Nacson (@shahafn), Dror Tirosh (@drortirosh), Yoav Weiss (@yoavw), Fredrik Svantes (@0xfredrik) discussions-to: https://ethereum-magicians.org/t/eip-restricted-behavior-transaction-type/23130 status: Draft type: Standards Track @@ -77,9 +77,9 @@ The available parameters are listed in the table below. | 0x0D | index in `events_count` | `events_address` - the address of the contract that emitted the event | | 0x0E | index in `events_count` | `event_topic_count` - the number of topics of the event (0–4) | | 0x0F | index in `events_count` | `event_topic0` - the first topic of the event; exceptional halt if no topic | -| 0x10 | index in `events_count` | `event_topic1` - the second topic of the event | -| 0x11 | index in `events_count` | `event_topic2` - the third topic of the event | -| 0x12 | index in `events_count` | `event_topic3` - the fourth topic of the event | +| 0x10 | index in `events_count` | `event_topic1` - the second topic of the event; exceptional halt if no such topic | +| 0x11 | index in `events_count` | `event_topic2` - the third topic of the event; exceptional halt if no such topic | +| 0x12 | index in `events_count` | `event_topic3` - the fourth topic of the event; exceptional halt if no such topic | | 0x13 | index in `events_count` | `event_data_len` - the byte length of the event's non-indexed data | | 0x14 | must be 0 | `gas_pre_charge` - the total amount deducted from the gas payer | | 0x15 | must be 0 | `gas_payer_address` - the address charged the gas pre-charge | @@ -97,6 +97,8 @@ The `before` values reflect the transaction prestate values recorded before the An address will appear in `balances_changed` when its balance at the time of the `TXTRACE` call differs from its balance at transaction start. This includes the gas fee pre-charge applied to the gas payer address. Callers computing the net ETH transferred to or from an address can look up the gas payer via `gas_payer_address` (param `0x15`) and subtract `gas_pre_charge` (param `0x14`) from that address's balance delta. +An address appears in `contracts_deployed` when its code hash changed during the transaction from the empty-code hash to a non-empty code hash that is not an [EIP-7702](./eip-7702.md) delegation designator. An account that a `CREATE`/`CREATE2` leaves with empty code is not enumerated, as it has no code change. + ### Transaction Diff Lookup Opcode While `TXTRACE` enumerates the full state diff, it has no mechanism to directly query the diff for one specific account's balance, codehash, or storage slot. @@ -159,10 +161,14 @@ The actual nonce value is not observable through `TXTRACE` or `TXDIFF`. - Balance and codehash params (`0x02`–`0x05`): `COLD_ACCOUNT_ACCESS_COST` (2600) if the address is not in the accessed addresses set; `WARM_STORAGE_READ_COST` (100) otherwise. - Per-address view and flags params (`0x06`–`0x0A`): a flat cost of `TXTRACE_GAS_COST`. These params are answered entirely from the transaction-local state diff and never read the live state. -For params `0x00`–`0x05`, the accessed slot or address is added to the respective access list after the call. Params `0x06`–`0x0A` do not interact with the [EIP-2929](./eip-2929.md) access lists. +For params `0x00`–`0x05`, the accessed slot or address is added to the [EIP-2929](./eip-2929.md) access list after the call, and β€” where [EIP-7928](./eip-7928.md) is active β€” recorded in the block-level access list, like any other state-reading opcode. `TXTRACE` and `EVENTDATACOPY` read only already-recorded diff and log data and therefore add no new accesses. Params `0x06`–`0x0A` do not interact with the [EIP-2929](./eip-2929.md) access lists. `codehash_before` is equal to the empty-code hash for undeployed contracts. +### Reserved Inputs + +Any `in2`/`in3` operand marked *must be 0* in the parameter tables above MUST be zero; supplying a non-zero value causes an exceptional halt. + ### Results Ordering Balance and storage slot changes returned by the `TXTRACE` opcode are enumerated in ascending order sorted by the affected address as a numerical `uint160` value. diff --git a/EIPS/eip-7999.md b/EIPS/eip-7999.md index 488fde88e02301..cb33457d1013f9 100644 --- a/EIPS/eip-7999.md +++ b/EIPS/eip-7999.md @@ -2,7 +2,7 @@ eip: 7999 title: Unified multidimensional fee market description: Let transactions specify one aggregate `max_fee` budget for all resources, unify fee markets, normalize gas, and generalize EIP-7918. -author: Anders Elowsson (@anderselowsson), Vitalik Buterin (@vbuterin) +author: Anders Elowsson (@anderselowsson), Vitalik Buterin (@vbuterin), Maria Silva (@misilva73) discussions-to: https://ethereum-magicians.org/t/eip-7999-unified-multidimensional-fee-market/25010 status: Draft type: Standards Track @@ -23,7 +23,7 @@ This EIP leverages the natural fungibility of the user's fee budget by letting u Ethereum's current fee market has "tech debt" in that two separate mechanisms are used: one for regular gas ([EIP-1559](./eip-1559.md)) and the other for blob gas ([EIP-4844](./eip-4844.md)). The proposal unifies the fee market under the preferred EIP-4844 design. That design allows for exact control over long-run resource consumption. In a multidimensional setting with individual base fees, we can then for example achieve precise control over state growth, while accommodating temporary spikes. Excess gas of EIP-4844 is further normalized relative to the limit, allowing for a single update fraction across resources that retains current percentage ranges while keeping the price stable if any gas limit changes. -Calldata is added first, to speed up worst-case payload propagation and expand available EVM gasβ€”without compromising gas introspection. A method for facilitating gas aggregation across resources within the EVM is outlined as an avenue for preserving backward compatibility when expanding further. The logic of EIP-7918 is integrated into the multidimensional setting to ensure that calldata has a higher cost per byte than blob data. +Calldata is added first, to speed up worst-case payload propagation and expand available EVM gasβ€”without compromising gas introspection. A method for facilitating gas aggregation across resources within the EVM is outlined as an avenue for preserving backward compatibility when expanding further. The logic of [EIP-7918](./eip-7918.md) is integrated into the multidimensional setting to ensure that calldata has a higher cost per byte than blob data. ## Specification @@ -162,7 +162,7 @@ def get_required_max_fee(base_fees: list[int], tx_gas_limits: list[int]) -> int: * Compute the fees to deduct initially: * `max_priority_fee = get_priority_fee(tx, tx_gas_limits, base_fees, max_fee - max_base)` * `fee_to_deduct = max_base + max_priority_fee` - * Deduct `fee_to_deduct` wei from the sender, which we define as the address recovered from the transaction’s signature. + * Deduct `fee_to_deduct` wei from the sender, which we define as the address recovered from the transaction's signature. **At the end of processing a transaction**: @@ -366,7 +366,7 @@ This EIP has been designed to facilitate a future expansion into multiple EVM re One long-term vision for the EVM is to move away from gas observability. This is one of the features of EOF ([EIP-7692](./eip-7692.md)), e.g., through revamped `CALL` instructions in [EIP-7069](./eip-7069.md) such as `EXTCALL`. The new calls no longer accept a gas stipend as an input parameter, and the EVM instead makes available some reasonable fraction of all gas across dimensions (e.g., 63/64). Legitimate use cases previously handled via gas observability are then instead taken over by, e.g., the `PAY` opcode [EIP-5920](./eip-5920.md). -For compatibility with legacy code, the EVM can in this scenario reinterpret legacy subcalls with a gas parameter by forwarding the same fraction of the caller’s remaining budget in each resource dimension. Concretely, if the call stipulates $g_c$ and the aggregate remaining EVM-gas budget is $g_a$, the callee receives, for each EVM resource with remaining budget $g_r$, the amount $\bigl\lfloor g_r \cdot \min\!\bigl(1,\tfrac{g_c}{g_a}\bigr) \bigr\rfloor$. For completeness, the `GAS` opcode could likewise return, e.g., $g_a$ (the per-call aggregate remaining budget at this point across all resource dimensions). Note, however, that reinterpreting legacy calls and `GAS` in this way can still change the behavior of contracts that rely on precise gas observability or gas-capped subcalls, and such contracts may break. +For compatibility with legacy code, the EVM can in this scenario reinterpret legacy subcalls with a gas parameter by forwarding the same fraction of the caller's remaining budget in each resource dimension. Concretely, if the call stipulates $g_c$ and the aggregate remaining EVM-gas budget is $g_a$, the callee receives, for each EVM resource with remaining budget $g_r$, the amount $\bigl\lfloor g_r \cdot \min\!\bigl(1,\tfrac{g_c}{g_a}\bigr) \bigr\rfloor$. For completeness, the `GAS` opcode could likewise return, e.g., $g_a$ (the per-call aggregate remaining budget at this point across all resource dimensions). Note, however, that reinterpreting legacy calls and `GAS` in this way can still change the behavior of contracts that rely on precise gas observability or gas-capped subcalls, and such contracts may break. #### EVM that retains gas observability diff --git a/EIPS/eip-8066.md b/EIPS/eip-8066.md index 48d829a0da11bb..9bc57e44c12e6b 100644 --- a/EIPS/eip-8066.md +++ b/EIPS/eip-8066.md @@ -4,7 +4,7 @@ title: Upgrade Mascots description: Process for assigning a mascot to each Ethereum network upgrade author: Jordan Holberg (@eviljordan), Andrew B Coathup (@abcoathup) discussions-to: https://ethereum-magicians.org/t/eip-8066-upgrade-mascots/26009 -status: Draft +status: Review type: Informational created: 2024-10-29 --- @@ -18,7 +18,7 @@ This EIP establishes a mascot for each Ethereum network upgrade. Mascots serve t Ethereum network upgrades often introduce complex technical changes that can feel abstract to the broader community. Mascots provide a fun, memorable, and relatable symbol for each upgrade, drawing inspiration from its headliner(s). By mandating emoji-representable mascots that are cute and non-offensive, this process: - Enhances community participation and excitement around network upgrades. -- Creates opportunities for creative expression in upgrade event branding (e.g., watch parties, POAPs). +- Creates opportunities for creative expression in upgrade event branding (e.g., watch parties, attendance NFTs). - Builds a consistent, whimsical tradition that differentiates Ethereum's upgrade narrative from other ecosystems. ## Specification @@ -28,7 +28,7 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S ### 1. Mascot Requirements - **Relevance**: The mascot **SHOULD** relate thematically to the network upgrade's headliner(s). -- **Representation**: The mascot **MUST** be expressible using one or more standard Unicode emojis (e.g., :panda: for the Merge). +- **Representation**: The mascot **MUST** be expressible using one or more standard Unicode emojis (e.g., 🐼 for the Merge). - **Form**: The mascot **SHOULD** depict an animal (real, mythical, or stylized, but always animal-adjacent). - **Tone**: The mascot **MUST NOT** be offensive (no depictions of violence, discrimination, or controversy) and **SHOULD** be inherently cute (e.g., avoiding aggressive or fearsome traits unless softened for adorability). @@ -73,7 +73,7 @@ Alternatives considered: ## Backwards Compatibility -This EIP does not directly change the Ethereum protocol. It formalizes part of the current network upgrade process. Past upgrades (e.g., Shapella's owl :owl:) are retroactively honored if they fit the criteria; future upgrades **MUST** comply starting with the next hard fork post-adoption. +This EIP does not directly change the Ethereum protocol. It formalizes part of the current network upgrade process. Past upgrades (e.g., Shapella's owl πŸ¦‰) are retroactively honored if they fit the criteria; future upgrades **MUST** comply starting with the next hard fork post-adoption. ## Security Considerations diff --git a/EIPS/eip-8070.md b/EIPS/eip-8070.md index 11e6fff14a76b0..7da52f3ce08264 100644 --- a/EIPS/eip-8070.md +++ b/EIPS/eip-8070.md @@ -96,7 +96,7 @@ Request: - params: 1. `forkchoiceState`: `ForkchoiceStateV1`. 2. `payloadAttributes`: `Object|null` - Instance of `PayloadAttributesV4` or `null`. - 3. `custodyColumns`: `DATA|null` - 16-byte value interpreted as a bitarray of length `CELLS_PER_EXT_BLOB`, where a `1` at position `i` indicates the CL node has custody of column `i`. `null` if the CL does not provide custody services. + 3. `custodyColumns`: `DATA|null` - 16-byte value interpreted as a bitarray of length `CELLS_PER_EXT_BLOB`, where a `1` at position `i` indicates the CL node has custody of column `i`. `null` if the CL is unable to provide an update. - timeout: 8s Response: @@ -110,7 +110,7 @@ Specification: 3. For type 3 transactions pending in the blobpool: 1. If the custody set has expanded relative to the previously known set, the Execution client MUST issue new sampling requests for the delta columns. It SHOULD broadcast updated `NewPooledTransactionHashes` announcements reflecting the expanded available set when act as a sampler. 2. If the custody set has contracted, the Execution client MAY prune dropped cells from local storage, but only AFTER broadcasting an updated `NewPooledTransactionHashes` announcement with the reduced available set, to avoid peers perceiving availability fault. -4. The Execution client MUST treat a `custodyColumns` value identical to the current custody set as a no-op with respect to blobpool state, while still processing the rest of the forkchoice update normally. +4. The Execution client MUST treat a `custodyColumns` value identical to the current custody set or a `null` value as a no-op with respect to blobpool state, while still processing the rest of the forkchoice update normally. **Method `engine_getBlobsV4`** diff --git a/EIPS/eip-8081.md b/EIPS/eip-8081.md index dfb910609b7ff6..4eb07ee8741cc6 100644 --- a/EIPS/eip-8081.md +++ b/EIPS/eip-8081.md @@ -58,6 +58,7 @@ Definitions for `Scheduled for Inclusion`, `Considered for Inclusion`, `Declined * [EIP-8200](./eip-8200.md): EVMification * [EIP-8205](./eip-8205.md): Withdrawal credentials preregistration * [EIP-8237](./eip-8237.md): Independent CL/EL Sync +* [EIP-8243](./eip-8243.md): Batching Attestations at Source * [EIP-8250](./eip-8250.md): Keyed Nonces for Frame Transactions * [EIP-8253](./eip-8253.md): Bump nonce of zero-nonce storage accounts * [EIP-8268](./eip-8268.md): Storage Roots in Block Access Lists @@ -65,6 +66,7 @@ Definitions for `Scheduled for Inclusion`, `Considered for Inclusion`, `Declined * [EIP-8279](./eip-8279.md): Block Access List Byte Floor * [EIP-8298](./eip-8298.md): SETCODEFROM Code Reuse Instruction * [EIP-8304](./eip-8304.md): Trustless log and transaction index +* [EIP-8333](./eip-8333.md): Align Checkpoint with Epoch Boundary Block ### Activation diff --git a/EIPS/eip-8130.md b/EIPS/eip-8130.md index 0f342ab9a721b5..06d5a517b454e4 100644 --- a/EIPS/eip-8130.md +++ b/EIPS/eip-8130.md @@ -8,7 +8,7 @@ status: Draft type: Standards Track category: Core created: 2025-10-14 -requires: 170, 2718 +requires: 155, 170, 712, 1271, 1559, 2028, 2718, 2929, 4337, 6780, 7702, 7708, 7819 --- ## Abstract @@ -23,44 +23,46 @@ This proposal separates authentication from account logic. Each transaction expl New signature algorithms are introduced through authenticator contracts and standardized through the canonical authenticator set. +Portability is also a top concern: the keystore lets accounts manage their authenticators and maintain cross-chain portability. + ## Specification ### Overview -An account authorizes one or more **actors** which are credentials permitted to act on its behalf. Each actor is bound to an **authenticator**, an onchain contract that checks a signature and returns the actor's identity (`actorId`). An account's actors, their authenticators, and their permissions are held in the **Account Configuration Contract** at `ACCOUNT_CONFIG_ADDRESS`. +An account manages its authentication configuration via the keystore, a contract that maps an account to its configured authenticators and stores their authorizations. An **actor** on the account is one that has an authorization in the keystore. Each actor is bound to an **authenticator**, an onchain contract that checks a signature and returns the actor's identity (`actorId`). A new [EIP-2718](./eip-2718.md) transaction type (`AA_TX_TYPE`) names the authenticator that authenticates it. Because the authenticator is declared explicitly, a node can tell exactly what computation a transaction requires, and reject unknown authenticators before executing any code. Validation reads the account's configuration, runs the named authenticator, and checks the resolved actor's permissions; execution then dispatches the transaction's calls. +The 8130 transaction type is not required for the accounts to work, and they are fully portable to other EVM-based chains. On chains that don't support the 8130 transaction type, they must use an alternative transport mechanism such as [ERC-4337](./eip-4337.md). See [Portability](#portability). + +This EIP specifies the protocol surface: the transaction type, node validation and execution rules, intrinsic gas, and the authenticator model. The **Keystore** and the canonical authenticator/account contracts β€” including exact storage packing, typehashes, event ABIs, and function bodies β€” live in the canonical contracts repository (the `base` organization's EIP-8130 repository on GitHub, `src/Keystore.sol`), which is authoritative for all contract internals. Where this document gives a byte layout or digest, it is a normative summary; the repository is the source of truth, and any implementation MUST match it (either by calling the deployed contracts or by reproducing their behavior with static/precomputed gas values). + The specification is organized around four pieces: - **Actors and Authenticators**: the authentication and authorization model (see [Authenticators](#authenticators)). -- **Account Configuration**: onchain management of account and its actors (see [Account Configuration](#account-configuration)). +- **Account Configuration**: onchain management of account and its actors (see [Keystore and Account Configuration](#keystore-and-account-configuration)). - **AA Transaction Type**: the wire format, signature payloads, and gas accounting (see [AA Transaction Type](#aa-transaction-type)). - **Account Changes**: how accounts are created and how actors are added, revoked, or delegated (see [Account Changes](#account-changes)). -Accounts are portable across EVM chains; see [Portability](#portability). - #### Account Types -This proposal supports three paths for accounts to use AA transactions: +8130 supports all account types, existing and newly created. | Account Type | How It Works | Key Recovery | |--------------|--------------|--------------| | **EOAs** | EOAs send AA transactions using their existing secp256k1 key via native ecrecover. If the account has no code, the protocol auto-delegates to `DEFAULT_ACCOUNT_ADDRESS` (see [Block Execution](#block-execution)). Accounts MAY override with a delegation entry in `account_changes` or a standard [EIP-7702](./eip-7702.md) transaction | Wallet-defined; EOA recoverable via standard transactions | -| **Existing Smart Contracts** | Already-deployed accounts (e.g., ERC-4337 wallets) register actors via `importAccount()` on the Account Configuration Contract | Wallet-defined | +| **Existing Smart Contracts** | Already-deployed accounts (e.g., [ERC-4337](./eip-4337.md) wallets) register actors via `importAccount()` on the Keystore contract | Wallet-defined | | **New Accounts (No EOA)** | Created via a create entry in `account_changes` with CREATE2 address derivation; runtime bytecode placed at address, actors + authenticators configured, `calls` handles initialization | Wallet-defined | ### Authenticators -Each actor is associated with an authenticator, a contract that performs signature authentication. In the protocol's terms, the authenticator *authenticates* the actor (it returns the actor's `actorId`); scope and policy then *authorize* what that authenticated actor may do. The authenticator address is stored in `actor_config` (see [Account Configuration](#account-configuration)). All authenticators implement `IAuthenticator.authenticate(hash, data)`. After the authenticator authenticates the signature, the protocol validates the returned `actorId` against `actor_config` and authorizes the actor by checking its scope against the authorization context; the policy gate (`POLICY`) is enforced later, during execution. - -Authenticators are executed via STATICCALL. Authenticator addresses MUST NOT be delegated accounts; reject if the code at the authenticator address starts with the delegation indicator (`0xef0100`). +Each actor is associated with an authenticator, a contract that performs signature authentication. In the protocol's terms, the authenticator *authenticates* the actor (it returns the actor's `actorId`); scope and policy on the keystore then *authorize* what that authenticated actor may do. The authenticator address is stored in `actor_config` (see [Keystore and Account Configuration](#keystore-and-account-configuration)). All authenticators implement `IAuthenticator.authenticate(hash, data)`. After the authenticator authenticates the signature, the protocol validates the returned `actorId` against `actor_config` and authorizes the actor by checking its scope against the authorization context. -Chains choose how authenticator execution is priced. A chain MAY meter authenticator execution as ordinary EVM execution (see [Mempool Acceptance](#mempool-acceptance) for rules), or it MAY enshrine canonical authenticators and charge a fixed, standard gas cost per enshrined authenticator instead of metering. When an authenticator is enshrined, its execution MUST produce identical results to the corresponding authenticator contract. +Authenticators are executed via STATICCALL, or optionally the equivalent native code on the 8130 transaction path. -`K1_AUTHENTICATOR` (`address(1)`) is a protocol-reserved address for native secp256k1 authentication. When the protocol encounters this address as an authenticator in auth data, it performs ecrecover directly rather than making a STATICCALL. The `data` portion is interpreted as raw ECDSA `(r || s || v)`, and the returned `actorId` is `bytes32(bytes20(recovered_address))`. The same identity serves both the implicit default EOA and any explicitly registered k1 actor; the `actor_config` slot alone distinguishes a full-owner EOA from a scoped key, so actors can be explicitly registered with `K1_AUTHENTICATOR` to use native ecrecover with a custom scope, without requiring a deployed authenticator contract. `address(0)` is considered empty. +Chains may choose how authenticator execution is priced. A chain MAY call directly into the EVM and meter as ordinary EVM execution (see [Mempool Acceptance](#mempool-acceptance) for rules), or it MAY enshrine the canonical authenticators and charge a fixed, standard gas cost per enshrined authenticator instead of metering. When an authenticator is enshrined, its execution MUST produce identical results to the corresponding authenticator contract. -Any contract implementing `IAuthenticator` can be permissionlessly deployed and registered as an actor's authenticator. However, registration does not make an authenticator usable on the 8130 path: only canonical authenticators in the node allowlist are accepted for block-level AA authentication (see [Canonical Authenticator Set](#canonical-authenticator-set)). Non-canonical authenticators remain fully usable within EVM execution; for example, an account can authenticate an actor against an arbitrary `IAuthenticator` via a config change call, enabling use cases such as wallet-defined recovery methods. Such actors simply cannot authenticate transactions directly over the 8130 path and they operate through ordinary EVM execution. +Any contract implementing `IAuthenticator` can be permissionlessly deployed and registered as an actor's authenticator. However, registration does not make an authenticator usable on the 8130 transaction path: only canonical authenticators in the node allowlist are accepted for block-level AA authentication (see [Canonical Authenticator Set](#canonical-authenticator-set)). Non-canonical authenticators remain fully usable within EVM execution; for example, an account can authenticate an actor against an arbitrary `IAuthenticator` via a config change call, enabling use cases such as wallet-defined recovery methods. #### Canonical Authenticator Set @@ -68,10 +70,10 @@ This specification defines a canonical authenticator set which is the set of sig | Name | Algorithm | Authenticator | `actorId` Derivation | |------|-----------|----------|----------------------| -| k1 | secp256k1 | `K1_AUTHENTICATOR` (native sentinel) | `bytes32(bytes20(recovered_address))` | +| k1 | secp256k1 | `K1_AUTHENTICATOR` (native sentinel) | `bytes32(uint256(uint160(recovered_address)))` (the address right-aligned into a 32-byte word: low 20 bytes, high 12 bytes zero) | | p256 | P-256 | Onchain contract | `keccak256(abi.encodePacked(x, y))` | | passkey | WebAuthn / FIDO2 | Onchain contract | `keccak256(abi.encodePacked(x, y))` | -| delegate | Signature delegation | Onchain contract | `bytes32(bytes20(delegated_address))`: signatures from `delegated_address` are valid for the registering account (see [Delegate Authenticator](#delegate-authenticator)) | +| delegate | Signature delegation | Onchain contract | `bytes32(uint256(uint160(delegated_address)))` (address right-aligned, high 12 bytes zero): signatures from `delegated_address` are valid for the registering account (see [Delegate Authenticator](#delegate-authenticator)) | The canonical authenticator set and corresponding contract addresses are maintained in a companion ERC (number TBD) and deployed at deterministic CREATE2 addresses across chains. The canonical set is expected to grow as new algorithms are adopted (e.g., post-quantum) through the companion ERC process. @@ -79,39 +81,21 @@ Nodes MUST include all canonical authenticators in their allowlist and SHOULD NO #### Delegate Authenticator -The delegate authenticator lets one account act on behalf of another. An account **A** registers a *delegate actor* that points at another account **B**; thereafter any key that can authenticate as **B** may authenticate as **A**, bounded by the scope **A** grants that delegate actor. - -**Registration.** **A** authorizes an actor with `authenticator = DELEGATE_AUTHENTICATOR` and `actorId = bytes32(bytes20(B))`. That actor's `scope` in **A**'s config governs what **B** may do for **A** under the normal [Actor Scope](#actor-scope) rules. - -**Wire format.** When authenticating through the delegate authenticator, the auth blob's `data` (the bytes following the 20-byte authenticator address, per [Signature Format](#signature-format)) is: - -``` -data = delegated_account (20 bytes) // B - || nested_auth // authenticator (20 bytes) || data, a normal auth blob for B -``` - -**Constraints**: +The delegate authenticator lets one account act on behalf of another: account **A** registers a delegate actor with `actorId = bytes32(uint256(uint160(B)))`, after which any key that can authenticate as account **B** may authenticate as **A**, bounded by the scope **A** grants that actor. Delegation MUST NOT chain (depth-1: the nested authenticator MUST NOT itself be the delegate authenticator) and the nested authenticator MUST be canonical, keeping total validation work bounded. The remaining details are defined in the canonical repository. -- **No nesting (depth-1):** the nested authenticator MUST NOT be the delegate authenticator; delegation cannot chain. -- **Nested authenticator MUST be canonical:** nodes apply the authenticator allowlist to the nested authenticator just as to the outer one, keeping total work bounded and enshrinable. -- **Admin on the nested actor:** the nested check is **B** vouching via a signature, and signing authority is the admin predicate (`scope == 0x00`), so the nested actor in **B** MUST be admin. +### Keystore and Account Configuration -### Account Configuration +Each account can authorize a set of actors through the Keystore contract at `KEYSTORE_ADDRESS`. This contract handles actor authorization, account creation, change sequencing, and account lock, and delegates signature authentication to onchain [Authenticators](#authenticators). Its full ABI, storage packing, typehashes, and events are defined in the canonical repository (`src/Keystore.sol`); this section specifies only the protocol-visible behavior and the state a node reads directly. -Each account can authorize a set of actors through the Account Configuration Contract at `ACCOUNT_CONFIG_ADDRESS`. This contract handles actor authorization, account creation, change sequencing, and delegates signature authentication to onchain [Authenticators](#authenticators). +Actors are identified by their `actorId`, a 32-byte identifier derived by the authenticator from public key material. Each authenticator defines its own actorId derivation algorithm (see [Canonical Authenticator Set](#canonical-authenticator-set)). Actors can be modified via calls within EVM execution by calling the authenticated change function ([`applySignedAccountChanges`](#account-config-change-paths)). -Actors are identified by their `actorId`, a 32-byte identifier derived by the authenticator from public key material. Each authenticator defines its own actorId derivation algorithm (see [Canonical Authenticator Set](#canonical-authenticator-set)). Actors can be modified via calls within EVM execution by calling the authenticated config change functions. +Actor enumeration is performed off-chain via `ActorAuthorized` and `ActorRevoked` event logs. #### Storage Layout -Each actor occupies a single `actor_config` slot containing the authenticator address, scope byte, and optional expiry. When `scope` includes `POLICY` (`0x02`), the actor also carries a signed policy commitment and a manager address in the separate policy slots `policy_commitment`/`policy_manager` (see [Actor Policies](#actor-policies)). Actors are revoked by deleting the `actor_config` slot. The self-actor (`actorId == bytes32(bytes20(account))`) is the one exception; its layout and rules are collected in [Self-Actor](#self-actor). +Each actor occupies a single `actor_config` slot containing the authenticator address, an optional expiry, and the scope bitmask. The protocol reads this slot directly during validation, so its packing is normative: `authenticator(20) β€– expiry(6) β€– scope(2) β€– reserved(4)`, where `expiry` is a `uint48` Unix timestamp in **seconds** (`0` = no expiry; the actor is invalid once `block.timestamp > expiry`) and `scope` is the `uint16` permission bitmask (`0x0000` = unrestricted, also the admin predicate; see [Actor Scope](#actor-scope)). The exact slot derivation and byte offsets are defined in the canonical repository (`src/Keystore.sol`). -| Field | Bytes | Description | -|-------|-------|-------------| -| `authenticator` | 0–19 | Authenticator contract address | -| `scope` | 20 | Permission bitmask (`0x00` = unrestricted; also the admin predicate, see [Actor Scope](#actor-scope)) | -| `expiry` | 21–26 | `uint48` Unix timestamp (seconds); the actor is invalid once `block.timestamp > expiry`. `0` = no expiry | -| reserved | 27–31 | MUST be zero on write (native and EVM paths). Implicit version discriminator: new-format configs with nonzero reserved fail to apply through legacy deployments rather than applying with restrictions dropped | +When `scope` includes `POLICY` (`0x02`), the actor also carries a signed policy commitment and a manager address in the separate policy slots `policy_commitment`/`policy_manager` (see [Actor Policies](#actor-policies)). Actors are revoked by deleting the `actor_config` slot. The self-actor (`actorId == bytes32(uint256(uint160(account)))`) is the one exception: it is held inline in the packed account-state slot and resolved per [Validation](#validation). When `scope & POLICY != 0`, the actor's policy is held in two additional slots: @@ -120,64 +104,44 @@ policy_commitment(account, actorId) β†’ bytes32 // set when POLICY is set policy_manager(account, actorId) β†’ address // set when POLICY is set ``` -These slots are read only during execution (see [Actor Policies](#actor-policies)); validity is still decided by the single `actor_config` SLOAD. - -#### Self-Actor - -The **self-actor** is the actor whose `actorId == bytes32(bytes20(account))`. All self-actor rules β€” referenced from Storage Layout, Validation, `account_changes_cost`, Execution, `importAccount`, and Security Considerations β€” are collected here: - -- **Two mutually exclusive forms.** The self-actor exists in exactly one of two forms at a time, and registering one clears the other: - 1. **Inline secp256k1 self (default EOA).** Held **inline in the packed account-state slot** (`default_eoa_scope`/`default_eoa_expiry` plus the `DEFAULT_EOA_REVOKED` flag; see [Account Lock](#account-lock)), so the account's own key resolves in a single SLOAD, including the scoped-self case. Authenticated via native ecrecover, never an external authenticator contract. - 2. **Non-secp256k1 self authenticator.** Held in the `actor_config(self)` slot (e.g. an authenticator that returns the self-actorId). Registering it **sets** `DEFAULT_EOA_REVOKED`, disabling the inline k1 self. -- **Implicit EOA authorization.** A native secp256k1 signature recovering to the account authenticates as the self-actor whenever `DEFAULT_EOA_REVOKED` is not set, resolved from the inline default-EOA config. For a fresh account that inline config is all-zero: `scope == 0x00`, `expiry == 0` (non-expiring admin). This lets every existing EOA send AA transactions immediately without prior registration. (Scoping restriction: [Implicit EOA Rule Scoping](#security-considerations).) -- **Scoped/disabled inline self.** Registering the self with `K1_AUTHENTICATOR` (`address(1)`) sets a custom `scope`/`expiry` while retaining native ecrecover: it writes the inline default-EOA fields (a non-zero `scope` downgrades the key) and leaves `DEFAULT_EOA_REVOKED` clear. Setting `DEFAULT_EOA_REVOKED` disables the inline secp256k1 self in its entirety. -- **Revocation.** Revoking the self-actor writes the account-state slot (`DEFAULT_EOA_REVOKED`) and leaves the `actor_config(self)` slot empty and reusable. -- **Defaults.** `createAccount` and `importAccount` set `DEFAULT_EOA_REVOKED` by default, so a newly created or imported account does not leave a native secp256k1 owner live unless one is among `initial_actors` (a quantum-safe default). +These slots are read only during execution (see [Actor Policies](#actor-policies)); validity is decided by the single `actor_config` SLOAD giving cheap, predictable validation and invalidation. #### Actor Scope -The scope byte in `actor_config` is a permission bitmask of **grants**. A value of `0x00` means unrestricted (all contexts) and is also the account's **admin** predicate: any check in this specification that requires an "admin" actor is exactly `scope == 0x00`. Any non-zero value grants only the contexts whose bits are set. Reads are fail-closed: a context is authorized only when `scope == 0x00` or the corresponding grant bit is present. Unknown bits grant nothing. All future scope bits MUST be pure grants. An actor whose scope sets only unknown bits is stored verbatim but authorizes no context β€” a permanently inert actor until a protocol change defines those bits. +The scope field in `actor_config` is a `uint16` permission bitmask of **grants**. A value of `0x00` means unrestricted (all contexts) and is also the account's **admin** predicate: any check in this specification that requires an "admin" actor is exactly `scope == 0x00`. Any non-zero value grants only the contexts whose bits are set. Reads are fail-closed: a context is authorized only when `scope == 0x00` or the corresponding grant bit is present. Unknown bits grant nothing. All future scope bits MUST be pure grants. An actor whose scope sets only unknown bits is stored verbatim but authorizes no context in the protocol. | Bit | Value | Name | Context | |-----|-------|------|---------| -| 0 | `0x01` | SENDER | Ungated initiation: `sender_auth`; may originate transactions to any `call.to`. Carries operational authority (unless combined with `POLICY`) β€” also satisfies `verifySignature()` (ERC-1271 signing); see below | +| 0 | `0x01` | SENDER | Ungated initiation: `sender_auth`; may originate transactions to any `call.to`. Carries operational authority (unless combined with `POLICY`), which also governs account-level signing ([ERC-1271](./eip-1271.md)); see [Signature Verification](#signature-verification) | | 1 | `0x02` | POLICY | Gated initiation: `sender_auth`; may originate transactions only to the actor's `policy_manager` (see [Actor Policies](#actor-policies)) | | 2 | `0x04` | NONCE | Permits a restricted actor to use sequenced `nonce_key`s for sender-context transactions (see [Actor Nonce Scope](#actor-nonce-scope)) | | 3 | `0x08` | SELF_PAYER | Self-pay gas: authorizes paying the account's own gas when `payer == sender` | | 4 | `0x10` | SPONSOR_PAYER | Sponsor gas: authorizes acting as `payer_auth` for a different sender (`payer != sender`) | -| 5–7 | | (spare) | Reserved for future pure grants | - -**Operational authority.** An actor is *operational* when it can originate calls to any target: `operational := admin (scope == 0x00) || (SENDER && !POLICY)`. ERC-1271 signing (`verifySignature()`) is **not** a grant; it is authorized for any operational actor. Signing is an *encoding* of authority, not a separate capability: an operational key can already call `approve`/`transfer` directly, so letting it produce a `Permit` or order signature grants nothing it did not already have (`approve ≑ permit`). Conversely a `POLICY` actor is never operational and MUST NOT sign raw hashes β€” a signature acts off the policy gate, so a gated key that could sign would escape its gate. A sign-only restriction would be illusory anyway (a hash-blind signer can still sign a Permit2 drain), so signing gets no bit; scoped signing is expressed at the account layer via an approved-hash / approve-typed-data pattern driven by a `POLICY` key (see [Actor Policies](#actor-policies)). +| 5–15 | | (spare) | Reserved for future pure grants | -**Admin.** Config-change authority (`authorizeActor`, `revokeActor`, `applySignedActorChanges`, delegation) is exactly the admin predicate `scope == 0x00`: the same unrestricted root every other check already special-cases. Accounts are born with a scope-0 root (the implicit EOA, or an unrestricted actor named at create/import); everything below that root is granted. See [Why Admin Is Scope Zero](#why-admin-is-scope-zero). +**Admin.** Config-change authority (`authorizeActor`, `revokeActor`, `applySignedAccountChanges`, delegation) is exactly the admin predicate `scope == 0x00`: the same unrestricted root every other check already special-cases. Accounts are born with a scope-0 root (the implicit EOA, or an unrestricted actor named at create/import); everything below that root is granted. **Initiation grants.** `SENDER` and `POLICY` compose: `SENDER` allows initiation to any `call.to`, `POLICY` allows gated initiation to the actor's `policy_manager`. An actor may hold either or both; whenever `POLICY` is set the call is gated to the `manager` regardless of `SENDER`, so the gate always binds a policy-bearing actor. When both are set, `SENDER` conveys no additional authority; wallets SHOULD NOT set `SENDER | POLICY`. `POLICY | SELF_PAYER` and `POLICY | NONCE` compose likewise. `POLICY | SPONSOR_PAYER` also composes: the actor's initiation is gated to its `manager`, but its sponsor authority is **not** gated and it may underwrite third-party gas off its policy gate. `authorizeActor` stores any scope combination verbatim; combination semantics are checked at the point of use. -**Verbatim reporting.** `getActorConfig` and `authenticateActor` MUST return stored scope bytes unmodified β€” no normalization or masking of unrecognized bits. +Scope authorization is applied only after an authenticator authenticates the signature and returns an `actorId`: the protocol loads that actor's `scope` and checks it against the context being authorized. For `sender_auth`, require `scope == 0x00 || (scope & SENDER) != 0 || (scope & POLICY) != 0`; when `POLICY` is set the actor is gated to its `manager` regardless of `SENDER` (see [Actor Policies](#actor-policies)). For self-pay (payer account == sender account) require SELF_PAYER on the actor that authorizes payment, and for sponsorship (payer account β‰  sender) require SPONSOR_PAYER on the `payer_auth`-resolved actor (the respective bit, or unrestricted). For 2D nonce usage require NONCE and for config change `auth` require admin (`scope == 0x00`). -Scope authorization is applied only after an authenticator authenticates the signature and returns an `actorId`: the protocol loads that actor's `scope` and checks it against the context being authorized. For `sender_auth`, require `scope == 0x00 || (scope & SENDER) != 0 || (scope & POLICY) != 0`; when `POLICY` is set the actor is gated to its `manager` regardless of `SENDER` (see [Actor Policies](#actor-policies)). For self-pay (payer account == sender account) require SELF_PAYER on the actor that authorizes payment, and for sponsorship (payer account β‰  sender) require SPONSOR_PAYER on the `payer_auth`-resolved actor (the respective bit, or unrestricted); for `verifySignature()` require **operational** (`scope == 0x00`, or `SENDER` and not `POLICY`); for config change `auth` require admin (`scope == 0x00`). +See [Validation](#validation) for the full flow. -The protocol validates signatures by reading `actor_config` directly and delegating authentication to [Authenticators](#authenticators); see [Validation](#validation) for the full flow. Actor enumeration is performed off-chain via `ActorAuthorized` and `ActorRevoked` event logs. - -#### Actor Nonce Scope - -`NONCE` (`0x04`) grants a restricted actor access to ordered (sequenced) `nonce_key`s for **sender-context** transactions. +#### Actor Expiry -| Actor | `nonce_key` allowed | -|-------|----------------------| -| Admin (`scope == 0x00`) | Any `nonce_key`, including `NONCE_KEY_MAX` (nonceless), freely | -| Restricted, `NONCE` unset | `nonce_key == NONCE_KEY_MAX` only (nonceless) | -| Restricted, `NONCE` set | Any `nonce_key`, including `NONCE_KEY_MAX` β€” the full 2D key space | +The protocol enforces one rule: an actor is **live** at `now` (the inclusion block timestamp) iff `expiry == 0 || now <= expiry` (see [Storage Layout](#storage-layout)), evaluated at inclusion. It checks only the acting key's own `expiry` and never walks authorizer lineage, so a live admin's authorization survives that admin's own later expiry or revocation; removal requires an explicit `revokeActor`. -This makes nonceless (`NONCE_KEY_MAX`) the default for every restricted (non-admin) actor: a freshly authorized session key can send transactions without touching a nonce channel. An actor that needs an ordered, replay-protected sequence (e.g. a high-throughput automation key) opts in with `NONCE` and may then use any `nonce_key` in the full 2D space. +Note for wallets: `expiry` is set only by a config change (initial actors are always non-expiring), and SHOULD be placed only on non-admin actors, where a lapsed key simply stops working. Using it on an admin (`scope == 0x00`) can break valid change chains for cross-chain actor-change replay and account sync, so an admin SHOULD NOT expire unless the wallet owner deliberately plans for it and keeps a non-expiring owner in place. -`NONCE` constraints apply to **sender-context** transactions only. `payer_auth` consumes no payer-side sequence and is unaffected. The check (reject a sequenced `nonce_key` from a restricted actor lacking `NONCE`) is enforced by the protocol; see [Validation](#validation). +#### Epoch System -#### Actor Expiry +To enable uncoordinated session-key, subscription, and JIT actor additions there is an epoch system. The local change channel carries an **epoch** (`local_epoch`) alongside its sequence counter (`local_sequence`); together they form the signed local word `local_epoch(high 32) || local_sequence(low 32)` (see [Config Change Authorization](#config-change-authorization)). The epoch is a blunt cancellation control for the account's own outstanding **local** signatures, distinct from actor expiry: -The protocol enforces one rule: an actor is **live** at `now` (the inclusion block timestamp) iff `expiry == 0 || now <= expiry` (see [Storage Layout](#storage-layout)), evaluated at inclusion. It checks only the acting key's own `expiry` and never walks authorizer lineage, so a live admin's authorization survives that admin's own later expiry or revocation; removal requires an explicit `revokeActor`. +- `IncrementLocalEpoch` (an account change carried on either channel) increments `local_epoch` and resets `local_sequence` to `0`. Every local batch signed at the prior epoch β€” sequenced or unsequenced/JIT β€” no longer matches the current `local_epoch` and is rejected with `StaleEpoch`; it can never land. +- The epoch does **not** revoke live actors or touch `actor_config`. An actor authorized in a landed batch stays authorized across an epoch bump; only *unlanded local signatures* are invalidated. Removing an actor is still an explicit `RevokeActor`. +- Multichain batches are unaffected: they carry no epoch and are ordered solely by the monotonic multichain counter. -Note for wallets: `expiry` is set only by a config change (initial actors are always non-expiring), and SHOULD be placed only on non-admin actors, where a lapsed key simply stops working. Using it on an admin (`scope == 0x00`) can break valid change chains for cross-chain actor-change replay and account sync, so an admin SHOULD NOT expire unless the wallet owner deliberately plans for it and keeps a non-expiring owner in place. +This is what makes the unsequenced/JIT mode (`UNSEQUENCED`) safe to hand out: an unsequenced batch is intentionally replayable within its epoch, and the account durably retires it by bumping the epoch β€” typically batching the reducing `RevokeActor`/`AuthorizeActor` with `IncrementLocalEpoch` in one signed batch. See [Why a Local Epoch?](#why-a-local-epoch). #### Actor Policies @@ -190,20 +154,19 @@ Actor policies gate a key to a single `manager` contract that enforces applicati | unset | (no gate) | empty | | set | the actor's `manager` (`address(0)` = no productive target) | `manager` (20) β€– `commitment` (32) | -A policy-bearing actor may call exactly one target: its configured `manager`. The contract at that target reads the actor's `commitment` (via [`getPolicy`](#iaccountconfiguration)), validates presented parameters against it, enforces *what* the call may do, and carries out the approved action. The protocol's only responsibility is the single-target gate. +A policy-bearing actor may call exactly one target: its configured `manager`. The contract at that target reads the actor's `commitment` (via [`getPolicy`](#ikeystore)), validates presented parameters against it, enforces *what* the call may do, and carries out the approved action. The protocol's only responsibility is the single-target gate. A key that should be enforced by the account's own code rather than a separate contract sets `manager = account`. Because protocol dispatch originates the call from the account itself (`msg.sender == account`), this is only meaningful with policy-aware wallet code; code that implicitly trusts self-calls (e.g. a standard `executeBatch`) turns such a key into an unrestricted one (see [Security Considerations](#security-considerations)). -**Scope.** `POLICY` is the gated initiation grant. How it composes with `SENDER` is defined in [Actor Scope](#actor-scope). It MAY combine with `SELF_PAYER` so a session key can self-pay gas (bounded by balance and `expiry`). It MAY also combine with `NONCE` so a policy key can use a sequenced `nonce_key`. A `POLICY` key is not operational and cannot sign ERC-1271 (a signature would act off its gate; see [Actor Scope](#actor-scope)); scoped signing SHOULD use an account-level approved-hash / approve-typed-data pattern (Safe `approveHash` precedent) driven by the `POLICY` key. +**Scope.** `POLICY` is the gated initiation grant. How it composes with `SENDER` is defined in [Actor Scope](#actor-scope). It MAY combine with `SELF_PAYER` so a session key can self-pay gas (bounded by balance and `expiry`). It MAY also combine with `NONCE` so a policy key can use a sequenced `nonce_key`. **Reference flow** (non-normative). One construction of a session-key policy: -1. **Authorize.** The account authorizes the key with `POLICY` (optionally `| SELF_PAYER` and/or `| NONCE`), the `manager`, and a `commitment`. The key's call authority is only the gate to its `manager`. -2. **Install (once).** The `params` are installed at the `manager`, permissionlessly, gated by the signed commitment. -3. **Use (each call).** The protocol gate routes the key's call to the `manager`; the manager enforces installed `params` and drives the account. -4. **Retire.** `revokeActor` zeroes the commitment; `expiry` rejects further authentication. +1. **Authorize.** The account authorizes the key with `POLICY`, the `manager`, and a `commitment`. The key's call authority is only the gate to its `manager`. +2. **Use (each call).** The protocol gate routes the key's call to the `manager`; the manager enforces installed `params` and drives the account by checking the policy commitment. +3. **Retire.** `revokeActor` zeroes the commitment or `expiry` rejects further authentication. -**Example** (non-normative). A subscription session key limited to **5 USDC per 30-day period** with a two-target allowlist. Authorized with `POLICY` (nonceless by default, and without `SELF_PAYER` if a sponsor pays gas). Over-budget transfers, wrong targets, expiry, or revoke all fail. +**Example** (non-normative). A subscription session key limited to **5 USDC per 30-day period** with a two-target allowlist. Authorized with `POLICY`. Over-budget transfers, wrong targets, expiry, or revoke all fail. **The commitment is signed, opaque, and protocol-stored.** One signature fully describes the key's manager and policy and travels with the portable actor-change path. @@ -223,11 +186,21 @@ The transaction carries two nonce fields: `nonce_key` (`uint256`) selects the no | `1` through `NONCE_KEY_MAX - 1` | User-defined | Parallel transaction channels defined by wallets | | `NONCE_KEY_MAX` | Nonce-free | No nonce state read or incremented | +##### Actor Nonce Scope + +`NONCE` (`0x04`) grants a restricted actor access to sequenced `nonce_key`s for **sender-context** transactions; it does not apply to `payer_auth`. The protocol enforces this during validation (see [Validation](#validation)): + +| Actor | `nonce_key` allowed | +|-------|----------------------| +| Admin (`scope == 0x00`) | Any `nonce_key`, including `NONCE_KEY_MAX` | +| Restricted, `NONCE` unset | `NONCE_KEY_MAX` (nonce-free) only | +| Restricted, `NONCE` set | Any `nonce_key`, the full 2D space | + ##### Nonce-Free Mode (`NONCE_KEY_MAX`) -When `nonce_key == NONCE_KEY_MAX`, the protocol does not read or increment the nonce counter. `nonce_sequence` MUST be `0`. Replay protection relies on `expiry`, which MUST be non-zero, together with `replay_id` deduplication state distinct from the nonce counter. That state is a fixed-capacity **circular buffer** and is **consensus state**, not per-node bookkeeping: a `seen` map (`replay_id β†’ expiry`) holds live entries, and a ring of `replay_id`s in insertion order lets the oldest entry be evicted once expired. Per transaction the protocol reads `seen[replay_id]` and rejects if a still-live entry exists; reads the ring slot at the current pointer; if that slot is occupied, reads that entry's expiry and, when expired, clears its `seen` entry (rejecting only if the buffer is full of still-live entries); then writes the new `replay_id` into the ring slot, sets `seen[replay_id] = expiry`, and advances the pointer. Because the `seen[replay_id]` liveness check and the full-buffer rejection both decide block validity, the buffer capacity MUST be a **protocol constant or explicit chain parameter** (`REPLAY_BUFFER_CAPACITY`), identical for every node on the chain β€” a per-node capacity would split consensus. It MUST be at least `peak accepted nonce-free throughput Γ— NONCE_FREE_EXPIRY_WINDOW`, so an entry always expires before its ring slot is reused. Because entries are ephemeral and the buffer is fixed-size, there is no permanent state growth. See `nonce_key_cost` in [Intrinsic Gas](#intrinsic-gas). +When `nonce_key == NONCE_KEY_MAX`, the protocol neither reads nor increments a nonce counter; `nonce_sequence` MUST be `0` and `valid_before` MUST be non-zero. Replay protection uses `replay_id` deduplication held in a fixed-capacity **circular buffer** that is **consensus state**, not per-node bookkeeping: a `seen` map (`replay_id β†’ valid_before`) of live entries plus a ring that evicts the oldest entry once elapsed, so a still-live `replay_id` is rejected and a buffer full of still-live entries rejects the transaction. Because both checks decide block validity, the capacity is a **protocol constant or explicit chain parameter** (`REPLAY_BUFFER_CAPACITY`), identical for every node on the chain, and MUST be at least `peak accepted nonce-free throughput Γ— NONCE_FREE_EXPIRY_WINDOW` so an entry always elapses before its ring slot is reused. Entries are ephemeral, so there is no permanent state growth. See `nonce_key_cost` in [Intrinsic Gas](#intrinsic-gas). -The maximum `expiry` window accepted for nonce-free transactions is likewise an explicit chain parameter (`NONCE_FREE_EXPIRY_WINDOW`), not a per-node choice, and MUST be sized together with `REPLAY_BUFFER_CAPACITY` so `peak accepted nonce-free throughput Γ— NONCE_FREE_EXPIRY_WINDOW` stays within capacity. Replay protection is handled by the **replay identifier** defined below. +The maximum validity window (`valid_before βˆ’ now`) accepted for nonce-free transactions is likewise an explicit chain parameter (`NONCE_FREE_EXPIRY_WINDOW`), not a per-node choice, and MUST be sized together with `REPLAY_BUFFER_CAPACITY` so `peak accepted nonce-free throughput Γ— NONCE_FREE_EXPIRY_WINDOW` stays within capacity. Replay protection is handled by the **replay identifier** defined below. ###### Replay Identifier @@ -237,7 +210,7 @@ Nonce-free (`NONCE_KEY_MAX`) transactions have no nonce slot to key deduplicatio REPLAY_ID_TYPE = 0x7901 replay_id = keccak256(REPLAY_ID_TYPE || rlp([ - chain_id, resolved_sender, expiry, + chain_id, resolved_sender, valid_after, valid_before, account_changes, calls, metadata, payer ])) @@ -267,83 +240,35 @@ In both modes a replacement MUST increase `max_priority_fee_per_gas` by at least #### Account Lock -Account lock state is stored in a single packed 32-byte account-state slot that also holds the change sequences, an account-flags byte, and the inline default-EOA (self-actor) config: +Account lock state is stored in a single packed 32-byte account-state slot that also holds the change channels, an account-flags byte, and the inline default-EOA config. The protocol may read this slot's raw layout directly for mempool rate-limit tiering, so its field order and widths are normative: -| Field | Description | -|-------|-------------| -| `multichain_sequence` | Change-sequence counter for `chain_id 0` (`uint64`) | -| `local_sequence` | Change-sequence counter for the local chain (`uint64`); `> 0` doubles as the initialized flag | -| `flags` | Account flags byte: bit 0 (`DEFAULT_EOA_REVOKED`) disables the secp256k1 self-actor; bit 1 (`LOCKED`) freezes actor configuration; bit 2 (`UNLOCK_INITIATED`) selects how the `lock_union` field is interpreted | -| `lock_union` | `uint40` union field. While `UNLOCK_INITIATED` is clear it holds `unlock_delay` (seconds, `uint16` range): the notice required before config can change, which nodes read for rate-limit tiering. While `UNLOCK_INITIATED` is set it holds `unlocks_at` (the timestamp at which unlock takes effect) | -| `default_eoa_scope` | Inline self-actor `scope` (`uint8`; `0x00` = full owner) | -| `default_eoa_expiry` | Inline self-actor `expiry` (`uint48` Unix seconds; `0` = no expiry) | -| reserved | Remaining bytes in the packed slot; MUST be zero | - -The packed slot is exactly 32 bytes, so the inline self-actor config and lock state cost no extra SLOAD/SSTORE beyond the account-state access already performed for the change-sequence fields. - -When `LOCKED`, all actor-config changes and delegation are rejected on both paths (config entries in `account_changes` and `applySignedActorChanges()`). The only operation permitted while locked is `unlock` below. The stored `unlock_delay` is bounded to the `uint16` range (~18.2h max): lock exists for mempool permissioning, and a short ceiling prevents an account from self-bricking config rotations. +When `LOCKED`, all authority changes (authorize/revoke actor) and delegation are rejected on both paths (config entries in `account_changes` and `applySignedAccountChanges`). Only two changes remain permitted while locked: `Unlock` (which begins the timed release) and `IncrementLocalEpoch` (which cancels outstanding local signatures without touching actor authority β€” see [Epoch System](#epoch-system)). Lock and unlock are themselves the only lock-state transitions, carried as standalone changes (below). The stored `unlock_delay` is bounded to the `uint16` range (~18.2h max): lock exists for mempool permissioning, and a short ceiling prevents an account from self-bricking config rotations. ##### Lock Operations -Lock state changes only through `applySignedLockChanges`, a dedicated admin-authorized entry point accessible in the EVM only. - -``` -LOCK_CHANGE_TYPEHASH = keccak256( - "SignedLockChange(address account,uint256 chainId,uint8 op," - "uint16 unlockDelay,uint64 sequence)") -// op: 1 = lock, 2 = unlock - -applySignedLockChanges(address account, uint8 op, - uint16 unlockDelay, bytes calldata auth) -``` - -Lock operates using the local account channel. The digest binds `chainId = block.chainid` and `sequence = local_sequence` (the **current** counter value); the contract rejects a mismatch and, on success, increments `local_sequence` β€” the same sign-current-then-increment convention as transaction nonces and local config changes (which share this counter). `auth` is a standard `authenticator \|\| data` blob (see [Signature Format](#signature-format)) validated as **admin** (`scope == 0x00`) against `account`. Anyone may relay; authorization comes from the signature. +Lock and unlock are **local-only** account changes carried in a signed batch through the single [`applySignedAccountChanges`](#account-config-change-paths) entry point; there is no separate lock function. Each is admin-authorized (`scope == 0x00`), binds `chainId = block.chainid`, and is consumed against the local channel like any sequenced local change. `Lock` and `Unlock` MUST each be the **sole** change in their batch, so a lock transition can never interleave with actor changes in the same signed batch. Anyone may relay; authorization comes from the signature. The signed digest and typehashes are defined in the canonical repository. **Lifecycle** β€” `lock`, then `unlock`, with no other actions in between: -1. **Lock** (`op = 1`): only from the unlocked state. Sets `LOCKED` and stores `unlock_delay = unlockDelay`. Rejected if already locked; the delay cannot be changed while locked. -2. **Unlock** (`op = 2`): only from `LOCKED` with no pending unlock; `unlockDelay` MUST be `0`. Sets `UNLOCK_INITIATED` and `unlocks_at = block.timestamp + unlock_delay` (from the stored delay). +1. **Lock**: only from the unlocked state. Sets `LOCKED` and stores `unlock_delay`. Rejected if already locked (a zero delay is rejected); the delay cannot be changed while locked. Emits `AccountLocked`. +2. **Unlock**: only from `LOCKED` with no pending unlock. Sets `UNLOCK_INITIATED` and `unlocks_at = block.timestamp + unlock_delay` (from the stored delay). Emits `AccountUnlockInitiated`. 3. **Effective unlock**: once `block.timestamp >= unlocks_at`, the account is unlocked and config changes resume; the flags and `lock_union` are lazily cleared by the next op. Locking again requires a fresh `lock`. #### Account Import -`importAccount(address account, uint256 chainId, InitialActor[] calldata initialActors, bytes calldata signature)` is a one-time call that registers an already-deployed account into the Account Configuration Contract with an initial actor set. `chainId` is the replay domain of the import signature, mirroring `applySignedActorChanges`: `0` = multichain (valid on every chain); otherwise it MUST equal `block.chainid`. The call is rejected when: +`importAccount(address account, uint256 chainId, InitialActor[] calldata initialActors, bytes calldata signature)` is a one-time call that registers an already-deployed account into the Keystore contract with an initial actor set. `chainId` is the replay domain of the import signature, mirroring `applySignedAccountChanges`: `0` = multichain (valid on every chain); otherwise it MUST equal `block.chainid`. The call is rejected when: - `chainId` is neither `0` nor the current chain. - The account already has 8130 state: **either** change-sequence channel is non-zero. Import is a one-time bootstrap, so it requires both the local and multichain channels empty. A locked account is always caught here: a `lock` is a signed local config change, so it has advanced `local_sequence` and the account is considered initialized (see [Account Lock](#account-lock)). +- The account has no bytecode (there is no contract to run the ERC-1271 signature check against), or its ERC-1271 check does not return the magic value. -The `signature` is validated against the account via [ERC-1271](./eip-1271.md) `isValidSignature(digest, signature)`, binding the initial actor set to the account's existing authorization logic. `digest` is a typed `ActorInitialization` struct hash: +The `signature` is validated against the account via [ERC-1271](./eip-1271.md) over a typed ([EIP-712](./eip-712.md)-style) `ActorInitialization` digest that omits the EIP-712 domain separator (anti-phishing) and binds the account's own actorId (`bytes32(uint256(uint160(account)))`, so it cannot be replayed against another account), `chainId` (matching the `applySignedAccountChanges` replay domain), and each initial actor with `expiry = 0` (imported actors are always non-expiring; an actor-provided expiry MUST NOT be accepted). `policyData` follows `authorizeActor`'s rule and, unlike create, `manager = account` is expressible here. On success `importAccount` sets `DEFAULT_EOA_REVOKED` (parity with `createAccount`); to keep using the native key past import, include the self-actorId as a `K1_AUTHENTICATOR` entry in `initialActors`. Exact typehashes and digest construction are defined in the canonical repository (`src/Keystore.sol`). -``` -ACTORCONFIG_TYPEHASH = - keccak256("ActorConfig(address authenticator,uint8 scope,uint48 expiry)") - -ACTOR_TYPEHASH = - keccak256("Actor(bytes32 actorId,ActorConfig config,bytes policyData)" - "ActorConfig(address authenticator,uint8 scope,uint48 expiry)") - -ACTOR_INITIALIZATION_TYPEHASH = - keccak256("ActorInitialization(bytes32 salt,uint256 chainId,Actor[] initialActors)" - "Actor(bytes32 actorId,ActorConfig config,bytes policyData)" - "ActorConfig(address authenticator,uint8 scope,uint48 expiry)") - -// Per-actor. Imported actors carry scope and policyData like create; expiry is always 0 -// (expiry is added post-import via config changes). policyData follows authorizeActor's rule: -// manager (20) || commitment (32) when POLICY is set, empty otherwise, and is hashed for real: -configHash_i = keccak256(abi.encode(ACTORCONFIG_TYPEHASH, authenticator_i, scope_i, 0)) -actorHash_i = keccak256(abi.encode(ACTOR_TYPEHASH, actorId_i, configHash_i, keccak256(policyData_i))) - -digest = keccak256(abi.encode( - ACTOR_INITIALIZATION_TYPEHASH, - bytes32(bytes20(account)), // salt, bound to the account address - chainId, - keccak256(abi.encodePacked(actorHash_0, ..., actorHash_n)) -)) -``` +#### Account Establishment -This digest is a typed (EIP-712-style) struct hash that intentionally omits the EIP-712 domain separator, which prevents phishing via standard wallet signing flows. The `salt` field is bound to the account address, and `chainId` binds the replay domain (matching `applySignedActorChanges`). This typed style is distinct from the packed style used for [Address Derivation](#address-derivation); that split is intentional. Imported actors are always non-expiring: implementations MUST hash `expiry = 0` into every `configHash_i` (as shown), and MUST NOT accept an actor-provided expiry at import. `policyData` is validated with `authorizeActor`'s frozen rule (well-formed `manager β€– commitment` when `POLICY` is set, empty otherwise), written to `policy_manager`/`policy_commitment`, and hashed into `actorHash_i`; unlike create, `manager = account` is expressible here. +Both `createAccount` and `importAccount` set the `CONTRACT_ESTABLISHED` flag (`flags` bit 0) in the packed account-state slot, regardless of the account's code shape (fresh deployment, plain contract, or [EIP-7702](./eip-7702.md) delegate). The flag is permanent, has no effect on authentication, and marks the account as **keystore-established rather than backed by a proven address-bound key**. -On success, `importAccount` sets the `DEFAULT_EOA_REVOKED` flag (parity with `createAccount`), disabling the implicit native-secp256k1 owner. An owner who wants to keep using that key past import includes the self-actorId as a `K1_AUTHENTICATOR` entry in `initialActors` (lossless: still a full owner, now resolved through its inline default-EOA config, which clears the flag for the self). +It exists because 8130 state can outlive code: an account established and `SELFDESTRUCT`ed in the same transaction ([EIP-6780](./eip-6780.md)) is left with empty code but retains its 8130 state. Consumers (and protocol logic that makes code-delegation decisions) therefore MUST NOT treat "empty code (or a delegate) plus 8130 state" as proof of a known EOA key; they check `CONTRACT_ESTABLISHED` instead. A future genuinely key-backed native path MAY deliberately leave the bit clear. The flag does not infer an address-bound key from code shape β€” delegate imports remain fully supported. #### Delegation Indicator @@ -351,7 +276,17 @@ The delegation indicator is the [EIP-7702](./eip-7702.md) mechanism for pointing An account is delegated when its code is exactly `0xef0100 || target`, where `target` is a 20-byte address. Delegated accounts MAY originate transactions, and all code-executing operations targeting a delegated account MUST load code from `target` instead of the indicator. -`DEFAULT_ACCOUNT_ADDRESS` SHOULD implement [ERC-1271](./eip-1271.md) by delegating to the Account Configuration Contract's `verifySignature()`, and SHOULD implement token receiver hooks (ERC-721, ERC-1155) to safely receive assets. +### Signature Verification + +The Keystore exposes one raw primitive, `authenticateActor(account, hash, auth)`, which maps a hash and signature to a verified `(actorId, scope)`. Wallet-originated flows β€” transactions, account changes, and other protocol paths β€” authenticate directly against it over their own digest. + +Applications that instead request a signature (Sign-In with Ethereum, `Permit`, order flow) use `validateSignature(account, hash, auth)`. This wraps `authenticateActor` with an identity binding that makes the signature replay-safe: `replaySafeHash(account, chainId, hash) = keccak256(SIGNED_MESSAGE_TYPEHASH, account, chainId, hash)` binds the signature to a specific account and chain (`chainId = 0` binds all chains). The `auth` blob is `sigType(1) β€– authenticator(20) β€– data`, where `sigType` selects the **Local** (`0x01`) or **Multichain** (`0x02`) domain. + +Because both methods return `(actorId, scope)` rather than a bare boolean, a consumer can attribute a signature to a specific key and make a granular authorization decision β€” the "who signed" and capability set that plain ERC-1271 discards, surfaced only when a consumer needs it. + +This supersedes ERC-1271 while remaining backwards compatible: an 8130 account implements `isValidSignature(hash, signature)` on top of `validateSignature`, returning the magic value when the resolved actor is operational and treating any revert as invalid. The recommended wrapper is `Scopes.isOperator` (admin, or `SENDER` without `POLICY`; see [Actor Scope](#actor-scope)); the canonical `DefaultAccount` in the contract repository ships this wrapper. + +**Precompiles and native signature verification.** A precompile calling back into the EVM is both undesirable and unimplemented at the time of writing, so ERC-1271 β€” which verifies by calling the account's `isValidSignature` β€” is unavailable for native or precompile-based checks. 8130 removes that dependency: the canonical authenticator set is small and enshrinable (see [Canonical Authenticator Set](#canonical-authenticator-set)) and authority lives in a flat `actor_config` slot, so `validateSignature` can run entirely in native code and be exposed as a precompile, producing identical results to EVM execution on non-8130 chains. Future applications may depend on this, as it replaces both `ecrecover` and ERC-1271 cleanly across all chain types. ### AA Transaction Type @@ -363,7 +298,8 @@ AA_TX_TYPE || rlp([ sender, // Sender address (20 bytes) | empty for EOA signature nonce_key, // uint256: nonce channel selector nonce_sequence, // uint64: sequence number - expiry, // Unix timestamp (seconds) + valid_after, // uint64: Unix timestamp (milliseconds); 0 = no lower bound + valid_before, // uint64: Unix timestamp (milliseconds); 0 = no expiry max_priority_fee_per_gas, max_fee_per_gas, gas_limit, @@ -386,7 +322,8 @@ call = rlp([to, data]) // to: address, data: bytes | `sender` | Sending account address. **Required** (non-empty) for configured actor signatures. **Empty** for EOA signatures; address recovered via ecrecover. The presence or absence of `sender` is the sole distinguisher between EOA and configured actor signatures. | | `nonce_key` | `uint256` nonce channel selector. `0` for standard sequential ordering, `1` through `NONCE_KEY_MAX - 1` for parallel channels, `NONCE_KEY_MAX` for nonce-free mode. | | `nonce_sequence` | `uint64` expected sequence number within `nonce_key`. Must match current sequence for `(sender, nonce_key)`. Incremented after inclusion regardless of execution outcome. Must be `0` when `nonce_key == NONCE_KEY_MAX`. | -| `expiry` | `uint64` Unix timestamp (seconds since epoch); encoded as a canonical minimal-length RLP integer. Transaction invalid when `block.timestamp > expiry`. A value of `0` means no expiry. Must be non-zero when `nonce_key == NONCE_KEY_MAX`. | +| `valid_after` | `uint64` Unix timestamp in **milliseconds**; encoded as a canonical minimal-length RLP integer. Transaction invalid when `block.timestamp * 1000 < valid_after`. A value of `0` means no lower bound. Note: many mempools will not hold a not-yet-active transaction and MAY reject one whose `valid_after` is in the future, so it is best used for near-term activation rather than long-dated scheduling. | +| `valid_before` | `uint64` Unix timestamp in **milliseconds**; encoded as a canonical minimal-length RLP integer. Transaction invalid when `block.timestamp * 1000 > valid_before`. A value of `0` means no expiry. Must be non-zero when `nonce_key == NONCE_KEY_MAX`. | | `max_priority_fee_per_gas` | Priority fee per gas unit ([EIP-1559](./eip-1559.md)) | | `max_fee_per_gas` | Maximum fee per gas unit ([EIP-1559](./eip-1559.md)) | | `gas_limit` | Maximum gas budget for sender-intrinsic gas (intrinsic gas excluding payer authentication) and call execution (see [Intrinsic Gas](#intrinsic-gas)). Payer authentication is metered separately and does not draw from `gas_limit` | @@ -397,10 +334,14 @@ call = rlp([to, data]) // to: address, data: bytes | `sender_auth` | See [Signature Format](#signature-format) | | `payer_auth` | Payer authorization. **Empty**: self-pay. **Non-empty**: `authenticator \|\| data`, same format as `sender_auth`. See [Payer Modes](#payer-modes) | +**Millisecond window.** The transaction window (`valid_after`/`valid_before`) is denominated in milliseconds, while on-chain actor and lock expiries are seconds; this split is deliberate and there is no second-denominated variant of these fields. Because `block.timestamp` has second granularity, the **effective** activation/expiry resolution equals the chain's block-timestamp resolution (1 second on most chains today); the millisecond field exists for off-chain clock compatibility (e.g. `Date.now()`, sequencer clocks) and forward-compatibility with sub-second block times, not for finer resolution than the chain itself provides. Can easily be scaled on ingress. + #### Intrinsic Gas **Intrinsic gas** follows the standard Ethereum meaning: the total cost to include the transaction. As in standard transactions it accounts for signature authentication, here both the sender's authentication (`sender_auth_cost`) and the payer's (`payer_auth_cost`) are included. +8130 admits two implementation strategies, and a chain MUST fix which one it uses under consensus because the gas schedule has to agree across all nodes: (1) a **pure-EVM** implementation, where the protocol drives account creation, changes, and validation by calling the Keystore contract in the EVM and meters that as ordinary execution; or (2) a **native** implementation, where the canonical authenticator set (and the Keystore reads it depends on) is native code on the 8130 transaction path, priced with fixed component values rather than metered EVM execution. Both produce identical results (see [Portability](#portability)); they differ only in how authentication work is priced (see [Authenticators](#authenticators)). + The specific per-component gas *values* in this section are a **recommended schedule** that reflects the EVM access and data-availability costs ([EIP-2929](./eip-2929.md) / [EIP-2028](./eip-2028.md)) at the time of writing. They are a reference, not protocol constants: just as a chain may choose how it prices authenticator execution (see [Authenticators](#authenticators)), a chain MAY adopt a different intrinsic-gas schedule, for example to track a future EVM repricing or a local cost model. The formula's structure (its components and what each one accounts for) is the normative part; the absolute numbers below are the recommended values for a chain that mirrors current EVM costs. ``` @@ -434,7 +375,7 @@ execution_gas_available = gas_limit - sender_intrinsic_gas | `tx_payload_cost` | Standard per-byte cost over the entire RLP-serialized transaction: 16 gas per non-zero byte, 4 gas per zero byte, consistent with [EIP-2028](./eip-2028.md). Ensures all transaction fields (`account_changes`, `sender_auth`, `calls`, `metadata`, etc.) are charged for data availability | | `nonce_key_cost` | `NONCE_KEY_MAX`: 13,000 gas (ring-buffer replay state: 2 cold SLOADs + 1 warm SLOAD + 3 warm SSTORE resets; the ring pointer's SLOAD/SSTORE are amortized across the block). Otherwise: 22,100 gas for first use of a `nonce_key` (cold SLOAD + SSTORE set), 5,000 gas for existing keys (cold SLOAD + warm SSTORE reset) | | `bytecode_cost` | 0 if no create entry in `account_changes`. Otherwise: 32,000 (deployment base) + code deposit cost (200 gas per deployed byte). Byte costs for `code` are covered by `tx_payload_cost`; the create entry's initial-actor slot writes are covered by `account_changes_cost` | -| `account_changes_cost` | Per applied create entry: one `actor_config` slot write per initial actor (22,100 gas each: cold SLOAD + SSTORE set). When an initial actor sets `POLICY`, its `policy_manager` and `policy_commitment` slots are also written (22,100 gas each), so a POLICY initial actor is 3 slot-sets (~66,300 gas) versus 1 (~22,100) for a non-policy actor; the extra `policyData` bytes are charged through `tx_payload_cost`. Expiry is not expressible at create. Per applied config change entry: auth authentication cost (same model as `sender_auth_cost`) + storage write costs for each mutated actor slot (`actor_config`; plus `policy_commitment` and `policy_manager` when `(scope & POLICY) != 0`). A config change that authorizes or revokes the **self-actor** mutates the packed account-state slot rather than an `actor_config` slot (the inline default-EOA `scope`/`expiry` and the `DEFAULT_EOA_REVOKED` bit) and, for the mutual-exclusion check between the inline secp256k1 self and a non-k1 self, additionally accesses the reserved `actor_config(self)` slot: +2,100 (cold SLOAD) for a `K1_AUTHENTICATOR` self change, or the normal `actor_config` write plus the account-state slot write for a non-secp256k1 self change. Per applied delegation entry: delegation indicator deposit (4,600 gas, 200 Γ— 23 bytes). Per skipped config change entry (already applied): 2,100 (SLOAD to check sequence). 0 if no create, config change, or delegation entries in `account_changes` | +| `account_changes_cost` | Per applied **create** entry: one `actor_config` slot write per initial actor (22,100 gas each: cold SLOAD + SSTORE set); a `POLICY` actor also writes its `policy_manager`/`policy_commitment` slots (~3 slot-sets, ~66,300 gas). Per applied **config change** entry: auth authentication cost (same model as `sender_auth_cost`) plus the storage writes for each mutated slot (`actor_config`, plus policy slots when `POLICY` is set); self-actor changes mutate the packed account-state slot instead. Per applied **delegation** entry: 4,600 gas (indicator deposit, 200 Γ— 23 bytes). Per **skipped** config-change entry: 2,100 gas (sequence SLOAD). `0` when there is no create, config-change, or delegation entry. Expiry is not expressible at create; `policyData` bytes are charged through `tx_payload_cost` | | `auto_delegation_cost` | Delegation indicator deposit: 4,600 gas (200 Γ— 23 bytes for the `0xef0100 \|\| address` indicator) when a code-less `sender` is auto-delegated to `DEFAULT_ACCOUNT_ADDRESS` (Block Execution step 4). 0 otherwise. | #### Signature Format @@ -453,9 +394,9 @@ The first 20 bytes identify the authenticator address. When the authenticator is ##### Validation -1. **Resolve sender**: If `sender` empty, ecrecover derives the sender address (EOA path) with `actorId = bytes32(bytes20(sender))`. If `sender` set, read the first 20 bytes of `sender_auth` as the authenticator address. -2. **Authenticate**: Route by authenticator address. For the EOA path (`sender` empty), ecrecover was already performed in step 1. For `K1_AUTHENTICATOR` (`address(1)`), the protocol natively ecrecovers from `data` (as `r || s || v`), returning `actorId = bytes32(bytes20(recovered_address))`. For all other authenticators, call `authenticator.authenticate(hash, data)` via STATICCALL, returning `actorId` (or `bytes32(0)` for invalid). `address(0)` is never a valid authenticator selector (it is the empty `actor_config` sentinel). -3. **Authorize**: **Self-actor (native secp256k1) rule**: if authentication in step 2 used the native secp256k1 path (the EOA path or `K1_AUTHENTICATOR`) and `actorId == bytes32(bytes20(sender))`, resolve the self-actor from the inline default-EOA config in the packed account-state slot: reject if `DEFAULT_EOA_REVOKED` is set; otherwise take `scope` and `expiry` from the inline fields (all-zero = unrestricted, non-expiring full owner, i.e. admin). Otherwise SLOAD `actor_config(sender, actorId)`, reject if its reserved bytes are nonzero (version gate, see [Storage Layout](#storage-layout)), and require that the stored authenticator address matches the effective authenticator (this covers every other actor, including a non-secp256k1 self). In either case, if the resolved `expiry` is non-zero, also require `block.timestamp <= expiry`; an expired actor is rejected. +1. **Resolve sender**: If `sender` empty, ecrecover derives the sender address (EOA path) with `actorId = bytes32(uint256(uint160(sender)))`. If `sender` set, read the first 20 bytes of `sender_auth` as the authenticator address. +2. **Authenticate**: Route by authenticator address. For the EOA path (`sender` empty), ecrecover was already performed in step 1. For `K1_AUTHENTICATOR` (`address(1)`), the protocol natively ecrecovers from `data` (as `r || s || v`), returning `actorId = bytes32(uint256(uint160(recovered_address)))`. For all other authenticators, call `authenticator.authenticate(hash, data)` via STATICCALL, returning `actorId` (or `bytes32(0)` for invalid). `address(0)` is never a valid authenticator selector (it is the empty `actor_config` sentinel). +3. **Authorize**: **Self-actor (native secp256k1) rule**: if authentication in step 2 used the native secp256k1 path (the EOA path or `K1_AUTHENTICATOR`) and `actorId == bytes32(uint256(uint160(sender)))`, resolve the self-actor from the inline default-EOA config in the packed account-state slot: reject if `DEFAULT_EOA_REVOKED` is set; otherwise take `scope` and `expiry` from the inline fields (all-zero = unrestricted, non-expiring full owner, i.e. admin). Otherwise SLOAD `actor_config(sender, actorId)`, reject if its reserved bytes are nonzero (version gate, see [Storage Layout](#storage-layout)), and require that the stored authenticator address matches the effective authenticator (this covers every other actor, including a non-secp256k1 self). In either case, if the resolved `expiry` is non-zero, also require `block.timestamp <= expiry`; an expired actor is rejected. 4. **Check scope**: Read the resolved `scope` byte (from the inline default-EOA config for the secp256k1 self-actor, or `actor_config` otherwise) and check it against the context being authorized per [Actor Scope](#actor-scope) (for `sender_auth`: `scope == 0x00 || (scope & (SENDER | POLICY)) != 0`, with `POLICY` gating the actor to its `manager`). Payer scope is checked when the payer is resolved (see [Validation Flow](#validation-flow) step 6). 5. **Check nonce scope** (sender-context only, when `nonce_key != NONCE_KEY_MAX`): require the resolved actor be admin or carry `NONCE`, per [Actor Nonce Scope](#actor-nonce-scope). @@ -467,7 +408,7 @@ Sender and payer use different type bytes for domain separation, preventing sign ``` keccak256(AA_TX_TYPE || rlp([ - chain_id, sender, nonce_key, nonce_sequence, expiry, + chain_id, sender, nonce_key, nonce_sequence, valid_after, valid_before, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, account_changes, calls, metadata, payer @@ -478,7 +419,7 @@ keccak256(AA_TX_TYPE || rlp([ ``` keccak256(AA_PAYER_TYPE || rlp([ - chain_id, sender, nonce_key, nonce_sequence, expiry, + chain_id, sender, nonce_key, nonce_sequence, valid_after, valid_before, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, account_changes, calls, metadata, payer @@ -506,7 +447,6 @@ Gas payment and sponsorship are controlled by two independent fields: | `sender` address | `authenticator (20) \|\| data` | `sender` | Self-pay via a dedicated gas key: payer account == sender. Reads the `payer_auth`-resolved actor on the account, which MUST have SELF_PAYER scope (lets a SELF_PAYER-only key fund another sender key's transactions on the same account) | | other address | `authenticator (20) \|\| data` | `payer` field | Sponsored (payer != sender): any authenticator. Reads payer's `actor_config`, validates against `payer` address, and requires SPONSOR_PAYER scope | - ### Transaction Metadata The `metadata` field is optional opaque bytes for attaching attribution or annotation data to a transaction, for example builder/app attribution, a payment reference or memo, or a commitment to off-chain data. Legacy transactions carry such data as a "data suffix" appended to `tx.input`; because [calls](#call-phases) replace the single input blob, `metadata` provides the equivalent home. @@ -520,10 +460,10 @@ The `account_changes` field is an array of typed entries for account creation an | Type | Name | Description | |------|------|-------------| | `0x00` | Create | Deploy a new account with initial actors (must be first, at most one) | -| `0x01` | Config change | Actor management: authorizeActor, revokeActor | +| `0x01` | Config change | Signed account-change batch: authorize/revoke actors, increment local epoch, lock/unlock | | `0x02` | Delegation | Set code delegation via the delegation indicator (at most one per transaction) | -Create and delegation entries are authorized by the transaction's `sender_auth` and there is no separate authorization field. The initial `actorId`s for create entries are salt-committed to the derived address. Delegation requires the sender to be the account's implicit EOA actor and admin (`scope == 0x00`). Config change entries carry their own `auth` and use a sequence counter for deterministic cross-chain ordering. Nodes SHOULD enforce a configurable per-transaction limit on the number of config change entries (mempool rule). +Create and delegation entries are authorized by the transaction's `sender_auth` and there is no separate authorization field. The initial `actorId`s for create entries are salt-committed to the derived address. Delegation requires the sender to be the account's implicit EOA actor and admin (`scope == 0x00`). Config change entries carry their own admin `auth` and a `channel`/`sequence` for deterministic ordering and cross-chain replay (see [Config Change Authorization](#config-change-authorization)). Nodes SHOULD enforce a configurable per-transaction limit on the number of config change entries (mempool rule). #### Create Entry @@ -538,27 +478,27 @@ rlp([ ]) ``` -Each initial actor carries an `actorId`, `authenticator`, `scope` (`uint8`; `0x00` = unrestricted admin), and `policyData` (empty, or `manager β€– commitment` when `POLICY` is set), all committed to the derived address (see [Address Derivation](#address-derivation)) so the counterfactual address binds each initial actor's authority. Two things are **not** expressible here: `expiry`, and a self-referential `manager = account` (the account address is not yet known at commitment time). Both are added post-creation with a config change entry, which MAY accompany the create entry in the same `account_changes` array for atomic setup and, running after creation, resolves `manager = account` to a concrete address. For example, a subaccount is created with an admin actor (a delegate to the primary account, `scope = 0x00`) and a restricted app key (`SENDER | SELF_PAYER`, or `POLICY` with an external `manager` inline, or `manager = account` via an accompanying config change). +Each initial actor carries an `actorId`, `authenticator`, `scope` (`uint16`; `0x0000` = unrestricted admin), and `policyData` (empty, or `manager β€– commitment` when `POLICY` is set), all committed to the derived address (see [Address Derivation](#address-derivation)) so the counterfactual address binds each initial actor's authority. Two things are **not** expressible here: `expiry`, and a self-referential `manager = account` (the account address is not yet known at commitment time). Both are added post-creation with a config change entry, which MAY accompany the create entry in the same `account_changes` array for atomic setup and, running after creation, resolves `manager = account` to a concrete address. For example, a subaccount is created with an admin actor (a delegate to the primary account, `scope = 0x00`) and a restricted app key (`SENDER | SELF_PAYER`, or `POLICY` with an external `manager` inline, or `manager = account` via an accompanying config change). ##### Address Derivation -Addresses are derived using the CREATE2 address formula with the Account Configuration Contract (`ACCOUNT_CONFIG_ADDRESS`) as the deployer. The `initial_actors` MUST be provided already sorted by `actorId` in strictly ascending order. Requiring a single canonical ordering keeps address derivation deterministic (a given set of actors always produces the same address), and the strict ordering also rejects duplicate `actorId`s: +Addresses are derived using the CREATE2 address formula with the Keystore contract (`KEYSTORE_ADDRESS`) as the deployer. The `initial_actors` MUST be provided already sorted by `actorId` in strictly ascending order. Requiring a single canonical ordering keeps address derivation deterministic (a given set of actors always produces the same address), and the strict ordering also rejects duplicate `actorId`s: ``` // initial_actors MUST already be sorted by actorId (strictly ascending); reject otherwise -actors_commitment = keccak256( - actorId_0 || authenticator_0 || scope_0 || policyData_0 || - ... - actorId_n || authenticator_n || scope_n || policyData_n -) +// Hash each actor to a fixed-width leaf, then hash the ordered list of leaves +leaf_i = keccak256(actorId_i || authenticator_i || scope_i || policyData_i) +actors_commitment = keccak256(leaf_0 || leaf_1 || ... || leaf_n) effective_salt = keccak256(user_salt || actors_commitment) deployment_code = DEPLOYMENT_HEADER(len(code)) || code -address = keccak256(0xff || ACCOUNT_CONFIG_ADDRESS || effective_salt || keccak256(deployment_code))[12:] +address = keccak256(0xff || KEYSTORE_ADDRESS || effective_salt || keccak256(deployment_code))[12:] ``` -The per-actor contribution is `actorId || authenticator || scope || policyData`: the 32-byte `actorId`, 20-byte `authenticator`, 1-byte `scope`, and the `policyData` bytes (empty when `POLICY` is unset, or exactly 52 bytes β€” `manager (20) β€– commitment (32)` β€” when `POLICY` is set). The per-actor length is fully determined by `scope`, so the concatenation remains unambiguous. Expiry does not participate. The required strictly-ascending `actorId` ordering makes the commitment canonical. +This is the same hash-the-leaves-then-hash-the-list scheme used for the signed batch and import digests. Each actor's leaf commits `actorId || authenticator || scope || policyData`: the 32-byte `actorId`, 20-byte `authenticator`, 2-byte `scope`, and the `policyData` bytes (empty when `POLICY` is unset, or exactly 52 bytes β€” `manager (20) β€– commitment (32)` β€” when `POLICY` is set). Fixed-width 32-byte leaves make the commitment unambiguous by construction and linear in the actor count. Expiry does not participate (always `0` at create). The required strictly-ascending `actorId` ordering makes the commitment canonical. + +Off-chain address prediction (`computeAddress`) MUST apply the **same** initial-actor validation as create β€” non-empty set, strictly ascending (non-zero, non-duplicate) `actorId`s, each `authenticator >= K1_AUTHENTICATOR`, and `policyData` length matching `scope` (52 bytes when `POLICY` is set, empty otherwise) β€” so a predicted address always corresponds to an actor set that create will accept. Otherwise an address could be derived and prefunded for a set a later create would reject, stranding the funds. `DEPLOYMENT_HEADER(n)` is a fixed 14-byte EVM loader that returns the trailing code (see [Appendix: Deployment Header](#appendix-deployment-header) for the full opcode sequence). On non-8130 chains, `createAccount()` constructs `deployment_code` and passes it as init_code to CREATE2. On 8130 chains, the protocol constructs the same `deployment_code` for address derivation but places `code` directly. Callers only provide `code`, the header is never user-facing. @@ -568,85 +508,80 @@ When a create entry is present in `account_changes`: 1. Parse `[0x00, user_salt, code, initial_actors]` where each entry is `[actorId, authenticator, scope, policyData]`. `scope` is stored verbatim (unknown scope bits allowed, per [Actor Scope](#actor-scope)). Apply `authorizeActor`'s frozen `policyData` rule: reject unless `policyData` is exactly `manager (20) β€– commitment (32)` when `scope & POLICY != 0`, or empty otherwise. `expiry` is not accepted in the create entry 2. Require `initial_actors` are sorted by `actorId` in strictly ascending order; reject any unsorted set (strict ascending order also rejects duplicate `actorId` values). -3. Reject if `code` is empty or `len(code) > MAX_CODE_SIZE` ([EIP-170](./eip-170.md): 24576 bytes), to keep the placed code within the EVM contract size limit that CREATE/CREATE2 would otherwise enforce -4. Use `initial_actors` in the provided (sorted) order -5. Compute `actors_commitment` per [Address Derivation](#address-derivation) -6. Compute `effective_salt = keccak256(user_salt || actors_commitment)` -7. Compute `deployment_code = DEPLOYMENT_HEADER(len(code)) || code` -8. Compute `expected = keccak256(0xff || ACCOUNT_CONFIG_ADDRESS || effective_salt || keccak256(deployment_code))[12:]` -9. Require `sender == expected` -10. Require the destination matches CREATE2 freshness: `code_size(sender) == 0` and `nonce(sender) == 0` (matching the conditions under which CREATE2 would be permitted to deploy) -11. Validate `sender_auth` against one of `initial_actors` (actorId resolved from auth must match an entry's actorId and the auth authenticator must match that entry's authenticator) +3. Use `initial_actors` in the provided (sorted) order +4. Compute `actors_commitment` per [Address Derivation](#address-derivation) +5. Compute `effective_salt = keccak256(user_salt || actors_commitment)` +6. Compute `deployment_code = DEPLOYMENT_HEADER(len(code)) || code` +7. Compute `expected = keccak256(0xff || KEYSTORE_ADDRESS || effective_salt || keccak256(deployment_code))[12:]` +8. Require `sender == expected` +9. Require the destination matches CREATE2 freshness: `code_size(sender) == 0` and `nonce(sender) == 0` (matching the conditions under which CREATE2 would be permitted to deploy) +10. Validate `sender_auth` against one of `initial_actors` (actorId resolved from auth must match an entry's actorId and the auth authenticator must match that entry's authenticator) #### Config Change Entry -Config change entries manage the account's actors. Each entry includes a `chain_id` field where `0` means valid on any chain, allowing replay across chains to synchronize actor state. +A config change entry carries a signed **account-change batch** β€” the same `SignedAccountChanges` structure applied by the Keystore's single [`applySignedAccountChanges`](#account-config-change-paths) entry point. The batch binds a replay `channel` and a `sequence`, and applies its ordered list of changes atomically (any rejected change reverts the whole batch). ##### Config Change Format ``` rlp([ 0x01, // type: config change - chain_id, // integer (EIP-155); 0 = valid on any chain. Hashed as uint256 in the signed digest - sequence, // uint64: monotonic ordering - actor_changes, // Array of actor changes - auth // Signature from an actor valid at this sequence + channel, // uint8: 0 = Local (binds block.chainid), 1 = Multichain (binds chain_id 0) + sequence, // uint64: Local = local_epoch(high 32) || local_sequence(low 32) + // Multichain = a plain monotonic counter + changes, // Array of account changes + auth // Admin (scope == 0x00) signature over the batch digest ]) -actor_change = rlp([ - change_type, // uint8: operation type (see below) - actorId, // bytes32: actor identifier - data // bytes: operation-specific, ABI-encoded (see below) +change = rlp([ + change_type, // uint8: 0 AuthorizeActor, 1 RevokeActor, 2 IncrementLocalEpoch, 3 Lock, 4 Unlock + payload // bytes: operation-specific, ABI-encoded (see below) ]) ``` -The operation-specific `data` is an opaque `bytes` blob carried in the RLP envelope but encoded with the contract ABI, so the same blob is decoded identically whether the change is applied natively or via `applySignedActorChanges()` on the Account Configuration Contract. It is also the value hashed (as `keccak256(data)`) in the [Config Change Signature Payload](#config-change-signature-payload). +The operation-specific `payload` is an opaque `bytes` blob carried in the RLP envelope but encoded with the contract ABI, so the same blob is decoded identically whether the change is applied natively or via `applySignedAccountChanges` on the Keystore contract. It is also the value hashed (as `keccak256(payload)`) in the [Config Change Signature Payload](#config-change-signature-payload). -**Operation types**: +**Change types**: -| change_type | Name | `data` | Description | -|-------------|------|--------|-------------| -| `0x01` | `authorizeActor` | `abi.encode(ActorConfig config, bytes policyData)` (see [`ActorConfig`](#iaccountconfiguration)) | Authorize a new actor. Writes `actor_config` with `authenticator`, `scope`, and `expiry` (`0` = no expiry) verbatim (unknown scope bits stored as-is). If `POLICY` is set, requires `policyData = manager β€– commitment` (exactly 52 bytes; neither field need be nonzero) and writes those slots; otherwise requires empty `policyData` and clears policy slots. Writes the packed `actor_config` word with its reserved bytes zeroed (they are not a caller input; see [Storage Layout](#storage-layout)). Does **not** enforce scope-combination exclusivity (that is use-time / protocol). Emits `ActorAuthorized` whose `actorData` is 32 bytes (`authenticator β€– scope β€– expiry β€– reserved`) when `POLICY` is unset, or 84 bytes (appending `manager β€– commitment`) when `POLICY` is set. | -| `0x02` | `revokeActor` | empty (`0x`) | Revoke an existing actor. Deletes `actor_config` (and `policy_commitment`/`policy_manager`). For the implicit EOA actor (`actorId == bytes32(bytes20(account))`), instead sets the account's `DEFAULT_EOA_REVOKED` flag bit (no `actor_config` write) to prevent implicit re-authorization. Emits `ActorRevoked`. | +| change_type | Name | `payload` | Description | +|-------------|------|-----------|-------------| +| `0` | `AuthorizeActor` | `abi.encode(bytes32 actorId, ActorConfig config, bytes policyData)` (see [`ActorConfig`](#ikeystore)) | Upsert an actor. Writes `actor_config` with `authenticator`, `expiry` (`0` = no expiry; on the unsequenced/JIT path an already-lapsed non-zero expiry causes the grant to be **silently skipped** β€” not applied, no revert β€” otherwise the grant installs **inert** β€” see [Config Change Authorization](#config-change-authorization)), and `scope` verbatim (unknown scope bits stored as-is), reserved bytes zeroed. If `POLICY` is set, requires `policyData = manager β€– commitment` (exactly 52 bytes) and writes those slots; otherwise requires empty `policyData` and clears policy slots. Does **not** enforce scope-combination exclusivity (that is use-time / protocol). Emits `ActorAuthorized` whose `actorData` is 32 bytes (`authenticator β€– expiry β€– scope β€– reserved`) when `POLICY` is unset, or 84 bytes (appending `manager β€– commitment`) when `POLICY` is set. | +| `1` | `RevokeActor` | `abi.encode(bytes32 actorId)` | Revoke an actor. For a non-self actor, deletes `actor_config` (and `policy_commitment`/`policy_manager`). For the self-actorId, both homes are handled together: a k1 self (held inline in the packed account-state slot) is revoked by setting `DEFAULT_EOA_REVOKED` with no `actor_config` write, while a non-k1 self (held in `actor_config`, per [Storage Layout](#storage-layout)) has that slot deleted like any other actor; either way the inline default-EOA path is disabled. Emits `ActorRevoked`. | +| `2` | `IncrementLocalEpoch` | empty | **Either channel.** Increments `local_epoch` and resets `local_sequence` to `0`, invalidating every unlanded local signature (sequenced and unsequenced) at the prior epoch. Always targets the local epoch, even when carried in a Multichain batch. Consumes no sequence itself. See [Epoch System](#epoch-system). | +| `3` | `Lock` | `abi.encode(uint16 unlockDelay)` | **Local only, standalone.** Locks the account. See [Account Lock](#account-lock). | +| `4` | `Unlock` | empty | **Local only, standalone.** Initiates unlock. See [Account Lock](#account-lock). | #### Config Change Authorization -Each config change entry represents a set of operations authorized at a specific sequence number. The `auth` must be valid against the account's actor configuration *at the point after all previous entries in the list have been applied*. The authorizing actor must be **admin**: `scope == 0x00` (see [Actor Scope](#actor-scope)). - -The sequence number is scoped by `chain_id`: `0` uses the multichain sequence channel (valid on any chain), while a specific `chain_id` uses that chain's local channel. +Each batch is authorized by a single **admin** signature (`scope == 0x00`; flat authorization, see [Actor Scope](#actor-scope)) over the batch digest. The `auth` follows the same [Signature Format](#signature-format) as `sender_auth` (`authenticator || data`) and, when a batch is applied inside a list, must be valid against the account's actor state *after all previous entries have been applied*. Anyone may relay; authorization comes from the signature. -The change-sequence channels double as the initialized flag. Creation and import set the local channel to `1`, and any applied config change bumps whichever channel it used (local for a chain-specific `chain_id`, multichain for `chain_id 0`). Otherwise only the implicit EOA is on the account. +The `channel` selects the replay domain and how `sequence` is interpreted: -##### Config Change Signature Payload +- **Multichain** (`chain_id 0`): a plain monotonic `uint64` counter, valid on any chain, for synchronizing actor state across chains. Each applied batch consumes the counter (`sequence` MUST equal the current value, then it increments). There is no epoch and no unsequenced mode on this channel, though a Multichain batch MAY still carry an `IncrementLocalEpoch` change that bumps the account's local epoch. +- **Local** (`block.chainid`): `sequence` is the packed word `local_epoch(high 32) || local_sequence(low 32)`. The batch is rejected with `StaleEpoch` unless its high half equals the current `local_epoch`. The low half selects one of two modes: + - **Sequenced** (low half `< UNSEQUENCED`): the low half MUST equal the current `local_sequence`; on success `local_sequence` increments. This is the ordered, replay-once mode. + - **Unsequenced / JIT** (low half `== UNSEQUENCED`, i.e. `uint32` max): consumes no counter and does not increment `local_sequence`. It is permissive β€” any change may ride an unsequenced batch β€” and remains replayable until the epoch moves. Because it consumes no counter, ordering between two unsequenced batches is undefined. Retiring an unsequenced (JIT) authorization durably is done by bumping the epoch (`IncrementLocalEpoch`), typically batched with the reducing change. See [Epoch System](#epoch-system). -Entry signatures use ABI-encoded type hashing. Operations within an entry are individually ABI-encoded and hashed into an array digest: - -``` -TYPEHASH = keccak256("SignedActorChanges(address account,uint256 chainId,uint64 sequence,ActorChange[] actorChanges)ActorChange(uint8 changeType,bytes32 actorId,bytes data)") -ACTORCHANGE_TYPEHASH = keccak256("ActorChange(uint8 changeType,bytes32 actorId,bytes data)") +**Expiry on `AuthorizeActor` is channel/mode-aware and never reverts.** On the **unsequenced/JIT path**, an already-lapsed grant (a non-zero `expiry` that has passed per [Actor Expiry](#actor-expiry)) is **silently skipped**: the change is not applied and does not revert, and any live sibling changes in the same batch still apply. A JIT grant is replayable, so skipping a lapsed one keeps it from ever clobbering its slot β€” a renewed grant cannot be overwritten by replaying the old, expired one. A **single-consume** batch (any Multichain batch, or a **Sequenced** local batch) cannot be replayed, so an already-lapsed grant is instead installed **inert** β€” the `actor_config` slot is written (the actor is present and revocable) but the actor is not live, since authentication yields expired per [Actor Expiry](#actor-expiry) β€” and still consumes its sequence. Multichain relies on this: a chain catching up must replay a historical expiring grant's slot (e.g. a periodically-renewed operator) in order to reach the current live grant, so dropping the stale slot would strand its counter. A zero `expiry` is the "no expiry" sentinel and is always accepted. -actorChangeHashes = [keccak256(abi.encode(ACTORCHANGE_TYPEHASH, changeType, actorId, keccak256(data))) for each actorChange] -actorChangesHash = keccak256(abi.encodePacked(actorChangeHashes)) +The combined local word (`local_epoch || local_sequence`) doubles as the initialized flag: creation and import set `local_sequence = 1`, so an all-zero word means uninitialized (only the implicit EOA is on the account). -digest = keccak256(abi.encode(TYPEHASH, account, chainId, sequence, actorChangesHash)) -``` - -Domain separation from transaction signatures (`AA_TX_TYPE`, `AA_PAYER_TYPE`) is structural; transaction hashes use `keccak256(type_byte || rlp([...]))`, which cannot produce the same prefix as `abi.encode(TYPEHASH, ...)`. +##### Config Change Signature Payload -The `auth` follows the same [Signature Format](#signature-format) as `sender_auth` (`authenticator || data`), validated against the account's actor state at that point in the sequence. +The batch digest is a typed (ABI-encoded, EIP-712-style) struct hash binding `account`, the resolved `chainId` (`0` for Multichain, else `block.chainid`), the `sequence` word, and the ordered `changes` (each change hashed over its `change_type` and `keccak256(payload)`). Domain separation from transaction signatures (`AA_TX_TYPE`, `AA_PAYER_TYPE`) is structural: transaction hashes use `keccak256(type_byte || rlp([...]))`, which cannot collide with the ABI-encoded struct hash. The exact typehash strings and digest construction are defined in the canonical repository (`src/Keystore.sol`). ##### Account Config Change Paths -The same signed actor change can be applied through two paths: +The same signed batch (`SignedAccountChanges`) can be applied through two paths: -- **`account_changes` (tx field)**: processed by the protocol before code deployment on 8130 chains. -- **`applySignedActorChanges()` (EVM)**: applied during EVM execution on any chain, including non-8130 chains and ERC-4337 deployments. +- **`account_changes` (tx field)**: consumed by the protocol before code deployment on 8130 chains. +- **`applySignedAccountChanges` (EVM)**: applied during EVM execution on any chain, including non-8130 chains and ERC-4337 deployments. -Both paths carry the same signed actor changes, share the same `change_sequence` counters, and are equally portable (`chain_id 0` for cross-chain or a specific `chain_id` for chain-local). They differ only in transport: the protocol consumes the signed change directly on 8130 chains, while everywhere else the EVM function does. `applySignedActorChanges()` parses the authenticator address from `auth`, calls the authenticator to get the `actorId`, and checks `actor_config`. `authorizeActor` writes `actor_config` and, when `POLICY` is set, the `policy_commitment` and `policy_manager` slots; `revokeActor` clears them all. Anyone can call these functions; authorization comes from the signed operation, not the caller. `authorizeActor`/`revokeActor` and delegation are blocked when the account is locked; lock/unlock use the dedicated `applySignedLockChanges` entry point (see [Account Lock](#account-lock)). +Both paths carry the same batch, share the same channels and counters, and are equally portable (Multichain for cross-chain, Local for chain-local). They differ only in transport. `applySignedAccountChanges` parses the authenticator from `auth`, resolves the admin `actorId`, verifies the batch digest, and applies each change in order per the [change-type table](#config-change-format). Anyone may call it; authorization comes from the signed batch, not the caller. Authority changes and delegation are blocked while the account is locked; only `Unlock` and `IncrementLocalEpoch` may be applied to a locked account (see [Account Lock](#account-lock)). #### Delegation Entry -Delegation entries set [EIP-7702](./eip-7702.md)-style code delegation for the sender's account, replacing the need for an `authorization_list` in the transaction. Delegation is authorized by the transaction's `sender_auth`, no separate signature is required. The sender must have been **authenticated via the native secp256k1 path** (the EOA path or `K1_AUTHENTICATOR`; mirroring the [Implicit EOA Rule Scoping](#security-considerations)), with `actorId == bytes32(bytes20(sender))` and admin (`scope == 0x00`). A non-secp256k1 self authenticator that returns the self-actorId does not qualify, keeping delegation authority ECDSA/7702-portable. +Delegation entries set [EIP-7702](./eip-7702.md)-style code delegation for the sender's account, replacing the need for an `authorization_list` in the transaction. Delegation is authorized by the transaction's `sender_auth`, no separate signature is required. The sender must have been **authenticated via the native secp256k1 path** (the EOA path or `K1_AUTHENTICATOR`; mirroring the [Implicit EOA Rule Scoping](#security-considerations)), with `actorId == bytes32(uint256(uint160(sender)))` and admin (`scope == 0x00`). A non-secp256k1 self authenticator that returns the self-actorId does not qualify, keeping delegation authority ECDSA/7702-portable. ##### Delegation Format @@ -672,7 +607,7 @@ For 8130 transactions, successful delegation updates emit a protocol-injected `D `account_changes` entries are processed in order before call execution: -1. **Create entry** (if present): Register `initial_actors` in Account Config storage for `sender`, for each `[actorId, authenticator, scope, policyData]` tuple writing `actor_config` with the given `authenticator` and `scope` verbatim and `expiry = 0` (expiry is not expressible at create). When `scope & POLICY != 0`, also write `policy_manager`/`policy_commitment` from `policyData` (`manager β€– commitment`); when unset there are no policy slots. A tuple naming the self-actorId (`actorId == bytes32(bytes20(sender))`) with `K1_AUTHENTICATOR` writes its `scope` into the inline default-EOA fields instead (`expiry = 0`). Mark the account initialized by setting its local change-sequence channel to `1` (see [Config Change Authorization](#config-change-authorization)). Initialize lock state to safe defaults: `LOCKED`/`UNLOCK_INITIATED` flags clear, `lock_union = 0`. Set the `DEFAULT_EOA_REVOKED` flag so a freshly created account does not leave a native secp256k1 owner live unless one is among `initial_actors`. Place `code` at `sender`. +1. **Create entry** (if present): apply the create entry (see [Create Entry](#create-entry)) β€” place `code` at `sender`, register `initial_actors` in Keystore storage, mark the account initialized (local channel = `1`), initialize lock state to safe defaults, and set `DEFAULT_EOA_REVOKED` unless a self-actor is among `initial_actors`. 2. **Config change entries** (if any): Apply operations in entry order. Reject transaction if account is locked. 3. **Delegation entries** (if any): Require admin EOA-actor delegation authority (see [Delegation Entry](#delegation-entry)). Reject if account is locked. For each entry, set `code(sender) = 0xef0100 || target` (or clear if `target` is `address(0)`). Reject if account has non-delegation bytecode. @@ -695,7 +630,7 @@ Calls carry no ETH value. ETH transfers are initiated by the account's wallet by ##### Call Phases -`calls` is a two-level structure: an ordered array of **phases**, where each phase is an ordered array of individual calls (`[[call, ...], [call, ...]]`). This gives two levels of atomicity where calls grouped within a phase are all-or-nothing, while phases commit independently in sequence (see [Why Call Phases?](#why-call-phases)). +`calls` is a two-level structure: an ordered array of **phases**, where each phase is an ordered array of individual calls (`[[call, ...], [call, ...]]`) β€” **sequentially-committed atomic call groups**. This gives two levels of atomicity where calls grouped within a phase are all-or-nothing, while phases commit independently in sequence. Phases execute in order from a single gas pool (`gas_limit`). Within each phase, calls execute in order and are **atomic** so if any call in a phase reverts, all state changes for that phase are discarded and remaining phases are **skipped**. Completed phases **persist** and their state changes are committed and survive later phase reverts. @@ -723,7 +658,7 @@ The Transaction Context precompile at `TX_CONTEXT_ADDRESS` provides read-only ac | `getTransactionPayer()` | `address`, gas payer (`sender` for self-pay, payer for sponsored) | Execution only | | `getTransactionSenderActorId()` | `bytes32`, authenticated actor's actorId | Execution only | -If the wallet needs the authenticator address or scope, it calls `getActorConfig(account, actorId)` on the Account Configuration Contract. A policy target reached as a `call.to` identifies which key it is acting for by combining `getTransactionSender()` and the authenticated `getTransactionSenderActorId()` from this precompile, then reads the actor's gate target and signed `commitment` via `getPolicy(account, actorId)` in one call and validates the presented policy parameters against the commitment. The commitment lives in Account Configuration storage (where it is written and revoked), not the precompile, keeping the precompile to immutable transaction context. +If the wallet needs the authenticator address or scope, it calls `getActorConfig(account, actorId)` on the Keystore contract. A policy target reached as a `call.to` identifies which key it is acting for by combining `getTransactionSender()` and the authenticated `getTransactionSenderActorId()` from this precompile, then reads the actor's gate target and signed `commitment` via `getActor(account, actorId)` in one call and validates the presented policy parameters against the commitment. The commitment lives in Keystore storage (where it is written and revoked), not the precompile, keeping the precompile to immutable transaction context. **Non-8130 chains**: No code at `TX_CONTEXT_ADDRESS`; STATICCALL returns zero/default values. @@ -733,8 +668,8 @@ The system is split into storage and authentication layers with different portab | Component | 8130 chains | Non-8130 chains | |-----------|-------------|-----------------| -| **Account Configuration Contract** | Protocol reads storage directly for validation; EVM interface available | Standard contract (ERC-4337 compatible factory) | -| **Authenticator Contracts** | Protocol calls authenticators via STATICCALL | Same onchain contracts callable by account config contract and wallets | +| **Keystore contract** | Protocol reads storage directly for validation; EVM interface available | Standard contract (ERC-4337 compatible factory) | +| **Authenticator Contracts** | Protocol calls authenticators via STATICCALL | Same onchain contracts callable by the Keystore contract and wallets | | **Code Delegation** | Delegation entry in `account_changes` (EOA-only authorization in this version) | Standard [EIP-7702](./eip-7702.md) transactions (ECDSA authority) | | **Transaction Context** | Precompile at `TX_CONTEXT_ADDRESS`; protocol populates, authenticators read | No code at address; STATICCALL returns zero/default values | | **Nonce Manager** | Precompile at `NONCE_MANAGER_ADDRESS` | Not applicable; nonce management by existing systems (e.g., ERC-4337 EntryPoint) | @@ -749,16 +684,16 @@ All contracts are deployed at deterministic CREATE2 addresses across chains. 2. Resolve sender: if `sender` set, use it; if empty, ecrecover from `sender_auth` 3. Determine effective actor state: a. If create entry present in `account_changes`: verify address derivation, `code_size(sender) == 0`, use `initial_actors` - b. Else: read from Account Config storage + b. Else: read from Keystore storage 4. If config change or delegation entries present in `account_changes`: reject if account is locked (see [Account Lock](#account-lock)). For config change entries: simulate applying operations in sequence, skip already-applied entries. For delegation entries: verify `code_size(sender) == 0` or existing delegation designator. 5. Validate `sender_auth` against resulting actor state (see [Validation](#validation)) and check scope per [Actor Scope](#actor-scope): SENDER (or `POLICY`) for the sender context, and admin or `NONCE` when `nonce_key != NONCE_KEY_MAX`. Payer scope (SELF_PAYER / SPONSOR_PAYER) is checked in step 6, not here β€” the sender actor is not required to carry a payer bit. If delegation entries are present, the resolved actor must additionally hold admin EOA-actor delegation authority (see [Delegation Entry](#delegation-entry)). 6. Resolve payer from `payer` and `payer_auth`: - `payer` empty and `payer_auth` empty: self-pay. Payer is `sender`; the resolved sender actor's SELF_PAYER scope authorizes payment. Reject if balance insufficient. - `payer` = `sender` (explicit) with `payer_auth`: self-pay via a dedicated gas key. Payer account == sender; validate `payer_auth` against the account's `actor_config` and require SELF_PAYER scope on the resolved actor. Reject if balance insufficient. - `payer` = a different 20-byte address (sponsored): `payer_auth` uses any authenticator. Validate `payer_auth` against the `payer` address's `actor_config`. Require SPONSOR_PAYER scope on the resolved actor. -7. Verify nonce, payer ETH balance, and expiry. Two independent `expiry` fields are checked and both MUST be satisfied: the transaction `expiry` (inclusion validity, `block.timestamp <= expiry`) and the resolved actor's `expiry` (key liveness, per [Actor Expiry](#actor-expiry) and step 3). Either one lapsed rejects the transaction. Regardless of nonce mode, nodes MAY also reject a transaction whose `expiry` is too near to be reliably included. +7. Verify nonce, payer ETH balance, and validity bounds. The transaction's validity window and the resolved actor's liveness are all checked and MUST hold: the transaction is valid only when `valid_after == 0 || valid_after <= block.timestamp * 1000` and `valid_before == 0 || block.timestamp * 1000 <= valid_before`, and the resolved actor's `expiry` (**seconds**) is unlapsed (`expiry == 0 || block.timestamp <= expiry`, per [Actor Expiry](#actor-expiry) and step 3). Any bound violated rejects the transaction. The transaction window is in milliseconds while on-chain actor/lock expiries are in seconds β€” a deliberate split that requires no sub-second timekeeping (see [Field Definitions](#field-definitions)). Regardless of nonce mode, nodes MAY also reject a transaction whose `valid_before` is too near to be reliably included, and MAY reject (rather than hold) one whose `valid_after` is not yet active. - **Standard keys** (`nonce_key != NONCE_KEY_MAX`): require `nonce_sequence == current_sequence(sender, nonce_key)`. - - **Nonce-free key** (`nonce_key == NONCE_KEY_MAX`): skip nonce check, require `nonce_sequence == 0`, require non-zero `expiry`, and reject if `expiry` is farther out than `NONCE_FREE_EXPIRY_WINDOW` (the chain parameter bounded by `REPLAY_BUFFER_CAPACITY`, see [Nonce-Free Mode](#nonce-free-mode-nonce_key_max)). Deduplicate by the [Replay Identifier](#replay-identifier) (`replay_id`), not the full transaction hash. + - **Nonce-free key** (`nonce_key == NONCE_KEY_MAX`): skip nonce check, require `nonce_sequence == 0`, require non-zero `valid_before`, and reject if `valid_before` is farther out than `NONCE_FREE_EXPIRY_WINDOW` (the chain parameter bounded by `REPLAY_BUFFER_CAPACITY`, see [Nonce-Free Mode](#nonce-free-mode-nonce_key_max)). Deduplicate by the [Replay Identifier](#replay-identifier) (`replay_id`), not the full transaction hash. 8. Mempool threshold: gas payer's pending count below node-configured limits. 9. Apply [Mempool Replacement](#mempool-replacement) rules: for standard and 2D transactions (`nonce_key != NONCE_KEY_MAX`), when a pending transaction from the same `sender` shares this transaction's `(nonce_key, nonce_sequence)`; for nonce-free transactions (`nonce_key == NONCE_KEY_MAX`), when a pending transaction from the same `sender` shares this transaction's `replay_id`. @@ -779,7 +714,7 @@ Nodes MAY apply higher pending transaction rate limits based on account lock sta 6. Set transaction context on the Transaction Context precompile (sender, payer, actorId). 7. Execute `calls` per [Call Execution](#call-execution) semantics. -Unused gas from `gas_limit` is refunded to the payer. For step 5, the protocol SHOULD inject log entries into the transaction receipt (e.g., `ActorAuthorized`, `ActorRevoked`, `AccountCreated`, `DelegationApplied`) matching the events defined in the [IAccountConfiguration](#iaccountconfiguration) interface, following the protocol-injected log pattern established by [EIP-7708](./eip-7708.md). These protocol-injected logs are emitted only for 8130 transactions. +Unused gas from `gas_limit` is refunded to the payer. For step 5, the protocol SHOULD inject log entries into the transaction receipt (e.g., `ActorAuthorized`, `ActorRevoked`, `AccountCreated`, `DelegationApplied`) matching the events defined in the [IKeystore](#ikeystore) interface, following the protocol-injected log pattern established by [EIP-7708](./eip-7708.md). These protocol-injected logs are emitted only for 8130 transactions. ### RPC Extensions @@ -791,7 +726,7 @@ Unused gas from `gas_limit` is refunded to the payer. For step 5, the protocol S - `status` (uint8): `0x01` = all phases succeeded (or `calls` was empty), `0x00` = one or more phases reverted. Existing tools checking `status == 1` remain correct for the success path. **`status == 0` does not imply "no state change."** Committed earlier phases (the sponsor pattern relies on this), auto-delegation, applied `account_changes`, nonce consumption, and gas payment all persist through a later phase revert. Consumers MUST NOT treat a failed AA receipt as a no-op. `gasUsed` includes `payer_auth_cost` (see [Intrinsic Gas](#intrinsic-gas)). - `phaseStatuses` (uint8[]): Per-phase status array. Each entry is `0x01` (success) or `0x00` (reverted). Phases after a revert are not executed and reported as `0x00`. Empty if `calls` was empty. -**`eth_estimateGas`** / **`eth_call`**: Accept the AA transaction fields (`sender`, `nonceKey`, `accountChanges`, `calls`, `expiry`, `metadata`, `payer`, `senderAuth`, `payerAuth`) alongside the standard request object and price the request as an `AA_TX_TYPE` transaction. Because authentication gas is determined by the auth blob's *shape* rather than a valid signature (see [Intrinsic Gas](#intrinsic-gas)), the request is priced from an **unsigned** representative blob; no signature is produced or verified. The sender is taken from `sender` or the standard `from` (equivalently) and if both are present they MUST be equal. +**`eth_estimateGas`** / **`eth_call`**: Accept the AA transaction fields (`sender`, `nonceKey`, `accountChanges`, `calls`, `validAfter`, `validBefore`, `metadata`, `payer`, `senderAuth`, `payerAuth`) alongside the standard request object and price the request as an `AA_TX_TYPE` transaction. Because authentication gas is determined by the auth blob's *shape* rather than a valid signature (see [Intrinsic Gas](#intrinsic-gas)), the request is priced from an **unsigned** representative blob; no signature is produced or verified. The sender is taken from `sender` or the standard `from` (equivalently) and if both are present they MUST be equal. ### Constants @@ -801,21 +736,18 @@ Unused gas from `gas_limit` is refunded to the payer. For step 5, the protocol S | `AA_PAYER_TYPE` | `0x7A` | Magic byte for payer signature domain separation | | `REPLAY_ID_TYPE` | `0x7901` | Magic prefix for `replay_id` domain separation (see [Replay Identifier](#replay-identifier)) | | `AA_BASE_COST` | 15000 | Base intrinsic gas cost | -| `ACCOUNT_CONFIG_ADDRESS` | CREATE2-derived (resolved at deployment) | Account Configuration system contract address | +| `KEYSTORE_ADDRESS` | CREATE2-derived (resolved at deployment) | Keystore system contract address | | `K1_AUTHENTICATOR` | `address(1)` | Native secp256k1 (ECDSA) authenticator (implicit default EOA and explicitly registered k1 actors) | -| `DEFAULT_EOA_REVOKED` | `0x01` | Account-state `flags` bit that disables the implicit default-EOA path | -| `LOCKED` | `0x02` | Account-state `flags` bit that freezes actor configuration (see [Account Lock](#account-lock)) | -| `UNLOCK_INITIATED` | `0x04` | Account-state `flags` bit selecting the `lock_union` interpretation (`unlock_delay` vs `unlocks_at`) | +| `CONTRACT_ESTABLISHED` | `0x01` | Account-state `flags` bit marking a keystore-established account (see [Account Establishment](#account-establishment)) | +| `DEFAULT_EOA_REVOKED` | `0x02` | Account-state `flags` bit that disables the implicit default-EOA path | +| `LOCKED` | `0x04` | Account-state `flags` bit that freezes actor configuration (see [Account Lock](#account-lock)) | +| `UNLOCK_INITIATED` | `0x08` | Account-state `flags` bit selecting the `lock_union` interpretation (`unlock_delay` vs `unlocks_at`) | | `NONCE_MANAGER_ADDRESS` | `0x813000000000000000000000000000000000aa01` | Nonce Manager precompile address | | `TX_CONTEXT_ADDRESS` | `0x813000000000000000000000000000000000aa02` | Transaction Context precompile address | | `DEFAULT_ACCOUNT_ADDRESS` | CREATE2-derived (resolved at deployment) | Default wallet implementation for auto-delegation | -| `NONCE_KEY_MAX` | `2^256 - 1` | Nonce-free mode (expiry-only replay protection) | +| `NONCE_KEY_MAX` | `2^256 - 1` | Nonce-free mode (validity-window replay protection) | | `REPLAY_BUFFER_CAPACITY` | chain parameter | Fixed capacity of the nonce-free `replay_id` ring buffer; identical for every node on the chain (consensus). See [Nonce-Free Mode](#nonce-free-mode-nonce_key_max) | -### Appendix: Storage Layout - -The protocol reads storage directly from the Account Configuration Contract (`ACCOUNT_CONFIG_ADDRESS`). The storage layout is defined by the deployed contract bytecode; slot derivation follows from the contract's Solidity storage declarations. The final deployed contract source serves as the canonical reference for slot locations. - ### Appendix: Deployment Header The `DEPLOYMENT_HEADER(n)` is a 14-byte EVM loader that copies trailing code into memory and returns it. The header encodes code length `n` into its `PUSH2` instructions: @@ -848,38 +780,25 @@ The canonical set establishes a shared baseline where wallets that use canonical ### Why Actor Policies? -Session keys often need narrow authority: only this token, only this much per day, only this action. `POLICY` makes gated initiation a first-class grant, distinct from ungated `SENDER`: the key may originate transactions, but only to a single call target, bound to a signed opaque **commitment**. The protocol's only policy responsibility is the single-target gate plus storing the commitment. Allowing `POLICY | SELF_PAYER` lets a session key self-pay; note this exposes the account's full ETH balance to gas spend (see [Why Split PAYER into SELF_PAYER and SPONSOR_PAYER?](#why-split-payer-into-self_payer-and-sponsor_payer)). +Session keys often need narrow authority: only this token, only this much per day, only this action. `POLICY` makes gated initiation a first-class grant, distinct from ungated `SENDER`: the key may originate transactions, but only to a single call target, bound to a signed opaque **commitment**. The protocol's only policy responsibility is the single-target gate plus storing the commitment. Allowing `POLICY | SELF_PAYER` lets a session key self-pay; note this exposes the account's full ETH balance to gas spend (see [Payer Modes](#payer-modes)). -### Why Admin Is Scope Zero +### Why a Local Epoch? -Admin is the predicate `scope == 0x00` rather than a dedicated grant bit because a "config-only" grant would be indistinguishable from full access. Any key that can rewrite `actor_config` can grant itself `scope == 0x00`, so the authority to change configuration is already administratively equivalent to unrestricted authority. Naming admin as the absence of any restriction makes this self-escalation explicit instead of hiding it behind a bit that could be misread as narrowly scoped. It also gives every account a natural root: the all-zero inline default-EOA config is a non-expiring admin, and restricted keys are strictly grants below that root. +Session keys and just-in-time (JIT) authorizations want two things that pull against a shared counter: they should be usable in any order (no coordination), and the account should be able to cancel any it has handed out that have not yet landed. A single monotonic `local_sequence` gives ordering but forces every signature to burn the next slot, so parallel keys contend and a stale signature can still land later. Splitting the local word into `local_epoch || local_sequence` separates the two concerns: sequenced changes still consume `local_sequence` for ordered, replay-once semantics, while an epoch bump is a single blunt "cancel everything outstanding on the local channel" that resets the counter and invalidates every not-yet-landed local signature at the prior epoch (`StaleEpoch`). Crucially it cancels *signatures*, not *authority* β€” actors from landed batches survive β€” so bumping the epoch is cheap to reason about and cannot brick a live actor set. That is what makes the unsequenced/JIT mode (`UNSEQUENCED`) safe: a JIT batch is intentionally replayable within its epoch, and durable retirement is just an epoch bump batched with the reducing change (see [Epoch System](#epoch-system)). ### Why No Public Key Storage? Authenticators receive the public key (or full credential) in transaction calldata and recover the `actorId` from it, rather than the protocol storing keys onchain. This keeps per-actor state to a single `actor_config` SLOAD regardless of key size, which matters most for large post-quantum credentials: storing them would add permanent state growth of tens of slots per actor. Calldata is also cheaper than cold storage for material read once per transaction β€” on the order of 2,048 vs 6,300 gas for a P256 key and 21,000 vs 88,000 gas for a PQ key β€” so the calldata-plus-recovery model is both smaller and cheaper than an onchain key registry. -### Design Layering - -Restrictions the Account Configuration contract must enforce are complete at deployment: the signer is admin (`scope == 0x00`), reserved-bytes-zero, and well-formed `policyData` when `POLICY` is set. Grants can grow because reads fail closed on unknown bits β€” `NONCE` itself is such a grant, added to the spare bit space without changing the frozen surface above. Signing semantics (e.g. approved typed data for session keys) evolve at the account layer. - ### Why Not Reject Scope Combinations at Write Time? -The Account Configuration contract is a single immutable CREATE2 deployment, so it cannot know which grants or combinations future forks will define; rejecting combinations at write time would freeze today's rules into unupgradeable code. `authorizeActor` therefore stores scope verbatim and validates only timeless structure (admin signer, reserved-bytes-zero, well-formed `policyData`). Combination semantics are checked at the point of use by whichever context reads the scope β€” protocol validation for the transaction paths, and the contract's `verifySignature()` (operational) for the ERC-1271 path β€” so an unsatisfiable combo is simply inert. - +The Keystore contract is a single immutable CREATE2 deployment, so it cannot know which grants or combinations future forks will define; rejecting combinations at write time would freeze today's rules into unupgradeable code. `AuthorizeActor` therefore stores scope verbatim and validates only timeless structure (admin signer, reserved-bytes-zero, well-formed `policyData`). Combination semantics are checked at the point of use by whichever context reads the scope β€” protocol validation for the transaction paths, and account-level ERC-1271 signing (operational) for the signing path (see [Signature Verification](#signature-verification)) β€” so an unsatisfiable combo is simply inert. ### Why 2D Nonce + `NONCE_KEY_MAX`? Additional `nonce_key` values allow parallel transaction lanes without nonce contention between independent workflows. -`NONCE_KEY_MAX` enables nonce-free transactions where replay protection comes from short-lived `expiry` and node-level deduplication by the fee- and signature-invariant [Replay Identifier](#replay-identifier). This is useful for operations where nonce ordering coordination is undesirable. Reserving `NONCE_KEY_MAX` as the sentinel permanently removes one channel from the `2^256`-wide nonce space, which is immaterial in practice; `NONCE_KEY_MAX` is always nonce-free and is never a sequenced channel, even for an actor holding `NONCE` scope. - -### Why the NONCE Grant? - -A restricted (non-admin) actor without `NONCE` is confined to `NONCE_KEY_MAX` (nonce-free), whose replay protection is a short `expiry` window (`NONCE_FREE_EXPIRY_WINDOW`): the transaction is invalid if not included in that window. An actor that instead needs an ordered, non-expiring sequence opts in with `NONCE` and may then use any `nonce_key` in the full 2D space. Making sequenced channels a grant, rather than the default, keeps the common session-key case coordination-free and reserves ordered channels for actors that explicitly want them. - -### Why Split PAYER into SELF_PAYER and SPONSOR_PAYER? - -Both authorize spending the account's ETH on gas, and neither bounds how much β€” a compromised key of either kind can burn the balance. The split separates what the spend can *buy*. `SELF_PAYER` converts ETH only into the account's own transactions (gated, if the actor carries `POLICY`); compromise is vandalism, monetizable only with a colluding builder taking priority fees. `SPONSOR_PAYER` converts ETH into inclusion for arbitrary third-party senders β€” an economically transferable authority that can be operated as a paymaster service and monetized out-of-band, no builder collusion required. Session keys get `SELF_PAYER` by default; a sponsorship service's hot key is `SPONSOR_PAYER`-only β€” the minimal grant for a paymaster operating from a locked treasury, with no `SENDER` or config authority. Paired with the locked-payer trusted-bytecode tier (see [Mempool Acceptance](#mempool-acceptance)), a node can reason "this key's only possible effect is a balance decrease via gas" from the scope byte alone. Because self-pay is defined relationally (`payer == sender`), the delegate-authenticator path threads through `SPONSOR_PAYER`: account B sponsoring via A's delegate actor is `payer != sender` from A's view and so requires `SPONSOR_PAYER` on that actor β€” default-deny for cross-account payment. +`NONCE_KEY_MAX` enables nonce-free transactions where replay protection comes from a short-lived validity window (`valid_before`) and node-level deduplication by the fee- and signature-invariant [Replay Identifier](#replay-identifier). This is useful for operations where nonce ordering coordination is undesirable. Reserving `NONCE_KEY_MAX` as the sentinel permanently removes one channel from the `2^256`-wide nonce space, which is immaterial in practice; `NONCE_KEY_MAX` is always nonce-free and is never a sequenced channel, even for an actor holding `NONCE` scope. ### Why Account Lock? @@ -887,7 +806,7 @@ Locked accounts have a frozen actor set, so the primary state that can invalidat ### Why CREATE2 for Account Creation? -The create entry uses the CREATE2 address formula with `ACCOUNT_CONFIG_ADDRESS` as the deployer address for cross-chain portability: +The create entry uses the CREATE2 address formula with `KEYSTORE_ADDRESS` as the deployer address for cross-chain portability: 1. **Deterministic addresses**: Same `user_salt + code + initial_actors` produces the same address on any chain 2. **Pre-deployment funding**: Users can receive funds at counterfactual addresses before account creation @@ -896,20 +815,13 @@ The create entry uses the CREATE2 address formula with `ACCOUNT_CONFIG_ADDRESS` ### Why Delegation via Account Changes? -[EIP-7702](./eip-7702.md) introduced `authorization_list` as a transaction-level field for code delegation, with ECDSA authority. This proposal moves delegation into `account_changes`, authorized by the transaction's `sender_auth`. Delegation is restricted to senders authenticated via the native secp256k1 path (EOA path or `K1_AUTHENTICATOR`) with `actorId == bytes32(bytes20(sender))`, so that code delegation remains portable across non-8130 chains via standard [EIP-7702](./eip-7702.md) transactions (a non-secp256k1 self authenticator does not qualify, even if it returns the self-actorId). Eventually this can be expanded to all authenticator types, not just K1 EOAs. +[EIP-7702](./eip-7702.md) introduced `authorization_list` as a transaction-level field for code delegation, with ECDSA authority. This proposal moves delegation into `account_changes`, authorized by the transaction's `sender_auth`. Delegation is restricted to senders authenticated via the native secp256k1 path (EOA path or `K1_AUTHENTICATOR`) with `actorId == bytes32(uint256(uint160(sender)))`, so that code delegation remains portable across non-8130 chains via standard [EIP-7702](./eip-7702.md) transactions (a non-secp256k1 self authenticator does not qualify, even if it returns the self-actorId). Eventually this can be expanded to all authenticator types, not just K1 EOAs. ### External Account Factories -Account creation, import, signed actor changes, and locking are ordinary EVM entry points on the Account Configuration Contract, so external factories can compose them to mint accounts into a desired end state. This is always possible in EVM but is **not** available on the 8130 transaction path. - -*Non-normative future work:* once [EIP-7819](./eip-7819.md) is adopted, the protocol could be extended with a single canonical external factory. The create entry would emit an [EIP-7702](./eip-7702.md) delegation prefix and deploy the account from that factory, which uses Account Configuration Contract state to check the signature and upgrade, extending the delegation account change to all authenticator types rather than the native secp256k1 EOA only as today. - -### Why Call Phases? +Account creation, import, signed actor changes, and locking are ordinary EVM entry points on the Keystore contract, so external factories can compose them to mint accounts into a desired end state. This is always possible in EVM but is **not** available on the 8130 transaction path. -Phases provide two atomic batching levels without per-call mode flags: - -- **Atomic batching**: One phase, all-or-nothing. -- **Sponsor protection**: Payment in phase 0 persists even if user actions in phase 1 revert. +*Non-normative future work:* once [EIP-7819](./eip-7819.md) is adopted, the protocol could be extended with a single canonical external factory. The create entry would emit an [EIP-7702](./eip-7702.md) delegation prefix and deploy the account from that factory, which uses Keystore contract state to check the signature and upgrade, extending the delegation account change to all authenticator types rather than the native secp256k1 EOA only as today. ### Why a Metadata Field? @@ -921,11 +833,7 @@ The protocol dispatches each call directly to the specified `to` address with `m ### Why a Transaction Context Precompile? -Transaction context (sender, payer, calls, gas) is immutable transaction metadata; it never changes during execution. `actorId` is set after validation and available during execution only. A precompile is the natural fit: - -- **Zero protocol write cost**: The precompile reads directly from the client's in-memory transaction struct: no HashMap insert, no journaling, no rollback tracking. -- **Pull model**: Consumers read only what they need: policies read `actorId` to enforce per-actor limits, and accounts may inspect `payer` for fee reimbursement or access logic. -- **Forward compatible**: New context fields are added as new precompile functions, with no interface changes to `IAuthenticator` or existing authenticator contracts. +Transaction context (sender, payer, calls, gas) is immutable transaction metadata; it never changes during execution, and `actorId` is set after validation and available during execution only. A precompile is the natural fit: it reads directly from the client's in-memory transaction struct (no journaling or rollback tracking), consumers pull only what they need (policies read `actorId` for per-actor limits, accounts inspect `payer`), and new context fields are added as new functions without changing `IAuthenticator` or existing authenticators. ## Backwards Compatibility @@ -935,103 +843,94 @@ No breaking changes. Existing EOAs and smart contracts function unchanged. Adopt - ERC-4337 infrastructure continues operating - Accounts gain AA capabilities by configuring actors. EOAs sending their first AA transaction are auto-delegated to `DEFAULT_ACCOUNT_ADDRESS` if they have no code. EOAs MAY override with a delegation entry in `account_changes` (EOA-only authorization), a standard [EIP-7702](./eip-7702.md) transaction, or use a create entry in `account_changes` for custom wallet implementations -The `actor_config` layout (`authenticator β€– scope β€– expiry β€– reserved`, with the scope-bit assignment and the reserved bytes acting as a version gate), the address-derivation commitment, and the ABI surface (typehashes, events, `importAccount`/`applySignedActorChanges` signatures) are defined by this specification. Because 8130 has no prior deployment, there is no live actor state written under an earlier format to migrate; the formats here are authoritative from first deployment. +The `actor_config` layout (`authenticator β€– expiry β€– scope β€– reserved`, with the scope-bit assignment and the reserved bytes acting as a version gate) and the address-derivation commitment are defined by this specification; the full contract ABI surface (typehashes, events, and function signatures such as `importAccount`/`applySignedAccountChanges`) is defined in the canonical repository (`src/Keystore.sol`). Because 8130 has no prior deployment, there is no live actor state written under an earlier format to migrate; the formats here are authoritative from first deployment. ## Reference Implementation -### IAccountConfiguration +### IKeystore -The Account Configuration contract is the canonical ABI surface (no separate interface file is kept in sync). The -reference below mirrors the public structs, events, and functions of that contract. +The Keystore contract is the canonical ABI surface. Its full source β€” exact storage packing, typehashes, event ABIs, and function bodies β€” lives in the canonical contracts repository (the `base` organization's EIP-8130 repository on GitHub, `src/Keystore.sol`) and is authoritative. The sketch below mirrors only the protocol-relevant shape; consult the repository for the complete interface. ```solidity -interface IAccountConfiguration { +interface IKeystore { + // Packed account state (see Account Lock). The signed local word is localEpoch(high 32) || localSequence(low 32). struct ChangeSequences { - uint64 multichain; // chain_id 0 - uint64 local; // chain_id == block.chainid; starts at 1 once initialized (created/imported), 0 = uninitialized + uint64 multichain; // chain_id 0 channel; a plain monotonic counter + uint32 localEpoch; // local channel epoch; IncrementLocalEpoch bumps it and resets localSequence to 0 + uint32 localSequence; // local channel counter; low half of the signed local word } struct ActorConfig { address authenticator; - uint8 scope; // grants bitmask; 0x00 = unrestricted (admin); 0x01 = SENDER; 0x02 = POLICY; 0x04 = NONCE; 0x08 = SELF_PAYER; 0x10 = SPONSOR_PAYER; ERC-1271 signing requires operational authority (admin or SENDER without POLICY), not a grant - uint48 expiry; // Unix seconds; 0 = no expiry. Actor invalid once block.timestamp > expiry + uint48 expiry; // Unix seconds; 0 = no expiry. Actor invalid once block.timestamp > expiry + uint16 scope; // grants bitmask; 0x0000 = unrestricted (admin). See Actor Scope } - // Actor used for account creation and import. Carries scope and policyData - // (empty unless POLICY set, then manager[20] || commitment[32], per authorizeActor's rule). - // expiry is NOT expressible here and is added post-deployment via config changes - // (initial actors are always non-expiring). + // Actor used for account creation and import. expiry is NOT expressible here (initial actors are always + // non-expiring; add expiry post-deployment via a config change). policyData: empty unless POLICY, then manager[20] || commitment[32]. struct InitialActor { bytes32 actorId; address authenticator; - uint8 scope; // 0x00 = unrestricted (admin) - bytes policyData; // empty unless POLICY set; then manager[20] || commitment[32] + uint16 scope; + bytes policyData; } - struct Actor { - bytes32 actorId; - ActorConfig config; - bytes policyData; // empty unless POLICY set; then manager[20] || commitment[32] + enum AccountChangeChannel { Local, Multichain } + enum ChangeType { AuthorizeActor, RevokeActor, IncrementLocalEpoch, Lock, Unlock } + // Leading byte of a signature envelope: Local (0x01) binds block.chainid, Multichain (0x02) binds chainId 0. + enum SignatureType { Invalid, Local, Multichain } + + struct AccountChange { + ChangeType changeType; + bytes payload; // AuthorizeActor: abi.encode(bytes32 actorId, ActorConfig, bytes policyData); + // RevokeActor: abi.encode(bytes32 actorId); Lock: abi.encode(uint16 unlockDelay); others empty } - struct ActorChange { - uint8 changeType; // 0x01 = authorizeActor, 0x02 = revokeActor - bytes32 actorId; - bytes data; // operation-specific: abi.encode(ActorConfig, bytes policyData) for authorize; empty for revoke + struct SignedAccountChanges { + AccountChangeChannel channel; + uint64 sequence; // Local: localEpoch || localSequence; Multichain: monotonic counter + AccountChange[] changes; + bytes signature; // admin (scope == 0x00): authenticator || data } - // Tightly packed authorization surface: - // authenticator(20) || scope(1) || expiry(6) || reserved(5) β€” 32 bytes β€” - // and, only when scope & POLICY != 0, manager(20) || commitment(32) (84 bytes total). + uint32 constant UNSEQUENCED = type(uint32).max; // JIT sentinel for the local low half + event ActorAuthorized(address indexed account, bytes32 indexed actorId, bytes actorData); event ActorRevoked(address indexed account, bytes32 indexed actorId); event AccountCreated(address indexed account, bytes32 userSalt, bytes32 codeHash); event AccountImported(address indexed account); + event LocalEpochIncremented(address indexed account, uint32 localEpoch); // Protocol-injected receipt log for successful EIP-8130 delegation updates (not emitted in EVM). event DelegationApplied(address indexed account, address target); event AccountLocked(address indexed account, uint16 unlockDelay); - event AccountUnlockInitiated(address indexed account, uint40 unlocksAt); + event AccountUnlockInitiated(address indexed account, uint48 unlocksAt); - // Account creation (factory) + // Account creation (factory) and counterfactual address preview. function createAccount(bytes32 userSalt, bytes calldata bytecode, InitialActor[] calldata initialActors) external returns (address); function computeAddress(bytes32 userSalt, bytes calldata bytecode, InitialActor[] calldata initialActors) external view returns (address); - // Import existing account (ERC-1271). chainId: 0 = multichain; else MUST equal block.chainid. + // Import an existing account (ERC-1271). chainId: 0 = multichain; else MUST equal block.chainid. function importAccount(address account, uint256 chainId, InitialActor[] calldata initialActors, bytes calldata signature) external; - // Portable actor changes. chainId: 0 = multichain sequence; else local (MUST equal block.chainid). - function applySignedActorChanges(address account, uint256 chainId, ActorChange[] calldata actorChanges, bytes calldata auth) external; - - // Account lock. Signed, relayable, admin-authorized. op: 1 = lock, 2 = unlock. - // Local channel only: the contract binds the digest to block.chainid and the current local_sequence, which the op then increments. - function applySignedLockChanges( - address account, - uint8 op, - uint16 unlockDelay, - bytes calldata auth - ) external; - - // Signature verification and actor authentication - // verifySignature: ERC-1271-style boolean check; returns false on any failure. - // authenticateActor: returns the actor's authorization surface verbatim; reverts on failure. - function verifySignature(address account, bytes32 hash, bytes calldata signature) external view returns (bool verified); - function authenticateActor(address account, bytes32 hash, bytes calldata auth) - external view returns (uint8 scope, address policyTarget); - - // Storage views - function isActor(address account, bytes32 actorId) external view returns (bool); - // Returns stored bytes unmodified (verbatim reporting). + // Single signed entry point for all account changes (authorize/revoke actor, increment epoch, lock/unlock). + function applySignedAccountChanges(address account, SignedAccountChanges calldata changes) external; + + // Typed-envelope message signing (see Signature Verification). Returns the verified signer and its authority + // β€” the "who signed" + granular authority that plain ERC-1271 lacks. auth = sigType(1) || authenticator(20) || data. + // 8130 accounts implement ERC-1271 isValidSignature on top of this, gating on operational scope. + function validateSignature(address account, bytes32 hash, bytes calldata auth) external view returns (bytes32 actorId, uint16 scope); + // Envelope digest a signer signs: keccak256(SIGNED_MESSAGE_TYPEHASH, account, chainId, hash). chainId 0 = all chains. + function replaySafeHash(address account, uint256 chainId, bytes32 hash) external pure returns (bytes32); + function envelopeDigest(SignatureType sigType, address account, bytes32 hash) external view returns (bytes32); + // Lower-level: authenticate an actor over a raw digest (no envelope). Used by off-8130 (e.g. ERC-4337) consumers. + function authenticateActor(address account, bytes32 hash, bytes calldata auth) external view returns (bytes32 actorId, uint16 scope); + + // Storage views (see the canonical repository for the complete set). function getActorConfig(address account, bytes32 actorId) external view returns (ActorConfig memory); - // Aggregate: (manager, commitment). Gating is determined by the POLICY scope bit; slots are zero when POLICY is unset. - function getPolicy(address account, bytes32 actorId) - external view returns (address target, bytes32 commitment); - // Single-SLOAD accessors for the execution hot path. - function getPolicyCommitment(address account, bytes32 actorId) external view returns (bytes32); - function getPolicyManager(address account, bytes32 actorId) external view returns (address); + function getActor(address account, bytes32 actorId) external view returns (ActorConfig memory config, address policyManager, bytes32 policyCommitment); function getChangeSequences(address account) external view returns (ChangeSequences memory); - function isLocked(address account) external view returns (bool); - // Decodes the lock_union / mode bit and folds in effective-unlock, so callers never see the packed layout. - function getLockStatus(address account) external view returns (bool locked, bool hasInitiatedUnlock, uint40 unlocksAt, uint16 unlockDelay); + function getLockStatus(address account) external view returns (bool locked, bool hasInitiatedUnlock, uint48 unlocksAt, uint16 unlockDelay); + function isContractEstablished(address account) external view returns (bool); // keystore-established, not a proven address key } ``` @@ -1072,15 +971,15 @@ Read-only. The protocol manages nonce storage directly; there are no state-modif **Validation Surface**: For canonical authenticators, invalidators are `actor_config` changes and nonce consumption. -**Replay Protection**: Transactions include `chain_id`, 2D nonce (`nonce_key`, `nonce_sequence`), and `expiry`. For standard and 2D transactions (`nonce_key != NONCE_KEY_MAX`), replay protection and deduplication are provided by the nonce sequence: inclusion increments `(sender, nonce_key)`, so a given `(sender, nonce_key, nonce_sequence)` can be included at most once. `replay_id` does not apply to these transactions. For `NONCE_KEY_MAX` (nonce-free mode), there is no nonce slot, so replay protection relies on short-lived `expiry` and deduplication by the fee- and signature-invariant [Replay Identifier](#replay-identifier) (`replay_id`); the chain enforces a tight expiry window (`NONCE_FREE_EXPIRY_WINDOW`) to bound the window, and block builders MUST NOT include two transactions with the same `(sender, replay_id)` (see [Mempool Replacement](#mempool-replacement)). +**Replay Protection**: Transactions include `chain_id`, 2D nonce (`nonce_key`, `nonce_sequence`), and a validity window (`valid_after`, `valid_before`). For standard and 2D transactions (`nonce_key != NONCE_KEY_MAX`), replay protection and deduplication are provided by the nonce sequence: inclusion increments `(sender, nonce_key)`, so a given `(sender, nonce_key, nonce_sequence)` can be included at most once. `replay_id` does not apply to these transactions. For `NONCE_KEY_MAX` (nonce-free mode), there is no nonce slot, so replay protection relies on the short-lived `valid_before` bound and deduplication by the fee- and signature-invariant [Replay Identifier](#replay-identifier) (`replay_id`); the chain enforces a tight window (`NONCE_FREE_EXPIRY_WINDOW`) to bound it, and block builders MUST NOT include two transactions with the same `(sender, replay_id)` (see [Mempool Replacement](#mempool-replacement)). The full transaction hash MUST NOT be used for nonce-free deduplication or mempool replacement. The transaction hash commits to fee fields (`max_fee_per_gas`, `max_priority_fee_per_gas`, `gas_limit`) and to `sender_auth` and `payer_auth` (the authorization blobs), all of which `replay_id` excludes. Keying on the transaction hash would allow trivial duplication of a single logical transaction, or would treat an intentional fee bump as an unrelated transaction: -- **Fee bumps**: a legitimate replacement raising `max_priority_fee_per_gas` (or `max_fee_per_gas`) changes the transaction hash while leaving the effects, `calls`, `metadata`, `expiry`, and `payer` identical. `replay_id` is unaffected, so the bump is recognized as a replacement of the same logical transaction rather than a distinct one; the bumped transaction still requires a fresh `payer_auth` when sponsored, since `payer_auth` commits to the fee fields. +- **Fee bumps**: a legitimate replacement raising `max_priority_fee_per_gas` (or `max_fee_per_gas`) changes the transaction hash while leaving the effects, `calls`, `metadata`, validity window (`valid_after`, `valid_before`), and `payer` identical. `replay_id` is unaffected, so the bump is recognized as a replacement of the same logical transaction rather than a distinct one; the bumped transaction still requires a fresh `payer_auth` when sponsored, since `payer_auth` commits to the fee fields. - **Re-signed `payer_auth`**: the sender signs over the `payer` *address* but not the payer's signature bytes, so a sponsor can produce a different `payer_auth` for the same sender body, yielding a different transaction hash for the same logical transaction. - **`sender_auth` non-determinism**: ECDSA signing is randomized (a fresh nonce `k` produces a different valid `(r, s)` for the same message and key) and signatures are additionally malleable, so the same key can produce multiple distinct-but-valid `sender_auth` values for one transaction body. Each recovers the same sender yet yields a different transaction hash. -All three cases resolve to the same `replay_id` (fee bumps included, since `replay_id` excludes the fee fields), so deduplicating and replacing on it collapses them to a single mempool slot and, ultimately, a single includable transaction. `expiry` cannot be extended via a fee-bump replacement: `expiry` is part of `replay_id`, so changing it produces a different `replay_id` and a new logical transaction, not a replacement of the old one which must independently satisfy nonce/sequence and mempool acceptance rules. +All three cases resolve to the same `replay_id` (fee bumps included, since `replay_id` excludes the fee fields), so deduplicating and replacing on it collapses them to a single mempool slot and, ultimately, a single includable transaction. The validity window cannot be extended via a fee-bump replacement: `valid_after` and `valid_before` are part of `replay_id`, so changing either produces a different `replay_id` and a new logical transaction, not a replacement of the old one which must independently satisfy nonce/sequence and mempool acceptance rules. **Actor Scope and Policy**: Scope grants are protocol-enforced after authenticator execution during validation (fail closed). The policy gate is protocol-enforced during execution (`ActorPolicyViolation`). See [Actor Scope](#actor-scope) and [Actor Policies](#actor-policies). @@ -1092,7 +991,9 @@ All three cases resolve to the same `replay_id` (fee bumps included, since `repl **Actor Expiry**: Prefer non-expiring admins and apply `expiry` only to restricted keys; a sole expiring admin bricks the account and breaks cross-chain reconstruction (see [Actor Expiry](#actor-expiry)). -**Implicit EOA Rule Scoping**: The implicit EOA authorization rule only applies when authentication used the native secp256k1 path, either the EOA path (`sender` empty) or `K1_AUTHENTICATOR`, and the account's `DEFAULT_EOA_REVOKED` flag is unset. Generic authenticator contracts MUST NOT satisfy the implicit branch even if they return `bytes32(bytes20(sender))`, otherwise an arbitrary authenticator could authenticate as any EOA whose implicit actor slot has never been written. +**Local Epoch and Outstanding Signatures**: `IncrementLocalEpoch` cancels every unlanded **local** signature at the prior epoch (`StaleEpoch`), including unsequenced/JIT batches; it does **not** revoke live actors or affect the multichain channel (see [Epoch System](#epoch-system)). Because an unsequenced (JIT) batch is replayable within its epoch, wallets MUST treat epoch increment β€” not mere non-inclusion β€” as the durable retirement of a JIT authorization, batching the reducing `RevokeActor`/`AuthorizeActor` with `IncrementLocalEpoch`. Since the epoch does not walk authorizer lineage, an actor added by a landed batch survives later epoch bumps and must be removed with an explicit `RevokeActor`. + +**Implicit EOA Rule Scoping**: The implicit EOA authorization rule only applies when authentication used the native secp256k1 path, either the EOA path (`sender` empty) or `K1_AUTHENTICATOR`, and the account's `DEFAULT_EOA_REVOKED` flag is unset. Generic authenticator contracts MUST NOT satisfy the implicit branch even if they return `bytes32(uint256(uint160(sender)))`, otherwise an arbitrary authenticator could authenticate as any EOA whose implicit actor slot has never been written. **actorId Binding**: The protocol checks that the authenticator's returned `actorId` maps back to that authenticator in `actor_config`, preventing a malicious authenticator from claiming control of another authenticator's actors. @@ -1100,7 +1001,7 @@ All three cases resolve to the same `replay_id` (fee bumps included, since `repl **Cross-sender Payer Replay**: The payer signature hash binds to the resolved sender via the `sender` field (see [Signature Payload](#signature-payload)). In the EOA path where `sender` is empty in the wire format, the recovered sender address MUST be substituted into the `sender` position before computing the hash. Without this substitution, two different EOAs that construct otherwise identical transaction data (same `chain_id`, `nonce_key`, `nonce_sequence`, `expiry`, fees, `account_changes`, `calls`) would produce identical payer hashes, allowing a second EOA to reuse a payer signature originally issued for the first and drain the payer's gas deposit. The 2D nonce alone does not prevent this: `nonce_key` and `nonce_sequence` are fields in the transaction payload, so each attacker controls their own values. Substituting the recovered sender into the hash makes the payer's commitment per-sender and closes this replay path. The configured-actor path is unaffected because `sender` is non-empty by definition. -**Account Creation Security**: `initial_actors` (`actorId`, `authenticator`, `scope`, and `policyData`; expiry is not expressible at create, per [Address Derivation](#address-derivation)) are salt-committed, preventing front-running of actor assignment. Wallet bytecode should be inert when uninitialized as it can be permissionlessly deployed. The create entry applies only to addresses that satisfy CREATE2 freshness. Without the nonce check, a create entry could be replayed against an EOA that has transaction history at the counterfactual address. Direct code placement also bypasses CREATE/CREATE2's [EIP-170](./eip-170.md) `MAX_CODE_SIZE` check, so the protocol enforces `len(code) <= MAX_CODE_SIZE` explicitly to keep the placed code within the EVM contract size limit. +**Account Creation Security**: `initial_actors` (`actorId`, `authenticator`, `scope`, and `policyData`; expiry is not expressible at create, per [Address Derivation](#address-derivation)) are salt-committed, preventing front-running of actor assignment. Wallet bytecode should be inert when uninitialized as it can be permissionlessly deployed. The create entry applies only to addresses that satisfy CREATE2 freshness. Without the nonce check, a create entry could be replayed against an EOA that has transaction history at the counterfactual address. ## Copyright diff --git a/EIPS/eip-8131.md b/EIPS/eip-8131.md index 758f39761a3381..2196dd7f42309a 100644 --- a/EIPS/eip-8131.md +++ b/EIPS/eip-8131.md @@ -1,7 +1,7 @@ --- eip: 8131 title: Unified Transaction Content Floor -description: Flat 64 gas per content byte at the floor, covering calldata, access-list entries, authorizations, and blob versioned hashes. +description: Flat 64 gas per content byte at the floor, covering all user-controlled transaction fields and capping worst-case block size. author: Toni WahrstΓ€tter (@nerolation) discussions-to: https://ethereum-magicians.org/t/eip-9999-add-auth-data-to-eip-7623-floor/12345 status: Draft @@ -13,11 +13,11 @@ requires: 2028, 4844, 7623, 7702, 7976, 7981 ## Abstract -Charge every user-controlled byte in a transaction (calldata, access-list entries, [EIP-7702](./eip-7702.md) auths, [EIP-4844](./eip-4844.md) blob hashes) at a flat 64 gas at the floor. One rule replaces [EIP-7623](./eip-7623.md)/[EIP-7976](./eip-7976.md) and [EIP-7981](./eip-7981.md) and closes the auth and blob hash floor gaps. Worst-case user-controlled tx content per block is bounded by `block_gas_limit / 64 β‰ˆ 0.89 MB`. Fewer than 4% of mainnet transactions hit the new floor. +Charge every user-controlled byte in a transaction (calldata, access-list entries, [EIP-7702](./eip-7702.md) auths, [EIP-4844](./eip-4844.md) blob hashes) at a flat 64 gas at the floor. One rule replaces [EIP-7623](./eip-7623.md)/[EIP-7976](./eip-7976.md) and [EIP-7981](./eip-7981.md) and closes the auth and blob hash floor gaps. Worst-case user-controlled tx content per block is bounded by `block_gas_limit / 64 β‰ˆ 0.89 MB`, a hard cap that keeps worst-case blocks small enough to propagate safely as the gas limit rises. Fewer than 4% of mainnet transactions hit the new floor. ## Motivation -[EIP-7623](./eip-7623.md) introduced a floor cost on calldata bytes to cap worst-case block size. [EIP-7976](./eip-7976.md) raised that floor to a uniform 64 gas per calldata byte, zero and non-zero alike (by treating every byte as 4 floor tokens at 16 gas each). [EIP-7981](./eip-7981.md) extended the same 64 gas/B rate to access-list bytes via a flat data-cost surcharge of 1,280 gas per address and 2,048 gas per storage key. The principle is consistent across both: every user-controlled content byte priced at 64 gas at the floor. +Worst-case block size constrains scaling: the gas limit can only be raised as far as the largest block an attacker can build still propagates reliably. [EIP-7623](./eip-7623.md) introduced a floor cost on calldata bytes to cap worst-case block size. [EIP-7976](./eip-7976.md) raised that floor to a uniform 64 gas per calldata byte, zero and non-zero alike (by treating every byte as 4 floor tokens at 16 gas each). [EIP-7981](./eip-7981.md) extended the same 64 gas/B rate to access-list bytes via a flat data-cost surcharge of 1,280 gas per address and 2,048 gas per storage key. The principle is consistent across both: every user-controlled content byte priced at 64 gas at the floor. [EIP-7702](./eip-7702.md) authorization tuples (up to 108 B each) and [EIP-4844](./eip-4844.md) blob versioned hashes (32 B each) were added without matching floor terms and still pay nothing at the floor. diff --git a/EIPS/eip-8136.md b/EIPS/eip-8136.md index abe460c75c7aea..e973ab63e4a672 100644 --- a/EIPS/eip-8136.md +++ b/EIPS/eip-8136.md @@ -4,7 +4,7 @@ title: Cell-Level Deltas for Data Column Broadcast description: Optimization for disseminating only previously unseen cells to the network for PeerDAS. author: Marco Munizaga (@MarcoPolo) , Daniel Knopik (@dknopik) , Sukun Tarachandani (@sukunrt) discussions-to: https://ethereum-magicians.org/t/eip-8136-cell-level-deltas-for-data-column-broadcast/27675 -status: Draft +status: Review type: Standards Track category: Networking created: 2025-01-23 @@ -13,7 +13,7 @@ requires: 7594 ## Abstract -Cell-Level Deltas for Data Column Broadcast optimizes PeerDAS (EIP-7594) by +Cell-Level Deltas for Data Column Broadcast optimizes PeerDAS ([EIP-7594](./eip-7594.md)) by allowing more efficient transfers of blob data columns across the network. Instead of having to exchange full data columns, peers exchange only the cells they need within a column. This becomes especially useful when the majority of @@ -38,15 +38,10 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S Cell-Level Deltas uses Gossipsub's Partial Messages Extension to exchange cell bitmaps and request/provide cells. - - -*Editor's Note*: Update the libp2p spec link to a proper commit once the PR is merged. In an effort to maintain a single source of truth, the specification is defined -in the ethereum/consensus-specs repo. - +in the [ethereum/consensus-specs](https://github.com/ethereum/consensus-specs/tree/b3b06d939c3f7301d218bca7adc39eed5806d553/./) repo. -*Editor's Note*: Update the Consensus Spec link to a proper commit once the PR is merged. ## Rationale diff --git a/EIPS/eip-8141.md b/EIPS/eip-8141.md index 7be34f84844eb4..e6cbf5b7cd73c7 100644 --- a/EIPS/eip-8141.md +++ b/EIPS/eip-8141.md @@ -8,7 +8,7 @@ status: Draft type: Standards Track category: Core created: 2026-01-29 -requires: 1559, 2718, 3529, 3607, 4844, 7594, 7623, 7702 +requires: 1559, 2718, 2780, 3529, 3607, 4844, 7594, 7623, 7702, 7708, 7825, 8037 --- ## Abstract @@ -34,7 +34,7 @@ Ultimately, frame transactions realize the original vision of account abstractio | Name | Value | |----------------------------|-------------------| | `FRAME_TX_TYPE` | `0x06` | -| `FRAME_TX_INTRINSIC_COST` | `15000` | +| `FRAME_TX_INTRINSIC_COST` | `12000` | | `FRAME_TX_PER_FRAME_COST` | `475` | | `ENTRY_POINT` | `address(0xaa)` | | `EXPIRY_VERIFIER` | `address(0x8141)` | @@ -42,7 +42,20 @@ Ultimately, frame transactions realize the original vision of account abstractio | `MAX_FRAMES` | `64` | | `SECP256K1N` | `0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141` | | `SECP256R1N` | `0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551` | -| `VERSIONED_HASH_VERSION_KZG` | `Bytes1(0x01)` | + +The following parameters are defined in other EIPs and referenced by this specification: + +| Name | Value | Source | +|------|-------|--------| +| `TX_MAX_GAS_LIMIT` | `16,777,216` | [EIP-7825](./eip-7825.md) | +| `TX_VALUE_COST` | `6,000` | [EIP-2780](./eip-2780.md) | +| `CPSB` | `1,530` | [EIP-8037](./eip-8037.md) | +| `STATE_BYTES_PER_NEW_ACCOUNT` | `120` | [EIP-8037](./eip-8037.md) | +| `STANDARD_TOKEN_COST` | `4` | [EIP-7976](./eip-7976.md) | +| `TOTAL_COST_FLOOR_PER_TOKEN` | `16` | [EIP-7976](./eip-7976.md) | +| `MAX_REFUND_QUOTIENT` | `5` | [EIP-3529](./eip-3529.md) | +| `GAS_PER_BLOB` | `131,072` | [EIP-4844](./eip-4844.md) | +| `VERSIONED_HASH_VERSION_KZG` | `Bytes1(0x01)` | [EIP-4844](./eip-4844.md) | ### Frame Transaction @@ -53,10 +66,12 @@ A new [EIP-2718](./eip-2718.md) transaction with type `FRAME_TX_TYPE` is introdu The payload is defined as the RLP serialization of the following: ``` -[chain_id, nonce, sender, frames, signatures, max_priority_fee_per_gas, max_fee_per_gas, max_fee_per_blob_gas, blob_versioned_hashes] +[chain_id, nonce, sender, frames, signatures, fees, blob_versioned_hashes] -frames = [[mode, flags, target, gas_limit, value, data], ...] +frames = [[mode, flags, target, limits, value, data], ...] +limits = [execution, state] signatures = [[scheme, signer, msg, signature], ...] +fees = [max_priority_fee_per_gas, max_fee_per_gas, max_fee_per_blob_gas] ``` #### Field Definitions @@ -70,9 +85,10 @@ Below are high-level definitions of each field in the transaction definition. De - `sender` -- the address of the intended sender of the transaction. - `frames` -- list of frames to execute. - `signatures` -- list of validated signatures available to the transaction. -- `max_priority_fee_per_gas` -- the EIP-1559 priority fee per gas the transaction will pay. -- `max_fee_per_gas` -- the maximum EIP-1559 fee the transaction is willing to pay, per gas. -- `max_fee_per_blob_gas` -- the maximum EIP-4844 fee per blob gas the transaction is willing to pay. Must be `0` if `blob_versioned_hashes` is empty. +- `fees` -- list of fee parameters for the transaction: + - `max_priority_fee_per_gas` -- the [EIP-1559](./eip-1559.md) priority fee per gas the transaction will pay. + - `max_fee_per_gas` -- the maximum EIP-1559 fee the transaction is willing to pay, per gas. + - `max_fee_per_blob_gas` -- the maximum [EIP-4844](./eip-4844.md) fee per blob gas the transaction is willing to pay. Must be `0` if `blob_versioned_hashes` is empty. - `blob_versioned_hashes` -- list of EIP-4844 blob versioned hashes. ##### Frame object @@ -80,7 +96,9 @@ Below are high-level definitions of each field in the transaction definition. De - `mode` -- the mode specifies the specific execution semantics the frame will execute with. - `flags` -- specifies optional frame / mode features. - `target` -- the destination or `to` address for the frame. -- `gas_limit` -- the maximum gas allowed to be execute in pursuit of the frame. +- `limits` -- list of gas limits for the frame: + - `execution` -- the maximum execution gas allowed to be expended in pursuit of the frame. + - `state` -- the maximum state gas ([EIP-8037](./eip-8037.md)) allowed to be expended in pursuit of the frame. - `value` -- the amount in wei that should transferred from the `sender` as part of the frame execution. - `data` -- the calldata provided to the top level call frame. @@ -123,16 +141,16 @@ Some validity constraints can be determined statically. They are outlined below: ```python assert tx.chain_id < 2**256 assert tx.nonce < 2**64 -assert tx.max_priority_fee_per_gas < 2**256 -assert tx.max_fee_per_gas < 2**256 -assert tx.max_fee_per_blob_gas < 2**256 +assert tx.fees.max_priority_fee_per_gas < 2**256 +assert tx.fees.max_fee_per_gas < 2**256 +assert tx.fees.max_fee_per_blob_gas < 2**256 assert len(tx.frames) > 0 and len(tx.frames) <= MAX_FRAMES assert len(tx.sender) == 20 for h in tx.blob_versioned_hashes: assert len(h) == 32 and h[0] == VERSIONED_HASH_VERSION_KZG if len(tx.blob_versioned_hashes) == 0: - assert tx.max_fee_per_blob_gas == 0 + assert tx.fees.max_fee_per_blob_gas == 0 for sig in tx.signatures: if sig.scheme in [SECP256K1, P256]: @@ -149,14 +167,16 @@ for sig in tx.signatures: invalid_transaction() total_frame_gas = 0 +total_frame_execution_gas = 0 for i, frame in enumerate(tx.frames): assert frame.mode < 3 assert frame.flags < 8 assert frame.target is None or len(frame.target) == 20 - assert frame.gas_limit <= 2**64 - 1 + assert frame.limits.state <= 2**64 - 1 assert frame.value < 2**256 assert frame.mode == SENDER or frame.value == 0 - total_frame_gas += frame.gas_limit + total_frame_execution_gas += frame.limits.execution + total_frame_gas += frame.limits.execution + frame.limits.state assert total_frame_gas <= 2**64 - 1 # Approval of execution is allowed only when target equals to None or tx.sender. @@ -169,6 +189,16 @@ for i, frame in enumerate(tx.frames): assert frame.mode != VERIFY assert i + 1 < len(tx.frames) # must not be last frame assert tx.frames[i + 1].mode != VERIFY # batches never contain VERIFY frames + + # A frame belongs to a batch when it or its predecessor carries ATOMIC_BATCH_FLAG. + if frame.flags & ATOMIC_BATCH_FLAG or (i > 0 and tx.frames[i - 1].flags & ATOMIC_BATCH_FLAG): + assert frame.flags & APPROVE_SCOPE_MASK == 0 + +# Intrinsic and execution gas must fit the EIP-7825 transaction cap. +assert max( + frame_tx_intrinsic_gas + total_frame_execution_gas, + calldata_floor_gas, +) <= TX_MAX_GAS_LIMIT ``` #### Transaction Signatures @@ -208,9 +238,12 @@ The `ReceiptPayload` is defined as: ``` [cumulative_gas_used, payer, [frame_receipt, ...]] frame_receipt = [status, gas_used, logs] +gas_used = [execution, state] ``` -`payer` is the address of the account that paid the fees for the transaction. `status` is the return code of the top-level call. A new code `0x2` is introduced for frames which are skipped due to failed atomic batch. `gas_used` is total gas used by the frame, not accounting for refunds. `cumulative_gas_used` is still computed by adding the total gas used by the transaction with the previous cumulative sum. The transaction's logs, for the purposes of the block header `logsBloom` and log indexing, are the concatenation of the `logs` fields of its frame receipts, in frame order. +`payer` is the address of the account that paid the fees for the transaction. `status` is the return code of the top-level call. A new code `0x2` is introduced for frames which are skipped due to failed atomic batch. `gas_used` is the list of gas used by the frame, mirroring the frame's `limits` list. `gas_used.execution` is the execution gas used by the frame, not accounting for refunds. `gas_used.state` is the final state gas attributed to the frame after all state-gas refills and rollbacks in the transaction have been applied (see [Gas Accounting](#gas-accounting)). A later frame may therefore reduce an earlier frame's `gas_used.state`. The sum of all frame `gas_used.state` values is the transaction's final state-gas usage. `cumulative_gas_used` is still computed by adding the total gas used by the transaction with the previous cumulative sum. The transaction's logs, for the purposes of the block header `logsBloom` and log indexing, are the concatenation of the `logs` fields of its frame receipts, in frame order. + +The `ReceiptPayload` carries no transaction-level status: the only statuses it holds are the per-frame ones. An interface that has to present a single status for the transaction therefore derives it, rather than reading it from the receipt. #### Signature Hash @@ -284,15 +317,18 @@ def validate_signature(sig, tx_sender, sig_hash) -> bool: return False ``` +Note: since the signature validation does not happen in EVM execution, the related precompiles `ecrecover` and `P256VERIFY` must not be added to the block-level access list. + #### Expiry Verifier Frame A `VERIFY` frame whose `frame.target` equals `EXPIRY_VERIFIER` is an **expiry verifier frame**. It calls the expiry verifier contract deployed at `EXPIRY_VERIFIER` with `frame.data` as calldata. The calldata is interpreted as an 8-byte unsigned big-endian expiry timestamp. The call reverts unless `block.timestamp <= expiry_timestamp`. An expiry verifier frame is invalid unless all of the following hold: -- `frame.flags == 0`, -- `frame.value == 0`, and -- `len(frame.data) == EXPIRY_DATA_LENGTH`. +- `frame.flags == 0` +- `frame.value == 0` +- `frame.limits.state == 0` +- `len(frame.data) == EXPIRY_DATA_LENGTH` A transaction can contain at most one expiry verifier frame. @@ -363,7 +399,9 @@ To begin processing a frame transaction: Then for each frame: -1. Execute a call with the specified `mode`, `flags`, `target`, `gas_limit`, `value`, and `data`. +1. Execute a call with the specified `mode`, `flags`, `target`, `limits`, `value`, and `data`. + - Initialize the frame's receipt with `gas_used = [0, 0]`. + - At frame entry, initialize the frame's gas pools per [Gas Accounting](#gas-accounting): `gas_left = frame.limits.execution` and `state_gas_left = frame.limits.state`. - Let `resolved_target = frame.target if frame.target is not None else tx.sender` - Unless otherwise stated, checks that refer to the target account during execution use the resolved target. - Set frame's `caller`: @@ -375,19 +413,22 @@ Then for each frame: - The `ORIGIN` opcode returns frame's `caller` throughout all call depths. - In the top-level frame call, `CALLVALUE` is `frame.value`. - As with an ordinary `CALL`, if the caller does not have sufficient balance to transfer `frame.value`, the frame reverts. + - If `frame.value > 0` and `resolved_target` does not exist, `STATE_BYTES_PER_NEW_ACCOUNT * CPSB` state gas is charged after the balance check and before the frame's code executes, as in [EIP-2780](./eip-2780.md). If `state_gas_left` cannot cover the charge, the frame halts exceptionally. + - A non-zero value transfer to an address other than `tx.sender` emits the transfer log specified by [EIP-7708](./eip-7708.md). - If `resolved_target` code hash is empty, i.e. `0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470`, execute the logic described in [default code](#default-code). - Otherwise, if `resolved_target` uses an [EIP-7702](./eip-7702.md) delegation indicator, execute according to [EIP-7702](./eip-7702.md)'s delegated-code semantics. - - If a frame's execution reverts, its state changes are discarded. Additionally, if this frame has the atomic batch flag set, mark all subsequent frames in the same atomic group as skipped. + - If a frame's execution reverts, its state changes and approval context (`payer`, `sender_approved`) are discarded. Additionally, if this frame has the atomic batch flag set, mark all subsequent frames in the same atomic group as skipped. 1. If frame has mode `VERIFY` the following additional requirements are imposed: - Execute the frame as a `STATICCALL`, disallowing state manipulation. - Only `APPROVE` can modify the state or transaction context in `VERIFY`. - - If the frame reverts, the transaction is invalid. This would unroll any effects of `APPROVE`. + - If the frame fails by reverting or halting exceptionally, the transaction is invalid. This unrolls any effects of `APPROVE`. 1. If a frame is part of an atomic batch and it fails, unroll the associated atomic batch. - An **atomic batch** is a maximal contiguous sequence of frames `[i, j]` where `j > i`, frames `i` through `j - 1` have `ATOMIC_BATCH_FLAG` set, and frame `j` does not have `ATOMIC_BATCH_FLAG` set. - When a frame in the batch fails, the state must be rolled back to the condition it was immediately before the atomic batch began. All remaining frames in the atomic batch are skipped. - - Logs emitted by frames that executed before the failure are discarded together with their state changes when the batch is unrolled. Those frame receipts retain their execution status and gas used, with empty logs. - - Since skipped frames are not executed, the gas value allotted to them is refunded at the end of the transaction. - - If the frame does not have an associated atomic batch, further special handling is not needed, simple revert the individual frame. + - Logs emitted by frames that executed before the failure are discarded together with their state changes when the batch is unrolled. Changes to any frame receipt's `gas_used.state` during the batch are reverted, as the batch's state modifications are reverted. Executed frame receipts retain their execution status and execution gas used, with empty logs and zero state gas used. + - Since skipped frames are not executed, the execution and state gas allotted to them are refunded at the end of the transaction. + - `APPROVE` is not possible within a batch, so it is not necessary to rollback anything related to `payer` or `sender_approved`. + - If the frame does not have an associated atomic batch, further special handling is not needed, simply revert the individual frame. After executing all frames, verify that `payer` has been set (i.e. `payer != None`). If it is not, the whole transaction is invalid. Then, settle fees as defined in [Gas Accounting](#gas-accounting), returning `payer_refund` to the payer. @@ -397,10 +438,13 @@ A few cross-frame interactions to note: - For the purposes of gas accounting of warm / cold state status, the journal of such touches is shared across frames. - If a frame reverts, warm / cold status reverts to the state before the frame. +- State-gas ownership records and changes to frame receipts' `gas_used.state` fields journaled alongside the state changes that produced them. Reverting a call, frame, or atomic batch restores all of them to the corresponding checkpoint. - Discard the `TSTORE` and `TLOAD` transient storage between frames. ##### Gas Accounting +Frame transactions adopt the two-dimensional gas model of [EIP-8037](./eip-8037.md): **execution gas** meters computation, data, and account access, while **state gas** meters durable state growth. Where EIP-8037 retrofits the split onto envelopes carrying a single gas field via the reservoir model, the frame transaction declares both budgets explicitly: `frame.limits.execution` bounds a frame's execution gas and `frame.limits.state` bounds its state gas. The reservoir model β€” including the `state_gas_reservoir` split and the `state_gas_from_gas_left` accounting β€” does not apply within frame transactions. + The total gas limit of the transaction is: ```python @@ -421,6 +465,11 @@ def tokens_in(data): def calldata_cost(data): return STANDARD_TOKEN_COST * tokens_in(data) +def value_cost(frame): + if frame.value > 0 and frame.target is not None and frame.target != tx.sender: + return TX_VALUE_COST + return 0 + signature_verification_cost = sum(signature_gas(sig) for sig in tx.signatures) frame_data_cost = sum(calldata_cost(frame.data) for frame in tx.frames) @@ -428,14 +477,21 @@ signature_data_cost = sum( calldata_cost(sig.signer) + calldata_cost(sig.msg) + calldata_cost(sig.signature) for sig in tx.signatures ) +value_transfer_cost = sum(value_cost(frame) for frame in tx.frames) -standard_gas_limit = ( +frame_tx_intrinsic_gas = ( FRAME_TX_INTRINSIC_COST + len(tx.frames) * FRAME_TX_PER_FRAME_COST + frame_data_cost + signature_data_cost + signature_verification_cost - + sum(frame.gas_limit for all frames) + + value_transfer_cost +) + +standard_gas_limit = ( + frame_tx_intrinsic_gas + + sum(frame.limits.execution for frame in tx.frames) + + sum(frame.limits.state for frame in tx.frames) ) calldata_tokens = ( @@ -447,39 +503,109 @@ calldata_floor_gas = ( FRAME_TX_INTRINSIC_COST + len(tx.frames) * FRAME_TX_PER_FRAME_COST + signature_verification_cost + + value_transfer_cost + TOTAL_COST_FLOOR_PER_TOKEN * calldata_tokens ) -max_gas = max(standard_gas_limit, calldata_floor_gas) +max_gas = max(standard_gas_limit, calldata_floor_gas + sum(frame.limits.state for frame in tx.frames)) +``` + +`frame_tx_intrinsic_gas` is the transaction's intrinsic cost in the sense of [EIP-2780](./eip-2780.md). It is derivable from the transaction fields alone, with no state access. It is charged entirely in the execution dimension and, together with the sum of the declared frame execution-gas limits, is limited by the [EIP-7825](./eip-7825.md) cap explained in [Constraints](#constraints). `value_transfer_cost` charges `TX_VALUE_COST` for each frame which carries value, covering the recipient balance write and transfer log exactly as EIP-2780 prices top-level value transfers. State dependent costs do not enter this calculation and are charged at runtime inside frame state-gas budgets, as described below. + +###### Frame gas pools + +Each frame executes against two pools, initialized at frame entry: + +```python +gas_left = frame.limits.execution +state_gas_left = frame.limits.state +``` + +The pools are independent: + +- Execution-gas draws from `gas_left`, under unchanged EVM semantics. `gas_left` is the standard callframe gas pool. The `GAS` instruction returns the current callframe's `gas_left` and never observes `state_gas_left`. The `GAS_CALL_STIPEND` pre-state check of EIP-8037 applies to `gas_left` only. +- State-gas draws from `state_gas_left` only. `state_gas_left` is frame-scoped. EVM call frames at any depth within the frame draw from it directly. It is not forwarded, split, or divided per callframe like execution gas. +- A charge exceeding its remaining pool is an exceptional halt of the current callframe, with ordinary out-of-gas semantics. Execution gas can never be spent on state charges. + +Charge points follow EIP-8037 unchanged. `SSTORE` state gas at the end of the opcode, the conditional account-creation charge of the `CALL*` and `CREATE`/`CREATE2` families in the creating frame, and code-deposit gas at deposit time. In addition, the frame-level account creation charge for value carrying frames ([Behavior](#behavior)) and the account creation charge applied by [`APPROVE`](#approve-instruction-0xaa) mirror EIP-2780's runtime account creation charges. A charge that exhausts either pool fails the frame rather than changing the transaction's static validity. As specified above, failure of a `VERIFY` frame nevertheless makes the transaction invalid. + +###### State-gas attribution and refills + +Every successful state-gas charge is attributed to the currently executing frame. After deducting the amount from `state_gas_left`, add it to that frame's receipt: + +```python +state_gas_left -= amount +frame_receipts[current_frame_index].gas_used.state += amount +``` + +The receipt mutation is journaled together with the state creation that caused it. Journal entries are retained after a frame completes so that a later refill or atomic-batch rollback can update that frame's receipt. For an `SSTORE` charge, also journal the charging frame's index as the outstanding charge owner for that `(address, storage_key)`. The refill conditions of EIP-8037 apply unchanged, with *original value* meaning the slot value at the start of the transaction. + +When a refill fires, subtract the amount from the receipt of the frame that paid the outstanding charge: + +```python +owner = outstanding_charge.frame_index +frame_receipts[owner].gas_used.state -= amount +``` + +The destination of the spendable refill remains frame-isolated: + +- If `owner == current_frame_index`, also credit the amount to the current frame's `state_gas_left`. A frame's spendable refills can never exceed its own outstanding charges, so `state_gas_left` never exceeds `frame.limits.state`. +- If `owner != current_frame_index`, reduce the owner frame's `gas_used.state` to return the correct amount during transaction settlement. + +In both cases the storage slot's ownership entry is cleared, so a subsequent creation there is charged and attributed to whichever frame performs it. + +All changes to `state_gas_left`, outstanding charge ownership, and frame receipts' `gas_used.state` fields are journaled at the same rollback boundaries as the associated state changes: + +- When an EVM call frame reverts or halts exceptionally, restore the state-gas journal and the active frame's `state_gas_left` to the checkpoint covering that call and any associated pre-call state charge. +- When a frame reverts, restore the journal and `state_gas_left` to frame entry. Its final `gas_used.state` is therefore zero, and any changes it made to earlier receipts are undone. Both remaining pools count toward `tx_unused_gas`. +- When a frame halts exceptionally, perform the same state-gas restoration, but consume its entire execution-gas pool. Its final `gas_used.state` is zero. +- When an atomic batch is unrolled, restore the state-gas journal to the batch-entry checkpoint. Charges attributed to frames in the batch are removed from their receipts, while refills of charges attributed to frames before the batch are undone. Closed frame pools are not reopened. + +EIP-8037 assigns no state-gas refill to `SELFDESTRUCT`, including when it deletes a contract created earlier in the transaction. Such an operation therefore does not modify the creating frame's `gas_used.state`. + +###### Transaction settlement + +Each frame's receipt reports its execution-gas usage measured at frame exit: + +```python +frame_receipt.gas_used.execution = frame.limits.execution - gas_left ``` -Each frame has its own `gas_limit` allocation and the receipt reports the frame's gross gas used. These values do not generally sum to `gas_used`: the intrinsic, per-frame, calldata, and signature verification costs are charged outside frame gas limits, and the storage refund and calldata floor apply at the transaction level. +At frame exit, `frame_receipt.gas_used.state` equals `frame.limits.state - state_gas_left`. It remains subject to journaled changes caused by later frames. After all frames and atomic rollbacks have completed, it is the frame's final attributed state gas. The sum of frame execution and state usage does not generally equal the transaction's `gas_used`: intrinsic costs are charged outside frame budgets, while the storage refund and calldata floor apply at the transaction level. -Unused gas from a frame is **not** available to subsequent frames. Let `frame_receipts` be the ordered frame receipt list. After all frames, unused gas can be calculated as: +Gas not charged at settlement includes execution and state gas left in frame pools at frame exit, skipped-frame budgets, and state gas removed from a receipt by a later refill or rollback. None of this gas becomes available to a subsequent frame. Let `frame_receipts` be the ordered frame receipt list. After all frames, the total amount not charged can be calculated as: ```python tx_unused_gas = sum( - frame.gas_limit - frame_receipt.gas_used + (frame.limits.execution - frame_receipt.gas_used.execution) + + (frame.limits.state - frame_receipt.gas_used.state) for frame, frame_receipt in zip(tx.frames, frame_receipts) ) ``` -Storage gas refunds ([EIP-3529](./eip-3529.md)) accumulate across frames into a single transaction-scoped `refund_counter`. Changes made to the counter by a reverted frame, or by frames unrolled as part of a failed atomic batch, are discarded together with that frame's state changes. At the end of the transaction, apply the storage refund and EIP-7623 rules to get the final `gas_used` value: +Storage gas refunds ([EIP-3529](./eip-3529.md)) accumulate across frames into a single transaction-scoped `refund_counter`. Changes made to the counter by a reverted frame, or by frames unrolled as part of a failed atomic batch, are discarded together with that frame's state changes. State-gas refills have already reduced the owning frame receipts before settlement. At the end of the transaction, apply the storage refund and the [EIP-7623](./eip-7623.md) calldata floor β€” compared against the transaction's execution component β€” to get the final `gas_used` value: ```python gas_used_before_refund = standard_gas_limit - tx_unused_gas -applied_refund = min(refund_counter, gas_used_before_refund // 5) +applied_refund = min(refund_counter, gas_used_before_refund // MAX_REFUND_QUOTIENT) gas_used_after_refund = gas_used_before_refund - applied_refund -gas_used = max(gas_used_after_refund, calldata_floor_gas) +tx_state_gas = sum(fr.gas_used.state for fr in frame_receipts) +tx_execution_gas = max(gas_used_after_refund - tx_state_gas, calldata_floor_gas) + +gas_used = tx_execution_gas + tx_state_gas ``` +`gas_used` is by construction the sum of the transaction's two block-accounted dimensions. When the calldata floor binds, it exceeds EIP-7623's transaction-total floor: state gas never absorbs into the data floor (see [Calldata floor and payment](#calldata-floor-and-payment)). + +Because a state-gas refill directly reduces `frame_receipt.gas_used.state`, it reduces `gas_used_before_refund` and is not subject to the refund cap. Per EIP-8037, a refill reverses a charge for state that was never durably created, while a refund rebates work that was actually performed. + The payer's refund is then computed below using the final `gas_used` value: ```python blob_gas = len(blob_versioned_hashes) * GAS_PER_BLOB max_cost = ( - max_gas * max_fee_per_gas + max_gas * tx.fees.max_fee_per_gas + blob_gas * blob_base_fee ) assert max_cost < 2**256 @@ -487,13 +613,39 @@ charged_fee = gas_used * effective_gas_price + blob_gas * blob_base_fee payer_refund = max_cost - charged_fee ``` -After execution, return `payer_refund` to the payer. This is the `resolved_target` that called `APPROVE(APPROVE_PAYMENT)` or `APPROVE(APPROVE_EXECUTION_AND_PAYMENT)`. Return `max_gas - gas_used` to the block gas pool. +After execution, return `payer_refund` to the payer. This is the `resolved_target` that called `APPROVE(APPROVE_PAYMENT)` or `APPROVE(APPROVE_EXECUTION_AND_PAYMENT)`. + +###### Block-level gas accounting + +Blocks account for the two dimensions separately, per EIP-8037. A frame transaction contributes its settlement dimensions directly: + +```python +block_output.block_execution_gas_used += tx_execution_gas +block_output.block_state_gas_used += tx_state_gas +``` + +All intrinsic costs are execution gas, so the execution dimension is the transaction total less its net state gas. Because `gas_used = tx_execution_gas + tx_state_gas`, the payer pays for exactly the capacity the transaction occupies across both dimensions, and receipt `cumulative_gas_used` deltas reconcile with the block counters. The block header `gas_used`, the block validity condition, and the base fee update rule follow EIP-8037 unchanged. + +A frame transaction may be included in a block only if its worst case fits the remaining capacity of **each** dimension: + +```python +execution_reservation = max( + frame_tx_intrinsic_gas + sum(frame.limits.execution for frame in tx.frames), + calldata_floor_gas, +) +state_reservation = sum(frame.limits.state for frame in tx.frames) + +assert execution_reservation <= block_gas_limit - block_output.block_execution_gas_used +assert state_reservation <= block_gas_limit - block_output.block_state_gas_used +``` + +Because both budgets are explicit, these reservations are exact per dimension; no portion of the transaction's gas is reserved in both dimensions at once, as it must be under EIP-8037's reservoir model. ##### Blob handling When `blob_versioned_hashes` is non-empty, the transaction is a blob-carrying transaction and follows [EIP-4844](./eip-4844.md), where the payer is also the blob-fee payer. The following distinctions from EIP-4844 must also be adhered to: -- The transaction is only valid for inclusion in a block if `tx.max_fee_per_blob_gas >= blob_base_fee` of that block. `max_fee_per_blob_gas` is used only for this inclusion check. The payer is charged `blob_gas * blob_base_fee`; no additional blob fee is collected or refunded. +- The transaction is only valid for inclusion in a block if `tx.fees.max_fee_per_blob_gas >= blob_base_fee` of that block. `max_fee_per_blob_gas` is used only for this inclusion check. The payer is charged `blob_gas * blob_base_fee`; no additional blob fee is collected or refunded. - It contributes `len(blob_versioned_hashes) * GAS_PER_BLOB` to the block's `blob_gas_used` and counts against the blob limits of the active fork. The per-transaction blob limit of [EIP-7594](./eip-7594.md) applies unchanged. - Unlike EIP-4844 transactions, a frame transaction is not required to carry blobs. - The `BLOBHASH` instruction returns `tx.blob_versioned_hashes[index]` in every frame, per EIP-4844. @@ -552,16 +704,18 @@ The behavior of `APPROVE` is defined as follows: - If `payer` was already set, revert the frame. - If `resolved_target` has insufficient balance, revert the frame. - If `sender_approved == false`, revert the frame. - - Increment the sender's nonce, set `payer = resolved_target`, and collect the transaction's `max_cost` from `payer`, + - Immediately before incrementing the sender's nonce, if `tx.sender` does not exist under the existence rule of [EIP-8037](./eip-8037.md), charge `STATE_BYTES_PER_NEW_ACCOUNT * CPSB` from the current frame's `state_gas_left`. If the pool cannot cover the charge, halt the frame exceptionally without applying any approval effects. + - Increment the sender's nonce, set `payer = resolved_target`, and collect the transaction's `max_cost` from `payer`. - For `APPROVE_EXECUTION_AND_PAYMENT`: - If `sender_approved` or `payer` was already set, revert the frame. - If `resolved_target` != `tx.sender`, revert the frame. - If `resolved_target` has insufficient balance, revert the frame. + - Immediately before incrementing the sender's nonce, if `tx.sender` does not exist under the existence rule of [EIP-8037](./eip-8037.md), charge `STATE_BYTES_PER_NEW_ACCOUNT * CPSB` from the current frame's `state_gas_left`. If the pool cannot cover the charge, halt the frame exceptionally without applying any approval effects. - Set `sender_approved = true`, increment the sender's nonce, set `payer = resolved_target`, and collect the transaction's `max_cost` from `payer`. #### Gas -`APPROVE` charges only the memory expansion gas for the return-data region `[offset, offset + length)`, with no additional base cost, matching `RETURN`. Updating the transaction-scoped approval context has no gas cost: its once-per-transaction effects β€” the sender nonce increment, setting `payer`, and collecting the maximum cost β€” are already covered by the frame transaction's intrinsic cost, exactly as they are for a standard transaction, so charging nothing extra here is consistent pricing rather than a free state write. +`APPROVE` charges the memory expansion gas for the return-data region `[offset, offset + length)`, with no additional execution-gas base cost, matching `RETURN`. Updating the transaction-scoped approval context has no additional execution-gas cost: its once-per-transaction effects β€” the sender nonce increment, setting `payer`, and collecting the maximum cost β€” are already covered by the frame transaction's intrinsic cost, exactly as they are for a standard transaction. If incrementing the nonce creates the sender account, the state-gas charge specified above applies in addition. ### Introspection @@ -578,15 +732,16 @@ This instruction gives access to transaction-scoped information. The gas cost of | 0x00 | current transaction type | | 0x01 | `nonce` | | 0x02 | `sender` | -| 0x03 | `max_priority_fee_per_gas` | -| 0x04 | `max_fee_per_gas` | -| 0x05 | `max_fee_per_blob_gas` | +| 0x03 | `fees.max_priority_fee_per_gas` | +| 0x04 | `fees.max_fee_per_gas` | +| 0x05 | `fees.max_fee_per_blob_gas` | | 0x06 | max cost (basefee=max, all gas used, includes blob cost at `blob_base_fee`, intrinsic cost, and signature verification cost) | | 0x07 | `len(blob_versioned_hashes)` | | 0x08 | `compute_sig_hash(tx)` | | 0x09 | `len(frames)` | | 0x0A | currently executing frame index | | 0x0B | `len(signatures)` | +| 0x0C | `state_gas_left` remaining in the currently executing frame | Undefined `param` values result in an exceptional halt. @@ -635,7 +790,7 @@ This instruction gives access to frame-scoped information. The gas cost of this | `param` | `frameIndex` | Return value | |---------|--------------|-----------------------------------------------------------| | 0x00 | frameIndex | `resolved_target` | -| 0x01 | frameIndex | `gas_limit` | +| 0x01 | frameIndex | `limits.execution` | | 0x02 | frameIndex | `mode` | | 0x03 | frameIndex | `flags` | | 0x04 | frameIndex | `len(data)` | @@ -643,10 +798,14 @@ This instruction gives access to frame-scoped information. The gas cost of this | 0x06 | frameIndex | `allowed_scope` (`frame.flags & APPROVE_SCOPE_MASK`) | | 0x07 | frameIndex | `atomic_batch` (`(frame.flags >> 2) & 0x01`, returns 0/1) | | 0x08 | frameIndex | `value` | +| 0x09 | frameIndex | `limits.state` | +| 0x0A | frameIndex | `gas_used.execution` (exceptional halt if current/future) | +| 0x0B | frameIndex | `gas_used.state` (exceptional halt if current/future) | Notes: - The `status` field (0x05) returns `0` for failure, `1` for success, or `2` for a frame skipped due to a failed atomic batch. +- The `gas_used.execution` (0x0A) and `gas_used.state` (0x0B) fields return the current values recorded in the frame's receipt. Like `status`, they are defined only for frames that have completed; accessing them for the current frame or a subsequent frame results in an exceptional halt. A completed frame's `gas_used.state` may subsequently decrease when another frame refills state gas attributed to it, or be restored if that later change is rolled back. `gas_used.execution` does not change after frame completion. - Undefined `param` values result in an exceptional halt. - Out-of-bounds access for `frameIndex` results in an exceptional halt. - Attempting to access the return `status` of the current frame or a subsequent frame results in an exceptional halt. @@ -688,6 +847,7 @@ This policy is inspired by [ERC-7562](./eip-7562.md), but removes staking and re | Name | Value | Description | |---|---|---| | `MAX_VERIFY_GAS` | `100_000` | Maximum amount of gas a node should expend validating signatures and simulating the validation prefix | +| `MAX_VERIFY_STATE_GAS` | `500_000` | Maximum amount of state gas that may be budgeted across the validation prefix | | `MAX_PENDING_TXS_USING_NON_CANONICAL_PAYMASTER` | `1` | Maximum amount of pending transactions that can be using any given non-canonical paymaster | #### Validation Prefix @@ -779,7 +939,7 @@ To be accepted into the public mempool, a frame transaction must satisfy the fol - `only_verify` must call `APPROVE(APPROVE_EXECUTION)`. 4. `pay` must execute in `VERIFY` mode, have flags set to `APPROVE_PAYMENT`, and must successfully call `APPROVE(APPROVE_PAYMENT)` 5. No frame in the validation prefix may have the `ATOMIC_BATCH_FLAG` set. -6. The sum of `gas_limit` values across the validation prefix, plus the intrinsic cost of validating `tx.signatures`, must not exceed `MAX_VERIFY_GAS`. +6. The sum of `limits.execution` values across the validation prefix, plus the intrinsic cost of validating `tx.signatures`, must not exceed `MAX_VERIFY_GAS`. Additionally, the sum of `limits.state` values across the validation prefix must not exceed `MAX_VERIFY_STATE_GAS`. State gas does not measure node validation work β€” simulation work remains bounded by `MAX_VERIFY_GAS` alone β€” but the cap bounds the state growth admitted through the public mempool. A `deploy` frame may use state gas for account and code creation, and a `VERIFY` frame may use state gas only through `APPROVE` when incrementing the nonce creates the sender account. 7. Nodes should stop simulation immediately once `payer` has been set and the associated `VERIFY` frame completes successfully. 8. There must not be `VERIFY` frame after validation prefix. @@ -801,7 +961,7 @@ Three frame species in the validation prefix have fully protocol-defined semanti When every frame in the validation prefix is one of these, direct evaluation of the protocol-defined semantics is equivalent to simulation, and a node MAY use it to satisfy the validation requirements below. Direct evaluation MUST apply the same limits as simulation: signature validation and the evaluated frames' work count against `MAX_VERIFY_GAS`, and the paymaster accounting rules in this section apply unchanged. -The complete state dependency set of such a validation prefix is: the sender's code hash and nonce, the payer's code hash and balance (or the canonical paymaster's tracked state), the runtime code at `EXPIRY_VERIFIER` together with the frame's deadline when an expiry verifier frame is present, and the current block timestamp. Nodes SHOULD index pending transactions by this set so that head-of-chain changes are revalidated without re-execution. +The complete state dependency set of such a validation prefix is: the sender's code hash, nonce, and balance (the balance participates in the sender-existence check that decides the `APPROVE` account-creation state-gas charge), the payer's code hash and balance (or the canonical paymaster's tracked state), the runtime code at `EXPIRY_VERIFIER` together with the frame's deadline when an expiry verifier frame is present, and the current block timestamp. Nodes SHOULD index pending transactions by this set so that head-of-chain changes are revalidated without re-execution. #### Validation Trace Rules @@ -832,6 +992,7 @@ For `VERIFY` frames, the usual `STATICCALL` restrictions apply except for the pr - BASEFEE (0x48) - BLOBHASH (0x49) - BLOBBASEFEE (0x4A) +- SLOTNUM (0x4B, [EIP-7843](./eip-7843.md)) - GAS (0x5A) - Except when followed immediately by a `*CALL` instruction. This is the standard method of passing gas to a child call and does not create an additional public mempool dependency. - CREATE (0xF0) @@ -937,7 +1098,7 @@ Validation logic for other transaction types remains unchanged, i.e. the transac In the `Receipts` message of the protocol version carrying this fork, a frame transaction receipt is encoded mirroring its consensus `ReceiptPayload`: ``` -receipt = [tx-type, cumulative-gas, payer, [[status, gas-used, logs], ...]] +receipt = [tx-type, cumulative-gas, payer, [[status, [execution-gas-used, state-gas-used], logs], ...]] ``` A frame transaction with non-empty `blob_versioned_hashes` is propagated with its blob sidecar exactly as an [EIP-4844](./eip-4844.md) blob transaction. During transaction gossip responses (`PooledTransactions`), its [EIP-2718](./eip-2718.md) `TransactionPayload` is wrapped per [EIP-7594](./eip-7594.md): @@ -982,7 +1143,7 @@ The EIP-7702 authorization list heavily relies on ECDSA cryptography to determin ### No access list -The access list was introduced to address a particular backwards compatibility issue that was caused by EIP-2929. The risk-reward of using an access list successfully is high. A single miss, paying to warm a storage slot that does not end up getting used, causes the overall transaction cost to be greater than had it not been included at all. +The access list was introduced to address a particular backwards compatibility issue that was caused by [EIP-2929](./eip-2929.md). The risk-reward of using an access list successfully is high. A single miss, paying to warm a storage slot that does not end up getting used, causes the overall transaction cost to be greater than had it not been included at all. Future optimizations based on pre-announcing state elements a transaction will touch will be covered by block level access lists. @@ -992,9 +1153,11 @@ Atomic batching allows multiple frames to be grouped into a single all-or-nothin Using a flag to indicate atomic batches saves us from having to introduce a new mode. Batches are identified purely by consecutive frames with the flag set, terminated by a frame without it. This design enables consecutive atomic batches since the batch boundary is clearly indicated by the frame without the flag. +Approval scope flags are disallowed on the frames of an atomic batch, including its terminating frame. Since `APPROVE` requires its scope to be present in `frame.flags`, this restriction is statically checkable, and it keeps the approval context constant across a batch. Unrolling a failed batch therefore never rolls back the sender nonce increment or the `max_cost` collection, a batch failure cannot retroactively withdraw an execution approval that later `SENDER` frames rely on, and whether the transaction sets `payer` never depends on a batch outcome. Approval can always be placed in a frame preceding the batch instead. + ### Per-frame cost -Each frame incurs a fixed CALL execution-context overhead (100) plus `G_log` (375) for the receipt sub-entry it produces, giving `FRAME_TX_PER_FRAME_COST = 475`. The execution-context component covers context setup, mode dispatch, and gas accounting at the frame boundary, analogous to the fixed overhead of a CALL. The `G_log` component covers the `[status, gas_used, logs]` receipt sub-entry that each frame adds to the transaction receipt, which must be serialized, hashed into the receipt trie, and proven by ZK-EVM implementations. Cold/warm access costs for the frame's target account are charged within the frame's own `gas_limit` through the normal EVM warm/cold accounting, not through the per-frame cost. +Each frame incurs a fixed CALL execution-context overhead (100) plus `G_log` (375) for the receipt sub-entry it produces, giving `FRAME_TX_PER_FRAME_COST = 475`. The execution-context component covers context setup, mode dispatch, and the two-pool gas accounting at the frame boundary, analogous to the fixed overhead of a CALL. The `G_log` component covers the `[status, gas_used, logs]` receipt sub-entry that each frame adds to the transaction receipt, which must be serialized, hashed into the receipt trie, and proven by ZK-EVM implementations. Cold/warm access costs for the frame's target account are charged within the frame's own `limits.execution` through the normal EVM warm/cold accounting, not through the per-frame cost. ### Per-frame value @@ -1002,6 +1165,38 @@ A design goal of the frame transaction is to provide a good experience out-of-th Restricting non-zero `value` to `SENDER` frames keeps `VERIFY` and `DEFAULT` frames side-effect-free with respect to ETH transfer semantics, preserves the intended `STATICCALL`-like behavior of `VERIFY`, and avoids requiring the protocol-defined `ENTRY_POINT` caller to fund top-level ETH transfers. +### Two-dimensional gas budgets + +[EIP-8037](./eip-8037.md) meters execution gas and state gas separately, but its transactions carry a single gas field. The split must therefore be derived at runtime. `gas_left` is capped at the [EIP-7825](./eip-7825.md) limit and the excess becomes a reservoir spendable only on state gas. Under this model the sender cannot bound either dimension individually, the split does not exist below the cap, and a block builder must reserve the transaction's full gas in both dimensions since either could consume it. + +The frame transaction is a new envelope, so the split is explicitly declared. Each frame's `limits` list carries a `state` budget next to its `execution` budget, and the two pools never mix. This avoids the reservoir accounting entirely, keeps the EIP-7825 check static, lets the mempool bound the validation prefix per dimension without simulation, and makes block reservations exact. A deploy frame that needs tens of millions of state gas for a code deposit just declares it. + +Budgets are per-frame rather than per-transaction for the same reason unused gas does not roll over between frames. Frames belong to mutually distrusting parties. With a shared pool, a user operation could drain the state gas a paymaster's `post_op` frame depends on. [ERC-4337](./eip-4337.md) works around this by reserving `postOp` gas in the `EntryPoint`; here the reservation is part of the transaction. Since `GAS` never reports state gas, `FRAMEPARAM` and `TXPARAM` expose state budgets and usage directly, allowing a paymaster to check budgets before approving payment and attribute usage afterwards. + +### Calldata floor and payment + +The EIP-7623 calldata floor participates in block accounting per dimension, exactly as under EIP-8037: the floor is compared against the transaction's execution component alone, because calldata is an execution-dimension resource and the bytes-per-block bound must be a property of that dimension's counter. Comparing against the transaction total would let state-gas padding carry calldata into the execution dimension below the floor rate. + +The payment rule departs from EIP-8037. EIP-8037 retrofits two-dimensional accounting onto existing transaction types and therefore keeps EIP-7623's transaction-total floor, accepting that a transaction's block-accounted dimensions can sum to more than it pays; state gas may absorb into floor headroom just as execution gas does today. The frame transaction is a new envelope with no such compatibility constraint, so it defines `gas_used = tx_execution_gas + tx_state_gas`. The payer buys exactly the capacity the transaction denies other transactions in each dimension, state growth never rides free under the data floor, and receipt `cumulative_gas_used` deltas reconcile with block accounting. The stricter rule costs only transactions that are simultaneously floor-bound and state-growing, which pay `calldata_floor_gas + tx_state_gas` where an EIP-8037 transaction with the identical workload pays its transaction-total floor. + +### Cross-frame state-gas refills + +EIP-8037's refill conditions are transaction-scoped and so a slot's *original value* is its value at transaction start. Only one refill can cross a frame boundary, an `SSTORE` clearing a slot that an earlier frame created. `SELFDESTRUCT` carries no refill under EIP-8037, even for contracts created earlier in the transaction ([EIP-6780](./eip-6780.md)), and account creation and code-deposit charges are only undone by rolling back the frame that made them. + +Crediting a cross-frame refill to the executing frame would hand it budget it never declared. Dropping the refill would overcharge common patterns like approve-and-swap and cause `block_state_gas_used` to overstate real state growth, which the `CPSB` derivation depends on. Reducing the owning frame's `gas_used.state` avoids both. The payer is made whole at settlement, receipts sum to the transaction's net state gas, and no frame gains spendable budget. Since receipt mutations are journaled with the state changes that cause them, atomic-batch unrolls reverse them automatically. + +### Intrinsic cost decomposition + +`FRAME_TX_INTRINSIC_COST` is [EIP-2780](./eip-2780.md)'s `TX_BASE_COST`, which prices one ECDSA recovery, the sender's account access and write, and inclusion of the transaction's bytes in a block. Frame transactions price signature recovery per `tx.signatures` entry, so the recovery component instead covers payer settlement, which standard transactions do not price separately. Like EIP-2780 and [EIP-8038](./eip-8038.md), these values are provisional benchmark targets. + +Value-bearing frames are charged `TX_VALUE_COST` for the recipient balance write and transfer log, as EIP-2780 charges top-level transfers. The charge is static because `value` and `target` are transaction fields. Frame targets do not pay EIP-2780's unconditional cold-rate recipient touch. Frames behave like internal calls, which stay on the [EIP-2929](./eip-2929.md) warm/cold model, and a transaction repeatedly targeting the same account would otherwise overpay. + +EIP-2780's split between intrinsic and runtime gas carries over. Intrinsic gas is state-independent and decides validity. Every state-dependent charge, including frame-entry account creation and sender creation by `APPROVE`, is metered inside a frame's `limits.state`. Exhausting it fails the frame. After approval the transaction is still included and pays for the gas consumed, while a failed `VERIFY` frame invalidates the transaction as usual. + +### Transaction execution gas cap + +[EIP-7825](./eip-7825.md) caps the transaction's full execution budget: `frame_tx_intrinsic_gas` plus the sum of all `limits.execution` values must not exceed `TX_MAX_GAS_LIMIT`, and the calldata floor is checked against the same cap. State gas is excluded, bounded only by the encoding limit and block state-gas capacity, matching EIP-8037. + ### Public Key Aliases Future signature schemes with large public keys may benefit from a state-backed alias mechanism. Such an alias could be a 20-byte address that identifies a public key stored in state, allowing transactions to reference the address instead of carrying the full public key each time. @@ -1020,7 +1215,7 @@ That extension could also define a `PUBLISHPK` instruction to validate a public While we expect EOA users to migrate to smart accounts eventually, we recognize that most Ethereum users today are using EOAs, so we want to improve UX for them where we can. -Thanks to the default code, EOAs today can use frame transactions to reap many benefits of account abstraction, including sending sponsored transactions, paying gas in ERC-20 tokens, batch transactions, and more. +Thanks to the default code, EOAs today can use frame transactions to reap many benefits of account abstraction, including sending sponsored transactions, paying gas in [ERC-20](./eip-20.md) tokens, batch transactions, and more. ### Non-canonical paymasters in the mempool @@ -1116,18 +1311,20 @@ Note: to be included in the public mempool under the current model, sponsors mus | Sender validation frame: mode | 1 | | Sender validation frame: flags | 1 | | Sender validation frame: target | 1 | -| Sender validation frame: gas | 2 | +| Sender validation frame: execution gas | 2 | +| Sender validation frame: state gas | 1 | | Sender validation frame: value | 1 | | Sender validation frame: data | 0 | | Execution frame: mode | 1 | | Execution frame: flags | 1 | | Execution frame: target | 20 | -| Execution frame: gas | 1 | +| Execution frame: execution gas | 1 | +| Execution frame: state gas | 3 | | Execution frame: value | 5 | | Execution frame: data | 0 | -| **Total** | 139 | +| **Total** | 141 | -Notes: Nonce assumes < 65536 prior sends. Fees assume < 1099 gwei. Validation frame target is 1 byte because target is `tx.sender`. Validation gas assumes <= 65,536 gas. Validation frame value is zero. Execution frame target is encoded directly as the destination address. Execution frame value assumes a compact 5-byte encoding. The execution frame data is empty for a plain ETH transfer. The signature is a secp256k1 entry with empty `msg` using a 65-byte ECDSA signature. Blob fields assume no blobs (empty list, zero max fee). +Notes: Nonce assumes < 65536 prior sends. Fees assume < 1099 gwei. Validation frame target is 1 byte because target is `tx.sender`. Validation gas assumes <= 65,536 gas. Validation frame value is zero. Execution frame target is encoded directly as the destination address. Execution frame value assumes a compact 5-byte encoding. The execution frame data is empty for a plain ETH transfer. The signature is a secp256k1 entry with empty `msg` using a 65-byte ECDSA signature. Blob fields assume no blobs (empty list, zero max fee). State gas limits are zero, assuming the destination exists; a transfer that creates a new account instead requires a `STATE_BYTES_PER_NEW_ACCOUNT Γ— CPSB = 183,600` state gas budget on the execution frame (three additional bytes). This is not much larger than an EIP-1559 transaction; the extra overhead is mainly the need to specify the sender and the per-frame wrapper explicitly. @@ -1138,12 +1335,13 @@ This is not much larger than an EIP-1559 transaction; the extra overhead is main | Deployment frame: mode | 1 | | Deployment frame: flags | 1 | | Deployment frame: target | 20 | -| Deployment frame: gas | 3 | +| Deployment frame: execution gas | 3 | +| Deployment frame: state gas | 3 | | Deployment frame: value | 1 | | Deployment frame: data | 100 | -| **Total additional** | 126 | +| **Total additional** | 129 | -Notes: Gas assumes cost < 2^24. Calldata assumes small proxy. +Notes: Gas assumes cost < 2^24. State gas covers account creation plus a proxy-sized code deposit (< 2^24). Calldata assumes small proxy. **Trustless pay-with-ERC-20 sponsor (add these frames):** @@ -1152,26 +1350,29 @@ Notes: Gas assumes cost < 2^24. Calldata assumes small proxy. | Sponsor validation frame: mode | 1 | | Sponsor validation frame: flags | 1 | | Sponsor validation frame: target | 20 | -| Sponsor validation frame: gas | 3 | +| Sponsor validation frame: execution gas | 3 | +| Sponsor validation frame: state gas | 1 | | Sponsor validation frame: value | 1 | | Sponsor validation frame: calldata | 0 | | Send to sponsor frame: mode | 1 | | Send to sponsor frame: flags | 1 | | Send to sponsor frame: target | 20 | -| Send to sponsor frame: gas | 3 | +| Send to sponsor frame: execution gas | 3 | +| Send to sponsor frame: state gas | 1 | | Send to sponsor frame: value | 1 | | Send to sponsor frame: calldata | 68 | | Sponsor post op frame: mode | 2 | | Sponsor post op frame: flags | 1 | | Sponsor post op frame: target | 20 | -| Sponsor post op frame: gas | 3 | +| Sponsor post op frame: execution gas | 3 | +| Sponsor post op frame: state gas | 1 | | Sponsor post op frame: value | 1 | | Sponsor post op frame: calldata | 0 | -| **Total additional** | 147 | +| **Total additional** | 150 | -Notes: Sponsor can read info from other fields. ERC-20 transfer call is 68 bytes. +Notes: Sponsor can read info from other fields. ERC-20 transfer call is 68 bytes. State gas fields assume no new storage slots are created; an ERC-20 transfer into a fresh balance slot requires a 97,920 state gas budget (three additional bytes). -There is some inefficiency in the sponsor case, because the same sponsor address must appear in three places (sponsor validation, send to sponsor inside ERC-20 calldata, post op frame), and the ABI is inefficient (~12 + 24 bytes wasted on zeroes). This is difficult to mitigate in a "clean" way, because one of the duplicates is inside the ERC-20 call, "opaque" to the protocol. However, it is much less inefficient than ERC-4337, because not all of the data takes the hit of the 32-byte-per-field ABI overhead. +There is some inefficiency in the sponsor case, because the same sponsor address must appear in three places (sponsor validation, send to sponsor inside ERC-20 calldata, post op frame), and the ABI is inefficient (~12 + 24 bytes wasted on zeroes). This is difficult to mitigate in a "clean" way, because one of the duplicates is inside the ERC-20 call, "opaque" to the protocol. However, it is much less inefficient than [ERC-4337](./eip-4337.md), because not all of the data takes the hit of the 32-byte-per-field ABI overhead. ### Blob support @@ -1182,6 +1383,8 @@ Blobs are optional for frame transactions because `FRAME_TX_TYPE` is a general-p The `ORIGIN` opcode behavior changes for frame transactions, returning the frame's caller rather than the traditional transaction origin. This is consistent with the precedent set by EIP-7702, which already modified `ORIGIN` semantics. Contracts that rely on `ORIGIN = CALLER` for security checks (a discouraged pattern) may behave differently under frame transactions. +Wallets and gas estimators must produce two-dimensional, per-frame gas estimates: each frame's `limits.execution` covers its execution gas and its `limits.state` covers its state gas, and neither budget can borrow from the other or from other frames. Under-provisioned state gas halts a frame exactly like out-of-gas; over-provisioned budgets are refunded at settlement but raise the up-front `max_cost` collected from the payer. When the calldata floor binds, `gas_used` is `calldata_floor_gas + tx_state_gas`, so fee estimation for data-heavy transactions must add the state dimension on top of the floor rather than assuming EIP-7623's transaction-total floor. + ## Security Considerations ### Transaction Propagation @@ -1228,6 +1431,10 @@ For deployment of the sender account in the first frame, the mempool enforces de In general, it can be assumed that handling of frame transactions imposes similar restrictions as EIP-7702 on mempool relay, i.e. only a single transaction can be pending for an account that uses frame transactions. +### State Gas Isolation Between Frames + +Frame state-gas budgets are deliberately not shared. A shared, transaction-scoped state budget would allow one frame to exhaust the state gas a later frame depends on: for example, a user operation could drain the budget a paymaster's `post_op` frame needs to write its accounting slot, causing it to halt. Because each frame's `limits.state` is declared in the transaction and unavailable to other frames, validation code and paymasters can verify via `FRAMEPARAM`, before approving, that every frame they depend on carries the state budget it needs. The cross-frame refill rules preserve this isolation: clearing state paid for by an earlier frame reduces that earlier frame's `gas_used.state` and credits the payer at settlement, but never increases the executing frame's spendable budget. + ### Execution Approval Authorizes All Subsequent Sender Frames `sender_approved` is a single transaction-scoped flag. Once a frame grants `APPROVE_EXECUTION` (or `APPROVE_EXECUTION_AND_PAYMENT`), every subsequent `SENDER` frame executes with `caller` set to `tx.sender`, not only the frame the approving code inspected. The approval is not scoped to a particular frame or call target. diff --git a/EIPS/eip-8146.md b/EIPS/eip-8146.md index a05c5a4f464abb..3a38372aa28030 100644 --- a/EIPS/eip-8146.md +++ b/EIPS/eip-8146.md @@ -1,7 +1,7 @@ --- eip: 8146 title: Block Access List Sidecars -description: Decouple block access list propagation from execution payload envelopes +description: Propagate block access lists as sidecars, keeping payload envelopes small and giving execution clients a prefetch head start author: Toni WahrstΓ€tter (@nerolation), RaΓΊl Kripalani (@raulk) discussions-to: https://ethereum-magicians.org/t/eip-8146-block-access-list-sidecars/27757 status: Draft @@ -13,13 +13,20 @@ requires: 7732, 7928 ## Abstract -This EIP removes the block access list (BAL) from the `ExecutionPayloadEnvelope` and propagates it as an independent sidecar on a dedicated gossip topic. Builders commit to the BAL exactly once, by including `keccak256(rlp(BAL))` (the same 32-byte value already defined as `block_access_list_hash` in the EL block header by [EIP-7928](./eip-7928.md)) in their `ExecutionPayloadBid`. Sidecar verification uses this commitment; the consensus layer treats the BAL as opaque bytes and never needs to RLP-decode it. No separate sidecar signature is required. The Payload Timeliness Committee (PTC) enforces BAL availability at the attestation deadline. +This EIP removes the block access list (BAL) from the `ExecutionPayloadEnvelope` and propagates it as an independent sidecar on a dedicated gossip topic. This keeps a large object (~70 KiB average, up to 1 MiB) off the latency-critical propagation path and lets the BAL arrive early, so execution layer clients can prefetch state and precompute the post-state root before the payload arrives. This shortens the slot's critical path, creating headroom for gas limit increases, and helps FOCIL ([EIP-7805](./eip-7805.md)) inclusion list builders avoid listing transactions that the pending block invalidates. Builders commit to the BAL exactly once, by including `keccak256(rlp(BAL))` (the same 32-byte value already defined as `block_access_list_hash` in the EL block header by [EIP-7928](./eip-7928.md)) in their `ExecutionPayloadBid`. Sidecar verification uses this commitment; the consensus layer treats the BAL as opaque bytes and never needs to RLP-decode it. No separate sidecar signature is required. The Payload Timeliness Committee (PTC) enforces BAL availability at least one second before the execution payload deadline, guaranteeing one second of useful work on the BAL even in the worst case. ## Motivation [EIP-7928](./eip-7928.md) adds block access lists to the `ExecutionPayload`. Under [EIP-7732](./eip-7732.md), the execution payload travels inside a `SignedExecutionPayloadEnvelope` that the builder broadcasts after the beacon block. Including the BAL (~70 KiB average, up to 1 MiB) in the envelope increases its size and propagation latency on the critical path. -Separating the BAL into a sidecar reduces envelope size, improving propagation. The BAL remains required for execution validation; the PTC enforces availability so that the BAL is present before the payload processing deadline. +Separating the BAL into a sidecar has four benefits: + +- **No large object on the critical path**: the envelope stays small and propagates faster; the BAL travels on its own topic, off the latency-critical path. +- **A validation head start**: the sidecar can be published early because it exposes no unbundling risk (see [BAL publication timing](#bal-publication-timing)), so execution layer clients prefetch the declared state and can begin post-state root computation while the envelope is still in flight, shortening payload validation. +- **Better FOCIL inclusion lists**: FOCIL ([EIP-7805](./eip-7805.md)) inclusion list builders must build their list before they can receive or execute the block of that same slot, risking invalid transactions in the list and degrading the mechanism's quality. An early BAL exposes the block's touched accounts and post-values, letting builders filter invalidated transactions without executing the payload. +- **Slot-time headroom**: faster propagation and validation free up time in the slot, headroom that gas limit increases depend on. + +The BAL remains required for execution validation; the PTC enforces availability so that the BAL is present at least one second before the execution payload deadline. ## Specification diff --git a/EIPS/eip-8219.md b/EIPS/eip-8219.md index 1fbb5890a7367c..5bd66434fda160 100644 --- a/EIPS/eip-8219.md +++ b/EIPS/eip-8219.md @@ -12,7 +12,7 @@ created: 2026-04-08 ## Abstract -This EIP introduces four new opcodes β€” `SAFEADD` (`0x0c`), `SAFESUB` (`0x0d`), `SAFEMUL` (`0x0e`), and `SAFEDIV` (`0x0f`) β€” that perform unsigned 256-bit arithmetic with built-in overflow, underflow, and division-by-zero checking. On error, these opcodes revert the current call frame with empty returndata, equivalent to `REVERT(0, 0)`. A single `SAFEADD` instruction replaces the 11-instruction compiler-generated overflow check pattern, reducing checked addition cost from ~79 gas to 5 gas and from 22 bytes to 1 byte of bytecode. +This EIP introduces four new opcodes β€” `SAFEADD` (`0x0c`), `SAFESUB` (`0x0d`), `SAFEMUL` (`0x0e`), and `SAFEDIV` (`0x0f`) β€” that perform unsigned 256-bit arithmetic with built-in overflow, underflow, and division-by-zero checking. On error, these opcodes revert the current call frame with empty returndata, equivalent to `REVERT(0, 0)`. A single `SAFEADD` instruction replaces the 11-instruction compiler-generated overflow check pattern, reducing checked addition cost from ~79 gas to 5 gas and from 12 bytes to 1 byte of bytecode. ## Motivation @@ -28,15 +28,17 @@ A typical checked addition compiles to something like: 4. Check for overflow 5. (Jump back) -This contains at least one conditional jump and potentially multiple more jumps. Benchmarks conducted with Solidity 0.8.33 (optimizer enabled, 200 runs) and Vyper 0.4.3 on equivalent ERC-20 contracts isolating a single checked addition yield the following overhead: +This contains at least one conditional jump and potentially multiple more jumps. Benchmarks conducted with Solidity 0.8.33 (optimizer enabled, 200 runs) and Vyper 0.4.3 on equivalent [ERC-20](./eip-20.md) contracts isolating a single checked addition yield the following overhead: | Metric | Solidity 0.8.33 | Vyper 0.4.3 | With `SAFEADD` | |---|---|---|---| | Execution gas per checked add | ~79 (3 + 76) | ~41 (3 + 38) | 5 | -| Bytecode per checked add | 22 bytes | 26 bytes | 1 byte | -| Deployment gas per checked add | +4,704 | +5,208 | ~200 | +| Bytecode per checked add | 12 bytes | 14 bytes | 1 byte | +| Deployment gas per checked add | +2,352 | +2,604 | ~200 | | Gas reduction | 93.7% | 87.8% | β€” | +The bytecode and deployment figures above are per single checked addition. Each benchmark contract contains two of them β€” one in `transfer`, one in `transferFrom` β€” so the totals measured across a whole contract (22 bytes and 4,704 deployment gas for Solidity, 26 bytes and 5,208 for Vyper) are twice the per-addition cost. The execution gas figures are unaffected, since a single call executes only one of the two. In Solidity the per-addition cost is the expansion of the call site; the overflow-check helper it jumps into is emitted once and shared by every checked addition in the contract. + The raw `ADD` opcode costs 3 gas. A compiler-checked addition costs approximately 79 gas β€” a **25x multiplier** purely for safety. This overhead grows across every arithmetic operation in every function in every contract on the network. ### The Dangerous Trade-off @@ -243,7 +245,7 @@ The existing `DIV` opcode silently returns 0 when the divisor is zero. While thi ### Revert with Empty Returndata -Safe arithmetic opcodes revert with empty returndata (zero-length return buffer) rather than encoding a specific error message. This design is compiler-neutral: the EVM specification does not mandate ABI encoding, and different languages use different error encoding schemes. Empty returndata is also consistent with how compilers currently implement overflow reverts. +Safe arithmetic opcodes revert with empty returndata (zero-length return buffer) rather than encoding a specific error message. This design is compiler-neutral: the EVM specification does not mandate ABI encoding, and different languages use different error encoding schemes. Empty returndata is also consistent with how Vyper currently implements overflow reverts (`REVERT(0, 0)`); Solidity instead encodes a `Panic(0x11)`, a payload these opcodes trade away for the gas and bytecode savings. ## Backwards Compatibility diff --git a/EIPS/eip-8250.md b/EIPS/eip-8250.md index b8053ad054209b..b824d302c2c038 100644 --- a/EIPS/eip-8250.md +++ b/EIPS/eip-8250.md @@ -13,7 +13,7 @@ requires: 7623, 8141 ## Abstract -Replaces the single sender nonce of an [EIP-8141](./eip-8141.md) frame transaction with `(nonce_keys, nonce_seq)`: a bounded set of nonce keys sharing one sequence number. `nonce_keys == [0]` aliases the legacy account nonce; each non-zero key selects an independent protocol-managed nonce sequence stored in a `NONCE_MANAGER` system contract. Transactions whose non-zero key sets do not overlap are replay-independent. +Replaces the single sender nonce of an [EIP-8141](./eip-8141.md) frame transaction with `(nonce_keys, nonce_seq)`: a bounded set of nonce keys sharing one sequence number. `nonce_keys == [0]` aliases the legacy account nonce; each non-zero key selects an independent protocol-managed nonce sequence stored in a `NONCE_MANAGER` system contract. Transactions whose non-zero key sets do not overlap are replay-independent. This helps privacy applications use one shared sender for many users without forcing every transaction through the same nonce sequence. ## Motivation diff --git a/EIPS/eip-8261.md b/EIPS/eip-8261.md index cae33eab69169b..50c69a2d66d767 100644 --- a/EIPS/eip-8261.md +++ b/EIPS/eip-8261.md @@ -1,235 +1,161 @@ --- eip: 8261 title: Gas Limit Schedule -description: Move the block gas limit to a hard-fork-scheduled, consensus-enforced parameter, removing proposer/builder/operator configurability. +description: Recommend an optional post-Gloas consensus layer gas limit schedule for epoch-based defaults and maximum recommendations. author: Barnabas Busa (@barnabasbusa) discussions-to: https://ethereum-magicians.org/t/eip-8261-gas-limit-schedule/28494 -status: Draft -type: Standards Track -category: Core +status: Review +type: Informational created: 2026-05-11 -requires: 1559, 7840, 7892 +requires: 1559, 7732, 7892, 7935 --- ## Abstract -This EIP removes the block gas limit from the set of values that node operators, validators, builders, and proposers can choose freely. Instead, the gas limit becomes a hard-fork-scheduled parameter, configured through a `gasLimitSchedule` on the execution layer and a `GAS_LIMIT_SCHEDULE` on the consensus layer. Each fork (including BPO-style lightweight "Gas Parameter Only" forks) pins a single, exact gas limit value. Producing or attesting to a block whose `gas_limit` differs from the scheduled value is a consensus error and renders the block invalid. The legacy Β±1/1024 elasticity rule from [EIP-1559](./eip-1559.md) is removed and the validator gas-limit preference exposed via the Engine API is deprecated. +This EIP recommends that, starting with Gloas ([EIP-7732](./eip-7732.md)), consensus layer clients source their gas limit preference from an optional `GAS_LIMIT_SCHEDULE` field in the consensus layer `config.yaml` instead of hardcoded, release-scoped defaults. Each entry activates at the start of its specified epoch and provides a value that plays two roles: it is the **default** gas limit that validator clients target in the absence of operator configuration, and the **recommended maximum**. The schedule is ignored before Gloas, preserving existing gas limit behavior. Operators remain free to configure any value; clients should warn when a configured preference exceeds the active scheduled value, but honor it because the maximum is a recommendation, not a rule. The schedule lives only on the consensus layer because post-Gloas the validator's preference flows through validator registrations and the builder pipeline, with no execution layer configuration involved. No consensus rules are changed: the [EIP-1559](./eip-1559.md) Β±1/1024 elasticity rule remains the only gas-limit validity rule, and blocks above or below the scheduled value remain valid. ## Motivation -Today the block gas limit is effectively a free parameter set by each block proposer, plumbed through: +Today the block gas limit is set by each block proposer, driven by validator client "preferred gas limit" settings, client default configurations, and builder bids, with the Β±1/1024 elasticity rule from [EIP-1559](./eip-1559.md) moving the realized limit block by block. -1. Execution layer client flags (e.g., `--miner.gaslimit`, `--gas-ceil`, `--target-gas-limit`). -2. The consensus layer validator client's "preferred gas limit", forwarded to the execution layer via `engine_forkchoiceUpdatedV*` payload attributes. -3. Builder bids in the MEV-Boost / PBS flow, which advertise a `gas_limit` chosen by the builder to match (or approximate) the proposer's preference. -4. Block-level "voting" via the Β±1/1024 elasticity rule introduced in [EIP-1559](./eip-1559.md), allowing the gas limit to drift up or down per block. +The pain point this EIP addresses is that gas limit defaults are **release-scoped** rather than **epoch-based**: a new default activates whenever an operator happens to update their node, not at a network-coordinated epoch. This creates a concrete coordination problem for large increases. Suppose clients want to raise the default from 60M toward a substantially higher value at a future epoch: -This design has several drawbacks: +- If clients ship the new default in releases ahead of the activation epoch, every node updated early starts voting the gas limit up *immediately*, before the network has agreed the new value is safe to reach. +- The alternative, shipping the new default in a back-to-back release immediately after the intended activation epoch and asking all operators to update quickly, is operationally inconvenient and slow to take effect. +- One-off override flags for each planned increase push the coordination burden onto every individual operator. -- **Operational risk.** A coordinated client-default change or a popular configuration push can move the gas limit by millions of gas in days, with no protocol-level safety net. Recent gas limit changes have been preceded by months of off-chain coordination precisely because there is no in-protocol gate. If clients ship a default that turns out to be unsafe at scale (e.g., worst-case block validation times, mempool blow-ups, state-growth surprises), there is no consensus rule preventing the network from reaching it. -- **Implicit governance.** Setting the gas limit on mainnet is currently an opaque social process performed by individual validators and large staking operators. This makes it difficult for client teams and researchers to commit to safe upper bounds that are guaranteed to be respected. -- **Asymmetry with blob parameters.** [EIP-7892](./eip-7892.md) already established BPO ("Blob Parameter Only") hard forks as the canonical, low-overhead path for scaling blob capacity. Blob `target`, `max`, and `baseFeeUpdateFraction` are now hard-fork-scheduled and consensus enforced. Block gas remains the only major capacity dial still set by social consensus among validators. -- **Builder/proposer surface area.** Builder bids and validator preferences carry a `gas_limit` field that must be validated, communicated, and reconciled across the EL, CL, and relay. Removing this field shrinks the trusted interface and removes a class of bugs. +There is also no single place where "the gas limit the network should run at" is written down: moving the network today requires either every client team shipping a new hardcoded default, or a large share of operators updating flags, coordinated socially over months. [EIP-7935](./eip-7935.md) demonstrated that coordinating a default gas limit recommendation at a known epoch works; this EIP generalizes that approach into a reusable, machine-readable schedule, mirroring the epoch-based `BLOB_SCHEDULE` configuration format from [EIP-7892](./eip-7892.md). -The goal of this EIP is to make the gas limit behave like every other hard-fork parameter: a single value, agreed at fork time, enforced by consensus, changeable only through a fork (a normal fork, or a lightweight Gas Parameter Only fork modeled on [EIP-7892](./eip-7892.md)). +With Gloas ([EIP-7732](./eip-7732.md)), the consensus layer becomes the natural single home for this schedule: the gas limit a block is built to originates from the validator side of the builder flow, so a CL configuration entry can drive the network's gas limit end to end without any execution layer configuration. ## Specification The key words "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](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174). -### Activation - -Let `FORK_TIMESTAMP` denote the activation timestamp of the fork that includes this EIP. The rules below apply to any execution payload whose `timestamp >= FORK_TIMESTAMP`, and to any beacon block whose slot maps to an epoch at or after the corresponding consensus-layer fork epoch. +This EIP changes no consensus rules. The [EIP-1559](./eip-1559.md) gas limit validity rules (Β±1/1024 elasticity, 5000 minimum) are unchanged, and a block whose `gas_limit` differs from the scheduled value in either direction remains valid. Everything below is recommended client and tooling behavior. ### Gas limit schedule -The protocol gas limit at any timestamp `t >= FORK_TIMESTAMP` is determined by a `gasLimitSchedule` keyed by fork name. Each fork that changes the gas limit MUST add (or modify) its own entry. Subsequent forks that do not change the gas limit MUST copy the previous fork's value forward into their own entry. - -#### Execution layer configuration - -The chain configuration is extended with a `gasLimitSchedule` object and per-fork `Time` activation timestamps, following the convention established by [EIP-7840](./eip-7840.md) and [EIP-7892](./eip-7892.md): - -```json -{ - "gasLimitSchedule": { - "glamsterdam": 60000000, - "gpo1": 75000000, - "gpo2": 90000000 - }, - "glamsterdamTime": 1772000000, - "gpo1Time": 1780000000, - "gpo2Time": 1788000000 -} -``` - -`gpo` ("Gas Parameter Only") forks follow the same naming convention and lifecycle as BPO forks defined in [EIP-7892](./eip-7892.md). Activation timestamps are required only for forks at or after the activation fork of this EIP. - -Define: - -```python -def get_scheduled_gas_limit(timestamp: int, config: ChainConfig) -> int: - active_fork = config.fork_active_at(timestamp) - return config.gas_limit_schedule[active_fork] -``` - -`config.fork_active_at(timestamp)` returns the most recently activated fork (regular or `gpo`) whose `Time <= timestamp`. - -#### Consensus layer configuration - -A new `GAS_LIMIT_SCHEDULE` field is added to consensus layer configuration, mirroring the `BLOB_SCHEDULE` mechanism in [EIP-7892](./eip-7892.md). Entries represent gas limit changes that take effect at the start of the listed epoch: +The consensus layer `config.yaml` MAY be extended with a `GAS_LIMIT_SCHEDULE` field, mirroring the `BLOB_SCHEDULE` mechanism in [EIP-7892](./eip-7892.md). Entries represent recommended gas limit changes that take effect at the start of the listed epoch: ```yaml GAS_LIMIT_SCHEDULE: - - EPOCH: 500000 # Activation fork (e.g., Glamsterdam) + - EPOCH: 500000 # Gloas activation epoch GAS_LIMIT: 60000000 - - EPOCH: 520000 # A future GPO fork + - EPOCH: 520000 # A later scheduled increase GAS_LIMIT: 75000000 - - EPOCH: 540000 # A future GPO fork + - EPOCH: 540000 # Another scheduled increase GAS_LIMIT: 90000000 ``` -**Requirements:** +`EPOCH` and `GAS_LIMIT` are `uint64` values; `GAS_LIMIT` matches the type of `ExecutionPayload.gas_limit` in the consensus specifications. -- Execution and consensus clients MUST share consistent gas-limit schedules. -- For every entry, the consensus-layer epoch start slot MUST map (via the slot-to-timestamp function) to the same activation timestamp used in the EL's `gasLimitSchedule` for that fork. -- The `GAS_LIMIT` value MUST equal the EL's `gasLimitSchedule[fork_name]` for that fork. +The field is OPTIONAL: CL clients MAY add support for it, and clients that do not recognize it ignore it, per the existing convention for unknown configuration keys. Supporting clients MUST ignore the schedule for duties before `GLOAS_FORK_EPOCH`. A client that is not using the parameter because the duty is pre-Gloas, the field is absent, or no schedule entry is active simply keeps its existing gas limit behavior. -### Block-validity rule +Each entry independently schedules a recommended gas limit change at its `EPOCH`. An entry MAY align with a protocol upgrade, but the schedule itself does not create or require a fork. -The execution payload header field `gas_limit` is now consensus-fixed. - -For any block with `timestamp >= FORK_TIMESTAMP`, the block is valid only if: +Define: +```python +def get_scheduled_gas_limit(epoch: Epoch, config: Config) -> Optional[int]: + if epoch < config.GLOAS_FORK_EPOCH: + return None + active_entries = [ + entry for entry in config.GAS_LIMIT_SCHEDULE + if entry.EPOCH <= epoch + ] + if not active_entries: + return None + # Latest schedule entry whose EPOCH <= epoch + return max(active_entries, key=lambda entry: entry.EPOCH).GAS_LIMIT ``` -block.header.gas_limit == get_scheduled_gas_limit(block.header.timestamp, config) -``` - -A block whose `gas_limit` does not match the scheduled value MUST be rejected by both execution and consensus clients. This applies symmetrically to: -- Block production (proposers and builders MUST set `gas_limit` to the scheduled value). -- Block import and re-execution. -- Beacon block / `ExecutionPayloadHeader` validation in the consensus layer. -- Attestation: validators MUST NOT attest to a block that violates this rule. +### Recommended client behavior -This replaces the legacy [EIP-1559](./eip-1559.md) elasticity rule. Specifically, the constraints - -``` -parent.gas_limit - parent.gas_limit // 1024 < block.gas_limit < parent.gas_limit + parent.gas_limit // 1024 -block.gas_limit >= 5000 -``` +For CL clients that support `GAS_LIMIT_SCHEDULE`, an active scheduled value serves as both the default gas limit and the recommended maximum: -are no longer enforced for blocks at or after `FORK_TIMESTAMP`; they are superseded by the exact-match rule above. +- **Default.** When no operator preference is supplied and `get_scheduled_gas_limit(epoch)` returns a value, the client SHOULD use it as the gas limit preference, where `epoch` is the epoch of the slot the registration or proposal applies to. Before Gloas or when no entry is active, the client SHOULD retain its existing default behavior. Clients SHOULD NOT introduce a separate release-scoped default for epochs covered by an active schedule entry. +- **Recommended maximum.** If an operator-supplied preference exceeds the active scheduled value, the client SHOULD honor it, since this EIP introduces no enforcement, but SHOULD log a clear warning, e.g., `configured gas limit 100000000 exceeds the recommended maximum of 90000000 at epoch 540000`. Exceeding the recommendation is thereby a deliberate, visible operator decision rather than a silent one. +- **Operator preferences are always honored.** Values below the scheduled value work unchanged, as today. +- **Builders and relays.** When a schedule entry is active, builders SHOULD produce, and relays SHOULD prefer, bids whose `gas_limit` does not exceed the scheduled value for the bid's slot. -### Engine API changes +#### Epoch-based switching -Starting at the engine API version released alongside this EIP: +The schedule only delivers its benefit if the effective default changes exactly at the configured epoch boundary, which requires new client logic: today, gas limit defaults are read once (at startup, or when a validator registration is created) and held constant. Under this EIP, the scheduled value SHOULD be looked up per duty against the epoch of the slot being proposed or registered for, never against wall-clock time or a value cached at startup. A running node then switches its preference at the first slot of the configured epoch with no restart, follow-up release, or operator action. Since validator registrations are re-issued periodically, registrations naturally roll over to a new scheduled value as duties cross the activation epoch. -- The `payloadAttributes` object passed to `engine_forkchoiceUpdatedV*` MUST NOT include a proposer-supplied gas limit field. Any existing field carrying the proposer's preferred gas limit (e.g., `gasLimit` payload attribute on networks where it was added) is removed; if present, it MUST be ignored by the execution layer and SHOULD cause the call to be rejected with `-32602 invalid payload attributes`. -- When building a payload, the execution layer MUST set `gas_limit = get_scheduled_gas_limit(payloadAttributes.timestamp, config)`. It MUST NOT read this value from any operator-supplied configuration (CLI flag, JSON config, RPC, etc.). -- Execution layer clients SHOULD log a warning and ignore any operator-supplied gas-limit configuration at startup for timestamps at or after `FORK_TIMESTAMP`. Operator configuration MAY still apply to pre-fork blocks (e.g., for historical reproduction and replay). +Because the schedule is keyed by epoch, a release shipped months before an entry activates keeps using the current value until that epoch, then switches automatically. This makes it safe to ship large future increases well ahead of time: early updaters do not begin voting toward the new value before its configured activation epoch. -### Validator client and builder API changes +#### The consensus layer drives the gas limit -- Validator clients MUST NOT expose or transmit a "preferred gas limit" setting that influences post-fork blocks. -- The builder API's `SignedBuilderBid` / `ExecutionPayloadHeader` MUST carry `gas_limit` equal to the scheduled value. Relays MUST reject builder bids whose `gas_limit` does not match `get_scheduled_gas_limit(timestamp, config)`. Proposers MUST NOT sign a header that violates this rule. -- The proposer's pre-registration with a relay (the "validator registration" carrying `gas_limit`) is deprecated for purposes of influencing the block's gas limit. Relays SHOULD continue to accept the field for backward compatibility but MUST ignore it when constructing post-fork bids. +Starting with Gloas ([EIP-7732](./eip-7732.md)), the gas limit a block is built to originates on the consensus layer side of the builder flow β€” the validator's preference, expressed through validator registrations and the payload bid path β€” rather than from execution layer configuration. The schedule therefore lives only in the CL `config.yaml`; no execution layer chain-configuration counterpart is needed, and EL gas limit flags retain their existing pre-Gloas and local-tooling meaning. This EIP does not change gas limit selection before Gloas. -### Chain Specifics +Illustrative preference selection: -Testnets and devnets MUST include a `gasLimitSchedule` entry for genesis if their genesis is at or after this EIP's activation. For testnets that were live before the activation fork, the genesis entry is unnecessary; only the activation fork's entry is required. +```python +def effective_gas_limit_preference( + operator_preference: Optional[int], + existing_default: int, + epoch: Epoch, + config: Config, +) -> int: + scheduled = get_scheduled_gas_limit(epoch, config) + if scheduled is None: + return operator_preference if operator_preference is not None else existing_default + if operator_preference is None: + return scheduled + if operator_preference > scheduled: + log.warning( + f"configured gas limit {operator_preference} exceeds the recommended " + f"maximum of {scheduled} at epoch {epoch}" + ) + return operator_preference +``` -For private testnets exercising rapid scaling (e.g., shadowforks of `gpo` forks), the same mechanism is used: define a new `gpo` entry and the corresponding activation time. +The realized network gas limit continues to move under the [EIP-1559](./eip-1559.md) Β±1/1024 rule, so a scheduled increase plays out as a gradual, bounded ramp starting at the activation epoch rather than an instant step. ## Rationale -### Why a schedule rather than a runtime parameter? - -A schedule (rather than, say, an on-chain vote or a moving average) keeps the change minimal, mirrors the established BPO mechanism from [EIP-7892](./eip-7892.md), and matches how the community already coordinates gas-limit changes in practice: socially, with months of lead time, tied to a specific upgrade window. Encoding that decision into config and enforcing it in consensus is the smallest change that gives the desired safety property. - -### Why remove validator/proposer choice entirely? +### Why epoch-based defaults instead of release-scoped defaults? -Per-proposer choice was originally motivated by the desire to let stakers respond quickly to network conditions. In practice the mechanism is used for slow, coordinated changes ([EIP-7935](./eip-7935.md) being a recent example), not for rapid response. Meanwhile, the configurability creates risk: a single popular client default change can move the network's effective gas limit without any in-protocol gate. The `gpo` mechanism preserves the ability to move quickly when needed β€” a GPO fork can be scheduled with the same lead time as a BPO fork β€” while ensuring the change is explicit and auditable. +Keying the default on the duty's epoch rather than the release version decouples *shipping* a new gas limit from *activating* it. This directly resolves the large-increase dilemma described in the Motivation: clients can ship the schedule entry for a big raise arbitrarily early, and no node will begin voting toward it until the configured epoch. An entry can align with a hard fork when desired, but the mechanism itself is only an epoch-based client configuration. -### Why exact equality, not an upper bound? +### Why one value for both the default and the recommended maximum? -An upper bound (e.g., "block.gas_limit MUST NOT exceed scheduled value") would still allow proposers to set lower values, which preserves a class of edge cases (split views, builder/proposer disagreement, accidental misconfiguration that produces unusually small blocks). Exact equality is simpler, cheaper to verify, and forecloses these edge cases. If a future hard fork wants to reintroduce flexibility within a bounded range, it can do so explicitly. +The number the community coordinates on for an epoch is the number the network runs at and the number operators are asked not to exceed. A single dial means there is no second parameter to negotiate or get out of sync, and no untested operating region between a default and a higher ceiling. Operators who want to run below it can; running above it is possible (nothing in consensus prevents it) but happens against a clear warning, which is a much stronger signal than today's silently configurable free-for-all. -### Why is this consensus-breaking instead of a client-side default? +### Why does the schedule live only on the consensus layer? -The motivating concern is exactly that client-side defaults are not consensus-enforced. A misconfigured or malicious client could ship a default well above what the network has been tested for, and the protocol would accept those blocks. Moving the rule into consensus closes that gap. +Post-Gloas, the CL is authoritative for the gas limit in practice: the validator preference drives registrations, builder bids, and payload construction. A parallel execution layer schedule would be redundant configuration that could only ever agree with or diverge from the CL's β€” and divergence is a new failure mode with no benefit. One schedule, in the layer that actually drives the value, is the minimal design. EL clients require no changes at all under this EIP. -### Relationship to EIP-1559 - -[EIP-1559](./eip-1559.md) introduced the elasticity multiplier and the Β±1/1024 gas-limit adjustment rule. The base-fee mechanism (target = `gas_limit / elasticity_multiplier`, max = `gas_limit`) is unchanged in spirit; it now uses the scheduled `gas_limit` as its input. The per-block adjustment rule is removed because the gas limit no longer varies block-to-block within a fork. - -### Relationship to EIP-7825 - -[EIP-7825](./eip-7825.md) caps per-transaction gas. This EIP caps (and fixes) per-block gas. The two are complementary: EIP-7825 prevents single-transaction DoS within a block; this EIP prevents the block-level capacity itself from drifting away from a tested safe value. - -## Backwards Compatibility +### Why informational rather than a consensus rule? -This change is consensus-breaking. Specifically: +An earlier draft of this EIP made the scheduled value a consensus-enforced maximum (and, before that, an exact-match requirement). This version is deliberately advisory: -- Blocks valid under the [EIP-1559](./eip-1559.md) elasticity rule but whose `gas_limit` differs from the scheduled value are invalid after `FORK_TIMESTAMP`. -- Execution layer client configuration related to gas limit (CLI flags, JSON keys) becomes a no-op for post-fork blocks. Clients SHOULD continue to honor these flags for historical replay and for pre-fork blocks. -- Validator client "preferred gas limit" settings become a no-op for post-fork blocks. -- Builder API consumers (relays, builders, proposer middleware) must be updated to set `gas_limit` to the scheduled value and to reject mismatches. +- **Adoptability.** A recommendation requires no fork, no engine API changes, and no coordination risk. The configuration field is optional, so CL teams can adopt it independently and incrementally, and entries can activate at any coordinated post-Gloas epoch. +- **Validator sovereignty is preserved.** Gas limit choice remains, in the limit, with proposers β€” this EIP changes the *defaults and social convention* around that choice, not the protocol. +- **It solves the actual coordination problem.** The premature-voting and back-to-back-release problems are default-management problems, and defaults are client behavior. Consensus enforcement addresses a different (drift-above-tested-envelope) risk, and can be pursued later as a separate Core EIP layered on the same schedule if the community wants it. -Tooling that reads `block.gas_limit` continues to work unchanged; the field is still present in the header, it is simply protocol-determined. +### Why warn rather than clamp out-of-range preferences? -## Test Cases +Clamping a preference down to the scheduled value would be enforcement β€” just relocated from consensus into the client. Without a consensus rule behind it, that would be dishonest in both directions: it implies a network ceiling that does not actually exist (any proposer running a non-conforming client could still exceed it), and it silently overrides an explicit operator decision that produces perfectly valid blocks. It would also fragment behavior across clients, since only those that adopt the optional schedule would clamp. A prominent warning keeps the mechanism honest: the schedule shapes defaults, and exceeding the recommended maximum remains possible but becomes a deliberate, visible act instead of a silent misconfiguration. -The following cases describe the expected validation behavior at and after `FORK_TIMESTAMP`. Let `S = get_scheduled_gas_limit(block.timestamp, config)`. +### Relationship to EIP-7935 -1. **Equal to schedule.** `block.gas_limit == S` β†’ block valid (with respect to this rule). -2. **Above schedule.** `block.gas_limit == S + 1` β†’ block invalid. -3. **Below schedule.** `block.gas_limit == S - 1` β†’ block invalid. -4. **Within legacy elasticity, not on schedule.** `parent.gas_limit == S`, `block.gas_limit == S + S // 1024` β†’ block invalid (legacy rule no longer applies). -5. **GPO transition block.** Block with `timestamp` exactly equal to `gpo1Time`: `block.gas_limit == gasLimitSchedule["gpo1"]` β†’ valid; any other value β†’ invalid. -6. **Pre-fork block at fork boundary.** Block with `timestamp == FORK_TIMESTAMP - 1` is still subject to the legacy rule; the new rule does not apply. -7. **Engine API payload attributes carrying gas-limit field.** `engine_forkchoiceUpdated` call with a non-empty proposer gas-limit attribute β†’ call rejected with `-32602`. -8. **Builder bid mismatch.** Relay receives a builder bid with `gas_limit != S` β†’ relay rejects bid. +[EIP-7935](./eip-7935.md) recommended a single default (60M) tied to Fusaka's activation. This EIP generalizes that one-off into a standing, machine-readable epoch schedule so future changes reuse the same mechanism instead of requiring a new EIP and a fresh round of client-default coordination. -## Reference Implementation - -Pseudocode for the execution-layer block validity check: - -```python -def validate_gas_limit(block: Block, parent: Block, config: ChainConfig) -> None: - if block.header.timestamp < config.activation_timestamp(THIS_EIP_FORK): - # Legacy EIP-1559 elasticity rule - delta = parent.header.gas_limit // 1024 - assert parent.header.gas_limit - delta < block.header.gas_limit < parent.header.gas_limit + delta - assert block.header.gas_limit >= 5000 - return - - scheduled = get_scheduled_gas_limit(block.header.timestamp, config) - if block.header.gas_limit != scheduled: - raise InvalidBlock( - f"gas_limit {block.header.gas_limit} != scheduled {scheduled}" - ) -``` +## Backwards Compatibility -Pseudocode for the consensus-layer execution payload header check: - -```python -def verify_execution_payload_header(state: BeaconState, header: ExecutionPayloadHeader) -> None: - scheduled = get_scheduled_gas_limit_cl(compute_epoch_at_slot(state.slot), state.config) - assert header.gas_limit == scheduled - # ... other existing checks ... -``` +No consensus rules change and no blocks become invalid. Pre-Gloas behavior and existing operator settings remain unchanged. For post-Gloas duties covered by an active schedule entry, unconfigured validators derive their gas limit preference from the schedule instead of a hardcoded constant, and configured values above the scheduled maximum produce a warning but are honored. CL clients that do not adopt the field, and all EL clients, are unaffected. Tooling that reads `block.gas_limit` is unaffected. ## Security Considerations -**Primary goal: prevent unsafe gas-limit drift.** The dominant risk this EIP addresses is that the network reaches a gas limit that has not been tested at scale, due to a coordinated default change in clients or staking pools. Encoding the limit in consensus closes this gap: no popular default and no validator preference can push the network past the scheduled value. - -**Liveness.** Because the rule is exact-equality, a misconfigured proposer or builder that produces a block with the wrong `gas_limit` orphans that slot rather than producing an invalid-but-followed chain. This is the intended behavior β€” incorrect gas-limit values are now a self-correcting safety condition rather than a silent capacity change β€” but it does mean that bugs in the schedule plumbing manifest as missed slots. Clients SHOULD validate the schedule at startup and refuse to start if the EL and CL disagree. +**The ceiling is advisory.** Nothing in consensus prevents a proposer from exceeding the scheduled value, so this EIP does not protect against a deliberately non-conforming actor. It does protect against the accidental paths β€” a default shipped early, a stale setting, an uncoordinated bump β€” which historically are how unintended gas limit movement happens. The Β±1/1024 elasticity rule further rate-limits how fast any actor, conforming or not, can move the realized limit. -**Fork coordination risk.** This EIP creates a hard dependency between EL and CL gas-limit schedules. A divergence (EL says 90M, CL says 75M) will split the network at the fork boundary. The same risk exists today for BPO blob schedules; the same mitigations apply: schedule consistency checks at startup, devnet rehearsals, and clear off-chain coordination of `gpo` schedules. +**Herding on the schedule.** If most validators follow the schedule, the network's gas limit becomes highly predictable, which is the goal. It also means an error in a scheduled value propagates widely; scheduled values should receive the same devnet and worst-case-block testing that gas limit increases receive today (as described in [EIP-7935](./eip-7935.md)) before being added to a network's configuration. -**Upgrade pressure.** Removing per-validator gas-limit voting removes one informal mechanism for the staking community to signal concerns about capacity. EIP authors and core devs SHOULD treat this as a reason to maintain visible, structured channels for capacity discussion (e.g., All Core Devs calls, public test results) ahead of any `gpo` fork. +**Partial adoption.** Because the field is optional, clients that do not support it keep their existing defaults, and their operators keep coordinating manually. The mechanism's coordination benefit is proportional to adoption; a mixed network is no worse than today, since non-supporting clients behave exactly as they do now. -**Privacy/MEV considerations.** Builders and relays no longer compete on or advertise gas limits. This is a small reduction in the builder-proposer interface and is not expected to affect MEV economics meaningfully, since post-fork the gas limit is the same across all blocks. +**Path to enforcement.** If the community later wants a hard guarantee that the network cannot exceed a tested envelope, a future Standards Track Core EIP can promote this schedule's value to a consensus-enforced maximum without changing the configuration format. ## Copyright diff --git a/EIPS/eip-8272.md b/EIPS/eip-8272.md index f164fd853bf974..1ff890ed8ce03b 100644 --- a/EIPS/eip-8272.md +++ b/EIPS/eip-8272.md @@ -13,7 +13,7 @@ requires: 7623, 7843, 8141 ## Abstract -[EIP-8141](./eip-8141.md) frame transactions can reference recent roots without reading mutable storage during validation. A root source writes roots to a system contract, with each root keyed by `(source_id, slot)`, where `source_id` derives from the writer address and a salt. A frame transaction may declare recent root references of the form: +[EIP-8141](./eip-8141.md) frame transactions can reference recent roots without reading mutable storage during validation. This helps privacy applications validate spends using proofs against recent commitment tree roots named in the signed transaction envelope. A root source writes roots to a system contract, with each root keyed by `(source_id, slot)`, where `source_id` derives from the writer address and a salt. A frame transaction may declare recent root references of the form: ```text (source_id, slot, root) diff --git a/EIPS/eip-8279.md b/EIPS/eip-8279.md index 4cdda7e9c7bf04..e26b06936b3fac 100644 --- a/EIPS/eip-8279.md +++ b/EIPS/eip-8279.md @@ -1,7 +1,7 @@ --- eip: 8279 title: Block Access List Byte Floor -description: Meter EIP-7928 Block Access List bytes at runtime and fold them into the EIP-7623 transaction floor. +description: Meter EIP-7928 Block Access List bytes at runtime and fold them into the EIP-7623 transaction floor, capping worst-case block size. author: Toni WahrstΓ€tter (@nerolation) discussions-to: https://ethereum-magicians.org/t/eip-8279-block-access-list-byte-floor/28662 status: Draft @@ -13,11 +13,11 @@ requires: 7623, 7702, 7928, 7976, 7981, 8131 ## Abstract -Meter the bytes each opcode adds to the [EIP-7928](./eip-7928.md) Block Access List in an explicit per-transaction counter, and fold that count, at 64 gas/byte, into the transaction's floor accumulator β€” checked at runtime before the BAL grows. Today an attacker can pack ~1.55 MB into a 60M-gas block: 75% of gas on cold `SLOAD`s (32 BAL bytes per 2,100 gas) + 25% on calldata at 16 gas/byte. On top of [EIP-8131](./eip-8131.md)'s tx-content floor (same rate), block content is capped at `block_gas_limit / 64 β‰ˆ 0.89 MB` (~42% reduction). Neither EIP alone closes the bypass: 8131 does not price BAL bytes; 8279 reuses 8131's floor. Typical transactions are unaffected: the runtime BAL floor never binds in isolation. +Meter the bytes each opcode adds to the [EIP-7928](./eip-7928.md) Block Access List in an explicit per-transaction counter, and fold that count, at 64 gas/byte, into the transaction's floor accumulator β€” checked at runtime before the BAL grows. Today an attacker can pack ~1.55 MB into a 60M-gas block: 75% of gas on cold `SLOAD`s (32 BAL bytes per 2,100 gas) + 25% on calldata at 16 gas/byte. On top of [EIP-8131](./eip-8131.md)'s tx-content floor (same rate), block content is capped at `block_gas_limit / 64 β‰ˆ 0.89 MB` (~42% reduction), restoring the worst-case block-size bound that safe gas limit increases depend on. Neither EIP alone closes the bypass: 8131 does not price BAL bytes; 8279 reuses 8131's floor. Typical transactions are unaffected: the runtime BAL floor never binds in isolation. ## Motivation -[EIP-7623](./eip-7623.md) caps worst-case block size by charging at least 64 gas per non-zero calldata byte. [EIP-7981](./eip-7981.md) extended that to access-list entries, and [EIP-8131](./eip-8131.md) generalises the floor to a uniform per-byte rule over all tx-content fields, including [EIP-7702](./eip-7702.md) authorization tuples and [EIP-4844](./eip-4844.md) blob versioned hashes. [EIP-7928](./eip-7928.md) introduces a new source of block bytes, the BAL, populated by runtime opcodes. None of those static floors cover it. +Worst-case block size constrains scaling: the gas limit can only be raised as far as the largest possible block still propagates reliably. [EIP-7623](./eip-7623.md) caps worst-case block size by charging at least 64 gas per non-zero calldata byte. [EIP-7981](./eip-7981.md) extended that to access-list entries, and [EIP-8131](./eip-8131.md) generalises the floor to a uniform per-byte rule over all tx-content fields, including [EIP-7702](./eip-7702.md) authorization tuples and [EIP-4844](./eip-4844.md) blob versioned hashes. [EIP-7928](./eip-7928.md) introduces a new source of block bytes, the BAL, populated by runtime opcodes. None of those static floors cover it. The cheapest BAL contributor is a cold `SLOAD`: 32 bytes for 2,100 gas. Combined with all-non-zero calldata, an attacker pushes intrinsic gas above the calldata floor, pays intrinsic, and gets calldata at 16 gas/byte while loading the rest of the block via `SLOAD` keys. diff --git a/EIPS/eip-8282.md b/EIPS/eip-8282.md index ab44fb76a626f5..b81eb7f65831af 100644 --- a/EIPS/eip-8282.md +++ b/EIPS/eip-8282.md @@ -2,35 +2,30 @@ eip: 8282 title: Builder Execution Requests description: Predeploy builder deposit and exit request contracts for EIP-7732 builders on the EIP-7685 request bus -author: Cayman (@wemeetagain), Nico Flaig , Justin Traglia +author: Cayman (@wemeetagain), Nico Flaig , Felix Lange , Justin Traglia discussions-to: https://ethereum-magicians.org/t/eip-8282-builder-execution-requests/28699 status: Review type: Standards Track category: Core created: 2026-05-22 -requires: 1559, 7685, 7732 +requires: 1559, 7685, 7732, 7997 --- ## Abstract -Predeploy two [EIP-7685](./eip-7685.md) request contracts for [EIP-7732](./eip-7732.md) builders, modelled on the request bus that [EIP-7002](./eip-7002.md) (withdrawals) and [EIP-7251](./eip-7251.md) (consolidations) use: - -- a builder **deposit** contract that takes a raw 184-byte request β€” `pubkey ++ withdrawal_credentials ++ amount ++ signature` β€” and appends it to its queue. It serves both first deposits and top-ups: the consensus layer registers a builder on a `pubkey`'s first appearance and credits additional stake on later deposits. The signature is carried in the record and verified by the consensus layer on dequeue. -- a builder **exit** contract that takes a raw 48-byte `pubkey` and appends a full-exit record authorized by the caller's address (recorded as `source_address`). - -Each contract maintains an in-state request queue drained by an end-of-block `SYSTEM_ADDRESS` system call; the dequeued records become the contract's [EIP-7685](./eip-7685.md) `request_data`, committed in the block `requests_hash`, and each accepted request is also emitted as an anonymous log. Neither touches the validator deposit contract or the validator request predeploys; for builders created after the fork, they replace EIP-7732's onboarding through the validator deposit flow. +This EIP introduces two [EIP-7685](./eip-7685.md) request types and corresponding predeploy contracts for [EIP-7732](./eip-7732.md) builders. A builder deposit contract handles initial registration and stake top-ups. A builder exit contract lets a builder's `execution_address` trigger a full exit. Both contracts follow the request-bus pattern of [EIP-7002](./eip-7002.md) and [EIP-7251](./eip-7251.md). Builders no longer onboard through the validator deposit flow or exit through the voluntary-exit operation. ## Motivation -[EIP-7732](./eip-7732.md) introduces builders as a separate, staked consensus-layer class. A builder is created by a deposit, can have stake added, and must be able to exit. Today EIP-7732 sources this lifecycle from the *validator* flows: a builder is registered by an ordinary validator deposit request whose withdrawal credential carries the `0xB0` `BUILDER_WITHDRAWAL_PREFIX`, and a builder exits through a builder branch of the consensus-layer voluntary-exit operation. This EIP instead gives builders their own dedicated [EIP-7685](./eip-7685.md) request contracts. +[EIP-7732](./eip-7732.md) introduces builders as a separate, staked consensus-layer actor, but reuses validator flows for their lifecycle. A builder is registered by a validator deposit whose withdrawal credential carries the `0xB0` `BUILDER_WITHDRAWAL_PREFIX`, and it exits through a builder branch of the voluntary-exit operation. Dedicated builder request types improve on this in several ways. -**Dedicated request types remove cross-actor coupling.** Routing builders through the validator contracts forces the consensus layer to decide, on every request, whether it acts on the validator set or the builder set (today by inspecting the credential prefix). Dedicated builder request types make the actor explicit from the request type alone, so the validator and builder registries are keyed independently. A single public key can then be registered as both a validator and a builder. Under EIP-7732 the two cannot coexist for one key β€” a builder deposit is routed to the builder registry only when the key is not already a validator or pending validator, so deposit *routing*, not an explicit prohibition, keeps each key in at most one registry. Keying by request type removes that coupling: the registries become independent, and a key may appear in both with distinct indices and lifecycles (the only practical consequence is implementation-side; see [Security Considerations](#security-considerations)). +Dedicated types make the actor explicit from the request type alone. The consensus layer no longer routes deposits by inspecting credential prefixes, and the validator and builder registries are keyed independently. -**The deposit bounds a consensus-side denial-of-service surface.** A builder deposit's proof-of-possession is verified *inline* by the consensus layer when the deposit is processed β€” unlike a validator deposit, which is deferred to the churn-limited `pending_deposits` queue. Carried on the validator deposit request, builder deposits inherit its high per-payload ceiling, so an attacker submitting invalid-signature builder deposits at the 1-ETH builder minimum could force a full payload's worth of proof-of-possession checks. The coupled deposit request scheme also requires verifying all matching pending validator deposit signatures at builder deposit verification time. A *dedicated* request bus **separates** builder deposits from validator deposits β€” isolating the builder-side verification work β€” and caps them at `MAX_DEPOSIT_REQUESTS_PER_BLOCK` per block. The cap and separation are what bound the builder-side verification the consensus layer performs per block. +A builder deposit's proof-of-possession is verified inline when the deposit is processed, unlike a validator deposit's, which is deferred to the churn-limited `pending_deposits` queue. Builder deposits carried on the validator deposit request inherit its high per-payload ceiling. A dedicated request type isolates the verification work and caps it at `MAX_DEPOSIT_REQUESTS_PER_BLOCK` per block. -**Exit gains a cold-key path builders lack today.** EIP-7732 lets a builder exit only via a voluntary exit signed by its BLS key β€” the same hot key it uses to sign bids. The exit contract instead authorizes a full exit by the builder's `execution_address` (the address that owns its stake), exactly as [EIP-7002](./eip-7002.md) lets a validator's withdrawal credential trigger an exit. Routing builder exits through this request makes the consensus-layer voluntary-exit operation validator-only again. +Under [EIP-7732](./eip-7732.md), a builder can only exit with a signature from its BLS key, the same hot key that signs its bids. The exit contract instead authorizes exit by the builder's `execution_address`, as [EIP-7002](./eip-7002.md) does for validators. -Builders that must exist at the fork are unaffected: EIP-7732's fork-transition onboarding of builder-credentialed pending deposits is retained (see [Changes to EIP-7732](#changes-to-eip-7732)); only post-fork onboarding moves to the deposit contract. The deployed validator deposit contract is left untouched, and builder stake withdrawals continue to flow through EIP-7732's existing full-balance sweep. +Builders that must exist at the fork are unaffected. The [EIP-7732](./eip-7732.md) fork-transition onboarding of builder-credentialed pending deposits is retained, and only post-fork onboarding moves to the new contract. ## Specification @@ -38,8 +33,6 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S ### Constants -The `0x03`/`0x04` request types MUST be unique across **all** active [EIP-7685](./eip-7685.md) request types; final allocation is coordinated in consensus-specs. - | Name | Value | Comment | | --- | --- | --- | | `BUILDER_DEPOSIT_CONTRACT_ADDRESS` | `0x0000bFF46984e3725691FA540a8C7589300D8282` | Predeploy address of the builder deposit contract | @@ -53,67 +46,98 @@ The `0x03`/`0x04` request types MUST be unique across **all** active [EIP-7685]( | `TARGET_EXIT_REQUESTS_PER_BLOCK` | `2` | Per-block request count above which the fee rises for the exit contract | | `MIN_REQUEST_FEE` | `1` | Minimum request fee, in wei | | `REQUEST_FEE_UPDATE_FRACTION` | `17` | Controls the fee's rate of change | -| `EXCESS_INHIBITOR` | `2**256-1` | Excess value that makes the fee getter revert before the first system call (as in [EIP-7002](./eip-7002.md)/[EIP-7251](./eip-7251.md)); set at deployment, cleared by the first system call | +| `INHIBITOR` | `2**256 - 1` | Sentinel value written to `stored_excess` to inhibit non-system calls. Matches the source-level macro in `src/builder_deposits/main.eas:31` and `src/builder_exits/main.eas:32`. | | `BUILDER_MIN_DEPOSIT` | `1000000000000000000` | Minimum credited stake for a deposit, in wei (1 ETH β€” the [EIP-7732](./eip-7732.md) builder minimum) | | `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE` | *see [Reference Implementation](#reference-implementation)* | Runtime bytecode of the builder deposit contract | | `BUILDER_EXIT_CONTRACT_RUNTIME_CODE` | *see [Reference Implementation](#reference-implementation)* | Runtime bytecode of the builder exit contract | +Final request-type values MUST be unique across all active [EIP-7685](./eip-7685.md) request types. Allocation is coordinated in consensus-specs, where the existing types are defined ([`electra/beacon-chain.md`](https://github.com/ethereum/consensus-specs/blob/e310d1c0d316e4dcc822164966c5bfa592adae2b/specs/electra/beacon-chain.md)). + ### Deployment -Each predeploy is deployed exactly as the [EIP-7002](./eip-7002.md) and [EIP-7251](./eip-7251.md) request contracts are: by a one-time presigned transaction from a single-use deployer account (the Nick's-method scheme), so that `BUILDER_DEPOSIT_CONTRACT_ADDRESS` and `BUILDER_EXIT_CONTRACT_ADDRESS` are the addresses cryptographically derived from those transactions. Each contract's init code sets its `excess` slot to `EXCESS_INHIBITOR`, so no request can be enqueued until the inhibitor is cleared (see [Request fee](#request-fee)). The addresses above are derived from the presigned deployment transactions for the current reference bytecode; they will change if the runtime bytecode changes before it is audited and frozen (see [Reference Implementation](#reference-implementation)). +Both contracts are deployed by a `CREATE2` factory ([EIP-7997](./eip-7997.md)). Each address is determined by the factory, a salt, and the contract's init code. The salt was mined so that the addresses above result from the current reference bytecode. + +The contracts MUST be deployed before the fork that activates this EIP. If there is no code at either address once the EIP is active, every block from activation onward MUST be invalid. -The deployment transactions MUST be included before the fork that activates this EIP. If there is no code at either predeploy address once the EIP is active, every block from activation onward MUST be invalid β€” the same handling [EIP-7002](./eip-7002.md) and [EIP-7251](./eip-7251.md) specify for their predeploys. +The deposit contract's constructor leaves all storage at zero. Its `stored_excess` and `count` start at zero, so the write path is active from deployment and pre-fork deposits are accepted. The exit contract's constructor initializes its `stored_excess` to `INHIBITOR`, which inhibits non-system calls until a system call with empty calldata clears it to zero. ### Request queue and system call -Both predeploys follow the [EIP-7002](./eip-7002.md) / [EIP-7251](./eip-7251.md) request-bus pattern, reusing those contracts' storage layout: the [EIP-1559](./eip-1559.md)-style `excess` counter in slot 0, the per-block request `count` in slot 1, the FIFO queue's head and tail indices in slots 2 and 3, and the queued records from slot 4 onward. There is no ABI: like EIP-7002/EIP-7251, each contract dispatches on the caller and on `calldatasize` alone. +Both predeploys follow the [EIP-7002](./eip-7002.md) and [EIP-7251](./eip-7251.md) contract design, with minor tweaks, and reuse their storage layout. There is no Solidity-compatible ABI. Each contract dispatches on the caller and on `calldatasize` alone. + +The contracts use the following storage layout: + +- slot `0` stores `stored_excess`; +- slot `1` stores `count`; +- slot `2` stores `queue_head`; +- slot `3` stores `queue_tail`; +- queued records start at slot `4`. + +Before dispatching any call from an address other than `SYSTEM_ADDRESS`, the contract MUST revert if `stored_excess == INHIBITOR`. Calls that match none of the cases below MUST also revert. + +#### Write path + +A call from any address other than `SYSTEM_ADDRESS`, with calldata of exactly the contract's input size, submits a request. The contract MUST validate the request and the value sent (see below), append one record to its queue, increment `count`, and emit the accepted record as an anonymous log. + +`count` is the number of successful submissions since the last system call. It is stored in slot `1` and reset to zero by the system call. -- From `SYSTEM_ADDRESS` (the end-of-block system call): the predeploy MUST dequeue up to `MAX_REQUESTS_PER_BLOCK` records (oldest first), return their concatenation as that contract's `request_data`, advance its queue head past the returned records (resetting head and tail to zero when the queue fully drains, so the storage slots are reused), then update `excess` from the number of requests added in the block (`excess = max(0, excess + count - TARGET_REQUESTS_PER_BLOCK)`, treating a current value of `EXCESS_INHIBITOR` as `0` so the first system call clears the inhibitor) and reset that count. Records beyond the per-block cap remain queued for subsequent blocks. -- From any other caller with calldata of exactly the contract's input size: the write path. The predeploy MUST validate the request and the value sent (see the request sections below), append one record to its queue, increment the per-block count, and emit the accepted request as an anonymous log (`LOG0`, no topics). -- From any other caller with empty calldata: the fee getter. The predeploy MUST return the current fee without modifying state, and MUST revert if any value is attached (preventing accidentally lost funds). -- Any other calldata size MUST revert. +#### Fee getter -The execution layer prepends the contract's request-type byte and includes `request_type ++ request_data` in the block requests list, committed via the `requests_hash` ([EIP-7685](./eip-7685.md)). The logs are informational only β€” the canonical flow of a request into the chain is the `requests_hash`. +A call from any address other than `SYSTEM_ADDRESS`, with empty calldata, returns the current fee without modifying state. The contract MUST revert if any value is attached. The fee is returned as a 32-byte big-endian unsigned integer. -The end-of-block system call to each predeploy follows the same rules [EIP-7002](./eip-7002.md) and [EIP-7251](./eip-7251.md) specify, restated here because [EIP-7685](./eip-7685.md) does not: the call is made as `SYSTEM_ADDRESS` with a dedicated gas limit of `30_000_000`; the gas it consumes does not count against the block gas limit and no value is transferred; and **if any of the predeploys' system calls fails or returns an error, the block MUST be invalid.** +#### System call + +At the end of each block, the contract is called by `SYSTEM_ADDRESS`. The system call performs the following transition unconditionally and in order: + +1. Dequeue up to the per-block maximum of records (`MAX_DEPOSIT_REQUESTS_PER_BLOCK` or `MAX_EXIT_REQUESTS_PER_BLOCK`, oldest first) and construct `request_data` from their concatenation. If the dequeue empties the queue, set both `queue_head` and `queue_tail` to zero. Otherwise, advance `queue_head` past the dequeued records. The contract does not clear the dequeued record slots. +2. If calldata is non-empty, set `stored_excess` to `INHIBITOR`. This inhibits non-system calls. +3. If calldata is empty and `stored_excess` equals `INHIBITOR`, set `stored_excess` to zero, re-enabling non-system calls. If calldata is empty and `stored_excess` does not equal `INHIBITOR`, set `stored_excess` to `max(0, stored_excess + count - target)` where `target` is `TARGET_DEPOSIT_REQUESTS_PER_BLOCK` or `TARGET_EXIT_REQUESTS_PER_BLOCK` as applicable. +4. Reset `count` to zero. +5. Return `request_data`. + +Records beyond the cap remain queued for subsequent blocks. A system call with non-empty calldata always sets the inhibitor, regardless of whether records were dequeued. If `stored_excess` remains equal to `INHIBITOR`, the next system call with empty calldata clears it to zero. + +The execution layer prepends the contract's request-type byte and includes `request_type ++ request_data` in the block requests list, committed via the `requests_hash` ([EIP-7685](./eip-7685.md)). The system call follows the same rules as in [EIP-7002](./eip-7002.md). It runs with a dedicated gas limit of `30_000_000` that does not count against the block gas limit. If either contract's system call fails, the block MUST be invalid. + +### Contract upgrade + +The reversible inhibition mechanism exists to support a future contract upgrade without losing queued requests. To upgrade the predeploy, a new contract is deployed, the old contract continues to drain its queue through subsequent system calls with empty calldata, and the system invocation is changed to call the new contract with non-empty calldata. The non-empty calldata sets `stored_excess` to `INHIBITOR` on the old contract, rejecting all new requests, while the new contract accepts new requests. For the duration of one fork, the old contract is drained to empty and the new contract receives new deposits. ### Request fee -Each request carries a fee, computed exactly as in [EIP-7002](./eip-7002.md): +Each request carries a fee, computed as in [EIP-7002](./eip-7002.md): ``` -fee = fake_exponential(MIN_REQUEST_FEE, excess, REQUEST_FEE_UPDATE_FRACTION) +effective_excess = stored_excess + max(0, count - TARGET_REQUESTS_PER_BLOCK) +fee = fake_exponential(MIN_REQUEST_FEE, effective_excess, REQUEST_FEE_UPDATE_FRACTION) ``` -where `fake_exponential` is the integer approximation of `MIN_REQUEST_FEE Β· e^(excess / REQUEST_FEE_UPDATE_FRACTION)` used by [EIP-1559](./eip-1559.md). Because `excess` grows whenever a block contains more than `TARGET_REQUESTS_PER_BLOCK` requests and decays otherwise, the fee rises super-linearly under sustained demand and returns to `MIN_REQUEST_FEE` when demand subsides. The fee is charged on top of any staked value (see the request sections below) and is left locked in the contract. +where `TARGET_REQUESTS_PER_BLOCK` is `TARGET_DEPOSIT_REQUESTS_PER_BLOCK` or `TARGET_EXIT_REQUESTS_PER_BLOCK` as applicable, `count` is read from slot `1` before incrementing it for the current request, and `fake_exponential` is the [EIP-1559](./eip-1559.md)-style integer approximation of `MIN_REQUEST_FEE * e**(effective_excess / REQUEST_FEE_UPDATE_FRACTION)`. The fee rises super-linearly while blocks contain more than the target number of requests and decays back to `MIN_REQUEST_FEE` otherwise. It is charged on top of any staked value and is left locked in the contract. -As in EIP-7002/EIP-7251, each contract's `excess` is initialized to `EXCESS_INHIBITOR` at deployment, and the fee getter reverts while `excess == EXCESS_INHIBITOR`. Since a request is only appended after its fee is paid, this blocks every request between deployment and the first end-of-block system call; that call clears the inhibitor (treating the prior `excess` as `0`), and normal fee operation runs from the activation block onward. +Unlike [EIP-7002](./eip-7002.md), both contracts include the current `count` when they compute the fee for a non-system call. This allows the fee to increase within a block. The end-of-block system call still updates `stored_excess` as specified above. ### Deposit requests -A deposit request is submitted by calling the deposit contract with calldata of exactly `184` bytes: +A deposit request is submitted by calling `BUILDER_DEPOSIT_CONTRACT_ADDRESS` with calldata of exactly `184` bytes: -| Bytes | Field | -| --- | --- | -| `0:48` | `pubkey` β€” 48-byte BLS public key | -| `48:80` | `withdrawal_credentials` β€” 32-byte commitment (`version` byte + `execution_address`) | -| `80:88` | `amount` β€” big-endian `uint64`, in gwei ([EIP-7002](./eip-7002.md)'s input convention) | -| `88:184` | `signature` β€” 96-byte BLS proof-of-possession | - -A deposit request serves both a builder's first deposit and subsequent top-ups. The contract MUST reject the request unless both of the following hold: +| Bytes | Field | Description | +| --- | --- | --- | +| `0:48` | `pubkey` | 48-byte BLS public key | +| `48:80` | `withdrawal_credentials` | 32-byte commitment (`version` byte + `execution_address`) | +| `80:88` | `amount` | Big-endian `uint64`, in gwei | +| `88:184` | `signature` | 96-byte BLS proof-of-possession | -1. `amount * 1 gwei >= BUILDER_MIN_DEPOSIT`. -2. `msg.value >= fee`, where `fee` is the current request fee, **and** `msg.value - fee >= amount * 1 gwei` β€” the value beyond the fee fully funds the stake. Any value beyond `amount * 1 gwei + fee` is retained by the contract and not credited to the builder. +A deposit request serves both a builder's first deposit and subsequent top-ups. The contract MUST reject the request unless `amount * 1 gwei >= BUILDER_MIN_DEPOSIT` and `msg.value >= amount * 1 gwei + fee`. Any value beyond `amount * 1 gwei + fee` is retained by the contract and not credited to the builder. -On success it MUST append the 184 input bytes to its queue and emit them as an anonymous log (which therefore carries the amount big-endian, as submitted). The dequeued `BUILDER_DEPOSIT_REQUEST_TYPE` record is `pubkey (48) ++ withdrawal_credentials (32) ++ amount (8, little-endian) ++ signature (96)`: the input verbatim, with the amount converted to its little-endian SSZ encoding, as [EIP-7002](./eip-7002.md) returns its amount. The `signature` is carried in the record and verified by the consensus layer, which checks the proof-of-possession only on the `pubkey`'s first appearance and treats a later deposit to an existing builder as a stake top-up (see [Consensus-layer processing of records](#consensus-layer-processing-of-records)). +On success, the contract queues the exact 184-byte input and emits the same bytes as an anonymous log. The amount is therefore big-endian in both queue storage and the log. When the record is dequeued, the system output converts the amount to little-endian, as in [EIP-7002](./eip-7002.md). The contract does not verify the `signature`. It is carried in the record and verified by the consensus layer. Submitters SHOULD verify the proof-of-possession off-chain before broadcasting a first deposit. ### Exit requests -An exit request is submitted by calling the exit contract with calldata of exactly `48` bytes: the `pubkey` of the builder to exit. The contract MUST require `msg.value >= fee` (the same request fee as the deposit contract); it stakes no value and moves no ETH on the execution layer. On success it MUST append a `BUILDER_EXIT_REQUEST_TYPE` record of `source_address (20) ++ pubkey (48)` to its queue, where `source_address` is `msg.sender`, and emit the record as an anonymous log. +An exit request is submitted by calling `BUILDER_EXIT_CONTRACT_ADDRESS` with calldata of exactly `48` bytes, the `pubkey` of the builder to exit. The contract MUST require `msg.value >= fee`. No value is staked. On success it queues a record of `source_address (20) ++ pubkey (48)`, where `source_address` is `msg.sender`. -Authorization is by `source_address`, as in [EIP-7002](./eip-7002.md): the caller proves control of the builder by transacting from the builder's `execution_address`. The contract records `msg.sender` verbatim and performs no further check; the consensus layer honours the request only when `source_address` equals the target builder's `execution_address` (see [Consensus-layer processing of records](#consensus-layer-processing-of-records)). +Authorization is by `source_address`, as in [EIP-7002](./eip-7002.md). The contract records `msg.sender` and performs no further check. The consensus layer honors the request only when `source_address` equals the target builder's `execution_address`. -### Consensus layer request objects +### Consensus layer processing The consensus layer decodes each dequeued record into one of two SSZ containers, selected by request type: @@ -129,58 +153,83 @@ class BuilderExitRequest(Container): pubkey: Bytes48 ``` -A type's `request_data` is the concatenation of the fixed-size SSZ serializations of its records β€” 184 bytes per `BuilderDepositRequest` (`pubkey ++ withdrawal_credentials ++ amount ++ signature`) and 68 bytes per `BuilderExitRequest` (`source_address ++ pubkey`), with `amount` little-endian β€” exactly the bytes the system call returns, in the same order. `BuilderDepositRequest` is the validator [EIP-6110](./eip-6110.md) `DepositRequest` without the `index` field; the consensus layer verifies its `signature` (the proof-of-possession) on the builder's first registration. - -### Consensus-layer processing of records +A type's `request_data` is the concatenation of the fixed-size SSZ serializations of its records, which is exactly the bytes the system call returns, in the same order. `BuilderDepositRequest` is the [EIP-6110](./eip-6110.md) `DepositRequest` without the `index` field. -Both request types are applied immediately when processed β€” a `BuilderDepositRequest` is **not** routed through the validator `pending_deposits` queue, so a builder's balance is credited without an activation-churn queue, preserving EIP-7732's existing behavior. (A newly registered builder still becomes active for bidding and exit only once its deposit epoch is finalized, per Gloas `is_active_builder`; only the churn queue is skipped, not finality.) +Detailed state-transition behavior is specified in consensus-specs. In summary: -- A `BuilderDepositRequest` (type `0x03`) for a `pubkey` **not** yet in the builder set is a first deposit, handled by Gloas `process_builder_deposit_request`. The consensus layer registers the builder if the proof-of-possession `signature` over the `DepositMessage` `(pubkey, withdrawal_credentials, amount)` under `DOMAIN_BUILDER_DEPOSIT` is valid (`is_valid_builder_deposit_signature`) β€” a builder-specific signing domain, distinct from the validator deposit's `DOMAIN_DEPOSIT` (see [Security Considerations](#security-considerations)). On a valid signature it adds the builder with `balance = amount`, `execution_address` = the credential's last 20 bytes (`withdrawal_credentials[12:]`), and `version = withdrawal_credentials[0]` β€” the first credential byte is recorded as the builder's *version* (the only currently defined value is `PAYLOAD_BUILDER_VERSION`, `0`), **not** checked against a fixed prefix. A record whose signature is invalid is ignored (consumed, stake forfeited). There is no on-chain credential-prefix check on this path: the `0xB0` `BUILDER_WITHDRAWAL_PREFIX` is used only to mark deposits for builder onboarding *at the fork* (see [Changes to EIP-7732](#changes-to-eip-7732)) and is deprecated afterward. -- A `BuilderDepositRequest` (type `0x03`) for a `pubkey` **already** in the builder set is a top-up: it credits `amount` to the existing entry, and the record's `withdrawal_credentials` and `signature` are ignored β€” the registration is unchanged. This mirrors the validator deposit contract, where the proof-of-possession is checked only on a pubkey's first appearance and later deposits are stake additions. A builder index is reclaimed only once the builder has exited and its balance has swept to zero (`get_index_for_new_builder`); until the index is reclaimed, a deposit to that **exited** `pubkey` is still a top-up, not a fresh registration. Per `process_builder_deposit_request`, the top-up always credits `amount` to the exited entry; and *only* if that entry has already swept to zero (`withdrawable_epoch != FAR_FUTURE_EPOCH` and `balance == 0`) does it additionally *reset* `withdrawable_epoch` to `current_epoch + MIN_BUILDER_WITHDRAWABILITY_DELAY`, re-arming the withdrawal delay on the newly credited balance and deferring index reclamation. A top-up to an exited builder that has **not yet** swept simply adds to the balance, which still sweeps at the already-scheduled `withdrawable_epoch`. It does **not** reactivate the builder for bidding (`is_active_builder` requires `withdrawable_epoch == FAR_FUTURE_EPOCH`); the stake ultimately sweeps to the entry's `execution_address`. Re-registering the key as a fresh builder requires waiting for its index to be recycled. -- A `BuilderExitRequest` (type `0x04`) is handled by Gloas `process_builder_exit_request` and MUST be ignored unless its `pubkey` is a registered, active builder (`is_active_builder`: its deposit epoch is finalized and it is not already exiting), its `source_address` equals that builder's `execution_address`, and the builder has no pending balance to withdraw (`get_pending_balance_to_withdraw_for_builder == 0`). When all hold it runs `initiate_builder_exit` (`withdrawable_epoch = current_epoch + MIN_BUILDER_WITHDRAWABILITY_DELAY`). Like EIP-7002's `process_withdrawal_request`, it authorizes by `source_address` (no BLS signature) and silently returns on any failed check β€” the record is **consumed and discarded, not re-queued**, and the fee is spent. There is no builder-version check on exit; the `execution_address`, fixed at registration, is the sole authorizer. Because an active builder routinely has a non-zero pending balance from recent bid payments, a legitimate exit may be dropped until those settle, in which case the caller must resubmit once the pending balance has been swept. (The execution layer dequeues the record deterministically regardless, so a dropped request never affects `requests_hash` agreement.) +- A `BuilderDepositRequest` for a `pubkey` not in the builder registry registers a new builder if its `signature` is a valid proof-of-possession over `(pubkey, withdrawal_credentials, amount)` under `DOMAIN_BUILDER_DEPOSIT`, a builder-specific signing domain. A record with an invalid signature is ignored and its stake forfeited. Deposits are applied immediately rather than routed through the validator `pending_deposits` queue. +- A `BuilderDepositRequest` for an already-registered `pubkey` is a top-up. The `amount` is credited and the record's `withdrawal_credentials` and `signature` are ignored, as with validator deposits. +- A `BuilderExitRequest` initiates a full exit only if its `pubkey` is an active builder, its `source_address` equals that builder's `execution_address`, and the builder has no pending balance to withdraw. Otherwise the record is dropped rather than re-queued, and the request must be resubmitted. ### Changes to EIP-7732 -This EIP modifies EIP-7732's builder lifecycle on the consensus layer: +#### Deposit routing + +The builder branch of `process_deposit_request` is removed, so a deposit to the validator deposit contract is always an ordinary validator deposit. Builders are created and topped up only through `BUILDER_DEPOSIT_REQUEST_TYPE`. A post-fork deposit to the validator contract with a `0xB0` credential mints a validator that cannot withdraw its balance, so builder deposits MUST be sent to the builder deposit contract. + +#### Fork-transition onboarding -- **Deposit routing.** Builder onboarding and top-ups move off the validator deposit flow. Gloas no longer overrides `process_deposit_request` β€” the former builder branch (the `apply_deposit_for_builder` path) is removed, so the function reverts to its validator-only [EIP-6110](./eip-6110.md) behavior β€” and builders are created and topped up **only** through `BUILDER_DEPOSIT_REQUEST_TYPE`, handled by the new `process_builder_deposit_request`. A consequence operators must heed: a deposit to the **validator** deposit contract is now always an ordinary validator deposit, even if its `withdrawal_credentials` carries the `0xB0` prefix. Such a deposit is queued in `pending_deposits` and mints a validator that cannot withdraw its balance (a `0xB0` credential is neither a BLS nor an execution withdrawal credential). Builder deposits MUST therefore be sent to the builder deposit contract. -- **Fork-transition onboarding (at the Gloas fork).** `onboard_builders_from_pending_deposits`, run once by `upgrade_to_gloas`, is retained: builder-credentialed deposits already in `pending_deposits` at the upgrade are onboarded as builders, so builders exist from the first slot of the fork. This is the **only** path that onboards builders through the validator deposit contract. Operators seed the initial set by depositing to the existing deposit contract with a `BUILDER_WITHDRAWAL_PREFIX` (`0xB0`) credential before the fork β€” late enough that the deposit is still pending at the upgrade (a deposit applied earlier would create a stranded validator). These seed deposits are validated under `DOMAIN_DEPOSIT` (the only domain the validator deposit contract signs for) by `is_valid_deposit_signature`, and each onboarded builder is recorded with `version = PAYLOAD_BUILDER_VERSION`. After the fork, `BUILDER_WITHDRAWAL_PREFIX` is deprecated; a `0xB0`-credentialed deposit that misses the snapshot is processed as the stranded validator above, so the operator must onboard through `BUILDER_DEPOSIT_REQUEST_TYPE` instead. No `pubkey` is onboarded by more than one path. -- **Exit routing.** Gloas no longer overrides `process_voluntary_exit` β€” its former builder branch is removed, making the voluntary-exit operation validator-only again β€” and builders exit only via `BUILDER_EXIT_REQUEST_TYPE`, handled by the new `process_builder_exit_request`. +The one-time [EIP-7732](./eip-7732.md) onboarding of builder-credentialed pending deposits at the fork is retained, so builders exist from the first slot. This is the only path that onboards builders through the validator deposit contract. The `0xB0` `BUILDER_WITHDRAWAL_PREFIX` is deprecated afterward. + +#### Exit routing + +The builder branch of `process_voluntary_exit` is removed, making the voluntary-exit operation validator-only. Builders exit only through `BUILDER_EXIT_REQUEST_TYPE`. ## Rationale -- **Two predeploys, two request types.** Mirroring withdrawals (`0x01`) and consolidations (`0x02`) β€” each a single-type request predeploy β€” builder deposits (`0x03`) and exits (`0x04`) are separate predeploys sharing a common queue implementation. An empty-calldata `SYSTEM_ADDRESS` call returns a flat `request_data`, so the execution layer needs no new read semantics, and the consensus layer routes by request type rather than by inspecting credentials. +### Two predeploys, two request types -- **One request for deposits and top-ups.** A single deposit request serves both: a deposit to a new `pubkey` registers a builder (the consensus layer verifies the proof-of-possession), and a deposit to an existing builder tops up its stake β€” exactly as the validator deposit contract does. A top-up cannot redirect a builder's withdrawal target, because the consensus layer ignores the supplied `withdrawal_credentials` and `signature` for an existing builder; and a junk deposit to a new `pubkey` cannot register a builder without a valid proof-of-possession. +This mirrors withdrawals (`0x01`) and consolidations (`0x02`). The execution layer needs no new read semantics, and the consensus layer routes by request type rather than by inspecting credentials. -- **Exit by `execution_address`; voluntary exit becomes validator-only.** A builder's BLS key is hot β€” it signs bids continuously β€” so authorizing exit with that key is undesirable. Routing exit through the `execution_address` (the cold address that owns the builder's stake and receives its withdrawals) mirrors EIP-7002's rationale for letting `0x01` credentials trigger validator exits, and removing the builder branch from the voluntary-exit operation gives builders a single, well-defined exit authorizer. Losing the `execution_address` key strands no funds that were not already stranded: that address is where the builder's balance is swept regardless. +### One request for deposits and top-ups -- **EIP-1559-style request fee.** Each request carries the same demand-responsive fee as EIP-7002/EIP-7251: super-linear above `TARGET_REQUESTS_PER_BLOCK`, decaying back to `MIN_REQUEST_FEE` when demand subsides. Together with the per-block cap and the per-deposit stake, the fee meters submission to each predeploy. +This matches the validator deposit contract. The proof-of-possession is checked on a `pubkey`'s first appearance, and later deposits credit stake. A top-up cannot redirect a builder's withdrawals because its `withdrawal_credentials` and `signature` are ignored. -- **Onboarding via the fork transition.** Some applications depend on builders existing from the first slot of the fork. EIP-7732 already onboards builder-credentialed pending deposits during the fork upgrade; retaining that β€” rather than relying on post-fork deposits to the new contract, which cannot populate the first slot β€” keeps the initial builder set available immediately. This onboarding runs once, atomically, inside `upgrade_to_gloas`, so the per-block cap and request fee β€” which meter ongoing, adversarial submission to the steady-state contract β€” do not apply to it. Its cost is not constant-bounded: it processes the entire `pending_deposits` queue and verifies a proof-of-possession per new builder, which the consensus-layer spec notes may be slow and which clients SHOULD pre-verify and cache in the slots before the fork. +### Exit by `execution_address` -## Backwards Compatibility +A builder's BLS key is hot, since it signs bids continuously, so it should not also authorize exits. Routing exit through the cold `execution_address` mirrors the [EIP-7002](./eip-7002.md) rationale for validator withdrawal credentials and gives builders a single, well-defined exit authorizer. -This EIP is additive at the execution layer: it introduces new contracts at previously empty addresses. It does not modify the validator deposit contract at `0x00000000219ab540356cbb839cbe05303d7705fa`, the validator withdrawal/consolidation predeploys, or any existing validator's lifecycle. +### Request fee -At the consensus layer it modifies EIP-7732 (see [Changes to EIP-7732](#changes-to-eip-7732)): post-fork builder onboarding moves from the validator deposit request to `BUILDER_DEPOSIT_REQUEST_TYPE`, and builder exits move from the voluntary-exit operation to `BUILDER_EXIT_REQUEST_TYPE`. The fork-transition onboarding of builder-credentialed pending deposits is unchanged, so builders present at the fork are unaffected. The new request types are additive β€” blocks that contain no builder requests produce empty `request_data` for these types, which [EIP-7685](./eip-7685.md) excludes from the `requests_hash`. +The same demand-responsive fee as [EIP-7002](./eip-7002.md) and [EIP-7251](./eip-7251.md) meters submission, together with the per-block caps and the per-deposit stake. -## Reference Implementation +### Onboarding at the fork +Some applications depend on builders existing from the first slot of the fork, which post-fork deposits to the new contract cannot provide. The existing [EIP-7732](./eip-7732.md) onboarding path is therefore retained for the initial builder set. +## Backwards Compatibility + +This EIP is additive at the execution layer. It introduces new contracts at previously empty addresses and does not modify the validator deposit contract or the validator request predeploys. At the consensus layer it modifies the [EIP-7732](./eip-7732.md) builder lifecycle as described in [Changes to EIP-7732](#changes-to-eip-7732). Builders onboarded at the fork are unaffected. + +## Reference Implementation + +See [`src/builder_deposits`](https://github.com/ethereum/sys-asm/tree/83f9801245ff56878a450b5625801101b9a225a1/src/builder_deposits) and [`src/builder_exits`](https://github.com/ethereum/sys-asm/tree/83f9801245ff56878a450b5625801101b9a225a1/src/builder_exits). ## Security Considerations -- **Deposit proof-of-possession at the consensus layer.** The consensus layer verifies the proof-of-possession over the `DepositMessage` `(pubkey, withdrawal_credentials, amount)` under `DOMAIN_BUILDER_DEPOSIT` on a builder's first registration, and ignores the signature for top-ups. The per-block cap bounds how many such verifications the consensus layer performs per block; see *Spam and state growth* below for the full anti-abuse picture. -- **Cross-class deposit signatures.** Builder deposits are signed under `DOMAIN_BUILDER_DEPOSIT`, a signing domain distinct from the validator deposit's `DOMAIN_DEPOSIT`. The separate domain prevents cross-contract replay between the two deposit classes: a *validator* deposit proof-of-possession cannot be resubmitted as a builder deposit, and a *builder* deposit proof-of-possession cannot be resubmitted to the validator deposit contract β€” each contract's consensus-layer handler verifies only its own domain, so the two classes cannot cross-register. The single exception is fork-transition onboarding: the initial builder set is seeded through the validator deposit contract *before* the fork, so those seed deposits are necessarily signed under `DOMAIN_DEPOSIT` and validated under it by `onboard_builders_from_pending_deposits`, with the `0xB0` `BUILDER_WITHDRAWAL_PREFIX` on the credential distinguishing them for builder onboarding; after the fork the prefix is deprecated and steady-state builder deposits use `DOMAIN_BUILDER_DEPOSIT`. Like `DOMAIN_DEPOSIT`, the builder domain is chain- and fork-agnostic, so a builder's *own* public proof-of-possession remains replayable as a top-up (see *Replayable deposit records* below) β€” but only within the builder class, funding stake the original signer already authorized, and it can redirect nothing. -- **Exit authorization.** The exit contract records `msg.sender` as `source_address` and performs no further check. Because the request carries no signature, this is the sole authorization: the consensus layer MUST initiate an exit only when `source_address` equals the target builder's `execution_address`, or an arbitrary caller could exit a builder it does not control. A builder's only exit authorizer is therefore its `execution_address`; the voluntary-exit (BLS-key) path is removed for builders. -- **Custodial-split exit standoff.** A builder's exit precondition requires its pending balance to be zero (`get_pending_balance_to_withdraw_for_builder == 0`), every winning bid adds a pending payment, and the `execution_address` is the builder's sole exit authorizer (the BLS voluntary-exit path is removed). When the `execution_address` (the capital owner) and the BLS key (the bidding operator) are held by different parties β€” a custodial or staking-pool arrangement this design explicitly enables β€” the operator can keep the pending balance non-zero by continuing to win bids, so the capital owner cannot satisfy the exit precondition and the stake stays locked (a builder that never exits is never swept). The standoff is self-limiting, since the operator's bids must keep being included on-chain, but the protocol gives the `execution_address` holder no on-chain lever to halt bidding. Parties delegating builder operation SHOULD retain off-chain (contractual or operational) control over the operator's bidding, so a delegated builder can always be brought to a state in which it can exit. -- **Same public key as validator and builder.** Because the registries are keyed by independent request types, one public key may exist as both a validator and a builder. The two are distinct entries with distinct indices and distinct lifecycles; neither request type can act on the other registry. There is no shared-slashing or cross-registry safety concern. The only implementation consideration is that builder indices are **reusable** β€” an exited builder's index may later be reassigned to a different key (`process_builder_deposit_request` notes this) β€” so clients that cache builder state by index MUST account for reuse. -- **Replayable deposit records.** A deposit's `(pubkey, withdrawal_credentials, amount, signature)` is public in calldata, so a third party can submit a further `0x03` record for an already-registered builder at an arbitrary amount (funding it themselves). The consensus layer treats any `0x03` record for an already-registered `pubkey` as a top-up β€” crediting stake but ignoring the credentials and signature β€” so the replay cannot redirect a builder's withdrawals or re-register it; it is a harmless funded stake addition. -- **Spam and state growth.** The per-block cap bounds only the drain rate β€” the consensus-layer verifications and the `request_data` size per block β€” not enqueue: within a block, appends are limited only by gas, and the in-state queue grows across blocks, reclaiming slots only when it fully drains. Queue growth is instead gated by the value locked per record: every deposit locks at least `BUILDER_MIN_DEPOSIT` (1 ETH) plus the fee, so growing the queue by N records costs at least N ETH locked. A griefer submitting **valid** proofs-of-possession forfeits nothing β€” the stake becomes a real, withdrawable builder balance (a capital-lock for `MIN_BUILDER_WITHDRAWABILITY_DELAY`, not a burn) β€” so post-fork onboarding can be throttled behind a FIFO wall of attacker deposits for the cost of locking capital; the cap plus FIFO ordering, not the fee, is the binding throttle. This is tolerable because the time-critical initial builder set is seeded before the fork through the uncapped onboarding path, not through the steady-state contract. -- **Locked funds.** The request fee, any overpayment or sub-gwei remainder, and the principal of a first deposit the consensus layer rejects for an invalid proof-of-possession are permanently locked in the predeploy (which has no withdrawal path) and irrecoverable by anyone, including an honest depositor who submits a bad signature, since the execution layer does not verify BLS and the consensus-layer rejection is silent. This mirrors EIP-7002/EIP-7251; submitters SHOULD verify the proof-of-possession off-chain before broadcasting. (As in those contracts, the fee getter reverts when value is attached, so a mistaken value-bearing fee query cannot lose funds.) `BUILDER_MIN_DEPOSIT` is enforced only at the execution layer (as the validator deposit contract enforces its own minimum), with no consensus-layer re-assertion. -- **System-read access control and per-block cap.** Only `SYSTEM_ADDRESS` may invoke the end-of-block dequeue; any other empty-calldata call is the fee getter and does not modify state, so a non-system caller cannot drain or replay the queue. Each contract returns at most `MAX_REQUESTS_PER_BLOCK` records per block, bounding both the size each predeploy contributes to the block requests and the consensus-layer work to process them; excess records remain queued for later blocks. -- **Validator-contract co-existence.** The validator deposit contract and the validator request predeploys are unmodified; this EIP changes only EIP-7732's builder onboarding and exit routing (see [Changes to EIP-7732](#changes-to-eip-7732)). +### Exit authorization + +The exit contract records `msg.sender` as `source_address` and performs no further check. The request carries no signature, so the `source_address` check during [consensus layer processing](#consensus-layer-processing) is the only exit authorization; without it an arbitrary caller could exit any builder. + +### Signing-domain separation + +Builder deposits are signed under `DOMAIN_BUILDER_DEPOSIT`, distinct from the validator `DOMAIN_DEPOSIT`, so deposit proofs-of-possession cannot be replayed across the two classes. Fork-transition seed deposits are made through the validator deposit contract and are necessarily signed under `DOMAIN_DEPOSIT`. + +### Replayable deposit records + +A deposit's fields are public in calldata, so a third party can resubmit them for an already-registered builder at an amount it funds itself. Such a replay is a top-up whose credentials and signature are ignored. It credits stake and redirects nothing. + +### Custodial-split exit standoff + +Exit requires a zero pending balance, every winning bid adds pending balance, and only the `execution_address` can authorize exit. Where the `execution_address` (capital owner) and the BLS key (bidding operator) are held by different parties, an operator that keeps winning bids can indefinitely block the owner's exit. Delegating parties should retain off-chain control over the operator's bidding. + +### Spam and state growth + +The per-block caps bound the drain rate, not enqueue, so the in-state queues can grow across blocks. Growth is gated by value, since each deposit locks at least `BUILDER_MIN_DEPOSIT` plus the fee. An attacker submitting valid proofs-of-possession forfeits nothing, because the stake remains a withdrawable builder balance, so post-fork onboarding can be delayed behind a backlog of attacker deposits at the cost of locked capital. This is tolerable because the time-critical initial builder set is seeded through the fork transition rather than the steady-state contract. + +### Locked funds + +The request fee, any overpayment, and the principal of a first deposit that the consensus layer rejects for an invalid proof-of-possession are permanently locked in the predeploy. The execution layer does not verify BLS signatures, so the off-chain proof-of-possession check advised in [Deposit requests](#deposit-requests) is a submitter's only protection against losing a first deposit's principal. ## Copyright diff --git a/EIPS/eip-8297.md b/EIPS/eip-8297.md index 71aafd1b5c7e90..0a198dcbb76f2c 100644 --- a/EIPS/eip-8297.md +++ b/EIPS/eip-8297.md @@ -259,14 +259,15 @@ keys mutually prefix-free (see "Tree embedding"). All state is embedded into the single key/value space. Data accessed together is co-located under one shared key prefix ("stem") to reduce branch openings. The account header holds an account's basic data, code -hash, and first 64 storage slots under keys sharing one header stem. Code -is not in the header; it lives in `CODE_ZONE`, content-addressed (see -"Code"). +hash or delegation, and first 64 storage slots under keys sharing one header +stem. Code is not in the header; it lives in `CODE_ZONE`, content-addressed +(see "Code"). | Parameter | Value | | ----------------------- | ----- | | BASIC_DATA_LEAF_KEY | 0 | | CODE_HASH_LEAF_KEY | 1 | +| DELEGATION_LEAF_KEY | 2 | | HEADER_STORAGE_OFFSET | 64 | | HEADER_STORAGE_SLOTS | 64 | | STEM_SUBTREE_WIDTH | 256 | @@ -347,16 +348,41 @@ account with no code holds the Keccak hash of empty bytecode, unaffected by this EIP's choice of merkelization hash (see "Backwards Compatibility"). The header sub-indices in use are `BASIC_DATA_LEAF_KEY`, `CODE_HASH_LEAF_KEY`, -and `HEADER_STORAGE_OFFSET`..`HEADER_STORAGE_OFFSET + HEADER_STORAGE_SLOTS - 1`. -No key defined by this EIP resolves to any other sub-index. +`DELEGATION_LEAF_KEY`, and +`HEADER_STORAGE_OFFSET`..`HEADER_STORAGE_OFFSET + HEADER_STORAGE_SLOTS - 1`. +No key defined by this EIP resolves to any other sub-index; the remaining +sub-indices are reserved for future header fields (see "Storage layout"). + +### Delegation + +An account whose code is an [EIP-7702](./eip-7702.md) delegation indicator, the +23 bytes `0xef0100 || target`, holds it in its header stem rather than as code: + +```python +def get_tree_key_for_delegation(address: Address32): + return get_tree_key_for_header(address, DELEGATION_LEAF_KEY) +``` + +The value is the indicator followed by nine zero bytes, and `code_size` is 23. +Such an account has no `CODE_ZONE` leaves and no `code_hash` leaf, since this +leaf determines both the code and its hash: a code read takes the first +`code_size` bytes and `EXTCODEHASH` hashes them. Being delegated and holding +contract code are mutually exclusive, so every account that exists holds +exactly one of the `CODE_HASH_LEAF_KEY` and `DELEGATION_LEAF_KEY` leaves. An +authorization to the zero address clears the delegation, replacing this leaf +with a `code_hash` leaf holding the hash of empty bytecode and zeroing +`code_size`. + +An indicator cannot be deployed as contract code and none predates the ban +([EIP-3541](./eip-3541.md)), so an account holds one only by delegation. ### Code Every code chunk lives in `CODE_ZONE`, content-addressed by `code_hash` so contracts with identical bytecode share leaves. An aligned range of `STEM_SUBTREE_WIDTH` chunks sharing one `tree_index` is a code group; its -chunks share a stem and differ only in the sub-index byte. No code is keyed -by address. +chunks share a stem and differ only in the sub-index byte. No code chunk is +keyed by address. ```python def get_tree_key_for_code_chunk(code_hash: bytes32, chunk_id: int): @@ -473,9 +499,7 @@ Deleting an account ([EIP-161](./eip-161.md) state clearing, or [EIP-6780](./eip-6780.md)) MUST remove its header leaves and its storage leaves. Its code is content-addressed and may be shared with other accounts, so its `CODE_ZONE` leaves MUST be removed only if no account in the -resulting state has the same `code_hash`, and MUST be kept otherwise. The -same applies when an account's `code_hash` changes rather than the account -being deleted. +resulting state has the same `code_hash`, and MUST be kept otherwise. The MPT checked `storage_root` to decide whether an address has non-empty storage (i.e., the that condition [EIP-7610](./eip-7610.md) checks before contract @@ -529,6 +553,17 @@ Binding the group-spreading digest to the address as well as `tree_index` restricts the grinding analyzed in "Security Considerations" to the attacker's own bucket. +Within the header stem, `HEADER_STORAGE_OFFSET` sits on a power-of-two +boundary: with `HEADER_STORAGE_SLOTS = 64`, the header slots are exactly +the sub-indices whose two leading bits are `01`, so the whole range hangs +off a single branch and a witness touching several header slots shares one +path down to it. The sub-indices between `CODE_HASH_LEAF_KEY` and +`HEADER_STORAGE_OFFSET` are reserved for future header fields. The +reservation is free, since absent keys occupy no nodes, and a field +allocated there shares the leading `00` bits with the basic data and code +hash leaves that every account access already reads, placing it on the +branch a witness for those leaves already opens. + ### Content-addressed code Keying code by `code_hash` rather than by account lets all contracts with @@ -536,15 +571,37 @@ identical bytecode share leaves. Most deployed contracts repeat a small number o templates, so this removes a large amount of duplicate code from the state. For the same reason, a block witness contains at most one copy of a shared chunk, no matter how many contracts touch it. Sharing is also why account deletion checks -before removing a chunk (see "Zero values and deletion"). That check is cheap in -practice: the accounts a block deletes are those [EIP-161](./eip-161.md) clears, -which have no code at all, and those `SELFDESTRUCT` removes in their creation -transaction, whose code the same transaction wrote. +before removing a chunk (see "Zero values and deletion"). + +That check is decidable from the transaction alone. A code leaf is present +exactly while some account has that contract code, and [EIP-161](./eip-161.md) +clearing reaches only accounts with no code, so an account with code is deleted +only by `SELFDESTRUCT` in the transaction that created it. A leaf that predates +the transaction is therefore held by an account the transaction cannot delete +and stays; a leaf the transaction inserted is held only by accounts the +transaction wrote that code to, and goes when none of them remain. Neither case +reads state older than the transaction, so no reference count over the state is +needed. + +An account can replace a delegation indicator but never contract code, which +is why indicators are not kept as code (see "Delegation"). Whether another +account still delegates to the same target is answerable neither from the +transaction nor from a witness, since a shared leaf is byte-identical whether +one account holds it or a million; it would need a reference count over the +whole state, kept correct across restart, snap sync and reorgs. A later change +that lets a live account replace code in `CODE_ZONE` would have to say how the +check stays local. + +An indicator takes its own sub-index rather than sharing the code-hash leaf the +account never needs at the same time, because telling the two apart by their +leading bytes would let an attacker grind code whose hash begins `0xef0100` and +have the contract read as a delegation. Keeping a prefix of the code in the account header instead would avoid that check, but it would key that prefix by address, so the templates above would each store one copy per deployment: the duplication this zone exists to -remove. +remove. That reasoning does not extend to a delegation indicator, which is 23 +bytes and replaces the code-hash leaf the account would hold anyway. ### SNARK friendliness and post-quantum security @@ -648,11 +705,11 @@ ever created. Per-account and per-bucket expiry is a natural operation on the zone topology. The storage bucket keyed by `key_hash(address)` roots one account's storage in the common case. Record its hash and prune below it. -The account header's stem expires the account's core data and hot -storage in one step. Content-addressed code needs -reference counting or deferral to a state sweep, since its leaves may be -shared. Resurrection re-attaches a subtree consistent with the recorded -commitment. The mechanism itself is left to a separate EIP. +The account header's stem expires the account's core data, hot storage and +delegation in one step. Content-addressed code needs reference counting or +deferral to a state sweep, since its leaves may be shared and a sweep has no +transaction to reason from. Resurrection re-attaches a subtree consistent +with the recorded commitment. The mechanism itself is left to a separate EIP. ## Backwards Compatibility @@ -668,8 +725,9 @@ numbers through `SLOAD` and `SSTORE` and never see tree keys. Key derivation run inside the client, below the EVM, exactly as the MPT already hashes slot keys and addresses. No contract, Solidity, or Yul code changes. -`EXTCODEHASH` is unaffected since the `code_hash` leaf stores the Keccak hash of the -account's code regardless of the tree's own merkelization hash. +`EXTCODEHASH` is unaffected. The account's code hash is a Keccak hash +regardless of the tree's own merkelization hash, held in the `code_hash` leaf +or, for a delegated account, computed from the delegation leaf. ## Test Cases @@ -684,6 +742,15 @@ key = 0x00 || H(A) || 0x00 length = 1 + 32 + 1 = 34 bytes ``` +Delegation of address `A` to target `T`: + +``` +sub_idx = DELEGATION_LEAF_KEY = 2 (0x02) +key = 0x00 || H(A) || 0x02 +length = 34 bytes +value = 0xef0100 || T || 0x00 * 9 +``` + Storage slot `storage_key = 5` of address `A` (in the header, since 5 < 64): ``` diff --git a/EIPS/eip-8321.md b/EIPS/eip-8321.md new file mode 100644 index 00000000000000..5f7c43156745a3 --- /dev/null +++ b/EIPS/eip-8321.md @@ -0,0 +1,419 @@ +--- +eip: 8321 +title: Hash-Chain RANDAO +description: Replace the BLS-signature RANDAO reveal with a post-quantum hash-chain commit-reveal scheme +author: Kevaundray Wedderburn (@kevaundray), Benedikt Wagner (@benedikt-wagner), Tom Wambsgans (@TomWambsgans), Justin Drake (@JustinDrake), Thomas Coratger (@tcoratger) +discussions-to: https://ethereum-magicians.org/t/eip-8321-hash-chain-randao/28942 +status: Draft +type: Standards Track +category: Core +created: 2026-07-05 +requires: 7916 +--- + +## Abstract + +Replace the BLS-signature-based Random Decentralized Autonomous Organization (RANDAO) reveal with a hash-chain commit-reveal scheme. RANDAO's resistance to grinding currently relies on BLS signatures being *unique*, so that a proposer cannot bias its contribution. Since BLS relies on pre-quantum hardness assumptions, a quantum computer can recover a validator's key and predict its future reveals. + +A hash chain relies only on standard hash-function security: collision resistance to prevent grinding (the property that replaces BLS's uniqueness) and preimage resistance for unpredictability. Both are believed to hold against quantum attack, and this removes the dependency on the signature scheme entirely. Each validator commits to the tip of a generated hash chain. When proposing a block, the validator reveals the preimage of its currently stored commitment; the protocol verifies that the revealed value is indeed the preimage to the current stored commitment for that validator, folds the preimage into the RANDAO accumulator, and stores the preimage as the validator's new commitment. + +A commitment is registered once, via a new per-block-capped beacon operation (similar to `BLSToExecutionChange`); it cannot be updated in place, so a validator that ever needs a new chain exits and re-enters. Validators that have not yet registered a commitment continue to use the legacy BLS reveal; this is a transitional path intended for removal in a later fork. + +We note the simplicity: the protocol holds only the current commitment (32 bytes) and walks one link back per proposal for that validator. + +## Motivation + +The primary driver is post-quantum readiness. + +Today's RANDAO contribution is a BLS signature over the epoch number. Its security rests on the *uniqueness* of BLS signatures: given the message and public key, exactly one valid signature exists, so a proposer cannot grind their own contribution to bias the randomness. + +BLS is not post-quantum safe, so a cryptographically relevant quantum computer (CRQC) can recover a validator's secret key from its public key. Uniqueness still holds, but the attacker can compute the validator's reveals itself, predicting the chain's future randomness, and thus the proposer schedule, far in advance. + +Prediction also amplifies bias, beyond merely leaking the schedule. The standard reveal-or-withhold attack requires the attacker to control `k` *consecutive* proposer slots at the tail of an epoch to choose among `2**k` candidate mixes, because it cannot evaluate a candidate without knowing the contributions that land after its own. An attacker that can predict every honest contribution evaluates each candidate outright, so (assuming honest proposers always reveal) any `k` of its slots in the mixing period (the span of slots whose reveals feed the target seed) work; they need not be consecutive or at the end of the epoch. + +Most uses of signatures, such as block proposals and attestations, can move to any secure post-quantum scheme. RANDAO, however, additionally depends on signature *uniqueness*, and most post-quantum schemes do not provide it, allowing one to grind for a favorable RANDAO contribution. The hash-based signature scheme currently planned for the consensus layer (a generalized eXtended Merkle Signature Scheme (XMSS) ([RFC 8391](https://www.rfc-editor.org/rfc/rfc8391)), commonly referred to as the lean signature scheme) is grindable in exactly this way, since it includes a *salt* component. + +A hash chain sidesteps both problems in a simple way. There is no signature, it is just chaining hashes together. Grinding would require building a chain around a hash collision: if a proposer could find two values that hash to the same word, it could place that word in its chain and later choose which of the two preimages to reveal, biasing its contribution. This is exactly the freedom BLS's uniqueness denies, and collision resistance denies it here. Preimage resistance separately keeps each reveal unpredictable to others until it is published. Both properties are believed to hold even against a quantum attacker, with reduced but adequate security. + +The commit-reveal structure also preserves RANDAO's existing security model, since the whole chain is fixed at commitment time, long before the validator knows its proposal slots or the mixes it might want to bias, so the proposer's only remaining lever is the same as the one it has today: reveal, or withhold and forfeit the block. + +Note: This EIP does not make beacon chain randomness post-quantum secure end to end; block signatures and the registration operation's signature still use BLS. It incrementally introduces a post-quantum version of RANDAO, so that a later post-quantum fork which changes the signature-scheme and sets the initial hash-chain commitment at deposit time can complete the transition. + +## Specification + +The key words "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](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174). + +These changes are applied to the consensus specifications (`ethereum/consensus-specs`) at a fork to be scheduled; functions and constants not defined here retain their meaning from those specifications. + +### Cryptographic Functions + +`blake3(data: bytes) -> Bytes32` is the BLAKE3 hash function (version 1) in its default unkeyed hash mode, with no derive-key context, restricted to its default 32-byte output. All hashing introduced by this EIP uses `blake3` (see Rationale); the consensus specifications' `hash` helper continues to serve the legacy reveal path. + +### Constants + +| Name | Value | Description | +| - | - | - | +| `DOMAIN_RANDAO_COMMITMENT_REGISTRATION` | `DomainType('0x0F000000')` | Domain for signed commitment registrations | +| `HASH_CHAIN_RANDAO_DST` | `b'HASH_CHAIN_RANDAO'` | Domain-separation tag prefixed to each hash-chain link | + +### Preset + +| Name | Value | Description | +| - | - | - | +| `COMMITMENT_REGISTRATION_DELAY` | `Epoch(3)` | Epochs before a new commitment becomes active; MUST be at least `MIN_SEED_LOOKAHEAD + 2` (see Rationale) | +| `MAX_RANDAO_COMMITMENT_REGISTRATIONS` | `uint64(128)` | Maximum commitment registration operations per block | + +### Hash Chain Construction (Off-Chain) + +A validator generates a uniformly random 32-byte chain secret `c_0` and computes a chain of length `n`; every chain value `c_i` is a `Bytes32`, matching the BLAKE3 output width: + +```text +c_i = blake3(HASH_CHAIN_RANDAO_DST + c_{i-1}) for i in 1..n +``` + +where `blake3` is defined in the Cryptographic Functions section above. `HASH_CHAIN_RANDAO_DST` is the fixed byte-string domain-separation prefix defined in the Constants section above, so hash-chain links cannot collide with hashes used elsewhere in the protocol. It carries no per-link index, so a validator still needs to store only chain values, not their positions. + +Nothing in the construction is specific to BLAKE3; any collision- and preimage-resistant hash with a 32-byte output serves (see Rationale for why BLAKE3 over the consensus specifications' `hash` helper). Note that a commitment binds the hash function it was generated with, so a later fork that migrates to a different hash either keeps verifying previously registered chains under BLAKE3 or has validators re-register under the new hash. + +Chain values MUST NOT be the zero word: `process_randao` rejects a zero reveal (the zero word marks an unregistered validator), and a zero commitment cannot be registered. The seed `c_0` is chosen non-zero, and every other `c_i` is a BLAKE3 output, so a zero link occurs only with negligible probability (about `n / 2**256`). Even so, a validator MUST check that no `c_i` is the zero word and regenerate the chain if any link is zero. + +The validator publishes `c_n` as their commitment and stores the chain (or the chain secret plus periodic checkpoints if the chain is large). Reveals are consumed in reverse order: the first reveal is `c_{n-1}`, the next is `c_{n-2}`, and so on. Each revealed value becomes the new on-chain commitment, so the protocol requires no knowledge of `n` or of the validator's position in the chain. + +Validators SHOULD choose `n` large enough that the chain outlasts the validator, since it cannot be extended in place. `n >= 2**16` (~65,000 links) is RECOMMENDED. The whole chain can be generated in milliseconds, and storing every link takes ~2 MB. This exact value is of course arbitrary: the protocol never learns or enforces `n`, and since generation and storage stay cheap, most operators lose nothing by choosing a larger `n`. + +This bounded lifetime is the one structural difference from BLS, where a public key is a commitment that never expires, a hash chain instead lasts for its length. It is not a practical constraint though since we can generate a large enough chain that will last for centuries. + +#### Chain Exhaustion + +A commitment cannot be updated in place, so a chain cannot be extended once registered. The chain is exhausted only when its stored commitment reaches the secret seed `c_0`, whose preimage the validator does not hold. A validator whose chain runs out can no longer propose on the hash-chain path and must exit and re-enter as a new validator to obtain a fresh chain. + +With the recommended `n` this never happens in practice. A validator proposes on the order of 100 times per year even in aggressive futures, so a chain of `2**16` links lasts for centuries; sizing `n` generously (it is cheap) makes exhaustion a non-issue. The same applies to a lost or mis-generated chain secret: there is no in-place recovery, so the remedy is exit and re-entry, and the chain secret SHOULD be guarded like the signing key (see Security Considerations). + +### Containers + +#### New Containers + +```python +class RandaoCommitmentRegistration(Container): + validator_index: ValidatorIndex + commitment: Bytes32 # the hash-chain commitment to register +``` + +```python +class SignedRandaoCommitmentRegistration(Container): + message: RandaoCommitmentRegistration + signature: BLSSignature +``` + +```python +class PendingRandaoCommitment(Container): + validator_index: ValidatorIndex + commitment: Bytes32 + activation_epoch: Epoch +``` + +#### Modified Containers + +`BeaconBlockBody` gains two fields and retains `randao_reveal` transitionally: + +```python +class BeaconBlockBody(Container): + randao_reveal: BLSSignature # transitional; MUST be the G2 point at infinity once the proposer has an active commitment + # ... existing fields ... + hash_chain_reveal: Bytes32 # [New in this EIP] zero unless the proposer has an active commitment + randao_commitment_registrations: List[SignedRandaoCommitmentRegistration, MAX_RANDAO_COMMITMENT_REGISTRATIONS] # [New in this EIP] +``` + +The new fields are appended after all existing fields, following the convention since Capella of adding fields at the end. + +`BeaconState` gains a commitment registry and a pending queue. `randao_commitments` is indexed by validator index, holding one entry per registry member; a zero entry means no commitment is registered and the legacy BLS reveal path applies. The `Validator` container is unchanged. + +```python +class BeaconState(Container): + # ... existing fields ... + randao_commitments: List[Bytes32, VALIDATOR_REGISTRY_LIMIT] # [New in this EIP] + pending_randao_commitments: ProgressiveList[PendingRandaoCommitment] # [New in this EIP] +``` + +The pending queue is a `ProgressiveList` ([EIP-7916](./eip-7916.md)): it is almost always near-empty (worst-case steady state is ~12,300 entries, see Security Considerations), so a progressive shape avoids both an arbitrary capacity constant and the hashing overhead of a large fixed-limit list. `randao_commitments` stays a fixed-limit `List` because it holds one entry per registry member and must track the length of the other per-validator lists, which share the `VALIDATOR_REGISTRY_LIMIT` bound. + +### Block Processing + +#### Modified `process_randao` + +```python +def process_randao(state: BeaconState, body: BeaconBlockBody) -> None: + epoch = get_current_epoch(state) + proposer_index = get_beacon_proposer_index(state) + proposer = state.validators[proposer_index] + if state.randao_commitments[proposer_index] != Bytes32(): + # Hash chain reveal [New in this EIP] + assert body.hash_chain_reveal != Bytes32() + assert blake3(HASH_CHAIN_RANDAO_DST + body.hash_chain_reveal) == state.randao_commitments[proposer_index] # check proposer knows preimage + assert body.randao_reveal == G2_POINT_AT_INFINITY # bls randao reveal should be empty + mix = blake3(get_randao_mix(state, epoch) + body.hash_chain_reveal) + state.randao_commitments[proposer_index] = body.hash_chain_reveal + else: + # Legacy BLS reveal + assert body.hash_chain_reveal == Bytes32() # hash-chain reveal should be empty + signing_root = compute_signing_root(epoch, get_domain(state, DOMAIN_RANDAO)) + assert bls.Verify(proposer.pubkey, signing_root, body.randao_reveal) + mix = xor(get_randao_mix(state, epoch), hash(body.randao_reveal)) + state.randao_mixes[epoch % EPOCHS_PER_HISTORICAL_VECTOR] = mix +``` + +Note the structure of the hash-chain path: + +- Verification checks the proposer's chain step (`blake3(HASH_CHAIN_RANDAO_DST + reveal) == commitment`). +- The accumulator folds in the raw reveal with `mix = blake3(mix + reveal)`. +- `G2_POINT_AT_INFINITY` is the existing BLS point-at-infinity signature constant already defined in the consensus specs. + +The hash accumulator has no efficiently computable inverse, so a validator that copies another's commitment cannot cancel the victim's contribution: re-injecting the same revealed value produces a fresh, unrelated mix rather than undoing it. This is why the hash-chain path can fold in the raw reveal directly, with no assumption on the mixed-in value being unique per validator. The legacy BLS path keeps its existing `xor` accumulator; the two coexist only until the BLS path is sunset. + +The non-zero assert protects the sentinel: a zero entry in `randao_commitments` means "unregistered", and the reveal is stored as the next commitment. Without the guard, a validator that committed to the zero word as a chain value would, upon revealing it, silently store the sentinel and flip onto the legacy branch, while its client, still believing itself registered, produced invalid blocks indefinitely. With the guard, the zero-revealing block is itself invalid, so a validator that committed the zero word simply cannot propose (hence the rule above that no chain value may be the zero word). So "zero means unregistered" is an enforced invariant. + +#### Modified `process_operations` + +`process_operations` gains a loop over the new operation, appended after the existing operations so that existing processing is unchanged: + +```python +def process_operations(state: BeaconState, body: BeaconBlockBody) -> None: + # ... existing operation processing (proposer slashings, attester slashings, + # attestations, deposits, voluntary exits, bls_to_execution_changes) ... + for_ops(body.randao_commitment_registrations, process_randao_commitment_registration) # [New in this EIP] +``` + +#### New `process_randao_commitment_registration` + +```python +def process_randao_commitment_registration(state: BeaconState, signed_registration: SignedRandaoCommitmentRegistration) -> None: + registration = signed_registration.message + assert registration.validator_index < len(state.validators) + assert registration.commitment != Bytes32() + # Register-only: valid only while the validator is unregistered (stored commitment is zero) + assert state.randao_commitments[registration.validator_index] == Bytes32() + validator = state.validators[registration.validator_index] + domain = compute_domain( + DOMAIN_RANDAO_COMMITMENT_REGISTRATION, + genesis_validators_root=state.genesis_validators_root, + ) + signing_root = compute_signing_root(registration, domain) + assert bls.Verify(validator.pubkey, signing_root, signed_registration.signature) + queue_randao_commitment(state, registration.validator_index, registration.commitment) +``` + +Called from `process_operations` for each element of `body.randao_commitment_registrations`. This is the one-time registration path: it moves a validator from the legacy BLS reveal onto its hash chain, and works regardless of proposal schedule. It is valid only while the validator is unregistered (its stored commitment is zero). The signing domain is computed against the genesis fork version (similar to `BLSToExecutionChange`) so that messages remain valid across forks. + +Registration is single-use by construction, covering two windows with two mechanisms. Once a registration has **activated**, `randao_commitments[validator_index]` is non-zero, so any replay fails the unregistered check in this handler. While it is still **pending** (queued but not yet activated), the stored entry is still zero, but the one-pending rule in `queue_randao_commitment` rejects a second submission. Together these leave no gap from inclusion onward. This mirrors the replay-safety of `BLSToExecutionChange`, whose handler likewise asserts the credential has not already been changed. + +#### New `queue_randao_commitment` + +At most one registration per validator may be pending at any time; a block containing a registration for a validator with an in-flight one is invalid. + +```python +def queue_randao_commitment(state: BeaconState, index: ValidatorIndex, commitment: Bytes32) -> None: + # Rejects a second registration for the same validator, both across blocks and + # within a single block: the first op appends this entry, so a later op for the + # same index fails this assert (mirrors how voluntary exits self-reject in-block). + assert all(pending.validator_index != index for pending in state.pending_randao_commitments) + state.pending_randao_commitments.append(PendingRandaoCommitment( + validator_index=index, + commitment=commitment, + activation_epoch=get_current_epoch(state) + COMMITMENT_REGISTRATION_DELAY, + )) +``` + +The resulting lifecycle, worked through to be more explicit: + +- A registration included in epoch `N` is queued with activation epoch `N + COMMITMENT_REGISTRATION_DELAY` (`N + 3` with the preset value). +- The validator stays on the legacy BLS path throughout epochs `N` to `N + 2`; its proposals in that window are BLS reveals as before. +- At the epoch transition into `N + 3`, the pending entry is consumed and the hash chain becomes active; from then on the validator reveals from its chain. + +Activation is a property of the canonical state, not of the validator's broadcast history. A validator MUST continue producing legacy BLS reveals until `randao_commitments[validator_index]` is non-zero in the state it proposes against, even if it has observed its registration included in some block: if that block is orphaned, the pending entry never enters the canonical queue. The message stays valid in that case (the stored commitment is still zero) and remains includable: it persists in operation pools, as with `bls_to_execution_change`. + +### Epoch Processing + +#### New `process_pending_randao_commitments` + +Called from `process_epoch`, immediately after `process_registry_updates`. Pending commitments are applied in queue order once their activation epoch is reached. The one-pending-registration-per-validator rule enforced in `queue_randao_commitment` guarantees entries never conflict. + +```python +def process_pending_randao_commitments(state: BeaconState) -> None: + next_epoch = Epoch(get_current_epoch(state) + 1) + remaining = [] + for pending in state.pending_randao_commitments: + if pending.activation_epoch <= next_epoch: + state.randao_commitments[pending.validator_index] = pending.commitment + else: + remaining.append(pending) + state.pending_randao_commitments = ProgressiveList[PendingRandaoCommitment](remaining) +``` + +### Gossip + +A new global gossip topic `randao_commitment_registration` carries `SignedRandaoCommitmentRegistration` messages. Because a validator registers at most once, first-seen-per-validator deduplication suffices, exactly as for `bls_to_execution_change`. The rules are evaluated in order, so the cheap checks and the seen check precede signature verification. + +- **[REJECT]** `commitment` is zero, or `validator_index` is unknown. +- **[IGNORE]** a `SignedRandaoCommitmentRegistration` for `validator_index` has already been seen, or a pending registration for it already exists in the node's view of the state. +- **[REJECT]** the validator is already registered (a non-zero `randao_commitments` entry in the node's view of the head state). +- **[REJECT]** the signature is invalid. + +### Fork Transition + +At the fork epoch, the `upgrade_to_*` function initializes `randao_commitments` with one zero entry per registry member and `pending_randao_commitments` as empty: + +```python +def upgrade_to_(pre: ) -> BeaconState: + post = BeaconState( + # ... existing fields carried over from `pre` ... + randao_commitments=[Bytes32() for _ in range(len(pre.validators))], # [New in this EIP] + pending_randao_commitments=[], # [New in this EIP] + ) + return post +``` + +This establishes the invariant that `len(state.randao_commitments) == len(state.validators)`, which `process_randao` and `process_randao_commitment_registration` rely on when indexing by validator index. To preserve the invariant for validators onboarded after the fork, `add_validator_to_registry` is modified to append a zero entry alongside the other per-validator lists: + +```python +def add_validator_to_registry(state: BeaconState, + pubkey: BLSPubkey, + withdrawal_credentials: Bytes32, + amount: uint64) -> None: + index = get_index_for_new_validator(state) + validator = get_validator_from_deposit(pubkey, withdrawal_credentials, amount) + set_or_append_list(state.validators, index, validator) + set_or_append_list(state.balances, index, amount) + set_or_append_list(state.previous_epoch_participation, index, ParticipationFlags(0b0000_0000)) + set_or_append_list(state.current_epoch_participation, index, ParticipationFlags(0b0000_0000)) + set_or_append_list(state.inactivity_scores, index, uint64(0)) + set_or_append_list(state.randao_commitments, index, Bytes32()) # [New in this EIP] +``` + +A validator added this way starts unregistered and uses the legacy path until it registers. All validators start on the legacy BLS reveal path and migrate by broadcasting a `SignedRandaoCommitmentRegistration`. At `MAX_RANDAO_COMMITMENT_REGISTRATIONS = 128` per block, the full current validator set (~1M) can register in under two days of full blocks; there is no deadline, and unregistered validators simply continue on the legacy path. + +### Sunset of the Legacy Path + +The BLS reveal branch in `process_randao`, and the `randao_reveal` field itself, are transitional and SHOULD be removed in a later fork after registration has saturated, naturally the fork that reworks deposits for post-quantum signatures, at which point initial hash chain commitments move into the validator onboarding flow and the `RandaoCommitmentRegistration` signature migrates to the post-quantum scheme. + +Note: the `RandaoCommitmentRegistration` operation registers a validator's initial hash chain only. A validator that loses its chain secret must exit and re-enter. Once the post-quantum fork sets initial commitments at deposit time, the operation is no longer needed and can be deprecated alongside the BLS reveal path. + +## Rationale + +### Why a Hash Accumulator on the Hash-Chain Path + +The mixed-in contribution must be unpredictable before the reveal: the mix must never absorb a value derivable from pre-reveal public state. This rules out folding in `blake3(HASH_CHAIN_RANDAO_DST + reveal)`, which for a hash chain is by definition the validator's stored commitment, already public on-chain. The raw reveal is the opposite: it is the secret preimage, unknown to everyone until the proposer publishes it, so folding it in is safe. + +The complication is that hash-chain commitments carry no identity and are trivially copyable. A validator can register a value it did not generate by including a copy of another validator's current commitment, via a perfectly valid, freshly signed registration. Under a linear XOR accumulator this enables a cancellation attack. +A hash accumulator, `mix = blake3(mix + reveal)`, closes this at the root: the hash has no efficiently computable inverse, so re-injecting a copied reveal produces a fresh, unrelated mix rather than undoing anything. + +Because the accumulator itself neutralizes copyability, the hash-chain path folds in the raw reveal with no per-validator personalization. + +### Why BLAKE3 Instead of the Consensus `hash` + +The scheme needs nothing beyond standard collision and preimage resistance; the choice of BLAKE3 is forward-looking rather than security-driven. BLAKE3 is fast in software, and recent results on proving it efficiently in binary-field proof systems make it the leading candidate hash for the post-quantum consensus layer. + +### Why One Link per Block Proposed + +The legacy reveal is a signature over the epoch, so two proposals by the same validator in one epoch reveal the same value. Consuming one link per block is simpler (no per-epoch bookkeeping), makes every block contribute fresh entropy, and costs nothing, since chains are cheap to generate at any reasonable length. + +We also note that with consolidations, the probability of a validator proposing multiple times in the same epoch increases, effectively cancelling out their RANDAO contribution. + +### Why the Protocol Does Not Track Chain Length + +Storing only the current commitment and requiring a preimage per proposal makes the chain length a purely private, off-chain parameter. The protocol state cost is one 32-byte entry per validator regardless of chain length, and validators can size chains so that the chain outlasts the validator. The only reason to store the chain length would be to mandate a specific length for every validator, which seems unnecessarily restrictive. + +### Why an Activation Delay on Registration, and Why `MIN_SEED_LOOKAHEAD + 2` + +The scheme's grinding resistance comes entirely from the commitment predating the validator's knowledge of what it would want to bias. If a validator could register a chain just before proposing and it became active instantly, it could grind: generate many candidate chains, compute the mix each would produce at the upcoming slot, and register the most favourable one. We add a delay, sized so that the registrant cannot even know *whether* it will propose in the activation epoch at the time the registration is included. + +Proposer duties for epoch `E` are drawn from the RANDAO mix as of the end of epoch `E - MIN_SEED_LOOKAHEAD - 1`, i.e. `E - 2` with the current consensus-specs preset `MIN_SEED_LOOKAHEAD = 1`. For a registration included in epoch `N`: + +- Duties through epoch `N + 1` fixed at the end of `N - 1` or earlier: already known at inclusion. +- Duties for `N + 2` fix at the end of `N`, which is nearly over at inclusion: still grindable. +- Duties for `N + 3` fix at the end of `N + 1`: entirely after inclusion. + +The earliest safe activation epoch is therefore `N + MIN_SEED_LOOKAHEAD + 2 = N + 3`, met exactly by `COMMITMENT_REGISTRATION_DELAY = 3`; the new commitment is first usable from the validator's first proposal in that epoch. The constants table states the bound symbolically so it survives a future change to the preset. + +### Why at Most One Pending Registration per Validator + +Without the one-pending rule, a validator's stored entry stays zero until activation, so multiple registrations for it would all pass the unregistered check and could be queued at once. That enables queue stuffing: the entire `MAX_RANDAO_COMMITMENT_REGISTRATIONS` budget could be filled with registrations for a single validator, costing the network 128 signature verifications and 128 queue entries for a single state effect, and crowding legitimate registrations out of the shared per-block budget. + +The one-pending rule also keeps gossip sound: the "IGNORE if a pending registration exists" rule is only meaningful if a further registration for that validator is guaranteed redundant. With the rule, every included registration corresponds to a distinct validator making its one-time transition, and duplicates are invalid rather than merely wasteful. + +### Why Registration Is One-Time, and Replay-Safe + +A commitment can be registered but never updated in place. This is a deliberate simplification: because a chain can be sized to outlast the validator (see Chain Exhaustion), in-place rotation is never needed for exhaustion, and a lost chain secret is treated like a lost signing key, recovered by exiting and re-entering rather than by a protocol operation. + +Replay-safety falls out of the unregistered check. A registration is valid only while `randao_commitments[validator_index]` is zero. Once it activates, the entry is non-zero, so any later replay of the message is invalid; and while the registration is merely pending, the one-pending rule blocks a second submission. This is the analogue of the idempotence predicate that makes `BLSToExecutionChange` replay-safe, whose handler asserts the credentials are still BLS-prefixed, which the first application falsifies. + +### Why Keep a Legacy Fallback Instead of a Registration Deadline + +There is no urgent need for validators to be fully post-quantum yet, so a hard deadline buys little; unregistered validators keep functioning on the BLS path in the meantime. There will also likely be a long tail of validators who never submit the message because they run custom software. We therefore defer the forced cutover to a fork that must touch these code paths anyway, the post-quantum switchover, which also lets us exercise the new code paths first. + +## Backwards Compatibility + +This EIP requires a scheduled consensus-layer hard fork. Within the fork, the change is backwards compatible from the validator's perspective: unregistered validators continue proposing exactly as today. Downstream consumers of `randao_mixes` (including the execution layer's [EIP-4399's](./eip-4399.md) `PREVRANDAO`) are unaffected; the mix remains a 32-byte accumulator updated once per block, only the provenance of contributions changes. + +## Test Cases + +State-transition test vectors to be provided in the consensus-specs test suite. + +## Security Considerations + +### Biasability Is Unchanged + +The proposer's only degree of freedom is still withholding: reveal and propose, or withhold and forfeit the block plus its rewards. This is the same one-bit-per-proposer bias RANDAO has today, with the same economic cost. The validator freely choosing its own chain values (whereas a BLS reveal is uniquely determined by the key and epoch, leaving no freedom) does not add grinding power, because the entire chain is fixed before the validator knows its proposal slots or the co-contributions to any future mix. + +### Registration Cannot Be Used Reactively + +A validator that sees a proposal duty approaching cannot register a favourable chain to influence the outcome. Activation is derived from the inclusion epoch, the only event the protocol observes: registration activates `COMMITMENT_REGISTRATION_DELAY` epochs after inclusion, strictly after every epoch whose proposer-duty seed was fixed (or partially accumulated) at inclusion, so the chain is always fixed before the seed that draws any duty it could serve. Signing earlier than inclusion only means committing with less information. + +Note: This argument assumes the registrant cannot predict the contributions that land between inclusion and the fixing of the target seed. That holds today (a BLS reveal is computable only by its key holder) and after migration (a hash-chain reveal is protected by preimage resistance), but an attacker that already holds a CRQC during the transition could predict every remaining legacy contribution and grind its one-time registration against them. + +Timing games around inclusion add nothing: an unregistered validator stays on the BLS path until activation, and once registered it cannot re-register, so there is no second chain to time. + +Nor can registrations be replayed by third parties: once a validator is registered its stored entry is non-zero, so any replay fails the unregistered check. + +### Degenerate Chains + +A validator may choose pathological values for its *own* chain, for example deriving the chain secret from a publicly known constant like the genesis hash, so that all its reveals are predictable in advance and contribute no effective entropy. + +This cannot be exhaustively restricted, and we do not try to, because RANDAO's security does not assume that *every* contribution is honest. If this were the case, then a validator choosing to add nothing by withholding would also break the security; one honest contributor per mixing period suffices. + +We do forbid the pathological case of using the zero word, since this would collide with the unregistered sentinel when revealed, so `process_randao` rejects zero reveals. + +### Chain Loss and Theft + +Losing the chain secret makes a validator unable to produce valid blocks on the hash-chain path, and there is no in-place recovery: a commitment cannot be re-registered. The remedy is to exit and re-enter as a new validator. Funds are unaffected (withdrawal does not depend on the chain), and the failure mode is missed proposals, not slashing risk. + +Theft of the chain secret alone lets an attacker predict (not choose) the validator's future contributions. In effect this is equivalent to leaking the validator's entire list of future BLS-RANDAO reveals: the attacker learns every future contribution in advance, but can neither choose them nor sign anything else on the validator's behalf. It is therefore strictly less power than theft of the signing key today, which gives the attacker that same prediction plus the ability to sign the validator's blocks and attestations. Validator clients should treat the chain secret with the same custody standards as signing keys. As a safety measure, it should not be derived in a way that links it to the signing key: a hash chain, taken to its limit, eventually reveals its seed, whereas a secret key must never be revealed. + +### Reveal Exposure in Orphaned Blocks + +The legacy BLS reveal signs the epoch number, so a reveal exposed in a block that never makes the canonical chain is worthless outside that epoch. A hash-chain reveal has no such time binding: a reveal published in an *orphaned* block remains the validator's mandatory next contribution at whatever future slot it next proposes. An observer who collects reveals from orphaned blocks (or losing forks) accumulates known future co-contributions. + +Because a chain cannot be retired in place, there is no cheap remediation, but none is needed: the exposure is harmless. A published-but-not-yet-consumed reveal only makes that one future contribution predictable rather than secret, which is no worse than the validator withholding it, and RANDAO tolerates predictable contributions (one honest contributor per mixing period suffices). A validator sufficiently concerned about a specific exposed chain can exit and re-enter, but this is unwarranted in practice. + +### Erroneous Commitments + +Preimage possession is unverifiable at registration: the protocol cannot distinguish a commitment whose chain the validator holds from a typo or the output of a buggy key-derivation. + +Because registration is one-time, a bad commitment cannot be corrected in place: the validator can never propose on the hash-chain path, and the only remedy is to exit and re-enter. This makes an erroneous registration as costly as losing the chain secret, and it does not exist under BLS reveals, where there is nothing to misconfigure beyond the signing key itself. Validator clients should therefore verify a commitment by walking its full chain before registering, and treat the chain secret with the same care as the signing key. + +### Quantum Adversaries + +The scheme rests on two properties of BLAKE3. Grinding resistance rests on collision resistance: a proposer that could find a collision could give a chain link two preimages and choose between them at reveal time to bias its contribution. + +Unpredictability rests on preimage resistance: a reveal cannot be derived from the public commitment before it is published. The post-quantum security of the scheme is therefore comparable to the collision resistance of BLAKE3, which is believed to remain adequate against a quantum adversary. The transitional BLS reveal path and the BLS signature on `RandaoCommitmentRegistration` remain quantum-vulnerable, as do block signatures generally; this EIP removes RANDAO's structural dependence on signature uniqueness so that the eventual post-quantum fork is a signature-scheme swap rather than a randomness redesign. + +### Denial of Service via Registrations + +Registration confers no randomness advantage, so spamming registrations is a pure-cost DoS vector. It is bounded on every surface: + +- **Blocks.** The operations list is capped at `MAX_RANDAO_COMMITMENT_REGISTRATIONS` per block (at most 128 additional BLS verifications). +- **Gossip.** A validator registers at most once, so first-seen-per-validator deduplication (as for `bls_to_execution_change`) bounds gossip to one signature verification per validator, ever. Non-validators cannot participate: their messages fail signature validation and incur gossipsub peer penalties. Messages for an already-registered validator fail the head-state REJECT check, also penalized. +- **State.** `queue_randao_commitment` enforces at most one pending registration per validator, and the queue is additionally bounded by inclusion rates: at most `MAX_RANDAO_COMMITMENT_REGISTRATIONS` entries per block, each resident for `COMMITMENT_REGISTRATION_DELAY` epochs, giving a worst-case steady state of ~12,300 entries. + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md). diff --git a/EIPS/eip-8333.md b/EIPS/eip-8333.md index 1eba75916727f0..ac6461ef9940c1 100644 --- a/EIPS/eip-8333.md +++ b/EIPS/eip-8333.md @@ -34,19 +34,32 @@ This requires no changes to the execution layer. ### Consensus layer +#### New `get_checkpoint_slot` + +`get_checkpoint_slot(epoch)` returns the slot anchoring the checkpoint for `epoch`. From the activation epoch onward, it's the last slot before the epoch begins. Epochs before activation resolve under the previous anchoring. The genesis epoch, which has no slot before it, anchors at `GENESIS_SLOT`. The helper takes only an epoch, so the state accessor `get_checkpoint_root` and the fork choice's `get_checkpoint_block` both resolve through it and the two views of the same checkpoint cannot diverge. + +```python +def get_checkpoint_slot(epoch: Epoch) -> Slot: + """ + Return the slot anchoring the checkpoint for ``epoch`` + """ + if epoch == GENESIS_EPOCH: + return GENESIS_SLOT + if epoch < EIP8333_FORK_EPOCH: + return compute_start_slot_at_epoch(epoch) + return Slot(compute_start_slot_at_epoch(epoch) - 1) +``` + #### New `get_checkpoint_root` -`get_checkpoint_root(state, epoch)` returns the root of the boundary block anchoring the checkpoint for `epoch`: the last block before the epoch begins. In a healthy network this is the last block of the previous epoch; if the previous epoch's trailing slots are empty, it is an earlier block. The genesis epoch, which has no previous epoch, resolves to the genesis block. +`get_checkpoint_root(state, epoch)` returns the root of the block anchoring the checkpoint for `epoch`, the most recent block at or before `get_checkpoint_slot(epoch)`. From the activation epoch onward this is the boundary block. In a healthy network this is the last block of the previous epoch; if the previous epoch's trailing slots are empty, it is an earlier block. The genesis epoch, which has no previous epoch, resolves to the genesis block. ```python def get_checkpoint_root(state: BeaconState, epoch: Epoch) -> Root: """ - Return the block root anchoring the checkpoint for ``epoch`` -- the last - block before ``epoch`` (the epoch boundary). + Return the block root anchoring the checkpoint for ``epoch`` """ - if epoch == GENESIS_EPOCH: - return get_block_root_at_slot(state, GENESIS_SLOT) - return get_block_root_at_slot(state, Slot(compute_start_slot_at_epoch(epoch) - 1)) + return get_block_root_at_slot(state, get_checkpoint_slot(epoch)) ``` #### Modified functions @@ -58,7 +71,17 @@ The following functions resolve checkpoint roots via `get_checkpoint_root(state, #### Fork choice -`get_checkpoint_block(store, root, epoch)` resolves the checkpoint to the ancestor at slot `compute_start_slot_at_epoch(epoch) - 1`, or at the genesis slot for the genesis epoch. Its consumers (`on_attestation` validation, `filter_block_tree`, and the gossip conditions on the target block) need no further changes. +`get_checkpoint_block(store, root, epoch)` resolves the checkpoint to the ancestor at `get_checkpoint_slot(epoch)`. Its consumers (`on_attestation` validation, `filter_block_tree`, and the gossip conditions on the target block) need no further changes. + +```python +def get_checkpoint_block(store: Store, root: Root, epoch: Epoch) -> Root: + """ + Compute the checkpoint block for epoch ``epoch`` in the chain of block ``root`` + """ + node = ForkChoiceNode(root=root, payload_status=PAYLOAD_STATUS_PENDING) + # [Modified in EIP8333] + return get_ancestor(store, node, get_checkpoint_slot(epoch)).root +``` #### Honest validator @@ -66,9 +89,9 @@ The FFG target is set to `Checkpoint(epoch=get_current_epoch(head_state), root=g #### Fork transition -`get_checkpoint_root` and `get_checkpoint_block` MUST resolve epochs before the activation epoch via `compute_start_slot_at_epoch(epoch)`, i.e. under the previous anchoring. This is required for correctness: +`get_checkpoint_slot` MUST resolve epochs before the activation epoch to `compute_start_slot_at_epoch(epoch)`, i.e. under the previous anchoring. This is required for correctness: -- Checkpoints recorded before activation use the previous anchoring. `filter_block_tree` requires the finalized checkpoint root to match `get_checkpoint_block` on every viable branch. Re-resolving the finalized epoch under the new rule breaks this match, and with it head computation, until a post-activation checkpoint is finalized. +- Checkpoints recorded before activation use the previous anchoring. For example, `on_block` and `filter_block_tree` require the finalized checkpoint root to match `get_checkpoint_block` on every incoming block's parent and on every viable branch, respectively. Re-resolving the finalized epoch under the new rule breaks this match, and with it block import and head computation. - Attestations with pre-activation target epochs remain includable for an epoch after activation. Evaluating them under the previous anchoring preserves their validity and rewards. ## Rationale diff --git a/EIPS/eip-8347.md b/EIPS/eip-8347.md index c9fce407b38238..6884670b48e397 100644 --- a/EIPS/eip-8347.md +++ b/EIPS/eip-8347.md @@ -109,8 +109,8 @@ The converter is a deterministic function from MPT state to PBT state. Given the 1. Scan the source (MPT) leaves, recovering each leaf's full hashed path. A leaf node holds only the tail of that path, so the walk accumulates the rest from the branch indices and extension segments above it. The path is `keccak256(address)` in the account trie and `keccak256(slotKey)` in a storage trie, where `slotKey` is the 32-byte big-endian slot number. 2. Index the preimages by `keccak256(preimage)`, and require that this index and the set of leaf paths from step 1 match **exactly**, in both directions. A leaf path with no preimage means the set is incomplete. A preimage matching no leaf path means the set is not the one committed by `ANCHOR_BLOCK`'s `stateRoot`. The converter MUST reject either case. Per-leaf hash equality is not a check on its own, since a preimage is only ever found by hashing it. What is enforced is the exact match of the two sets. -3. Derive PBT keys per [EIP-8297](./eip-8297.md) using the preimages. Most of an account's state lands under its header stem in `ACCOUNT_ZONE`: basic data, `code_hash`, and storage slots 0 through 63 at sub-indices `HEADER_STORAGE_OFFSET`..`HEADER_STORAGE_OFFSET + 63`. Only slots 64 and above take storage-zone keys. -4. For each account with code, fetch the bytecode from the code store using the account's `code_hash`, chunk it per [EIP-8297](./eip-8297.md), and emit the resulting code leaves in `CODE_ZONE`, content-addressed by `code_hash`, so accounts sharing bytecode share them: a converter MUST emit each code leaf exactly once, since a duplicate key would break byte-canonicality. A chunk whose 32-byte value is all zero, that is 31 zero code bytes carrying no PUSHDATA, MUST NOT be emitted, since [EIP-8297](./eip-8297.md) requires a zero-valued key to be absent. +3. Derive PBT keys per [EIP-8297](./eip-8297.md) using the preimages. Most of an account's state lands under its header stem in `ACCOUNT_ZONE`: basic data, `code_hash` or a delegation, and storage slots 0 through 63 at sub-indices `HEADER_STORAGE_OFFSET`..`HEADER_STORAGE_OFFSET + 63`. Only slots 64 and above take storage-zone keys. +4. For each account with code, fetch the bytecode from the code store using the account's `code_hash`, chunk it per [EIP-8297](./eip-8297.md), and emit the resulting code leaves in `CODE_ZONE`, content-addressed by `code_hash`, so accounts sharing bytecode share them: a converter MUST emit each code leaf exactly once, since a duplicate key would break byte-canonicality. A chunk whose 32-byte value is all zero, that is 31 zero code bytes carrying no PUSHDATA, MUST NOT be emitted, since [EIP-8297](./eip-8297.md) requires a zero-valued key to be absent. An account whose code is a delegation indicator instead takes a single header leaf at `DELEGATION_LEAF_KEY`, and emits neither code leaves nor a `code_hash` leaf. 5. Sort leaves by PBT key order (an external merge-sort for mainnet-scale state). 6. Construct the tree bottom-up in a single sequential pass. @@ -160,11 +160,11 @@ The leading **zone byte** of `key` fixes its length, so a decoder knows what it | Zone byte | Zone | Key length, zone byte included | | --- | --- | --- | -| `0x00` | account header (basic data, `code_hash`, storage slots 0-63) | 34 | +| `0x00` | account header (basic data, `code_hash` or delegation, storage slots 0-63) | 34 | | `0x01` | code, content-addressed chunks | 34 | | `0xFF` | storage, slots 64 and above only | 66 | -An account or code key is `zone[1] | stem[32] | subindex[1]`, and a storage key is `zone[1] | stem[64] | subindex[1]`, per [EIP-8297](./eip-8297.md). A decoder MUST also reject any key in the `0x02`-`0xFE` range, which [EIP-8297](./eip-8297.md) reserves and no anchor-state leaf can occupy. +An account or code key is `zone[1] | stem[32] | subindex[1]`, and a storage key is `zone[1] | stem[64] | subindex[1]`, per [EIP-8297](./eip-8297.md). A decoder MUST also reject any key whose zone byte is in the `0x02`-`0xFE` range, which [EIP-8297](./eip-8297.md) reserves and no anchor-state leaf can occupy. The trailing byte of every key is the **sub-index**. Two records belong to the same **stem** if and only if their keys are equal in all bytes except the sub-index. In other words, `key[:-1]` is identical. Because the PBT snapshot is sorted in PBT-key order, all records sharing a stem are contiguous. @@ -184,9 +184,9 @@ Neither is a root of trust; a node runs the [dual-check](#verification-dual-chec Any node MUST be able to verify a downloaded PBT snapshot without trusting the distribution source. This includes a fresh node with no prior state. Because the snapshot carries only leaves and no intermediate nodes, verification is also the step in which each node **derives the full tree for itself**. To verify, the node performs both checks: 1. **Internal PBT consistency.** Rebuild the PBT from the PBT snapshot leaves: derive keys per [EIP-8297](./eip-8297.md), compute every intermediate node by hashing bottom-up, and verify that the resulting root matches the claimed PBT root. The intermediate nodes are a product of this step, not part of the distributed artifact. -2. **Consensus anchoring.** Re-hash the PBT snapshot leaves under the **MPT schema** using the distributed preimages and verify the result against block `ANCHOR_BLOCK`'s header `stateRoot`. That covers every field the MPT commits: nonce, balance, storage, and `code_hash`. +2. **Consensus anchoring.** Re-hash the PBT snapshot leaves under the **MPT schema** using the distributed preimages and verify the result against block `ANCHOR_BLOCK`'s header `stateRoot`. That covers every field the MPT commits: nonce, balance, storage, and `code_hash`. A delegated account carries its code hash in its delegation leaf rather than a `code_hash` leaf, so the re-hash recomputes it from that leaf. - The MPT holds no code, so the re-hash alone leaves the code leaves and `code_size` unchecked. They are anchored instead to the `code_hash` it does cover. For each distinct `code_hash`, reassemble the bytecode from the 31-byte slices of that code's chunk leaves in chunk order, taking the chunk count from `code_size` and reading an absent chunk as 31 zero bytes, truncate the result to `code_size`, and require its `keccak256` to equal that `code_hash`. Then re-chunk the recovered bytecode per [EIP-8297](./eip-8297.md) and require its non-zero chunks to equal the artifact's code leaves byte for byte, which pins each chunk's leading PUSHDATA count as well as its contents and its placement in `CODE_ZONE`. A wrong `code_size` yields different bytes, so the same preimage pins it. `version` is the one remaining field the MPT does not commit; [EIP-8297](./eip-8297.md) sets it to zero on every header write, so every basic-data leaf MUST carry `version == 0`. + The MPT holds no code, so the re-hash alone leaves the code leaves and `code_size` unchecked. They are anchored instead to the `code_hash` it does cover. For each distinct `code_hash`, reassemble the bytecode from the 31-byte slices of that code's chunk leaves in chunk order, taking the chunk count from `code_size` and reading an absent chunk as 31 zero bytes, truncate the result to `code_size`, and require its `keccak256` to equal that `code_hash`. Then re-chunk the recovered bytecode per [EIP-8297](./eip-8297.md) and require its non-zero chunks to equal the artifact's code leaves byte for byte, which pins each chunk's leading PUSHDATA count as well as its contents and its placement in `CODE_ZONE`. A wrong `code_size` yields different bytes, so the same preimage pins it. An account whose MPT code is a delegation indicator MUST instead carry that indicator followed by nine zero bytes in a `DELEGATION_LEAF_KEY` leaf, with `code_size == 23`, no `code_hash` leaf and no chunk leaves; the consensus-anchoring re-hash derives its `codeHash` from that leaf, which pins the leaf's contents and `code_size` together. `version` is the one remaining field the MPT does not commit; [EIP-8297](./eip-8297.md) sets it to zero on every header write, so every basic-data leaf MUST carry `version == 0`. A PBT snapshot that fails either check MUST be rejected, and the node MUST re-obtain or re-convert. @@ -197,7 +197,7 @@ Without the code limb of check 2, a snapshot carrying corrupted or wholly absent State transition from the anchor to the tip is performed by replaying Block-Level Access Lists ([EIP-7928](./eip-7928.md)) **without re-execution**. For each block after `ANCHOR_BLOCK`, per-entry translation rules apply the recorded writes to the PBT: - Balance and nonce changes are applied to the account header's basic-data leaf, and storage writes are applied as PBT leaf writes. Code changes and deletions are applied as described under [replay deletion semantics](#replay-deletion-semantics). -- A balance or nonce change for an address that holds no basic-data leaf creates the account, and replay MUST write its `code_hash` leaf as well: the Keccak hash of empty bytecode in the ordinary case of a transfer to a fresh address, or the hash of the deployed code where the same block also records a code change for it. [EIP-7928](./eip-7928.md) reports a plain transfer as a balance change alone, so nothing else in the BAL implies that leaf, while a direct conversion emits one for every account the MPT commits, since the MPT commits `codeHash` for codeless accounts too. Replay that wrote only the basic-data leaf would therefore diverge from a conversion at the same block. +- A balance or nonce change for an address that holds no basic-data leaf creates the account, and replay MUST write its `code_hash` leaf as well: the Keccak hash of empty bytecode in the ordinary case of a transfer to a fresh address, or the hash of the deployed code where the same block also records a code change for it; where that code change is a delegation indicator, replay writes the delegation leaf instead. [EIP-7928](./eip-7928.md) reports a plain transfer as a balance change alone, so nothing else in the BAL implies that leaf, while a direct conversion emits one for every non-delegated account the MPT commits, since the MPT commits `codeHash` for codeless accounts too. Replay that wrote only the basic-data leaf would therefore diverge from a conversion at the same block. - Replay MAY be batched over a range of blocks, coalescing repeated writes to the same key so that each key is written once with its final value. Only the end state of the replayed range is needed, so writing intermediate values is wasted work. Batching MUST keep the replay rate above steady-state block production, so that a converted PBT snapshot converges to the tip and then tracks it. #### Replay deletion semantics @@ -207,7 +207,7 @@ Replay MUST preserve one invariant: the PBT obtained by replaying from `ANCHOR_B Deletion itself is defined by [EIP-8297](./eip-8297.md): a key whose value is 32 zero bytes MUST NOT be present, writing zero to a key removes it, and deleting an account removes its header and storage leaves, together with any of its content-addressed `CODE_ZONE` leaves that no remaining account's `code_hash` addresses. Those rules govern replay before `PBT_ACTIVATION_FORK` and execution from it alike, so nothing about deletion changes at activation. What replay adds is how each deletion is recognized in a BAL, which carries no deletion marker and MUST therefore be read from recorded post-values: - **Storage.** A recorded write of zero MUST be applied as a deletion of that key, wherever the slot lives: the account's header stem for slots 0 through 63, its storage bucket above that. Deleting a header-stem storage leaf does not delete the account. -- **Code.** A code change MUST write the chunk leaves of the new code to `CODE_ZONE` and, where the account had previous code, MUST apply [EIP-8297](./eip-8297.md)'s deletion rule to it: the previous code's `CODE_ZONE` leaves are removed only if no account in the resulting state has the same `code_hash`, and kept otherwise. Code leaves are content-addressed and possibly shared, so a leaf that is already present is rewritten with the identical value, a no-op. In practice the removal limb fires almost only for [EIP-7702](./eip-7702.md) delegation indicators: an indicator is 23 bytes, occupying a single `CODE_ZONE` chunk leaf shared by every account delegating to the same target, and replacing or clearing a delegation removes that leaf exactly when no other delegation to the target remains, while updating the `code_hash` and basic-data leaves accordingly. The other path that removes deployed code is `SELFDESTRUCT`, which [EIP-6780](./eip-6780.md) confines to the account's creation transaction, and [EIP-7928](./eip-7928.md) records an account destroyed within a transaction without nonce or code changes, so replay never inserted that code's chunks in the first place. +- **Code.** A code change MUST write the chunk leaves of the new code to `CODE_ZONE`, or, where that code is an [EIP-7702](./eip-7702.md) delegation indicator, to the account's `DELEGATION_LEAF_KEY` leaf, which [EIP-8297](./eip-8297.md) places in the header stem rather than in `CODE_ZONE`; clearing a delegation MUST remove that leaf. Setting a delegation MUST also remove the account's `code_hash` leaf and clearing one MUST write it back, since [EIP-8297](./eip-8297.md) gives an account exactly one of the two; the basic-data leaf follows the new code in every case. Code leaves are content-addressed and possibly shared, so a leaf that is already present is rewritten with the identical value, a no-op. Replay never removes one: a delegation is not a code leaf, and the only other path that removes deployed code is `SELFDESTRUCT`, which [EIP-6780](./eip-6780.md) confines to the account's creation transaction, and [EIP-7928](./eip-7928.md) records an account destroyed within a transaction without nonce or code changes, so replay never inserted that code's chunks in the first place. - **Accounts.** If, after a block's writes are applied, an account's basic data would hold `nonce == 0`, `balance == 0`, and `code_size == 0`, replay MUST delete the account: its header stem together with any leaves under the shared prefix `STORAGE_ZONE || key_hash(address)`. The trigger requires `code_size == 0`, so the account addresses no code leaves and the `CODE_ZONE` limb of [EIP-8297](./eip-8297.md)'s account deletion has nothing to remove. The trigger is exact in both directions, since [EIP-7523](./eip-7523.md) leaves no empty account in the MPT and a non-empty account is never absent from it. #### Reorg handling diff --git a/EIPS/eip-8363.md b/EIPS/eip-8363.md new file mode 100644 index 00000000000000..6c90e8f1db6cc9 --- /dev/null +++ b/EIPS/eip-8363.md @@ -0,0 +1,408 @@ +--- +eip: 8363 +title: Tapered Issuance Burn +description: Burn a fraction of validator rewards that rises with the staking ratio, removing the issuance incentive to stake more than 50% of all ETH +author: pintail (@pintail-xyz), JΓ©rΓ΄me de Tychey (@jdetychey), dapplion (@dapplion), pa7x1 (@pa7x1), Ladislaus von Daniels (@ladidan), Justin Drake (@justindrake) +discussions-to: https://ethereum-magicians.org/t/eip-8363-tapered-issuance-burn/29263 +status: Draft +type: Standards Track +category: Core +created: 2026-07-14 +--- + +## Abstract + +This EIP introduces a **tapered issuance burn**: at each epoch boundary each validator is charged a deduction for every duty it was assigned (attestation, block proposal, sync committee participation), sized as a fraction of the idealised reward for that duty, and the deducted ETH is burned. The burn fraction tapers linearly with the staking ratio, reaching 100% at a fixed *saturation balance*, so net staking yield declines as more ETH is staked. This removes the yield floor implicit in the current curve, letting the staking market settle where the yield meets the risk premium stakers demand. For a positive premium, this occurs at a staking ratio below 50%, beyond which issuance no longer incentivises further stake growth. + +Applied in full at the fork, the burn would reduce yields sharply at today's staking ratio, so the reduction is phased in over an 18-month transition by temporarily raising `BASE_REWARD_FACTOR`, which scales rewards, penalties, and the burn together. Net yield therefore begins close to today's level and moves gradually to the permanent curve, with the balance between micro-incentives preserved throughout. The taper's *shape*, however, is in full effect from activation: from day one, issuance no longer rewards growth beyond a 50% staking ratio. + +## Motivation + +The choice between staking and simply holding ETH is made on the gap between the yields the two options offer and the risks each carries. For staking these include liquidity, slashing, operational, and regulatory risks; liquid staking exchanges some of these for risks attaching to the token issuer, whether smart-contract and governance risks for on-chain protocols or counterparty risk for a centralised provider. Stake is therefore expected to keep flowing in for as long as the net incentive to stake, given by the nominal yield, exceeds the premium the marginal staker demands for bearing those risks. The staking market reaches equilibrium only if the nominal yield falls to the level of the staking risk premium. This risk premium is trending down owing to the maturity of staking setups at the infrastructure, smart contract and software level. Furthermore, the credibility of slashing is negatively impacted by large amounts of ETH at stake, further weighing on the staking risk premium. + +Under the current issuance curve there is no point at which the incentive to stake switches off: the yield falls only as $1/\sqrt{f}$ in the staking ratio $f$, and retains a floor of roughly 1.5% however much ETH is staked. Where stake growth comes to rest therefore depends on the marginal staker's risk premium staying above that floor. There is reason to think the premium may continue to fall: as the staking ecosystem matures, trusted low-friction custodians such as ETF providers emerge, and tooling and liquid staking reduce the costs and risks that justify it. Meanwhile, since issuance increases with staking ratio, dilution costs to unstaked ETH holders increase, adding greater incentive to stake to avoid this dilution. + +A drift toward a very high staking ratio is undesirable for two distinct reasons, which correspond to the two goals of this proposal: preserving Ethereum's **security, neutrality and resistance to capture**, and protecting **ETH's role as money**. + +### Security, neutrality, and resistance to capture + +Beyond a certain level, additional stake makes Ethereum *less* secure, not more: the marginal contribution of new stake to economic security falls as the ratio rises, while several risks compound. As an ever-larger share of the ETH supply is held by custodians and staking providers rather than its owners, the social layer is deprived of its ability to hold large operators to account, while solo stakers are forced out. + +- **Stake concentration leads to moral hazard.** A large operator that misbehaves can degrade consensus for its own gain, and its delegators bear the loss if slashing follows. This is a risk that should be priced by delegators, increasing the effective cost of delegated staking. But whenever one event puts a large share of the supply at risk (for example, a slashing event, or an exploit of the operator's own systems) the holders with most to lose are also the best resourced and best organised to coordinate a fork reversing it. The DAO rescue ([EIP-779](./eip-779.md)) is the precedent for the social layer overriding protocol rules at that scale. A dominant operator could therefore come to be treated as "too big to fail". The resulting moral hazard is self-reinforcing: anticipating rescue rather than ruin, delegators stop demanding compensation for tail risk, the discount makes the largest operators cheaper to stake with, and stake concentrates further still. +- **The social layer loses its backstop.** Meanwhile, a fork that *should* happen becomes much harder to coordinate: "social slashing" of a colluding coalition can only succeed if the wider economy coordinates on the forked chain. Since most ETH holders cannot run validators themselves, the incentive toward ever-higher staking participation pushes an ever-larger share of the supply into custody with a handful of exchanges, ETFs, and staking service providers. With the owners of ETH no longer in full control of it ("not your keys, not your coins"), the credibility of a fork which implements social slashing, and therefore its deterrent effect, is diminished. +- **Solo stakers are forced out.** Dilution erodes everyone's real return as the ratio climbs, but solo stakers, who in most jurisdictions pay income tax on their *nominal* yield, cross into negative dilution-adjusted returns well before large operators and holders of tax-shielded positions do (this includes accumulating exchange traded products (ETPs) and non-rebasing or wrapped liquid staking tokens (LSTs)). + +The consequence is the capture of both the validator set and the ETH supply by a small group of actors who are at much greater risk of coercion, undermining core Ethereum properties of neutrality and censorship resistance. + +Nothing in the current curve arrests these trends. Because total issuance keeps rising as the staking ratio climbs, a large operator's income grows with every validator it adds. This applies equally at every operator size, and at every staking ratio. Under this proposal increasing size is not rewarded, and this applies soonest for the largest operators, as set out in [The effect on large operators](#the-effect-on-large-operators). + +### ETH as money + +Within the Ethereum economy ETH is not only the asset that secures the chain and pays for blockspace. It is also that economy's money: its default collateral, unit of account, and medium of exchange. Performing those functions earns ETH a monetary premium, giving it value beyond what its use as a fee asset alone would command. + +Monetary premium benefits ETH holders, but its significance is not confined to them. Economic security depends directly on the value of ETH: the cost of attacking Ethereum is the market value of the stake an attacker must acquire and forfeit, so a higher ETH price places a greater real value at slashing risk behind the same quantity of stake. This is security that scales with the value of ETH rather than with the quantity of it staked, and it carries none of the risks set out above. The existing issuance curve, however, undermines the monetary role on which that premium rests: + +- **Excess issuance taxes holders.** Issuance to the staked base operates as a continuous dilution tax on holders of unstaked ETH, eroding the scarcity and monetary premium that underpin its value as money. This forces on every holder an artificial choice: accept the dilution, or take on the costs and risks of staking merely to avoid it. Neither leaves ETH functioning as neutral money: the first taxes holding it, the second makes holding it conditional on running infrastructure or trusting somebody who does. +- **Staking derivatives displace ETH itself.** At high staking ratios, yield-bearing LSTs and other staking derivatives come to dominate raw ETH as collateral and medium of exchange within the Ethereum economy, displacing the most neutral, permissionless, trustless asset available with intermediated claims on staked ETH. At a high staking ratio, holding unstaked ETH means accepting dilution, so the yield-bearing form is preferred for collateral, settlement, and savings alike, and each application that adopts it raises the cost of not adopting it. In consequence, liquidity fragments across competing derivatives and slippage rises on decentralised exchanges, while every application that settles in a staking derivative inherits its issuer's smart-contract, counterparty, and governance risk. +- **The social layer gains a dependency.** Once one or more ETH derivatives become the ecosystem's working money, the social layer becomes dependent on outside organisations, the derivatives they issue, and their governance processes. The applications that power the money thereby acquire outsized influence over the applications that use the money, making Ethereum a less desirable blockchain to build and develop on. + +By removing the issuance incentive for further stake growth, this proposal keeps issuance itself bounded: it peaks at a staking ratio of roughly 20% and falls beyond that point, settling at the ratio where yield equals the compensation stakers require for the costs and risks they bear. Each staking derivative then faces stronger competition from non-staked ETH, keeping a trustless asset at the foundation of the ecosystem. On the supply side of the ledger, the tapered burn complements the fee burn of [EIP-1559](./eip-1559.md). Together they protect the monetary role of unstaked ETH, and with it the real value of the stake that secures the chain. + +### Restoring an equilibrium + +This EIP proposes the minimal change needed to ensure that the issuance mechanism no longer drives stake growth beyond a 50% staking ratio: after computing rewards exactly as today, deduct from each validator a fraction of the idealised reward for each assigned duty, with that fraction tapering linearly in the staking ratio up to a fixed saturation point. The deducted ETH is not credited anywhere and is therefore in effect burned. At the 50% saturation ratio the burn cancels the issuance a validator earns for performing its duties. It therefore meets any positive risk premium at a staking ratio strictly below 50%, and the level of that premium is what fixes the equilibrium. + +The two goals are served by different aspects of this change. The **security and capture-resistance** goal is met by the *shape* of the tapered curve: with the burn fully cancelling issuance at 50%, issuance no longer incentivises staking beyond that ratio. The **ETH-as-money** goal is served additionally by the *level* of issuance. This proposal bounds it below today's curve, so that holders of ETH do not overpay for security through dilution. + +The reduction is phased in over an 18-month transition, during which the effective base reward factor decays from twice its current value back to today's, limiting the impact on existing stakers at activation. The tapered *shape* is nonetheless in full effect from the moment the transition begins: immediately after the fork, the issuance mechanism no longer rewards growth in the staking ratio beyond 50%, so the market has an equilibrium below that point. + +## Specification + +The key words "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](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174). + +Let $D$ denote `get_total_active_balance(state)` in Gwei, and let `SATURATION_BALANCE` (Gwei, $D_\text{sat}$) be a fixed constant set at the hard fork to approximately half the ETH supply. Define the **burn fraction** $b = \left(\frac{D}{D_\text{sat}}\right)^{3/2}$, clamped so that $b \le 1$. + +For each validator duty, a deduction of $b$ times the idealised reward for that duty MUST be applied (via `decrease_balance`) to every validator assigned the duty, immediately after rewards and penalties are applied for the epoch; the deducted ETH is destroyed. The deduction MUST be a function of effective balance and total active balance only β€” it is charged whether or not the duty was performed. The attestation portion of the deduction MUST be suspended while the chain is in an inactivity leak. + +The only modification to the existing rewards machinery is temporary: `get_base_reward_per_increment` MUST read a time-varying *effective* base reward factor that starts at `TRANSITION_BASE_REWARD_FACTOR` (128) and decays linearly to `BASE_REWARD_FACTOR` (64) over `TRANSITION_DURATION_EPOCHS` (β‰ˆ 18 months). Because both rewards and the burn's reference rewards derive from `get_base_reward_per_increment`, this elevation scales the whole schedule β€” rewards, penalties, and burn β€” uniformly, so the balance between them is preserved. Once the transition completes the effective factor is permanently 64, and `process_rewards_and_penalties` behaves exactly as it does today. + +### Constants + +The following constants are added: + +| Name | Value | Notes | +|---|---|---| +| `SATURATION_BALANCE` | `Gwei(60_250_000 * 10**9)` | Total active balance $D_\text{sat}$ at which the burn fraction reaches 100%; set at the hard fork to approximately half the current ETH supply | +| `TRANSITION_BASE_REWARD_FACTOR` | `Uint64(128)` | Effective base reward factor at the start of the transition β€” twice `BASE_REWARD_FACTOR` β€” decaying linearly to `BASE_REWARD_FACTOR` over the transition | +| `TRANSITION_START_EPOCH` | `Epoch(...)` | Epoch at which the transition begins; set at the hard fork to the activation epoch | +| `TRANSITION_DURATION_EPOCHS` | `Epoch(123_300)` | Transition length; $123{,}300 = 548 \times 225$ epochs $\approx 18$ months (225 epochs per day) | + +`SATURATION_BALANCE` is expressed as a balance rather than as the ratio $f_\text{sat}$ because the protocol observes `get_total_active_balance(state)` directly but has no notion of total ETH supply, so $f$ is not something the state transition can compute. Fixing $D_\text{sat} = f_\text{sat}\,S$ at the hard fork, using the supply at that time, means the effective saturation ratio drifts slowly as supply changes thereafter; the drift is small over any reasonable horizon, and a future EIP that made the protocol aware of total supply could recover $D_\text{sat}$ from $f_\text{sat}\,S$ directly. + +### Helper functions + +`get_issuance_burn` applies the burn fraction to a reward. It is expressed as the cube of a square-root ratio so that it can be computed as three successive `Uint64` multiply-divides, keeping every intermediate value within `Uint64` β€” consistent with the rest of the reward machinery, which never forms a value wider than 64 bits. The two square roots are computed once per epoch by the caller and passed in. + +```python +def get_issuance_burn(reward: Gwei, sqrt_active: Uint64, sqrt_sat: Uint64) -> Gwei: + # reward * (sqrt_active / sqrt_sat)**3 == reward * (D / SATURATION_BALANCE)**(3/2) + burn = Uint64(reward) + for _ in range(3): + burn = burn * sqrt_active // sqrt_sat + return Gwei(burn) +``` + +`get_participating_increments` returns the participating balance per attestation flag, the quantity `get_flag_index_deltas` scales each flag's reward by. It sums effective balances directly rather than calling `get_total_balance`, whose `max(EFFECTIVE_BALANCE_INCREMENT, ...)` floor exists to keep existing reward denominators from dividing by zero: here the quantity is a numerator, and an empty flag set must contribute nothing rather than a phantom increment. Effective balances are always whole multiples of `EFFECTIVE_BALANCE_INCREMENT`, so this changes no non-empty result. The floor remains in use for `get_total_active_balance`, where it is still needed as a divisor. + +The flags are kept separate rather than pre-summed because the existing machinery floors each flag's reward to Gwei independently. Summing the weighted participation first and flooring once yields $\lfloor x_s + x_t + x_h \rfloor$, which exceeds $\lfloor x_s \rfloor + \lfloor x_t \rfloor + \lfloor x_h \rfloor$ by up to 2 Gwei; at saturation, where the deduction equals its basis exactly, that difference would come out of a faultless validator's principal. + +```python +def get_participating_increments(state: BeaconState, epoch: Epoch) -> Sequence[Uint64]: + increments = [] + for flag_index in range(len(PARTICIPATION_FLAG_WEIGHTS)): + indices = get_unslashed_participating_indices(state, flag_index, epoch) + increments.append(Uint64(sum( + state.validators[index].effective_balance // EFFECTIVE_BALANCE_INCREMENT + for index in indices + ))) + return increments +``` + +`get_attestation_participation` collapses those into the single weighted total that `process_attestation` forms as its `proposer_reward_numerator`. Proposer issuance takes one floor over the whole weighted sum, so pre-summing is correct there. + +```python +def get_attestation_participation(state: BeaconState, epoch: Epoch) -> Uint64: + return Uint64(sum( + weight * increments + for weight, increments + in zip(PARTICIPATION_FLAG_WEIGHTS, get_participating_increments(state, epoch)) + )) +``` + +This EIP also relies on one lookup not present in the current specification: `get_beacon_proposer_index_at_slot`, the existing `get_beacon_proposer_index` generalised to an arbitrary slot in the epoch. + +### The base reward factor transition + +`get_base_reward_factor` returns the effective base reward factor for an epoch: `TRANSITION_BASE_REWARD_FACTOR` at the start of the transition, decaying linearly to `BASE_REWARD_FACTOR` over `TRANSITION_DURATION_EPOCHS`, and permanently `BASE_REWARD_FACTOR` thereafter. Because the factor is an integer, the decay is a staircase of 65 steps, each lasting `TRANSITION_DURATION_EPOCHS` divided by the boost β€” about 1,927 epochs, or 8.6 days. Each step moves the whole schedule by under one part in sixty-four, and moves rewards, penalties and the burn together, so the balance between them is unaffected. + +```python +def get_base_reward_factor(epoch: Epoch) -> Uint64: + # Elevated during the transition to cushion the yield reduction, decaying + # linearly from TRANSITION_BASE_REWARD_FACTOR at TRANSITION_START_EPOCH to + # BASE_REWARD_FACTOR after TRANSITION_DURATION_EPOCHS. Permanently + # BASE_REWARD_FACTOR β€” today's value β€” once the transition completes. + if epoch <= TRANSITION_START_EPOCH: + return TRANSITION_BASE_REWARD_FACTOR + elapsed = epoch - TRANSITION_START_EPOCH + if elapsed >= TRANSITION_DURATION_EPOCHS: + return BASE_REWARD_FACTOR + remaining = Uint64(TRANSITION_DURATION_EPOCHS - elapsed) + boost = Uint64(TRANSITION_BASE_REWARD_FACTOR - BASE_REWARD_FACTOR) + duration = Uint64(TRANSITION_DURATION_EPOCHS) + return BASE_REWARD_FACTOR + (boost * remaining + duration // 2) // duration +``` + +`get_base_reward_per_increment` is modified to use this factor in place of the `BASE_REWARD_FACTOR` constant; this is the sole change to the existing rewards machinery, and it reverts to today's behaviour once the transition completes: + +```python +def get_base_reward_per_increment(state: BeaconState) -> Gwei: + active = get_total_active_balance(state) + factor = get_base_reward_factor(get_current_epoch(state)) + return Gwei(EFFECTIVE_BALANCE_INCREMENT * factor // integer_squareroot(active)) +``` + +### Beacon chain state transition + +A new step, `process_issuance_burn`, MUST be added to `process_epoch` immediately after `process_rewards_and_penalties` and before `process_effective_balance_updates`. Running it there means effective balances still hold the epoch's values, so the proposer burn below charges exactly the proposers that were selected during the epoch. Each duty is charged where its issuance is paid: the attestation and proposer burns in this new epoch step, the sync committee burn in `process_sync_aggregate`. + +- **Attestation burn**: a burn of $b$ times the attestation reward a perfect attester earned, applied to every validator that was active in the previous epoch. That set, rather than the currently active one, is what `process_rewards_and_penalties` has just paid: it settles the previous epoch's attestations over `get_eligible_validator_indices`; +- **Proposer burn**: a burn of $b$ times an equal share of the epoch's proposer issuance, charged to each of the 32 proposers. Its basis reads participation from the *current* epoch, not the previous one: proposer rewards were credited during this epoch as its blocks included attestations and sync aggregates, so the current epoch's participation is what they were paid on; +- **Sync committee burn**: a burn of $b$ times the sync committee reward for each block, charged to each of the 512 sync committee members. Because sync committee rewards are paid per block rather than per epoch, this deduction is applied in `process_sync_aggregate` rather than in the epoch step. + +Each basis MUST be sized on the issuance the duty actually paid, not on what it would have paid under perfect network conditions: the attestation and proposer bases scale with the participating balance, and the sync committee burn is levied only when a block exists to pay the reward it offsets. Every one of those factors is a network-wide aggregate, so no deduction depends on the behaviour of the validator paying it; see [Why the burn uses idealised rewards](#why-the-burn-uses-idealised-rewards). + +Splitting the burn by duty, rather than applying a single uniform per-validator deduction, keeps the variance between validators low despite the rare duties of proposal and sync committee participation. Every deduction reads `base` from `get_base_reward_per_increment`, so during the transition all three scale with the elevated effective factor automatically, and no further change is needed. Only the attestation pass is suspended during an inactivity leak, for the reasons given in [Why the burn uses idealised rewards](#why-the-burn-uses-idealised-rewards). + +```python +def process_issuance_burn(state: BeaconState) -> None: + # Clamping sqrt_active to sqrt_sat keeps the burn fraction at or below 1. + sqrt_sat = integer_squareroot(SATURATION_BALANCE) + sqrt_active = min(integer_squareroot(get_total_active_balance(state)), sqrt_sat) + increment = EFFECTIVE_BALANCE_INCREMENT + base = get_base_reward_per_increment(state) + epoch = get_current_epoch(state) + previous_epoch = get_previous_epoch(state) + n_total = get_total_active_balance(state) // increment + attest_weight = TIMELY_SOURCE_WEIGHT + TIMELY_TARGET_WEIGHT + TIMELY_HEAD_WEIGHT + + # Both burns are sized on the balance that attested, each taken from the + # epoch whose issuance it offsets: process_rewards_and_penalties has just + # settled the previous epoch's attestations, while proposer rewards were + # credited during the current epoch, as its blocks included attestations. + flag_increments = get_participating_increments(state, previous_epoch) + proposer_participation = get_attestation_participation(state, epoch) + + # 1. Attestation burn β€” the validators active in the previous epoch, which + # is the set process_rewards_and_penalties has just paid. Each flag is + # floored separately, with the same numerator, denominator and order as + # get_flag_index_deltas, so the basis equals a perfect attester's reward + # exactly rather than exceeding it by the rounding of the three flags. + # Suspended during an inactivity leak, when attestation rewards are + # withheld entirely. + if not is_in_inactivity_leak(state): + for index in get_active_validator_indices(state, previous_epoch): + n = state.validators[index].effective_balance // increment + attest_reward = Gwei(sum( + n * base * weight * participating // (n_total * WEIGHT_DENOMINATOR) + for weight, participating + in zip(PARTICIPATION_FLAG_WEIGHTS, flag_increments) + )) + burn = get_issuance_burn(attest_reward, sqrt_active, sqrt_sat) + decrease_balance(state, index, burn) + + # 2. Proposer burn β€” the 32 proposers of the epoch. Proposer rewards are + # paid during a leak, so this pass is never suspended. A proposer is + # paid from two sources, and the basis carries both explicitly rather + # than relying on the two shares summing to PROPOSER_WEIGHT: its share + # of the attestation rewards it includes, per process_attestation, and + # its share of the sync committee reward, per process_sync_aggregate. + # Sync participation is not accumulated in the state, so attestation + # participation stands in for it; the error vanishes when the two + # streams agree and is bounded by 3.57% of the proposer component. + attestation_component = ( + base * proposer_participation + // ((WEIGHT_DENOMINATOR - PROPOSER_WEIGHT) * WEIGHT_DENOMINATOR // PROPOSER_WEIGHT) + ) + sync_component = ( + base * proposer_participation * SYNC_REWARD_WEIGHT * PROPOSER_WEIGHT + // (attest_weight * WEIGHT_DENOMINATOR * (WEIGHT_DENOMINATOR - PROPOSER_WEIGHT)) + ) + ideal_proposer_reward = Gwei( + (attestation_component + sync_component) // SLOTS_PER_EPOCH + ) + proposer_burn = get_issuance_burn( + ideal_proposer_reward, sqrt_active, sqrt_sat + ) + start_slot = compute_start_slot_at_epoch(epoch) + for slot in range(start_slot, start_slot + SLOTS_PER_EPOCH): + proposer = get_beacon_proposer_index_at_slot(state, Slot(slot)) + decrease_balance(state, proposer, proposer_burn) +``` + +The sync committee burn is applied in block processing instead, because sync committee rewards are paid per block. `process_sync_aggregate` MUST be modified to deduct $b$ times `participant_reward` from every committee member, alongside the rewards and penalties it already applies: + +```python +def process_sync_aggregate(state: BeaconState, sync_aggregate: SyncAggregate) -> None: + # ... signature verification and reward computation unchanged, yielding + # participant_reward, proposer_reward and committee_indices ... + + # Burn the tapered fraction of the sync committee issuance for this block. + sqrt_sat = integer_squareroot(SATURATION_BALANCE) + sqrt_active = min(integer_squareroot(get_total_active_balance(state)), sqrt_sat) + sync_burn = get_issuance_burn(participant_reward, sqrt_active, sqrt_sat) + + # Apply participant and proposer rewards + for participant_index, participation_bit in zip( + committee_indices, sync_aggregate.sync_committee_bits, strict=True + ): + if participation_bit: + increase_balance(state, participant_index, participant_reward) + increase_balance(state, get_beacon_proposer_index(state), proposer_reward) + else: + decrease_balance(state, participant_index, participant_reward) + decrease_balance(state, participant_index, sync_burn) +``` + +The deduction sits outside the participation branch, so it is charged to every committee member whether or not it participated: were only participants charged, a member could avoid the burn by abstaining, and the balance difference between participating and not would fall from twice `participant_reward` to $(2-b)$ times it. The proposer's share of the sync reward is deliberately left alone, since a deduction contingent on having produced a block would weaken the incentive to propose; it remains covered by the proposer pass above. + +The dominant new cost is one O(1)-per-validator deduction (the attestation burn), whose basis is three multiply-divides rather than one because each flag is floored separately. That deduction is foldable into the existing rewards/penalties pass, which already walks the same previous-epoch flags. The proposer pass adds a fixed pass over the 32 proposers, and the sync committee deduction adds one balance update per member to a loop `process_sync_aggregate` already runs. The one genuinely new traversal is `get_participating_increments` over the *current* epoch, needed for the proposer basis: no existing step in `process_epoch` totals current-epoch participating balance, so this is an additional walk of the participation flags that cannot be folded into existing work. + +## Rationale + +### Why a burn + +An alternative route to lower issuance would be to redesign the reward curve itself. The burn approach in this proposal is to be preferred, for the following reasons: + +- **A single parameter, with a natural value.** Reward curves engineered to exhibit several desirable properties at once (a cap on total issuance, a yield floor, a target ratio) tend to introduce several new parameters, each of which must be set and defended. The burn introduces exactly one, `SATURATION_BALANCE`, and its value has a natural focal point: half the ETH supply. (Temporary parameters are introduced to specify the transition, but `SATURATION_BALANCE` is the only permanently operative parameter this proposal introduces.) +- **A market-determined yield.** To avoid introducing a vulnerability to griefing attacks, rewards and penalties must remain matched. With matched incentives, a performing validator always earns a positive yield, so any reshaped reward curve without burn still imposes a floor on the yield, and stake growth stops only if the market's risk premium happens to sit above that floor. A deduction removes the floor: the staking ratio settles at the point where the yield meets the premium stakers demand, so the equilibrium yield is set by the market rather than by the shape of the curve. +- **Micro-incentives at full strength.** Reaching a low yield by scaling the reward and penalty schedule down weakens the per-duty incentives for correct and timely participation. With substantial MEV (here including priority fees) available, consensus rewards and penalties must remain large relative to the external incentives to misbehave (through block-timing games or reorgs, for example), or chain stability is put at risk. The burn leaves the entire schedule at its current magnitude and nets macro yield off afterwards. +- **Credible neutrality.** Any redirection of the deducted ETH, whether to other validators, a treasury, or any other recipient, would leave total issuance unchanged and so defeat the proposal's monetary purpose, while creating a new claimant whose incentives must be analysed and whose share can be lobbied over. Destroying it, as established by the base-fee burn of EIP-1559, is the neutral alternative: the value removed accrues pro rata to all ETH holders. + +### Why a 50% saturation ratio + +The saturation ratio is not a target. It marks where the issuance incentive is fully neutralised, with the market settling below it, wherever net yield meets the premium stakers demand. Several of the risks set out above are qualitatively different once a majority of ETH is staked: a rescue coalition drawn from stakers is then a majority of the economy by construction, and a fork opposing a captured validator set has no larger constituency left to appeal to. The same threshold governs ETH's monetary role. Once most ETH is staked, the derivatives representing it are drawn from a larger pool than the unstaked ETH they compete with, and liquidity and collateral acceptance tend to bestow moneyness on the larger pool. + +Furthermore, above half there is no next natural stopping place. Nothing distinguishes 60% from 70%, or 70% from 80%. Half the supply is the last figure that refers to anything beyond preference: it is the majority threshold the risks above turn on. A constant anchored that way is far harder to argue upward than one resting on a judgement of degree. The choice is also bounded from below. With today's ratio near 33%, a saturation point at or beneath it would clamp the burn at 100% on activation and take net consensus yield to zero for every validator, forcing stake out rather than curbing its growth. + +### Why the burn uses idealised rewards + +The same concern that keeps the reward schedule at full magnitude governs how each deduction is sized: were the burn to track the reward actually earned, every duty's marginal payoff would be multiplied by $(1-b)$, reducing the incentive for correct participation. The burn is sized instead on what perfect performance would have earned. That is, perfect performance with respect to the validator's *own conduct*, not the network's. A faultless attester's reward already scales with network participation, so the burn scales with it too; sized on the full-participation figure it would take the full amount out of a reduced reward, leaving that validator at a loss whenever the offline share of the network exceeded $1-b$. During an inactivity leak, where attestation rewards are withheld outright, the attestation deduction is accordingly suspended entirely. + +### Why the burn is split by duty + +Applying $b$ as a single, uniform per-validator deduction each epoch would be simpler to specify, but every validator only proposes and joins a sync committee rarely. A uniform deduction sized to also cover those infrequent, larger rewards would exceed a typical epoch's attestation reward as the burn fraction grows, leaving even a perfectly performing validator with a negative balance change in every epoch spent waiting for a rare duty assignment. Allocating the deduction by assigned duty avoids this: a validator that performs its attestation duties is never pushed into a negative balance change merely for lack of a proposal or sync-committee assignment, and each deduction stays proportionate to the reward on offer at each opportunity. + +### Deriving the burn fraction + +Expressed as a function of the staking ratio $f = D/S$ (with $S$ the total ETH supply), the current consensus-layer yield is $y(f) = \frac{B\,E}{\sqrt{f\,S}}$, with `BASE_REWARD_FACTOR` $B$ and epochs per year $E$. Issuance is the yield paid on the staked fraction, $i(f) = B\,E\sqrt{f/S}$. The burn fraction derived below is independent of $B$: it scales the reward whatever its magnitude. The permanent policy uses today's $B = 64$; during the transition $B$ is temporarily elevated, which lifts $y(f)$ uniformly without changing $b(f)$. + +The goal is to subtract from the yield a term that is zero at $f = 0$ and grows linearly to cancel the yield entirely at a saturation ratio $f_\text{sat}$, so that the net yield reaches zero there. Writing the result piecewise, $\tilde y(f) = y(f) - \frac{f}{f_\text{sat}}\,y(f_\text{sat})$ for $f \le f_\text{sat}$, and $\tilde y(f) = 0$ for $f > f_\text{sat}$. + +![Consensus layer net yield under the current curve and under the tapered issuance burn](../assets/eip-8363/yield-curve.svg) + +Consensus layer net yield under the current curve and under the tapered issuance burn in its permanent (post-transition) state, both with `BASE_REWARD_FACTOR` at today's value of 64. Under the tapered curve the burn cancels a performing validator's issuance at the 50% saturation ratio. + +Writing $\tilde y(f) = (1 - b(f))\,y(f)$ and solving for the burn fraction $b(f)$ that reproduces this linear taper gives $b(f) = \left(\frac{f}{f_\text{sat}}\right)^{3/2}$. + +The exponent of $3/2$ rather than $1$ follows from the reward curve's $f^{-1/2}$ shape: the *absolute* deduction needed is linear in $f$, but expressed as a fraction of a reward that itself scales as $f^{-1/2}$, it picks up an extra half power. With $f_\text{sat} = 50\%$, the resulting issuance curve is $\tilde i(f) = f\,\tilde y(f)$ for $f \le f_\text{sat}$ and zero above it. It no longer rises monotonically with $f$: it rises, peaks at $f^* = 2^{-7/3} \approx 19.8\%$, and is fully cancelled by the burn at $f_\text{sat}$. + +![Annual ETH issuance under the current curve and under the tapered issuance burn](../assets/eip-8363/issuance-curve.svg) + +Annual ETH issuance under the current curve and under the tapered issuance burn in its permanent (post-transition) state, both with `BASE_REWARD_FACTOR` at today's value of 64. The tapered curve peaks at $f^* = 2^{-7/3} \approx 19.8\%$ and is fully cancelled by the burn at $f = 1/2$. + +### The effect on large operators + +An operator's income from consensus issuance is its share of the stake multiplied by the total amount issued. Under the current curve both factors move in its favour as it grows: its share rises, and because issuance keeps rising with the staking ratio, so does the pot being shared. There is no size, and no staking ratio, at which adding another validator fails to increase its income. + +The tapered burn changes this. Issuance now peaks at a staking ratio of roughly 20% and declines beyond it, so an operator that keeps growing is claiming a larger share of a shrinking pot, and past some point the second effect dominates the first. The point at which an operator's income reduces from increased scale comes sooner the larger the operator already is. An operator holding half the stake finds that growth stops paying once about 31% of the ETH supply is staked. Every operator has such a point somewhere below the 50% saturation staking ratio, but the smaller it is, the closer that point sits to saturation. + +![The staking ratio beyond which an operator's income falls as it adds stake](../assets/eip-8363/operator-threshold.svg) + +The staking ratio beyond which an operator's income from consensus issuance falls as it adds stake, plotted against the share of total stake it already holds, with other operators' stake held fixed. Under the current curve no such point exists at any operator size or staking ratio. + +It should be noted that MEV is unaffected by the burn and accrues in proportion to an operator's share whatever the staking ratio, so it always rewards growth and will act to increase the staking ratio at which a large operator is no longer rewarded for further growth. + +### The transition period + +Imposed in full at the fork, the burn would cut the net yield at today's staking ratio ($f \approx 33\%$) by more than half, from about 2.6% to 1.2% β€” enough to prompt a substantial exit of stake on activation. `TRANSITION_BASE_REWARD_FACTOR` is set to 128 because doubling the factor lifts the net yield curve to cross the current one at $f \approx 31\%$, close to today's ratio: stakers begin at a yield near what they receive now, and the reduction arrives gradually rather than at the fork. + +The 18-month figure is measured from activation, but the window for participants to adjust is longer. A hard fork is generally *scheduled for inclusion* (SFI) six months or more before it goes live, and this proposal is fully specified and predictable from that moment. Adding that lead time gives ecosystem participants on the order of two years, from the change becoming certain to yields reaching their permanent level, in which to respond. + +![Net yield and annual issuance as the tapered issuance burn phases in over the transition](../assets/eip-8363/transition.gif) + +Net yield (left) and annual issuance (right) as the tapered issuance burn phases in, driven together by a single control. The animation sweeps the 18-month transition: at month 0 the effective base reward factor is 128, so net yield starts close to today's; by month 18 it has decayed to 64 and both curves reach their permanent shape. Throughout, the burn cancels a performing validator's issuance at the 50% saturation ratio, whatever the effective base reward factor. + +The transition defers none of the security benefit: the burn fraction reaches 100% at the saturation balance whatever the base reward factor, so from the first epoch after activation issuance no longer rewards growth in the staking ratio beyond 50%. + +### Issuance and MEV + +Lowering issuance raises the question about the role of MEV, since if consensus rewards reduce, MEV becomes a larger share of a validator's return. Block proposal timing games are the most pernicious distortions caused by MEV, but these are unaffected by this proposal, since the microincentive levels are preserved. By contrast, reducing issuance by reshaping the reward curve would instead shrink the proposer reward and increase the significance of MEV on a per-block basis. + +Summing MEV-Boost relay payments to proposers over the year to 31 July 2026 gives about 72,600 ETH across 2.42 million blocks, a mean of 0.030 ETH per block. Pricing the further 190,000 locally built blocks at that same mean (an upper bound, since locally built blocks receive lower execution layer rewards) puts total execution-layer rewards below 78,300 ETH. Against the roughly 40 million ETH staked today ($f \approx 33\%$) that is a return of at most 0.20%. Consensus issuance pays $64\sqrt{D}$ Gwei per epoch, with $D$ the total active balance in Gwei: about 1,054,000 ETH a year at that staked base, or 2.62%. Issuance therefore accounts for at least 93% of the staking yield today. Under this proposal, if the staking ratio were to settle at 40%, issuance would still account for at least 80% of the yield. So the equilibrium staking ratio remains set predominantly by the issuance curve and the risk premium. + +Nonetheless, proposals which would address MEV (such as MEV burn) remain worth pursuing, and compose with this one: removing MEV from proposers would lower total staking yield, and with it the equilibrium staking ratio. + +## Backwards Compatibility + +This EIP introduces a backwards-incompatible change to the consensus-layer state transition and must be accompanied by a hard fork. No changes are required to the execution layer or to existing on-chain contracts. + + + +## Reference Implementation + +The Python in the [Specification](#specification) section constitutes the reference implementation, in the same style used throughout the beacon chain consensus specification. + +## Security Considerations + +**Incentive compatibility.** The burn fraction $b(f)$ is a deterministic, publicly computable function of total active balance, and applies identically to every validator. No validator or coalition can shift a larger share onto another, so it introduces no new griefing or discrimination vector. Because each deduction is computed from the idealised duty reward rather than the reward actually earned, it is independent of the validator's behaviour within the epoch, leaving every marginal performance incentive intact; during the transition the elevated base reward factor scales the reward and penalty schedule up together, preserving the balance between them, and once the transition completes the schedule is exactly as it is today. And because $b(f) \le 1$ for all $f$ (clamped via `sqrt_active = min(..., sqrt_sat)`), no deduction ever exceeds the reward it is derived from. Because each basis is scaled to the issuance the duty actually paid rather than to a headline figure the network may not have reached, a validator performing its duties correctly retains a non-negative net reward for the epoch β€” specifically $(1-b)$ times what it earned β€” at any level of participation or block production. The attestation pass is suspended outright during an inactivity leak, when attestation rewards are withheld entirely, so the guarantee holds in that case too. + +**Cost of downtime.** Because the deduction is independent of behaviour, an offline validator pays it alongside the usual penalties. The marginal incentive to be online is unaffected (the balance difference between performing a duty and missing it is exactly what it is today), so the cost of an outage relative to remaining online is no greater in ETH than it is today; what rises is that cost measured in days of net earnings. Since the penalty is unchanged while net earnings fall to $(1-b)$ of the idealised reward, the period needed to make good an outage grows by a factor of $(1 + b/p)/(1-b)$, where $p = 40/54 \approx 0.74$ is the ratio of penalty to idealised reward: roughly 3.8Γ— at today's staking ratio ($f \approx 33\%$), and rising as the ratio approaches saturation. + +This is the unavoidable consequence of holding the penalty schedule at full magnitude while net issuance falls. Scaling penalties down in step would hold the ratio constant, but that is precisely the weakening of per-duty incentives the burn exists to avoid. Its practical weight is limited by realised validator performance, which has run consistently above the levels anticipated when the penalty schedule was set before the beacon chain launched: at 99% uptime a validator retains 96% of a flawless validator's net yield under this proposal, against 98% today. + +**Effect on economic security.** This EIP deliberately results in a lower equilibrium quantity of stake than the current curve. In proof-of-stake with slashing, the cost of attack is set by the stock of slashable stake an attacker must acquire and forfeit, and at any staking ratio in the tens of percent that stock remains vast relative to any plausible attack reward. Nor does economic security increase monotonically with stake: as set out in the Motivation, beyond a certain level the accompanying concentration of stake and supply makes the network less secure, not more. To the extent the proposal protects ETH's monetary premium, it also supports the real value of the stake securing the chain. + +**Computational cost.** The additional per-epoch cost is one O(1) deduction per active validator (foldable into the existing rewards/penalties pass, which already walks the same previous-epoch participation flags), a fixed-size pass over the 32 proposers, and one further walk of the participation flags to total current-epoch participating balance for the proposer basis. That last traversal is the only one not shared with existing work; it is O(n) in the validator set, the same order as `process_rewards_and_penalties` itself, and is performed once per epoch rather than per validator. The whole step therefore remains linear in the validator set and introduces no new DoS surface. Per block, the sync committee deduction adds one balance update for each of the 512 members, within a loop `process_sync_aggregate` already performs. + +**Constant drift.** As noted in [Constants](#constants), `SATURATION_BALANCE` is fixed at the fork and does not track live supply, so the effective saturation ratio will drift slowly over time. This drift changes only the location of the equilibrium point, not any safety property of the mechanism, and is not expected to be security-relevant on the timescale of a single fork's lifetime. + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md). diff --git a/EIPS/eip-8368.md b/EIPS/eip-8368.md new file mode 100644 index 00000000000000..7be62939881cce --- /dev/null +++ b/EIPS/eip-8368.md @@ -0,0 +1,46 @@ +--- +eip: 8368 +title: CPSB Recalibration for New Gas Limit +description: Re-derives the cost per state byte (CPSB) parameter introduced in EIP-8037 for a new reference block gas limit +author: Maria Silva (@misilva73), Toni WahrstΓ€tter (@nerolation) +discussions-to: https://ethereum-magicians.org/t/eip-8368-cpsb-recalibration-for-new-gas-limit/29293 +status: Draft +type: Standards Track +category: Core +created: 2026-08-05 +requires: 8037 +--- + +## Abstract + +This proposal updates cost per state byte (`CPSB`), the unit gas cost per new state byte introduced in [EIP-8037](./eip-8037.md), by re-deriving it for a new reference block gas limit. All other parameters, mechanisms, and semantics defined in [EIP-8037](./eip-8037.md) are unaffected and remain unchanged. + +This is a placeholder EIP. The new reference block gas limit, the re-derived `CPSB` value, and the accompanying rationale are still to be determined. + +## Motivation + +[EIP-8037](./eip-8037.md) derives `CPSB` from a reference block gas limit of `150M` gas units, noting that "if a future block gas limit increase materially changes the expected state growth rate, `CPSB` can be re-derived in a subsequent EIP." As the block gas limit increases beyond that reference point, `CPSB` needs to be recalibrated to keep state growth on target. + +## Specification + +TBD. The `CPSB` value below will be re-derived using the same methodology as [EIP-8037](./eip-8037.md), with the reference block gas limit updated to a value still to be determined. + +| **Parameter** | **Value** | +|:---:|:---:| +| `CPSB` | TBD | + +## Rationale + +TBD + +## Backwards Compatibility + +This EIP updates a parameter defined by [EIP-8037](./eip-8037.md) and inherits its backwards compatibility considerations. + +## Security Considerations + +Needs discussion. + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md). diff --git a/EIPS/eip-8371.md b/EIPS/eip-8371.md new file mode 100644 index 00000000000000..329268ef949fe1 --- /dev/null +++ b/EIPS/eip-8371.md @@ -0,0 +1,181 @@ +--- +eip: 8371 +title: RowDAS - Distributed Blob Reconstruction +description: Distribute reconstruction load in the network through row-level cell messaging. +author: Csaba Kiraly (@cskiraly), Marco Munizaga (@MarcoPolo) +discussions-to: https://ethereum-magicians.org/t/eip-8371-rowdas-distributed-blobspace-reconstruction/29320 +status: Draft +type: Standards Track +category: Networking +created: 2026-08-05 +requires: 7594, 8136 +--- + +## Abstract + +PeerDAS (Peer Data Availability Sampling, [EIP-7594](./eip-7594.md)) requires supernodes to provide reconstruction, and this puts a high burden on supernodes that scales linearly with blob count. RowDAS enables distributed blobspace reconstruction using partial-message-based row topics, allowing all nodes to contribute to reconstruction, while significantly reducing the load on supernodes, leading to a more efficient and more resilient DAS construct. + +## Motivation + +[EIP-7594](./eip-7594.md) PeerDAS was designed with a simple but powerful-enough erasure coding based reconstruction model where any node receiving at least half of the 128 columns should reconstruct the whole extended blob content belonging to a block. As the number of blobs grows, however, the reconstruction burden on every supernode also grows linearly with blob count. + +Moreover, supernodes execute largely redundant work: each one of them reconstructing all missing blobs, without the means to distribute this work efficiently in the network. + +This EIP introduces distributed blobspace reconstruction, where different nodes prioritize the reconstruction of different parts of the blobspace, leading to a faster, less CPU-intensive, and more resilient construct. + +## Specification + +The key words "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](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174). + +The EIP introduces new Gossipsub topics, changes to the rules of reconstruction, and a few minor changes to how current column topics operate. + +In this document, a *supernode* is a node subscribed to all 128 column subnets, following the customary use of the term. A *row reconstructor* is any node subscribed to 64 or more of the 128 column subnets, and thus holding enough cells to reconstruct any row on its own; every supernode is also a row reconstructor. On mainnet, the row reconstructor class notably includes staking nodes whose validator custody requirement reaches 64 or more custody groups. + +### Parameters + +| Constant | Value | +| - | - | +| `ROW_SUBNET_COUNT` | `128` | + +### Column topics + +Regarding column topics [EIP-7594](./eip-7594.md) already mandates the following: + +> Once the node obtains a column through reconstruction, the node MUST expose the new column as if it had received it over the network. If the node is subscribed to the subnet corresponding to the column, it MUST send the reconstructed DataColumnSidecar to its topic mesh neighbors. If instead the node is not subscribed to the corresponding subnet, it SHOULD still expose the availability of the DataColumnSidecar as part of the gossip emission process. + +This is extended to allow cell-level operation towards peers that support it using the following rules: + +- Prior to reconstructing, a node MAY also use advertisements received as part of the Gossipsub "fanout" mechanism to collect relevant cells from other peers across column subnets it is not subscribed to. + +- After reconstruction, a node SHOULD use the Gossipsub "fanout" mechanism to provide cells from the reconstructed blob to peers across column subnets it is not subscribed to. A node MAY choose to only advertise to a random subset of these columns rather than all columns. This allows the node to provide another path for cell dissemination to the network. A node MAY choose to delay these fanout messages in order to conserve bandwidth by not competing with other nodes who are subscribed to the column topic, and can provide the cell instead. + +### Row topics + +Similar to column subnets, we introduce new row subnets: `data_row_{subnet_id}`. These resemble, but are not to be confused with, the deprecated `blob_sidecar_{subnet_id}` topics. Their properties: + +- A row subnet MUST use Cell-Level Deltas ([EIP-8136](./eip-8136.md)) without eager push. Like for Cell-Level Deltas in column subnets, the GroupID for a message in the row subnet is the block root. Since cells might arrive from three different sources (`getBlobs`, columns, rows) a node MAY choose to delay the request of cells from rows. +- The number of row subnets is `ROW_SUBNET_COUNT`, a fixed constant independent of the blob count. Blob rows are mapped to subnets with a per-slot pseudo-random permutation, reusing the consensus-layer swap-or-not shuffle: + + ```python + def get_blob_row_subnet(blob_index: BlobIndex, slot: Slot) -> RowSubnetIndex: + seed = hash(b"ROW_SUBNET" + uint_to_bytes(uint64(slot))) + return RowSubnetIndex( + compute_shuffled_index( + uint64(blob_index) % ROW_SUBNET_COUNT, uint64(ROW_SUBNET_COUNT), seed + ) + ) + ``` + + Being a permutation, the mapping assigns distinct subnets to the blobs of a slot as long as the blob count does not exceed `ROW_SUBNET_COUNT`. Changing every slot, it distributes load evenly across the network, including which subnets are idle when the blob count is below `ROW_SUBNET_COUNT`. The seed depends only on the slot, so the mapping is computable without access to chain state. If the blob count exceeds `ROW_SUBNET_COUNT`, blob indices that are equal modulo `ROW_SUBNET_COUNT` share a subnet, so a subnet carries up to `ceil(blob_count / ROW_SUBNET_COUNT)` rows in a slot; the bitmap of a row subnet covers the cells of all rows mapped to it, ordered by `blob_index`. +- Each node derives a single designated row subnet from its node ID, reusing the hash-based derivation of custody column selection in [EIP-7594](./eip-7594.md), but with a different byte window for domain separation: + + ```python + def get_row_subnet(node_id: NodeID) -> RowSubnetIndex: + return RowSubnetIndex( + bytes_to_uint64(hash(uint_to_bytes(uint256(node_id)))[8:16]) + % ROW_SUBNET_COUNT + ) + ``` + + Since custody group derivation uses bytes `[0:8]` of the same hash, taking bytes `[8:16]` keeps the row assignment decorrelated from custody assignment at no extra hashing cost. A row reconstructor MUST subscribe to its designated row subnet. Other nodes SHOULD subscribe to theirs if they support Cell-Level Deltas. Subscription carries no custody obligation. + +Row subnets carry partial messages only; no full-message equivalent is defined for them. A node MUST NOT subscribe to a row subnet unless it supports the Partial Messages Extension, and the subscription requirements above apply only to nodes with such support. A node MAY prune row subnet mesh peers that do not support the extension. Note that the non-discrimination guidance of [EIP-8136](./eip-8136.md) applies to column topics, where full-message fallback exists; on row subnets there is nothing to fall back to, while column topics and `getBlobs` remain fully available to nodes without the extension. + +Row subnet membership is computable from a peer's node ID alone, so no ENR extension is needed for discovery. A node SHOULD locate peers of its designated row subnet by applying `get_row_subnet` to the node IDs it discovers (e.g., through discv5 lookups), and SHOULD maintain enough connections to same-subnet peers to sustain a healthy Gossipsub mesh, keeping in mind that subnet members are a small fraction (approx. `1 / ROW_SUBNET_COUNT`) of the overall node population and are unlikely to appear in sufficient numbers among randomly selected peers. + +The exact wire format β€” SSZ containers, bitmap encoding, and GroupID derivation β€” is to be defined in the ethereum/consensus-specs repository, following the approach of [EIP-8136](./eip-8136.md). The semantics this EIP requires of it are: the bitmap of a row subnet covers the rows mapped to the subnet in `blob_index` order, and within a row follows `cell_index` order; the GroupID is derived from the block root, analogously to column topics. A cell received on a row subnet MUST be verified against the corresponding blob KZG commitment before it is forwarded or used, and peers providing invalid cells MUST be penalized under the same rules as on column subnets. + +A peer MAY limit the number of cells it serves a peer on the row subnet to just half of the cells of each mapped row, as the rest of that row can be reconstructed. The limit applies per row: serving fewer than 64 cells of a row does not allow its reconstruction, regardless of cells served from other rows. + +As a node receives cells from any source (either from row subnets, column subnets, or `getBlobs`), it SHOULD send updated bitmap states to its peers. A node MAY choose to debounce these updates. + +### Reconstruction + +A node, even if not a row reconstructor, SHOULD collect at least 64 cells on each row mapped to its designated row subnet and expose these in updated bitmap states to its peers. Note that these cells need not come from the node's own custody: the row subnet pools the custody cells of all its members, so sufficient cells can be collected through the row subnet itself. + +Similar to PeerDAS, reconstruction duties attach to nodes holding enough columns, but reconstruction becomes a phased process. Reconstruction of a row is REQUIRED only once a node holds at least 64 distinct verified cells of that row; subscription alone does not imply possession, so all reconstruction obligations below are conditional on this. + +- 1st phase: a row reconstructor MUST reconstruct each row mapped to its designated row subnet for which it holds sufficient cells, and it MUST send updated bitmap states to its peers. Note that a row reconstructor can satisfy this phase from its own column subscriptions, without foreign row information. A small random delay (recommended range TBD) before reconstruction is allowed to desynchronise nodes in the network and reduce overall load. +- 2nd phase: after a slightly longer random delay (recommended range TBD), during which cells are collected from `getBlobs`, columns, and rows, a supernode SHOULD β€” and any other row reconstructor MAY β€” do a second reconstruction phase, reconstructing all missing rows for which it holds sufficient cells, and sharing the results as defined above. This matches the reconstruction behavior PeerDAS already recommends for nodes holding half the columns, with an added delay. The stronger expectation is placed on supernodes because only a node subscribed to all column subnets can observe rows completing elsewhere and cancel the redundant work; row reconstructors with fewer subscriptions lack this signal. +- 3rd phase: after a delay longer than the 2nd-phase delay (recommended range TBD), any node subscribed to a row subnet SHOULD reconstruct the rows mapped to that subnet that are still incomplete and for which it holds sufficient cells, sharing the results as defined above. This provides a reconstruction path that does not depend on row reconstructors or supernodes at all. Its expected CPU cost is near zero, since this phase activates only when the earlier phases have failed to complete a row. + +All delays are measured from the moment the node first obtains the block root of a valid block for the slot. Reconstruction obligations attach to at most one block root per slot: the block root on the node's current head branch, or the first valid block root seen. For any additional (equivocating or competing) block roots of the same slot, reconstruction is OPTIONAL, so that equivocation cannot amplify reconstruction work. A node MUST cancel a pending reconstruction of a row that completes through other means before the timer fires. The delays MUST be bounded by a maximum (value TBD), so that the recovery path is not postponed indefinitely under network degradation. + +## Rationale + +Row topics were part of the DAS discussion from the early days, well before PeerDAS was designed. FullDAS (described in the ethresear.ch post "FullDAS: towards massive scalability with 32MB blocks and beyond") introduced cell-level messaging over both column and row topics, with cross-seeding and in-network reconstruction. It also introduced bitmap representations of partial IHAVE messages, but without the exact protocol details. + +The Gossipsub Partial Message Extension introduced the mapping of bitmap-based partial message representations into Gossipsub, opening the way to use them on columns in [EIP-8136](./eip-8136.md), which builds on that extension and cites its specification. + +Until now, while we have developed the tools to implement better schemes, we remained with the original simplified PeerDAS construct. At the same time, blob count scaling made the CPU and bandwidth requirement of supernodes more of a point of contention. Reliance on supernodes, while abundant on current mainnet, is also a point of concentration leading to a protocol with less resilience than desirable. + +This EIP corrects some of these shortcomings, making sure supernodes are not doing (as much) useless work, and reconstruction is possible (although not yet mandated) even without supernodes. + +### What it is not + +This EIP is not FullDAS. It does not introduce sub-linear (cell-level) sampling. The bandwidth requirement of sampling nodes is still proportional to the number of blobs. + +It also does not introduce column-wise encoding, so protection and reconstruction is still only along the row axis. + +Finally, it does not directly help L2 nodes retrieve individual blobs (although there are possible extensions in that direction). However, it helps them run supernodes with fewer resources, leading to a net gain. + +### Possible extension to retrieve individual blobs + +Rollup nodes need the contents of their own rollup's blobs β€” typically a few per block β€” to derive L2 state. PeerDAS sharded the DAS matrix by column, while the consumer's unit of interest is a row: an ordinary node holds a few cells of every blob and the entirety of none. Today, an L2 node either finds the blob in the local execution layer via `getBlobs` (which fails for private blobs and past the mempool window), runs a supernode (paying full-blobspace bandwidth to retrieve a few blobs), or fetches on demand through column-granular request-response, downloading 64 columns β€” the full width of the blobspace, half its depth β€” to extract a single row. + +RowDAS does not define a retrieval protocol, but it makes targeted retrieval *addressable*: from `(slot, blob_index)`, anyone can compute the blob's row subnet, and from discovered node IDs, anyone can compute which nodes participate in it β€” a deterministically identifiable set of nodes that plausibly hold the complete row, with no lookup infrastructure. + +Turning this into actual retrieval is left to a future proposal, as it needs pieces that are out of scope here: a cell-granular request-response method (request specific cells of a specific row), a retention window during which row subnet members serve their rows (a deliberate revision of this EIP's no-custody-obligation stance, at a modest, evenly rotating storage cost of `blob_count / ROW_SUBNET_COUNT` rows per slot), and ideally resolution behind the existing APIs, so that a CL client can fetch, reconstruct, and return any requested blob without its operator running a supernode. + +### Design decisions + +**Why are nodes without reconstruction duties part of the row topics?** + +This is to enable the possibility of reconstructing without supernodes. Even custody-minimum nodes contribute: with roughly 94 members each custodying at least 4 randomly assigned columns, a row subnet collectively covers well over 64 distinct columns with high probability, forming a virtual reconstructor even when no individual member could reconstruct alone. The additional traffic of one row, most probably already suppressed by `getBlobs`, is worth it in our opinion. + +**Why are ordinary nodes not required to reconstruct?** + +While mandated (MUST) reconstruction would be desirable from the perspective of not relying on supernodes at all, it would introduce unconditional CPU load on ordinary nodes. Instead, the 3rd reconstruction phase is a SHOULD, with a delay long enough that it activates only in the rare case when the earlier phases have not completed a row: the expected load is near zero, while the network retains a reconstruction path that works without row reconstructors. + +**Why a fixed number of row subnets, instead of one per blob?** + +Tying the subnet count to the maximum blob count would change every node's subnet assignment at each Blob Parameter Only fork, tearing down and re-forming all row meshes simultaneously, exactly when network stability matters most. A fixed count keeps node-to-subnet assignments stable across forks, and the blob count only affects the stateless per-slot blob-to-subnet mapping. The cost is somewhat thinner per-subnet coverage (nodes are spread over `ROW_SUBNET_COUNT` subnets even when fewer rows exist) and having to define the multi-row-per-subnet general case. + +**Why `ROW_SUBNET_COUNT = 128`?** + +The constant is chosen to keep the expected number of nodes per subnet in a healthy band across plausible participation scenarios. Since each node subscribes to a single row subnet, the expected team size behind a subnet is the number of participating nodes divided by `ROW_SUBNET_COUNT`. This team size has a floor: it must stay several times the Gossipsub mesh degree (accounting for the binomial spread of hash-based assignment and for churn), and it should give a high probability of at least one row reconstructor β€” a node with a mandatory 1st-phase duty β€” per subnet. Pushing the constant higher than needed thins subnets towards this floor, while a lower constant increases traffic duplication, as each active subnet's row flows to its entire team. + +With approx. 12K nodes on mainnet, of which approx. 2K are supernodes and approx. 500 more custody 64-127 columns (approx. 2.5K row reconstructors in total), `ROW_SUBNET_COUNT = 128` yields ~94 nodes and ~20 row reconstructors per subnet at full participation, and remains workable even at partial early adoption of Cell-Level Deltas. The value is also above currently planned maximum blob counts, so in practice each subnet carries at most one row per slot, while the construct remains well defined for higher blob counts: per-node row load is bounded by `ceil(blob_count / ROW_SUBNET_COUNT)` rows per slot. If the network size changes by an order of magnitude, raising or lowering the constant remains possible, at the cost of a one-time reshuffle of subnet assignments. + +**Why a pseudo-random per-slot permutation, and not a simple rotation?** + +With fewer rows than subnets, some subnets are idle in a given slot, so the mapping has to change over time for every node to contribute equally. A simple rotation (`(blob_index + slot) % ROW_SUBNET_COUNT`) achieves this only over a full cycle: each subnet would be active for `blob_count` consecutive slots and then idle for the rest of the cycle, making per-node load bursty. The pseudo-random permutation redraws the active subnet set every slot, evening out load also on short time horizons. A permutation (rather than an independent hash per blob) is needed to avoid mapping two blobs of a slot to the same subnet as long as the blob count does not exceed `ROW_SUBNET_COUNT`; `compute_shuffled_index` provides one that clients already implement. Note that the mapping remains publicly predictable; unpredictability would add little, since the block builder controls blob indices and could steer a blob to any of the subnets active in that slot under any public mapping. Guarantees against targeted suppression continue to come from the column topics. + +**Why only a single row, and why is it not dependent on custody?** + +As of August 2026, mainnet has approx. 12K nodes, of which approx. 2K are supernodes custodying all 128 columns, and approx. 500 more custody 64-127 columns, qualifying them as row reconstructors. This is much more than what we expected initially. With current and planned blob counts, even a single row creates abundant overlap. + +## Backwards Compatibility + +Row topics are limited to peers that have libp2p Gossipsub implementations supporting Cell-Level Deltas. The portion of peers that supports the extension is already reaching considerable numbers on mainnet, even before Glamsterdam. We expect the majority of peers to support it after the Glamsterdam fork and [EIP-8136](./eip-8136.md). For peers that do not support the extension, `getBlobs` and column topics are still fully available. + +## Security Considerations + +The EIP changes DAS networking, but it does not change the custody allocation and the probabilistic guarantees of PeerDAS. + +New Gossipsub topics might introduce new attack vectors. Since row distribution is a new additional recovery path, and the old paths are mainly intact, it is not expected that this adversely affects the system, except for a bounded traffic overhead: bitmap-based signaling, plus, when blob data is partially withheld, up to half a row of futile cell pulls per row subnet member per mapped row. Reconstruction CPU cannot be triggered by unavailable data, as all reconstruction duties are gated on holding at least 64 distinct verified cells of a row. +An exception to this is the phased reconstruction process. Here the 2nd phase, the full reconstruction, is explicitly delayed. This delay is, however, something implementations already practice, and it is a one-time (instead of hop-by-hop) delay. + +A withholding block producer can trigger recovery work on a doomed block: releasing 64 columns' worth of cells for all rows except one β€” kept below the reconstruction threshold by withholding as little as a single cell β€” causes the network to reconstruct and cross-seed all recoverable rows while the block still ends up unavailable. This attack is inherited from PeerDAS rather than introduced by this EIP: under PeerDAS, every supernode performs this recovery work redundantly, while here the 1st phase distributes it at roughly one row per row reconstructor and the 2nd phase skips rows observed complete, so the total work strictly decreases. Since the 2nd phase is a SHOULD, supernodes can mitigate it further: observing the bitmaps of all column subnets, they can detect the unrecoverable row and legitimately skip collecting and reconstructing for the doomed block. + +Row dissemination is an optimization and MUST NOT weaken availability guarantees: nodes MUST NOT alter sampling or availability decision rules based on row subnet state, nor delay these decisions waiting for row dissemination; column topics and request-response remain the authoritative paths. + +Bitmap-based signaling introduces load of its own. Sending an update on every received cell can lead to quadratic message complexity, so nodes SHOULD debounce and rate-limit bitmap updates, and SHOULD bound the number of GroupIDs tracked per peer, in line with the guidance of [EIP-8136](./eip-8136.md). A peer that repeatedly advertises cells it then fails to provide SHOULD be treated in local peer scoring like a peer providing untimely messages. + +Since row subnet assignment is a static, public function of the node ID, an attacker can grind node IDs to concentrate on, or eclipse, a chosen row subnet. The impact is bounded: a suppressed row subnet degrades to the status quo, as the 2nd reconstruction phase and the column topics cover the affected rows. + +Since the row subnet count is a fixed constant and node-to-subnet assignments do not depend on the blob count, Blob Parameter Only forks ([EIP-7892](./eip-7892.md)) do not affect row subnet subscriptions; only the blob-to-subnet mapping changes with the blob count, and that mapping is stateless per slot. + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md). diff --git a/EIPS/eip-8372.md b/EIPS/eip-8372.md new file mode 100644 index 00000000000000..d5bd301b8036a5 --- /dev/null +++ b/EIPS/eip-8372.md @@ -0,0 +1,109 @@ +--- +eip: 8372 +title: Normalized state gas limit +description: Scales and normalizes the state-gas limit to better balance state-gas and execution-gas utilization. +author: Anders Elowsson (@anderselowsson) +discussions-to: https://ethereum-magicians.org/t/eip-8372-normalized-state-gas-limit/29332 +status: Draft +type: Standards Track +category: Core +created: 2026-08-06 +requires: 8037 +--- + +## Abstract + +This EIP modifies [EIP-8037](./eip-8037.md) by assigning state gas a scaled raw limit and normalizing state-gas usage before computing block-level `gas_used`. At activation, cost per state byte (`CPSB`) and the limit scale are set so the state-byte price can reflect estimated demand while the selected state-growth target continues to correspond to 50% normalized state-gas utilization. No new transaction or block-header fields are introduced. + +## Motivation + +EIP-8037 gives execution gas and state gas the same block limit and uses the larger cumulative gas value as block-level `gas_used`. Its `CPSB` therefore determines how many state bytes correspond to the 50% state-gas target. + +Ethereum users are not guaranteed, in aggregate, to spend half of their gas budget on state creation. At the cost per state byte (`CPSB`) selected by EIP-8037, state demand may be lower or higher than the level that lets state gas and execution gas both approach their common 50% target. Figure 1 illustrates the resulting failure modes. Relatively low state demand produces less state than intended, while relatively high state demand makes state gas the bottleneck and suppresses execution-gas consumption. + +![Figure 1. The two possible equilibrium failure modes.](../assets/eip-8372/figure-1.png) + +**Figure 1.** Failure modes of EIP-8037. If demand for state creation is lower than anticipated, too little state is created. If it is higher than anticipated, state gas becomes the bottleneck and too little execution gas is consumed. + +Ideally, the state-byte price and the relative state-gas limit would adapt continuously to demand. This is the longer-term direction of [EIP-7999](./eip-7999.md), where resources have separate prices and limits. For simplicity, this EIP instead performs a one-time calibration at the hardfork boundary. Developers select a target annual state-growth rate and an expected future block gas limit, derive a baseline `CPSB` that maps this growth target to the 50% state-gas target, and then use demand elasticities observed during the Glamsterdam hardfork and gradual post-Glamsterdam gas-limit increases to select the actual `CPSB` and matching state-gas limit scale. The selected constants remain fixed after activation. + +## Specification + +### Parameters + +The EIP-8037 parameter table is updated as follows: + +| Parameter | Value | +|---|---:| +| `CPSB` | `TBD` | +| `STATE_GAS_LIMIT_SCALE` | `TBD` | +| `STATE_GAS_LIMIT_SCALE_DENOMINATOR` | `100` | + +The `CPSB` and `STATE_GAS_LIMIT_SCALE` parameters must be positive integers. The `STATE_GAS_LIMIT_SCALE` parameter specifies the raw state-gas limit as a percentage of the block gas limit. + +### Transaction validation + +The EIP-8037 definition of `state_gas_available` is updated to: + +```python +state_gas_limit = block_env.block_gas_limit * STATE_GAS_LIMIT_SCALE // STATE_GAS_LIMIT_SCALE_DENOMINATOR +state_gas_available = state_gas_limit - block_output.block_state_gas_used +``` + +### Block-level gas accounting + +The EIP-8037 block-level `gas_used` computation and validity conditions are updated to: + +```python +normalized_block_state_gas_used = (block_output.block_state_gas_used * STATE_GAS_LIMIT_SCALE_DENOMINATOR) // STATE_GAS_LIMIT_SCALE +gas_used = max(block_output.block_execution_gas_used, normalized_block_state_gas_used) +assert block_output.block_state_gas_used <= state_gas_limit +assert gas_used <= block_env.block_gas_limit +``` + +The `block_output.block_state_gas_used` counter remains denominated in raw state gas. No new block header field is introduced. + +## Rationale + +### Calibration methodology + +EIP-8037 uses one common base fee for execution gas and state gas. A mismatch between the state-byte price and user demand therefore affects more than state growth: it determines which dimension reaches the common target first and can leave the other dimension underutilized. + +This EIP makes one fixed best-effort calibration at activation. The objective is to select a `CPSB` that is expected to induce the desired amount of state creation, and to set the raw state-gas limit so that this amount of state creation occupies approximately 50% of that limit. If the demand estimate is accurate, state gas and execution gas can both approach their respective targets. + +This can be viewed as a manual, one-time analogue of [EIP-8075](./eip-8075.md): EIP-8075 adapts the state-byte price and relative state-gas limit with demand, whereas this EIP selects fixed values at activation. + +The calibration starts by determining the `target_state_growth_per_year` and `expected_block_gas_limit`, then deriving: +`baseline_cpsb = expected_block_gas_limit * blocks_per_year // (2 * target_state_growth_per_year)`. + +Both `blocks_per_year` and `baseline_cpsb` are here simply analytical values rather than additional consensus parameters. The `baseline_cpsb` value maps the selected annual state-growth target to 50% of the block gas limit when the raw state-gas limit equals the block gas limit. Thus, the `baseline_cpsb` is equal to `CPSB` only if demand at that price is expected to produce the target state growth. + +Figure 2 illustrates the three possible calibrations after comparing expected demand at `baseline_cpsb` with the target. If demand is expected to match the target, no scaling is needed. If expected demand is lower, both `CPSB` and the raw state-gas limit are reduced. If expected demand is higher, both are increased. These are alternative fixed settings selected from demand estimated before activation, not dynamic adjustments performed after activation. + +![Figure 2. The three possible state-gas calibrations.](../assets/eip-8372/figure-2.png) + +**Figure 2.** The hardfork may retain, contract, or expand the raw state-gas limit according to the state demand estimated before activation. The `CPSB` is changed proportionally so that the targeted state-byte capacity is preserved after normalization. + +The next step is to use observed demand elasticities to select the actual `CPSB` so that expected state growth is close to `target_state_growth_per_year`. Then select the matching limit scale according to: + +```python +STATE_GAS_LIMIT_SCALE = CPSB * STATE_GAS_LIMIT_SCALE_DENOMINATOR // baseline_cpsb +``` + +Scaling `CPSB` and the raw state-gas limit proportionally preserves the normalized state gas assigned to the targeted number of state bytes. It therefore changes the price needed to induce the desired state-byte consumption without changing the corresponding normalized blockspace allocation. The denominator of `100` provides one-percentage-point calibration steps, which are sufficiently granular relative to the uncertainty in demand estimates. + +Demand elasticity can be estimated from how state-byte consumption responds to the new `CPSB` of Glamsterdam and shifts in the demand for state creation during the gradual gas-limit increases after Glamsterdam. Application-specific analysis as well as analysis of past changes can supplement these observations. + +## Backwards Compatibility + +This EIP changes consensus-critical block validation and requires a scheduled network upgrade. After activation, clients that do not implement the scaled state-gas limit and normalization rules may disagree on transaction inclusion, block validity, or the block header `gas_used` value. + +Blocks before activation are unaffected. Transaction formats, the EIP-8037 reservoir model, transaction-level gas accounting, and receipt semantics remain unchanged. Block builders, execution clients, and gas-estimation implementations must use the new `CPSB`, raw state-gas limit, and normalized block-level accounting after activation. + +## Security Considerations + +The primary risk is parameter miscalibration, which may shift the system from one failure mode to the other and cannot be corrected without a subsequent hardfork. The selected parameters should therefore be stress-tested across plausible demand elasticities. Because the raw state-gas limit and `CPSB` scale proportionally, the maximum state-byte capacity remains approximately invariant across calibrations, up to integer rounding. + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md). diff --git a/assets/eip-8363/issuance-curve.svg b/assets/eip-8363/issuance-curve.svg new file mode 100644 index 00000000000000..5e8c0c33b97734 --- /dev/null +++ b/assets/eip-8363/issuance-curve.svg @@ -0,0 +1,1807 @@ + + + + + + + + 2026-08-03T12:26:20.852584 + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/eip-8363/operator-threshold.svg b/assets/eip-8363/operator-threshold.svg new file mode 100644 index 00000000000000..bb3a2f99d2dadb --- /dev/null +++ b/assets/eip-8363/operator-threshold.svg @@ -0,0 +1,1795 @@ + + + + + + + + 2026-08-03T12:26:21.169675 + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/eip-8363/transition.gif b/assets/eip-8363/transition.gif new file mode 100644 index 00000000000000..0d95e466a1329b Binary files /dev/null and b/assets/eip-8363/transition.gif differ diff --git a/assets/eip-8363/yield-curve.svg b/assets/eip-8363/yield-curve.svg new file mode 100644 index 00000000000000..31183bc86dba73 --- /dev/null +++ b/assets/eip-8363/yield-curve.svg @@ -0,0 +1,1599 @@ + + + + + + + + 2026-08-03T12:26:20.811102 + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/eip-8372/figure-1.png b/assets/eip-8372/figure-1.png new file mode 100644 index 00000000000000..9011bdcd79c312 Binary files /dev/null and b/assets/eip-8372/figure-1.png differ diff --git a/assets/eip-8372/figure-2.png b/assets/eip-8372/figure-2.png new file mode 100644 index 00000000000000..0a785d54c3151b Binary files /dev/null and b/assets/eip-8372/figure-2.png differ diff --git a/config/eipw.toml b/config/eipw.toml index a4b00089db824b..8b96c384f8aa10 100644 --- a/config/eipw.toml +++ b/config/eipw.toml @@ -148,6 +148,8 @@ exceptions = [ '^https://www\.w3\.org/TR/[0-9][0-9][0-9][0-9]/.*$', '^https://[a-z]*\.spec\.whatwg\.org/commit-snapshots/[0-9a-f]{40}/$', '^https://www\.rfc-editor\.org/rfc/.*$', + '^https://(www\.)?github\.com/ethereum/sys-asm/(blob|tree)/[a-f0-9]{40}/.+$', + '^https://(www\.)?github\.com/ethereum/sys-asm/commit/[a-f0-9]{40}$', '^https://www\.unicode\.org/reports/tr[0-9]+/tr[0-9]+-[0-9]+\.html$', ]