From cd6cc76d5826f4a4ff880a1b8a9fa3d58772928a Mon Sep 17 00:00:00 2001 From: terence Date: Fri, 25 Jul 2025 12:06:10 -0700 Subject: [PATCH] Add `BLOB_SCHEDULE` to `eth/v1/config/spec endpoint` (#15485) * Beacon api: fix get config blob schedule * Numbers should be string instead of float * more generalized implementation for nested objects * removing unused function * fixing linting * removing redundant switch fields * adding additional log for debugging * Fix build. * adding skip function based on kasey's recommendation * fixing test --------- Co-authored-by: james-prysm Co-authored-by: james-prysm <90280386+james-prysm@users.noreply.github.com> Co-authored-by: Manu NALEPA --- beacon-chain/rpc/eth/config/BUILD.bazel | 1 + beacon-chain/rpc/eth/config/handlers.go | 117 ++++++++++++++---- beacon-chain/rpc/eth/config/handlers_test.go | 91 +++++++++++++- ...d-blob-schedule-to-config-spec-endpoint.md | 3 + config/params/config.go | 6 +- 5 files changed, 191 insertions(+), 27 deletions(-) create mode 100644 changelog/add-blob-schedule-to-config-spec-endpoint.md diff --git a/beacon-chain/rpc/eth/config/BUILD.bazel b/beacon-chain/rpc/eth/config/BUILD.bazel index cfa4f2eef4..eab5f80401 100644 --- a/beacon-chain/rpc/eth/config/BUILD.bazel +++ b/beacon-chain/rpc/eth/config/BUILD.bazel @@ -12,6 +12,7 @@ go_library( "//network/forks:go_default_library", "//network/httputil:go_default_library", "@com_github_ethereum_go_ethereum//common/hexutil:go_default_library", + "@com_github_sirupsen_logrus//:go_default_library", ], ) diff --git a/beacon-chain/rpc/eth/config/handlers.go b/beacon-chain/rpc/eth/config/handlers.go index e8e248a385..e16af4b06d 100644 --- a/beacon-chain/rpc/eth/config/handlers.go +++ b/beacon-chain/rpc/eth/config/handlers.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "math" "net/http" "reflect" "strconv" @@ -13,6 +14,7 @@ import ( "github.com/OffchainLabs/prysm/v6/network/forks" "github.com/OffchainLabs/prysm/v6/network/httputil" "github.com/ethereum/go-ethereum/common/hexutil" + log "github.com/sirupsen/logrus" ) // GetDepositContract retrieves deposit contract address and genesis fork version. @@ -80,39 +82,108 @@ func GetSpec(w http.ResponseWriter, r *http.Request) { httputil.WriteJson(w, &structs.GetSpecResponse{Data: data}) } -func prepareConfigSpec() (map[string]string, error) { - data := make(map[string]string) +func convertValueForJSON(v reflect.Value, tag string) interface{} { + // Unwrap pointers / interfaces + for v.Kind() == reflect.Interface || v.Kind() == reflect.Ptr { + if v.IsNil() { + return nil + } + v = v.Elem() + } + + switch v.Kind() { + // ===== Single byte → 0xAB ===== + case reflect.Uint8: + return hexutil.Encode([]byte{uint8(v.Uint())}) + + // ===== Other unsigned numbers → "123" ===== + case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return strconv.FormatUint(v.Uint(), 10) + + // ===== Signed numbers → "123" ===== + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(v.Int(), 10) + + // ===== Raw bytes – encode to hex ===== + case reflect.Slice: + if v.Type().Elem().Kind() == reflect.Uint8 { + return hexutil.Encode(v.Bytes()) + } + fallthrough + case reflect.Array: + if v.Type().Elem().Kind() == reflect.Uint8 { + // Need a copy because v.Slice is illegal on arrays directly + tmp := make([]byte, v.Len()) + reflect.Copy(reflect.ValueOf(tmp), v) + return hexutil.Encode(tmp) + } + // Generic slice/array handling + n := v.Len() + out := make([]interface{}, n) + for i := 0; i < n; i++ { + out[i] = convertValueForJSON(v.Index(i), tag) + } + return out + + // ===== Struct ===== + case reflect.Struct: + t := v.Type() + m := make(map[string]interface{}, v.NumField()) + for i := 0; i < v.NumField(); i++ { + f := t.Field(i) + if !v.Field(i).CanInterface() { + continue // unexported + } + key := f.Tag.Get("json") + if key == "" || key == "-" { + key = f.Name + } + m[key] = convertValueForJSON(v.Field(i), tag) + } + return m + + // ===== Default ===== + default: + log.WithFields(log.Fields{ + "fn": "prepareConfigSpec", + "tag": tag, + "kind": v.Kind().String(), + "type": v.Type().String(), + }).Error("Unsupported config field kind; value forwarded verbatim") + return v.Interface() + } +} + +func prepareConfigSpec() (map[string]interface{}, error) { + data := make(map[string]interface{}) config := *params.BeaconConfig() + t := reflect.TypeOf(config) v := reflect.ValueOf(config) for i := 0; i < t.NumField(); i++ { tField := t.Field(i) - _, isSpecField := tField.Tag.Lookup("spec") - if !isSpecField { - // Field should not be returned from API. + _, isSpec := tField.Tag.Lookup("spec") + if !isSpec { + continue + } + if shouldSkip(tField) { continue } - tagValue := strings.ToUpper(tField.Tag.Get("yaml")) - vField := v.Field(i) - switch vField.Kind() { - case reflect.Int: - data[tagValue] = strconv.FormatInt(vField.Int(), 10) - case reflect.Uint64: - data[tagValue] = strconv.FormatUint(vField.Uint(), 10) - case reflect.Slice: - data[tagValue] = hexutil.Encode(vField.Bytes()) - case reflect.Array: - data[tagValue] = hexutil.Encode(reflect.ValueOf(&config).Elem().Field(i).Slice(0, vField.Len()).Bytes()) - case reflect.String: - data[tagValue] = vField.String() - case reflect.Uint8: - data[tagValue] = hexutil.Encode([]byte{uint8(vField.Uint())}) - default: - return nil, fmt.Errorf("unsupported config field type: %s", vField.Kind().String()) - } + tag := strings.ToUpper(tField.Tag.Get("yaml")) + val := v.Field(i) + data[tag] = convertValueForJSON(val, tag) } return data, nil } + +func shouldSkip(tField reflect.StructField) bool { + // Dynamically skip blob schedule if Fulu is not yet scheduled. + if params.BeaconConfig().FuluForkEpoch == math.MaxUint64 && + tField.Type == reflect.TypeOf(params.BeaconConfig().BlobSchedule) { + return true + } + return false +} diff --git a/beacon-chain/rpc/eth/config/handlers_test.go b/beacon-chain/rpc/eth/config/handlers_test.go index 3abc588e56..4a39daf8ce 100644 --- a/beacon-chain/rpc/eth/config/handlers_test.go +++ b/beacon-chain/rpc/eth/config/handlers_test.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "math" "net/http" "net/http/httptest" "testing" @@ -200,7 +201,7 @@ func TestGetSpec(t *testing.T) { data, ok := resp.Data.(map[string]interface{}) require.Equal(t, true, ok) - assert.Equal(t, 175, len(data)) + assert.Equal(t, 176, len(data)) for k, v := range data { t.Run(k, func(t *testing.T) { switch k { @@ -577,6 +578,11 @@ func TestGetSpec(t *testing.T) { assert.Equal(t, "102", v) case "BLOB_SIDECAR_SUBNET_COUNT_ELECTRA": assert.Equal(t, "103", v) + case "BLOB_SCHEDULE": + // BLOB_SCHEDULE should be an empty slice when no schedule is defined + blobSchedule, ok := v.([]interface{}) + assert.Equal(t, true, ok) + assert.Equal(t, 0, len(blobSchedule)) default: t.Errorf("Incorrect key: %s", k) } @@ -637,3 +643,86 @@ func TestForkSchedule_Ok(t *testing.T) { assert.Equal(t, os.Len(), len(resp.Data)) }) } + +func TestGetSpec_BlobSchedule(t *testing.T) { + params.SetupTestConfigCleanup(t) + config := params.BeaconConfig().Copy() + config.FuluForkEpoch = 1 + + // Set up a blob schedule with test data + config.BlobSchedule = []params.BlobScheduleEntry{ + { + Epoch: primitives.Epoch(100), + MaxBlobsPerBlock: 6, + }, + { + Epoch: primitives.Epoch(200), + MaxBlobsPerBlock: 9, + }, + } + params.OverrideBeaconConfig(config) + + request := httptest.NewRequest(http.MethodGet, "http://example.com/eth/v1/config/spec", nil) + writer := httptest.NewRecorder() + writer.Body = &bytes.Buffer{} + + GetSpec(writer, request) + require.Equal(t, http.StatusOK, writer.Code) + resp := structs.GetSpecResponse{} + require.NoError(t, json.Unmarshal(writer.Body.Bytes(), &resp)) + data, ok := resp.Data.(map[string]interface{}) + require.Equal(t, true, ok) + + // Verify BLOB_SCHEDULE is present and properly formatted + blobScheduleValue, exists := data["BLOB_SCHEDULE"] + require.Equal(t, true, exists) + + // Verify it's a slice of maps (actual JSON object, not string) + // The JSON unmarshaling converts it to []interface{} with map[string]interface{} entries + blobScheduleSlice, ok := blobScheduleValue.([]interface{}) + require.Equal(t, true, ok) + + // Convert to generic interface for easier testing + var blobSchedule []map[string]interface{} + for _, entry := range blobScheduleSlice { + entryMap, ok := entry.(map[string]interface{}) + require.Equal(t, true, ok) + blobSchedule = append(blobSchedule, entryMap) + } + + // Verify the blob schedule content + require.Equal(t, 2, len(blobSchedule)) + + // Check first entry - values should be strings for consistent API output + assert.Equal(t, "100", blobSchedule[0]["EPOCH"]) + assert.Equal(t, "6", blobSchedule[0]["MAX_BLOBS_PER_BLOCK"]) + + // Check second entry - values should be strings for consistent API output + assert.Equal(t, "200", blobSchedule[1]["EPOCH"]) + assert.Equal(t, "9", blobSchedule[1]["MAX_BLOBS_PER_BLOCK"]) +} + +func TestGetSpec_BlobSchedule_NotFulu(t *testing.T) { + params.SetupTestConfigCleanup(t) + config := params.BeaconConfig().Copy() + // Fulu not scheduled (default: math.MaxUint64) + config.FuluForkEpoch = math.MaxUint64 + config.BlobSchedule = []params.BlobScheduleEntry{ + {Epoch: primitives.Epoch(100), MaxBlobsPerBlock: 6}, + } + params.OverrideBeaconConfig(config) + + request := httptest.NewRequest(http.MethodGet, "http://example.com/eth/v1/config/spec", nil) + writer := httptest.NewRecorder() + writer.Body = &bytes.Buffer{} + + GetSpec(writer, request) + require.Equal(t, http.StatusOK, writer.Code) + resp := structs.GetSpecResponse{} + require.NoError(t, json.Unmarshal(writer.Body.Bytes(), &resp)) + data, ok := resp.Data.(map[string]interface{}) + require.Equal(t, true, ok) + + _, exists := data["BLOB_SCHEDULE"] + require.Equal(t, false, exists) +} diff --git a/changelog/add-blob-schedule-to-config-spec-endpoint.md b/changelog/add-blob-schedule-to-config-spec-endpoint.md new file mode 100644 index 0000000000..3f5aa09e50 --- /dev/null +++ b/changelog/add-blob-schedule-to-config-spec-endpoint.md @@ -0,0 +1,3 @@ +### Added + +- Add BLOB_SCHEDULE field to `/eth/v1/config/spec` endpoint response to expose blob scheduling configuration for networks. \ No newline at end of file diff --git a/config/params/config.go b/config/params/config.go index e05ea531cd..b5073c536a 100644 --- a/config/params/config.go +++ b/config/params/config.go @@ -297,7 +297,7 @@ type BeaconChainConfig struct { NodeIdBits uint64 `yaml:"NODE_ID_BITS" spec:"true"` // NodeIdBits defines the bit length of a node id. // Blobs Values - BlobSchedule []BlobScheduleEntry `yaml:"BLOB_SCHEDULE"` + BlobSchedule []BlobScheduleEntry `yaml:"BLOB_SCHEDULE" spec:"true"` // Deprecated_MaxBlobsPerBlock defines the max blobs that could exist in a block. // Deprecated: This field is no longer supported. Avoid using it. @@ -336,8 +336,8 @@ func (b *BeaconChainConfig) ExecutionRequestLimits() enginev1.ExecutionRequestLi } type BlobScheduleEntry struct { - Epoch primitives.Epoch `yaml:"EPOCH"` - MaxBlobsPerBlock uint64 `yaml:"MAX_BLOBS_PER_BLOCK"` + Epoch primitives.Epoch `yaml:"EPOCH" json:"EPOCH"` + MaxBlobsPerBlock uint64 `yaml:"MAX_BLOBS_PER_BLOCK" json:"MAX_BLOBS_PER_BLOCK"` } // InitializeForkSchedule initializes the schedules forks baked into the config.