Repository: ethereum/consensus-specs
Pull request #4277: Introduce blob schedule
Author: login: GaAstDev
Labels: name: fulu
Created: 2025-04-17T17:07:08Z | Merged: 2025-05-12T14:25:45Z

Addresses #4267 and #4266



Diff:
diff --git a/configs/mainnet.yaml b/configs/mainnet.yaml
index 60e9f39b6c..f95751121c 100644
--- a/configs/mainnet.yaml
+++ b/configs/mainnet.yaml
@@ -172,7 +172,6 @@ SAMPLES_PER_SLOT: 8
 CUSTODY_REQUIREMENT: 4
 VALIDATOR_CUSTODY_REQUIREMENT: 8
 BALANCE_PER_ADDITIONAL_CUSTODY_GROUP: 32000000000
-MAX_BLOBS_PER_BLOCK_FULU: 12
 MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 4096
 
 # EIP7441
@@ -190,3 +189,14 @@ VIEW_FREEZE_DEADLINE: 9
 MAX_REQUEST_INCLUSION_LIST: 16
 # 2**13 (= 8192)
 MAX_BYTES_PER_INCLUSION_LIST: 8192
+
+# Blob Scheduling
+# ---------------------------------------------------------------
+
+BLOB_SCHEDULE:
+  # Deneb
+  - EPOCH: 269568
+    MAX_BLOBS_PER_BLOCK: 6
+  # Electra
+  - EPOCH: 364032
+    MAX_BLOBS_PER_BLOCK: 9
diff --git a/configs/minimal.yaml b/configs/minimal.yaml
index 750457a2dd..464bc3dd5c 100644
--- a/configs/minimal.yaml
+++ b/configs/minimal.yaml
@@ -169,7 +169,6 @@ SAMPLES_PER_SLOT: 8
 CUSTODY_REQUIREMENT: 4
 VALIDATOR_CUSTODY_REQUIREMENT: 8
 BALANCE_PER_ADDITIONAL_CUSTODY_GROUP: 32000000000
-MAX_BLOBS_PER_BLOCK_FULU: 12
 MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 4096
 
 # EIP7441
@@ -187,3 +186,14 @@ VIEW_FREEZE_DEADLINE: 3
 MAX_REQUEST_INCLUSION_LIST: 16
 # 2**13 (= 8192)
 MAX_BYTES_PER_INCLUSION_LIST: 8192
+
+# Blob Scheduling
+# ---------------------------------------------------------------
+
+BLOB_SCHEDULE:
+  # Deneb
+  - EPOCH: 18446744073709551615
+    MAX_BLOBS_PER_BLOCK: 6
+  # Electra
+  - EPOCH: 18446744073709551615
+    MAX_BLOBS_PER_BLOCK: 9
diff --git a/pyproject.toml b/pyproject.toml
index aa67dab8db..86ef5e5235 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,6 +17,7 @@ dependencies = [
   "curdleproofs==0.1.2",
   "eth-typing==5.2.1",
   "eth-utils==5.3.0",
+  "frozendict==2.4.6",
   "lru-dict==1.3.0",
   "marko==2.1.3",
   "milagro_bls_binding==1.9.0",
diff --git a/pysetup/helpers.py b/pysetup/helpers.py
index 914dcef534..3295361019 100644
--- a/pysetup/helpers.py
+++ b/pysetup/helpers.py
@@ -1,5 +1,5 @@
 import re
-from typing import TypeVar, Dict
+from typing import TypeVar, Dict, Union, List
 import textwrap
 from functools import reduce
 
@@ -85,12 +85,25 @@ def format_protocol(protocol_name: str, protocol_def: ProtocolDefinition) -> str
     ordered_class_objects_spec = '\n\n\n'.join(ordered_class_objects.values())
 
     # Access global dict of config vars for runtime configurables
+    # Ignore variable between quotes and doubles quotes
     for name in spec_object.config_vars.keys():
-        functions_spec = re.sub(r"\b%s\b" % name, 'config.' + name, functions_spec)
-        ordered_class_objects_spec = re.sub(r"\b%s\b" % name, 'config.' + name, ordered_class_objects_spec)
-
-    def format_config_var(name: str, vardef: VariableDefinition) -> str:
-        if vardef.type_name is None:
+        functions_spec = re.sub(r"(?<!['\"])\b%s\b(?!['\"])" % name, "config." + name, functions_spec)
+        ordered_class_objects_spec = re.sub(r"(?<!['\"])\b%s\b(?!['\"])" % name, "config." + name, ordered_class_objects_spec)
+
+    def format_config_var(name: str, vardef) -> str:
+        if isinstance(vardef, list):
+            # A special case for list of records.
+            indent = " " * 4
+            lines = [f"{name}=("]
+            for d in vardef:
+                line = indent*2 + "frozendict({\n"
+                for k, v in d.items():
+                    line += indent * 3 + f'"{k}": {v},\n'
+                line += indent*2 + "}),"
+                lines.append(line)
+            lines.append(indent + "),")
+            return "\n".join(lines)
+        elif vardef.type_name is None:
             out = f'{name}={vardef.value},'
         else:
             out = f'{name}={vardef.type_name}({vardef.value}),'
@@ -98,10 +111,16 @@ def format_config_var(name: str, vardef: VariableDefinition) -> str:
             out += f'  # {vardef.comment}'
         return out
 
+    def format_config_var_param(value):
+        if isinstance(value, list):
+            # A special case for list of records.
+            return "tuple[frozendict[str, Any], ...]"
+        elif isinstance(value, VariableDefinition):
+            return value.type_name if value.type_name is not None else "int"
+
     config_spec = 'class Configuration(NamedTuple):\n'
     config_spec += '    PRESET_BASE: str\n'
-    config_spec += '\n'.join(f'    {k}: {v.type_name if v.type_name is not None else "int"}'
-                             for k, v in spec_object.config_vars.items())
+    config_spec += '\n'.join(f'    {k}: {format_config_var_param(v)}' for k, v in spec_object.config_vars.items())
     config_spec += '\n\n\nconfig = Configuration(\n'
     config_spec += f'    PRESET_BASE="{preset_name}",\n'
     config_spec += '\n'.join('    ' + format_config_var(k, v) for k, v in spec_object.config_vars.items())
@@ -267,13 +286,16 @@ def combine_spec_objects(spec0: SpecObject, spec1: SpecObject) -> SpecObject:
     )
 
 
-def parse_config_vars(conf: Dict[str, str]) -> Dict[str, str]:
+def parse_config_vars(conf: Dict[str, str]) -> Dict[str, Union[str, List[Dict[str, str]]]]:
     """
     Parses a dict of basic str/int/list types into a dict for insertion into the spec code.
     """
-    out: Dict[str, str] = dict()
+    out: Dict[str, Union[str, List[Dict[str, str]]]] = dict()
     for k, v in conf.items():
-        if isinstance(v, str) and (v.startswith("0x") or k == 'PRESET_BASE' or k == 'CONFIG_NAME'):
+        if isinstance(v, list):
+            # A special case for list of records
+            out[k] = v
+        elif isinstance(v, str) and (v.startswith("0x") or k == "PRESET_BASE" or k == "CONFIG_NAME"):
             # Represent byte data with string, to avoid misinterpretation as big-endian int.
             # Everything except PRESET_BASE and CONFIG_NAME is either byte data or an integer.
             out[k] = f"'{v}'"
diff --git a/pysetup/spec_builders/fulu.py b/pysetup/spec_builders/fulu.py
index 39befcb41e..f5cf0af329 100644
--- a/pysetup/spec_builders/fulu.py
+++ b/pysetup/spec_builders/fulu.py
@@ -10,6 +10,7 @@ class FuluSpecBuilder(BaseSpecBuilder):
     @classmethod
     def imports(cls, preset_name: str):
         return f'''
+from frozendict import frozendict
 from eth2spec.electra import {preset_name} as electra
 '''
 
diff --git a/setup.py b/setup.py
index bd7ad6a18c..7dc617c83d 100644
--- a/setup.py
+++ b/setup.py
@@ -3,6 +3,7 @@
 import json
 import logging
 import os
+import re
 import string
 import sys
 import warnings
@@ -215,6 +216,8 @@ def get_spec(file_name: Path, preset: Dict[str, str], config: Dict[str, str], pr
 
     current_name = None
     should_skip = False
+    list_of_records = None
+    list_of_records_name = None
     for child in document.children:
         if isinstance(child, BlankLine):
             continue
@@ -255,6 +258,62 @@ def get_spec(file_name: Path, preset: Dict[str, str], config: Dict[str, str], pr
                 ssz_objects[current_name] = "\n".join(line.rstrip() for line in source.splitlines())
             else:
                 raise Exception("unrecognized python code element: " + source)
+        elif isinstance(child, Table) and list_of_records is not None:
+            list_of_records_header = None
+            for i, row in enumerate(child.children):
+                # This will start as an empty list when there is a <!-- list-of-records --> comment,
+                # which indicates that the next table is a list-of-records. After we're done parsing
+                # the table, we will reset this to None.
+                if list_of_records is not None:
+                    if i == 0:
+                        # Save the table header, this will be used for field names
+                        # Skip the last item, which is the description
+                        list_of_records_header = [
+                            # Convert the titles to SNAKE_CASE
+                            re.sub(r'\s+', '_', value.children[0].children.upper())
+                            for value in row.children[:-1]
+                        ]
+                    else:
+                        # Add the row entry to our list of records
+                        list_of_records.append({
+                            list_of_records_header[i]: value.children[0].children
+                            for i, value in enumerate(row.children[:-1])
+                        })
+
+            # Make a type map from the spec definition
+            # We'll apply this to the file config (ie mainnet.yaml)
+            type_map: dict[str,str] = {}
+            pattern = re.compile(r'^(\w+)\(.*\)$')
+            for entry in list_of_records:
+                for k, v in entry.items():
+                    m = pattern.match(v)
+                    if m:
+                        type_map[k] = m.group(1)
+
+            # Apply the types to the file config
+            list_of_records_config: list[dict[str,str]] = []
+            for entry in config[list_of_records_name]:
+                new_entry: dict[str,str] = {}
+                for k, v in entry.items():
+                    ctor = type_map.get(k)
+                    if ctor:
+                        new_entry[k] = f"{ctor}({v})"
+                    else:
+                        new_entry[k] = v
+                list_of_records_config.append(new_entry)
+
+            # For mainnet, check that the spec config & file config are the same
+            # For minimal, we expect this to be different; just use the file config
+            if preset_name == "mainnet":
+                assert list_of_records == list_of_records_config, \
+                    f"list of records mismatch: {list_of_records} vs {list_of_records_config}"
+            elif preset_name == "minimal":
+                list_of_records = list_of_records_config
+
+            # Set the config variable and reset the global variable
+            config_vars[list_of_records_name] = list_of_records
+            list_of_records = None
+
         elif isinstance(child, Table):
             for row in child.children:
                 cells = row.children
@@ -315,6 +374,14 @@ def get_spec(file_name: Path, preset: Dict[str, str], config: Dict[str, str], pr
         elif isinstance(child, HTMLBlock):
             if child.body.strip() == "<!-- eth2spec: skip -->":
                 should_skip = True
+            # Handle list-of-records tables
+            match = re.match(r"<!--\s*list-of-records:([a-zA-Z0-9_-]+)\s*-->", child.body.strip())
+            if match:
+                # Initialize list-of-records, in the next iteration this will indicate that the
+                # table is a list-of-records and must be parsed differently.
+                list_of_records = []
+                # Use regex to extract the desired configuration list name
+                list_of_records_name = match.group(1).upper()
 
     # Load KZG trusted setup from files
     if any('KZG_SETUP' in name for name in constant_vars):
diff --git a/specs/fulu/beacon-chain.md b/specs/fulu/beacon-chain.md
index 717282a056..437ba671fc 100644
--- a/specs/fulu/beacon-chain.md
+++ b/specs/fulu/beacon-chain.md
@@ -6,7 +6,6 @@
 
 - [Introduction](#introduction)
 - [Configuration](#configuration)
-  - [Execution](#execution)
 - [Beacon chain state transition function](#beacon-chain-state-transition-function)
   - [Block processing](#block-processing)
     - [Execution payload](#execution-payload)
@@ -20,12 +19,6 @@
 
 ## Configuration
 
-### Execution
-
-| Name                       | Value        | Description                                                                                                   |
-| -------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------- |
-| `MAX_BLOBS_PER_BLOCK_FULU` | `uint64(12)` | *[New in Fulu:EIP7594]* Maximum number of blobs in a single block limited by `MAX_BLOB_COMMITMENTS_PER_BLOCK` |
-
 ## Beacon chain state transition function
 
 ### Block processing
@@ -45,7 +38,7 @@ def process_execution_payload(state: BeaconState, body: BeaconBlockBody, executi
     # Verify timestamp
     assert payload.timestamp == compute_timestamp_at_slot(state, state.slot)
     # Verify commitments are under limit
-    assert len(body.blob_kzg_commitments) <= MAX_BLOBS_PER_BLOCK_FULU  # [Modified in Fulu:EIP7594]
+    assert len(body.blob_kzg_commitments) <= get_max_blobs_per_block(get_current_epoch(state))  # [Modified in Fulu:EIP7892]
     # Verify the execution payload is valid
     versioned_hashes = [kzg_commitment_to_versioned_hash(commitment) for commitment in body.blob_kzg_commitments]
     assert execution_engine.verify_and_notify_new_payload(
diff --git a/specs/fulu/das-core.md b/specs/fulu/das-core.md
index 2777cb931d..f0594d5763 100644
--- a/specs/fulu/das-core.md
+++ b/specs/fulu/das-core.md
@@ -10,11 +10,13 @@
 - [Configuration](#configuration)
   - [Data size](#data-size)
   - [Custody setting](#custody-setting)
+  - [Blob schedule](#blob-schedule)
   - [Containers](#containers)
     - [`DataColumnSidecar`](#datacolumnsidecar)
     - [`MatrixEntry`](#matrixentry)
 - [Helper functions](#helper-functions)
   - [`get_custody_groups`](#get_custody_groups)
+  - [`get_max_blobs_per_block`](#get_max_blobs_per_block)
   - [`compute_columns_for_custody_group`](#compute_columns_for_custody_group)
   - [`compute_matrix`](#compute_matrix)
   - [`recover_matrix`](#recover_matrix)
@@ -67,6 +69,17 @@ The following values are (non-configurable) constants used throughout the specif
 | `NUMBER_OF_CUSTODY_GROUPS` | `128` | Number of custody groups available for nodes to custody                           |
 | `CUSTODY_REQUIREMENT`      | `4`   | Minimum number of custody groups an honest node custodies and serves samples from |
 
+### Blob schedule
+
+*[New in EIP7892]* This schedule defines the maximum blobs per block limit for a given epoch.
+
+<!-- list-of-records:blob_schedule -->
+
+| Epoch                       | Max Blobs Per Block | Description                      |
+| --------------------------- | ------------------- | -------------------------------- |
+| `Epoch(269568)` **Deneb**   | `uint64(6)`         | The limit is set to `6` blobs    |
+| `Epoch(364032)` **Electra** | `uint64(9)`         | The limit is raised to `9` blobs |
+
 ### Containers
 
 #### `DataColumnSidecar`
@@ -118,6 +131,20 @@ def get_custody_groups(node_id: NodeID, custody_group_count: uint64) -> Sequence
     return sorted(custody_groups)
 ```
 
+### `get_max_blobs_per_block`
+
+```python
+def get_max_blobs_per_block(epoch: Epoch) -> uint64:
+    """
+    Return the maximum number of blobs that can be included in a block for a given epoch.
+    """
+    assert len(BLOB_SCHEDULE) > 0
+    for entry in sorted(BLOB_SCHEDULE, key=lambda e: e["EPOCH"], reverse=True):
+        if epoch >= entry["EPOCH"]:
+            return entry["MAX_BLOBS_PER_BLOCK"]
+    return min(entry["MAX_BLOBS_PER_BLOCK"] for entry in BLOB_SCHEDULE)
+```
+
 ### `compute_columns_for_custody_group`
 
 ```python
diff --git a/specs/fulu/p2p-interface.md b/specs/fulu/p2p-interface.md
index 017e1b2d59..3201223fbc 100644
--- a/specs/fulu/p2p-interface.md
+++ b/specs/fulu/p2p-interface.md
@@ -167,7 +167,7 @@ Some gossip meshes are upgraded in the Fulu fork to support upgraded types.
 *Updated validation*
 
 - _[REJECT]_ The length of KZG commitments is less than or equal to the limitation defined in Consensus Layer --
-  i.e. validate that `len(signed_beacon_block.message.body.blob_kzg_commitments) <= MAX_BLOBS_PER_BLOCK_FULU`
+  i.e. validate that `len(signed_beacon_block.message.body.blob_kzg_commitments) <= get_max_blobs_per_block(get_current_epoch(state))`
 
 ##### Blob subnets
 
diff --git a/tests/core/pyspec/eth2spec/test/deneb/block_processing/test_process_execution_payload.py b/tests/core/pyspec/eth2spec/test/deneb/block_processing/test_process_execution_payload.py
index f9bd5ee14f..b089a63f84 100644
--- a/tests/core/pyspec/eth2spec/test/deneb/block_processing/test_process_execution_payload.py
+++ b/tests/core/pyspec/eth2spec/test/deneb/block_processing/test_process_execution_payload.py
@@ -411,7 +411,7 @@ def test_invalid_exceed_max_blobs_per_block(spec, state):
     execution_payload = build_empty_execution_payload(spec, state)
 
     opaque_tx, _, blob_kzg_commitments, _ = get_sample_blob_tx(
-        spec, blob_count=get_max_blob_count(spec) + 1
+        spec, blob_count=get_max_blob_count(spec, state) + 1
     )
 
     execution_payload.transactions = [opaque_tx]
diff --git a/tests/core/pyspec/eth2spec/test/deneb/sanity/test_blocks.py b/tests/core/pyspec/eth2spec/test/deneb/sanity/test_blocks.py
index a2baf73984..1cf5437966 100644
--- a/tests/core/pyspec/eth2spec/test/deneb/sanity/test_blocks.py
+++ b/tests/core/pyspec/eth2spec/test/deneb/sanity/test_blocks.py
@@ -84,28 +84,30 @@ def test_one_blob_two_txs(spec, state):
 @with_deneb_until_eip7732
 @spec_state_test
 def test_one_blob_max_txs(spec, state):
-    yield from run_block_with_blobs(spec, state, blob_count=1, tx_count=get_max_blob_count(spec))
+    yield from run_block_with_blobs(
+        spec, state, blob_count=1, tx_count=get_max_blob_count(spec, state)
+    )
 
 
 @with_deneb_until_eip7732
 @spec_state_test
 def test_invalid_one_blob_max_plus_one_txs(spec, state):
     yield from run_block_with_blobs(
-        spec, state, blob_count=1, tx_count=get_max_blob_count(spec) + 1, valid=False
+        spec, state, blob_count=1, tx_count=get_max_blob_count(spec, state) + 1, valid=False
     )
 
 
 @with_deneb_until_eip7732
 @spec_state_test
 def test_max_blobs_per_block(spec, state):
-    yield from run_block_with_blobs(spec, state, blob_count=get_max_blob_count(spec))
+    yield from run_block_with_blobs(spec, state, blob_count=get_max_blob_count(spec, state))
 
 
 @with_deneb_until_eip7732
 @spec_state_test
 def test_invalid_max_blobs_per_block_two_txs(spec, state):
     yield from run_block_with_blobs(
-        spec, state, blob_count=get_max_blob_count(spec), tx_count=2, valid=False
+        spec, state, blob_count=get_max_blob_count(spec, state), tx_count=2, valid=False
     )
 
 
@@ -113,7 +115,7 @@ def test_invalid_max_blobs_per_block_two_txs(spec, state):
 @spec_state_test
 def test_invalid_exceed_max_blobs_per_block(spec, state):
     yield from run_block_with_blobs(
-        spec, state, blob_count=get_max_blob_count(spec) + 1, valid=False
+        spec, state, blob_count=get_max_blob_count(spec, state) + 1, valid=False
     )
 
 
diff --git a/tests/core/pyspec/eth2spec/test/fulu/merkle_proof/test_single_merkle_proof.py b/tests/core/pyspec/eth2spec/test/fulu/merkle_proof/test_single_merkle_proof.py
index 3ace0576f0..54d16b8cbb 100644
--- a/tests/core/pyspec/eth2spec/test/fulu/merkle_proof/test_single_merkle_proof.py
+++ b/tests/core/pyspec/eth2spec/test/fulu/merkle_proof/test_single_merkle_proof.py
@@ -82,7 +82,7 @@ def test_blob_kzg_commitments_merkle_proof__random_block_1(spec, state):
 @with_fulu_and_later
 @spec_state_test
 def test_blob_kzg_commitments_merkle_proof__multiple_blobs(spec, state):
-    blob_count = spec.config.MAX_BLOBS_PER_BLOCK_FULU // 2
+    blob_count = spec.get_max_blobs_per_block(spec.get_current_epoch(state)) // 2
     rng = random.Random(2222)
     yield from _run_blob_kzg_commitments_merkle_proof_test(
         spec, state, rng=rng, blob_count=blob_count
@@ -93,7 +93,7 @@ def test_blob_kzg_commitments_merkle_proof__multiple_blobs(spec, state):
 @with_fulu_and_later
 @spec_state_test
 def test_blob_kzg_commitments_merkle_proof__max_blobs(spec, state):
-    max_blobs = spec.config.MAX_BLOBS_PER_BLOCK_FULU
+    max_blobs = spec.get_max_blobs_per_block(spec.get_current_epoch(state))
     rng = random.Random(3333)
     yield from _run_blob_kzg_commitments_merkle_proof_test(
         spec, state, rng=rng, blob_count=max_blobs
diff --git a/tests/core/pyspec/eth2spec/test/fulu/unittests/test_config_invariants.py b/tests/core/pyspec/eth2spec/test/fulu/unittests/test_config_invariants.py
index c765f4ff25..a1deb9bc52 100644
--- a/tests/core/pyspec/eth2spec/test/fulu/unittests/test_config_invariants.py
+++ b/tests/core/pyspec/eth2spec/test/fulu/unittests/test_config_invariants.py
@@ -1,7 +1,13 @@
 from eth2spec.test.context import (
     single_phase,
     spec_test,
+    spec_state_test,
+    with_presets,
     with_fulu_and_later,
+    expect_assertion_error,
+)
+from eth2spec.test.helpers.constants import (
+    MAINNET,
 )
 
 
@@ -30,5 +36,10 @@ def test_polynomial_commitments_sampling(spec):
 @with_fulu_and_later
 @spec_test
 @single_phase
-def test_networking(spec):
-    assert spec.config.MAX_BLOBS_PER_BLOCK_FULU <= spec.MAX_BLOB_COMMITMENTS_PER_BLOCK
+@with_presets([MAINNET], reason="to have fork epoch number")
+def test_blob_schedule(spec):
+    for entry in spec.config.BLOB_SCHEDULE:
+        # Check that all epochs are post-Deneb
+        assert entry["EPOCH"] >= spec.config.DENEB_FORK_EPOCH
+        # Check that all blob counts are less than the limit
+        assert entry["MAX_BLOBS_PER_BLOCK"] <= spec.MAX_BLOB_COMMITMENTS_PER_BLOCK
diff --git a/tests/core/pyspec/eth2spec/test/helpers/blob.py b/tests/core/pyspec/eth2spec/test/helpers/blob.py
index 1b1272ddc5..296e42a48b 100644
--- a/tests/core/pyspec/eth2spec/test/helpers/blob.py
+++ b/tests/core/pyspec/eth2spec/test/helpers/blob.py
@@ -118,9 +118,9 @@ def get_sample_blob_tx(spec, blob_count=1, rng=random.Random(5566), is_valid_blo
     return opaque_tx, blobs, blob_kzg_commitments, blob_kzg_proofs
 
 
-def get_max_blob_count(spec):
+def get_max_blob_count(spec, state):
     if is_post_fulu(spec):
-        return spec.config.MAX_BLOBS_PER_BLOCK_FULU
+        return spec.get_max_blobs_per_block(spec.get_current_epoch(state))
     elif is_post_electra(spec):
         return spec.config.MAX_BLOBS_PER_BLOCK_ELECTRA
     else:


Reviews:
user: login: ralexstokes
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T16:10:12Z
user: login: ralexstokes
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T16:11:14Z
user: login: rolfyone
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T22:32:17Z
user: login: rolfyone
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T22:37:05Z
user: login: jtraglia
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T01:00:07Z
user: login: jtraglia
body: LGTM, thanks @GabrielAstieres! If/when we decide to backport this to Deneb, there will be some necessary changes to `get_max_blobs_per_block` but I think we should deal with that then.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-01T14:25:57Z
user: login: ensi321
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T19:15:39Z
user: login: jtraglia
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T20:01:02Z
user: login: nflaig
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T20:09:44Z
user: login: jtraglia
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T21:05:39Z
user: login: nflaig
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T21:14:19Z
user: login: rolfyone
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T00:07:40Z
user: login: nflaig
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-02T08:19:54Z
user: login: prestonvanloon
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T16:27:04Z

Review comments:
diff_hunk: @@ -190,3 +189,9 @@ VIEW_FREEZE_DEADLINE: 9
 MAX_REQUEST_INCLUSION_LIST: 16
 # 2**13 (= 8192)
 MAX_BYTES_PER_INCLUSION_LIST: 8192
+
+BLOB_SCHEDULE:
+  - EPOCH: 269568  # Deneb
path: configs/mainnet.yaml
user: login: ralexstokes
body: not sure we need this
created_at: 2025-04-23T16:10:11Z
author_association: MEMBER
diff_hunk: @@ -190,3 +189,9 @@ VIEW_FREEZE_DEADLINE: 9
 MAX_REQUEST_INCLUSION_LIST: 16
 # 2**13 (= 8192)
 MAX_BYTES_PER_INCLUSION_LIST: 8192
+
+BLOB_SCHEDULE:
+  - EPOCH: 269568  # Deneb
+    MAX_BLOBS_PER_BLOCK: 6
+  - EPOCH: 364032  # Electra
+    MAX_BLOBS_PER_BLOCK: 9
path: configs/mainnet.yaml
user: login: ralexstokes
body: ```suggestion
  - EPOCH: 364032  # From Electra
    MAX_BLOBS_PER_BLOCK: 9
```
created_at: 2025-04-23T16:11:14Z
author_association: MEMBER
diff_hunk: @@ -118,6 +132,16 @@ def get_custody_groups(node_id: NodeID, custody_group_count: uint64) -> Sequence
     return sorted(custody_groups)
 ```
 
+### `get_max_blobs_per_block`
+
+```python
+def get_max_blobs_per_block(epoch: Epoch) -> uint64:
+    for entry in sorted(BLOB_SCHEDULE, key=lambda e: e["EPOCH"], reverse=True):
+        if epoch >= entry["EPOCH"]:
+            return entry["MAX_BLOBS_PER_BLOCK"]
+    return MAX_BLOBS_PER_BLOCK_ELECTRA
path: specs/fulu/das-core.md
user: login: rolfyone
body: I do think it's worth simplifying this to just have an entry for electra in the blob schedule, we could even specify the deneb blob limit from the deneb epoch, and assert if we get through the list without a result...

This allows a single data structure for these definitions going forward, and from fulu we could remove the use of the old constants, or just leave them dangling and not referenced, as we won't need them...

```
BLOB_SCHEDULE:
     # Electra mainnet fork
  - EPOCH: 364032
    MAX_BLOBS_PER_BLOCK: 9
    # Deneb mainnet fork
  - EPOCH: 269568
    MAX_BLOBS_PER_BLOCK: 6
```
created_at: 2025-04-23T22:32:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,6 +131,26 @@ def get_custody_groups(node_id: NodeID, custody_group_count: uint64) -> Sequence
     return sorted(custody_groups)
 ```
 
+### `get_max_blobs_per_block`
+
+```python
+def get_max_blobs_per_block(epoch: Epoch) -> uint64:
+    """
+    Return the maximum number of blobs that can be included in a block for the current epoch.
+    """
+    # This is a temporary block of code which is only necessary for testing. The testing
+    # infrastructure uses states where the current epoch is zero but the current version
+    # is whichever fork is being tested. This does not play well with a getter function
+    # which uses the current epoch to determine the appropriate return value.
+    if epoch < FULU_FORK_EPOCH:
+        return BLOB_SCHEDULE[-1]["MAX_BLOBS_PER_BLOCK"]
path: specs/fulu/das-core.md
user: login: rolfyone
body: So given this explanation i'd expect that the test config is correctly setting the current fork, and would have to also configure at least a single entry in this array?

I could see returning the maximum as a default being problematic if we're not testing fulu . eg. deneb needs less blobs or the comparison would fail due to structure issues iiuc (using 9 blobs instead of 6 etc)
created_at: 2025-04-30T22:37:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,6 +131,26 @@ def get_custody_groups(node_id: NodeID, custody_group_count: uint64) -> Sequence
     return sorted(custody_groups)
 ```
 
+### `get_max_blobs_per_block`
+
+```python
+def get_max_blobs_per_block(epoch: Epoch) -> uint64:
+    """
+    Return the maximum number of blobs that can be included in a block for the current epoch.
+    """
+    # This is a temporary block of code which is only necessary for testing. The testing
+    # infrastructure uses states where the current epoch is zero but the current version
+    # is whichever fork is being tested. This does not play well with a getter function
+    # which uses the current epoch to determine the appropriate return value.
+    if epoch < FULU_FORK_EPOCH:
+        return BLOB_SCHEDULE[-1]["MAX_BLOBS_PER_BLOCK"]
path: specs/fulu/das-core.md
user: login: jtraglia
body: Yes, this wouldn't have worked pre-fulu. I originally had a deneb+ solution, this:
```python
current_version = state.fork.current_version
if get_fork_epoch(current_version) == FAR_FUTURE_EPOCH:
    if current_version == DENEB_FORK_VERSION:
        return MAX_BLOBS_PER_BLOCK
    if current_version == ELECTRA_FORK_VERSION:
        return MAX_BLOBS_PER_BLOCK_ELECTRA
    if current_version >= FULU_FORK_VERSION:
        return BLOB_SCHEDULE[-1]["MAX_BLOBS_PER_BLOCK"]
```
But since this function is currently fulu-only, I thought that was too much. If/when we backport this to deneb, we can deal with that then. I've updated this function once again to just fallback to the minimum value.
created_at: 2025-05-01T01:00:07Z
author_association: MEMBER
diff_hunk: @@ -187,3 +186,14 @@ VIEW_FREEZE_DEADLINE: 3
 MAX_REQUEST_INCLUSION_LIST: 16
 # 2**13 (= 8192)
 MAX_BYTES_PER_INCLUSION_LIST: 8192
+
+# Blob Scheduling
+# ---------------------------------------------------------------
+
+BLOB_SCHEDULE:
+  # Deneb
+  - EPOCH: 18446744073709551615
+    MAX_BLOBS_PER_BLOCK: 6
+  # Electra
path: configs/minimal.yaml
user: login: ensi321
body: Should we add a `MAX_BLOBS_PER_BLOCK` for fulu? 

Currently how this is drafted, `get_max_blobs_per_block()` will return 9 during fulu. 
created_at: 2025-05-01T19:15:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -187,3 +186,14 @@ VIEW_FREEZE_DEADLINE: 3
 MAX_REQUEST_INCLUSION_LIST: 16
 # 2**13 (= 8192)
 MAX_BYTES_PER_INCLUSION_LIST: 8192
+
+# Blob Scheduling
+# ---------------------------------------------------------------
+
+BLOB_SCHEDULE:
+  # Deneb
+  - EPOCH: 18446744073709551615
+    MAX_BLOBS_PER_BLOCK: 6
+  # Electra
path: configs/minimal.yaml
user: login: jtraglia
body: Nah, during the 2nd to last PeerDAS breakout call we decided to remove any placeholder blob limit increases. We can decide these in a future PR. This PR just lays the foundation for that. Also, the current plan initially is to keep the Electra blob limit at the fork, then 2x two weeks later, then 2x two months later, and 2x once again another two months later. Resulting 72 blobs per block 4.5 months after Fulu.
created_at: 2025-05-01T20:01:02Z
author_association: MEMBER
diff_hunk: @@ -187,3 +186,14 @@ VIEW_FREEZE_DEADLINE: 3
 MAX_REQUEST_INCLUSION_LIST: 16
 # 2**13 (= 8192)
 MAX_BYTES_PER_INCLUSION_LIST: 8192
+
+# Blob Scheduling
+# ---------------------------------------------------------------
+
+BLOB_SCHEDULE:
+  # Deneb
+  - EPOCH: 18446744073709551615
+    MAX_BLOBS_PER_BLOCK: 6
+  # Electra
path: configs/minimal.yaml
user: login: nflaig
body: > then 2x two weeks later

how did we come up with that number btw? 2 weeks doesn't provide us any value, it's not sufficient to analyse the data on the network and push out new client releases + get everyone to update, minimum should be 2 months imo
created_at: 2025-05-01T20:09:44Z
author_association: MEMBER
diff_hunk: @@ -187,3 +186,14 @@ VIEW_FREEZE_DEADLINE: 3
 MAX_REQUEST_INCLUSION_LIST: 16
 # 2**13 (= 8192)
 MAX_BYTES_PER_INCLUSION_LIST: 8192
+
+# Blob Scheduling
+# ---------------------------------------------------------------
+
+BLOB_SCHEDULE:
+  # Deneb
+  - EPOCH: 18446744073709551615
+    MAX_BLOBS_PER_BLOCK: 6
+  # Electra
path: configs/minimal.yaml
user: login: jtraglia
body: This comes from Alex Stokes's proposal: [blob/acc in 2025](https://hackmd.io/@ralexstokes/blob-acc-2025). There's a some rationales in there. To be clear, this is just his proposed schedule. Nothing has been decided yet. And this discussion is out-of-scope for this PR 😅 
created_at: 2025-05-01T21:05:39Z
author_association: MEMBER
diff_hunk: @@ -187,3 +186,14 @@ VIEW_FREEZE_DEADLINE: 3
 MAX_REQUEST_INCLUSION_LIST: 16
 # 2**13 (= 8192)
 MAX_BYTES_PER_INCLUSION_LIST: 8192
+
+# Blob Scheduling
+# ---------------------------------------------------------------
+
+BLOB_SCHEDULE:
+  # Deneb
+  - EPOCH: 18446744073709551615
+    MAX_BLOBS_PER_BLOCK: 6
+  # Electra
path: configs/minimal.yaml
user: login: nflaig
body: I don't really see much rationale there but saw someone else left similar comment on the document, but agree this is out of scope of the PR :grin: 
created_at: 2025-05-01T21:14:18Z
author_association: MEMBER
diff_hunk: @@ -45,7 +38,7 @@ def process_execution_payload(state: BeaconState, body: BeaconBlockBody, executi
     # Verify timestamp
     assert payload.timestamp == compute_timestamp_at_slot(state, state.slot)
     # Verify commitments are under limit
-    assert len(body.blob_kzg_commitments) <= MAX_BLOBS_PER_BLOCK_FULU  # [Modified in Fulu:EIP7594]
+    assert len(body.blob_kzg_commitments) <= get_max_blobs_per_block(get_current_epoch(state))  # [Modified in Fulu:EIP7594]
path: specs/fulu/beacon-chain.md
user: login: prestonvanloon
body: ```suggestion
    assert len(body.blob_kzg_commitments) <= get_max_blobs_per_block(get_current_epoch(state))  # [Modified in Fulu:EIP7892]
```
Isn't this part of https://eips.ethereum.org/EIPS/eip-7892 ?


created_at: 2025-05-08T16:22:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -67,6 +69,17 @@ The following values are (non-configurable) constants used throughout the specif
 | `NUMBER_OF_CUSTODY_GROUPS` | `128` | Number of custody groups available for nodes to custody                           |
 | `CUSTODY_REQUIREMENT`      | `4`   | Minimum number of custody groups an honest node custodies and serves samples from |
 
+### Blob schedule
+
+*[New in EIP7594]* This schedule defines the maximum blobs per block limit for a given epoch.
path: specs/fulu/das-core.md
user: login: prestonvanloon
body: ```suggestion
*[New in EIP7892]* This schedule defines the maximum blobs per block limit for a given epoch.
```

Isn't this part of https://eips.ethereum.org/EIPS/eip-7892 ?
created_at: 2025-05-08T16:25:23Z
author_association: CONTRIBUTOR

PR comments:
user: login: ensi321
created_at: 2025-04-17T21:39:08Z
body: Should we move get_max_blobs_per_block() definition to deneb, and apply it to deneb and electra spec?

If so, we would want MAX_BLOBS_PER_BLOCK and MAX_BLOBS_PER_BLOCK_ELECTRA removed
author_association: CONTRIBUTOR
user: login: dguenther
created_at: 2025-04-23T15:48:33Z
body: I think the preceding question is how to resolve the conflict between `MAX_BLOBS_PER_BLOCK` and `MAX_BLOBS_PER_BLOCK_ELECTRA` once blob_schedule is implemented? Examples of a few options:

1. Ignore/error on pre-Fulu epochs in blob schedule
2. Insert Deneb+Electra block limits into blob schedule
    1. Ignore `MAX_BLOBS...` values
    1. Error if `MAX_BLOBS...` values don't match schedule
    1. Remove `MAX_BLOBS...` and backport to Deneb
    
I'm in favor of removing `MAX_BLOBS...`, with secondary preference for erroring on pre-Fulu epochs in the blob schedule, because I think these most clearly define which values to set when. 

author_association: CONTRIBUTOR
user: login: prestonvanloon
created_at: 2025-05-08T16:30:12Z
body: Another note: from the implementation standpoint, we would probably backport `get_max_blobs_per_block` to deneb/electra instead of using the  `MAX_BLOBS_PER_BLOCK_ELECTRA` and `MAX_BLOBS_PER_BLOCK` values. 

It would be nice to see that reflected in spec too
author_association: CONTRIBUTOR
user: login: jtraglia
created_at: 2025-05-08T19:01:25Z
body: > Another note: from the implementation standpoint, we would probably backport `get_max_blobs_per_block` to deneb/electra instead of using the `MAX_BLOBS_PER_BLOCK_ELECTRA` and `MAX_BLOBS_PER_BLOCK` values.
> 
> It would be nice to see that reflected in spec too

Thanks for the review! And yup, that's the plan. Will follow up with that asap after merging this.
author_association: MEMBER