diff --git a/pkg/beacon/state/blob_schedule.go b/pkg/beacon/state/blob_schedule.go new file mode 100644 index 0000000..cd4ac94 --- /dev/null +++ b/pkg/beacon/state/blob_schedule.go @@ -0,0 +1,48 @@ +package state + +import ( + "sort" + + "github.com/attestantio/go-eth2-client/spec/phase0" +) + +// BlobScheduleEntry represents a single entry in the BLOB_SCHEDULE configuration. +type BlobScheduleEntry struct { + Epoch phase0.Epoch `json:"EPOCH,string"` + MaxBlobsPerBlock uint64 `json:"MAX_BLOBS_PER_BLOCK,string"` +} + +// BlobSchedule represents the BLOB_SCHEDULE configuration. +type BlobSchedule []BlobScheduleEntry + +// GetMaxBlobsPerBlock returns the maximum number of blobs that can be included in a block for a given epoch. +func (bs BlobSchedule) GetMaxBlobsPerBlock(epoch phase0.Epoch) uint64 { + if len(bs) == 0 { + return 0 + } + + // Sort by epoch in descending order to find the most recent applicable entry. + sorted := make(BlobSchedule, len(bs)) + copy(sorted, bs) + + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Epoch > sorted[j].Epoch + }) + + // Find the first entry where epoch >= entry.Epoch. + for _, entry := range sorted { + if epoch >= entry.Epoch { + return entry.MaxBlobsPerBlock + } + } + + // If no entry is found, return the minimum value from all entries. + minBlobs := sorted[0].MaxBlobsPerBlock + for _, entry := range sorted { + if entry.MaxBlobsPerBlock < minBlobs { + minBlobs = entry.MaxBlobsPerBlock + } + } + + return minBlobs +} diff --git a/pkg/beacon/state/blob_schedule_test.go b/pkg/beacon/state/blob_schedule_test.go new file mode 100644 index 0000000..b77237d --- /dev/null +++ b/pkg/beacon/state/blob_schedule_test.go @@ -0,0 +1,150 @@ +package state + +import ( + "testing" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/assert" +) + +func TestBlobSchedule_GetMaxBlobsPerBlock(t *testing.T) { + tests := []struct { + name string + schedule BlobSchedule + epoch phase0.Epoch + expectedBlob uint64 + }{ + { + name: "empty schedule returns 0", + schedule: BlobSchedule{}, + epoch: phase0.Epoch(100), + expectedBlob: 0, + }, + { + name: "epoch before any schedule entry returns minimum", + schedule: BlobSchedule{ + {Epoch: phase0.Epoch(100), MaxBlobsPerBlock: 6}, + {Epoch: phase0.Epoch(200), MaxBlobsPerBlock: 9}, + }, + epoch: phase0.Epoch(50), + expectedBlob: 6, + }, + { + name: "epoch exactly matches first entry", + schedule: BlobSchedule{ + {Epoch: phase0.Epoch(100), MaxBlobsPerBlock: 6}, + {Epoch: phase0.Epoch(200), MaxBlobsPerBlock: 9}, + }, + epoch: phase0.Epoch(100), + expectedBlob: 6, + }, + { + name: "epoch exactly matches second entry", + schedule: BlobSchedule{ + {Epoch: phase0.Epoch(100), MaxBlobsPerBlock: 6}, + {Epoch: phase0.Epoch(200), MaxBlobsPerBlock: 9}, + }, + epoch: phase0.Epoch(200), + expectedBlob: 9, + }, + { + name: "epoch between entries uses earlier entry", + schedule: BlobSchedule{ + {Epoch: phase0.Epoch(100), MaxBlobsPerBlock: 6}, + {Epoch: phase0.Epoch(200), MaxBlobsPerBlock: 9}, + }, + epoch: phase0.Epoch(150), + expectedBlob: 6, + }, + { + name: "epoch after all entries uses latest entry", + schedule: BlobSchedule{ + {Epoch: phase0.Epoch(100), MaxBlobsPerBlock: 6}, + {Epoch: phase0.Epoch(200), MaxBlobsPerBlock: 9}, + }, + epoch: phase0.Epoch(300), + expectedBlob: 9, + }, + { + name: "unordered schedule entries are handled correctly", + schedule: BlobSchedule{ + {Epoch: phase0.Epoch(200), MaxBlobsPerBlock: 9}, + {Epoch: phase0.Epoch(100), MaxBlobsPerBlock: 6}, + {Epoch: phase0.Epoch(300), MaxBlobsPerBlock: 12}, + }, + epoch: phase0.Epoch(250), + expectedBlob: 9, + }, + { + name: "fulu testnet example - deneb epoch", + schedule: BlobSchedule{ + {Epoch: phase0.Epoch(512), MaxBlobsPerBlock: 12}, + {Epoch: phase0.Epoch(768), MaxBlobsPerBlock: 15}, + {Epoch: phase0.Epoch(1024), MaxBlobsPerBlock: 18}, + {Epoch: phase0.Epoch(1280), MaxBlobsPerBlock: 9}, + {Epoch: phase0.Epoch(1584), MaxBlobsPerBlock: 20}, + }, + epoch: phase0.Epoch(600), + expectedBlob: 12, + }, + { + name: "fulu testnet example - electra epoch", + schedule: BlobSchedule{ + {Epoch: phase0.Epoch(512), MaxBlobsPerBlock: 12}, + {Epoch: phase0.Epoch(768), MaxBlobsPerBlock: 15}, + {Epoch: phase0.Epoch(1024), MaxBlobsPerBlock: 18}, + {Epoch: phase0.Epoch(1280), MaxBlobsPerBlock: 9}, + {Epoch: phase0.Epoch(1584), MaxBlobsPerBlock: 20}, + }, + epoch: phase0.Epoch(1300), + expectedBlob: 9, + }, + { + name: "fulu testnet example - latest epoch", + schedule: BlobSchedule{ + {Epoch: phase0.Epoch(512), MaxBlobsPerBlock: 12}, + {Epoch: phase0.Epoch(768), MaxBlobsPerBlock: 15}, + {Epoch: phase0.Epoch(1024), MaxBlobsPerBlock: 18}, + {Epoch: phase0.Epoch(1280), MaxBlobsPerBlock: 9}, + {Epoch: phase0.Epoch(1584), MaxBlobsPerBlock: 20}, + }, + epoch: phase0.Epoch(2000), + expectedBlob: 20, + }, + { + name: "single entry schedule", + schedule: BlobSchedule{ + {Epoch: phase0.Epoch(100), MaxBlobsPerBlock: 6}, + }, + epoch: phase0.Epoch(150), + expectedBlob: 6, + }, + { + name: "single entry schedule before epoch", + schedule: BlobSchedule{ + {Epoch: phase0.Epoch(100), MaxBlobsPerBlock: 6}, + }, + epoch: phase0.Epoch(50), + expectedBlob: 6, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.schedule.GetMaxBlobsPerBlock(tt.epoch) + assert.Equal(t, tt.expectedBlob, result, "Expected %d blobs for epoch %d", tt.expectedBlob, tt.epoch) + }) + } +} + +func TestSpec_GetMaxBlobsPerBlock(t *testing.T) { + spec := Spec{ + BlobSchedule: BlobSchedule{ + {Epoch: phase0.Epoch(100), MaxBlobsPerBlock: 6}, + {Epoch: phase0.Epoch(200), MaxBlobsPerBlock: 9}, + }, + } + + result := spec.GetMaxBlobsPerBlock(phase0.Epoch(150)) + assert.Equal(t, uint64(6), result, "Spec.GetMaxBlobsPerBlock should delegate to BlobSchedule.GetMaxBlobsPerBlock") +} diff --git a/pkg/beacon/state/spec.go b/pkg/beacon/state/spec.go index 27cd434..deec431 100644 --- a/pkg/beacon/state/spec.go +++ b/pkg/beacon/state/spec.go @@ -44,7 +44,8 @@ type Spec struct { MinGenesisActiveValidatorCount uint64 `json:"MIN_GENESIS_ACTIVE_VALIDATOR_COUNT,string"` Eth1FollowDistance uint64 `json:"ETH1_FOLLOW_DISTANCE,string"` - ForkEpochs ForkEpochs `json:"-"` + ForkEpochs ForkEpochs `json:"-"` + BlobSchedule BlobSchedule `json:"BLOB_SCHEDULE"` } // NewSpec creates a new spec instance. @@ -192,6 +193,21 @@ func NewSpec(data map[string]interface{}) Spec { }) } + if blobSchedule, exists := data["BLOB_SCHEDULE"]; exists { + if scheduleData, ok := blobSchedule.([]interface{}); ok { + spec.BlobSchedule = make(BlobSchedule, len(scheduleData)) + + for i, entry := range scheduleData { + if entryMap, ok := entry.(map[string]interface{}); ok { + spec.BlobSchedule[i] = BlobScheduleEntry{ + Epoch: phase0.Epoch(cast.ToUint64(entryMap["EPOCH"])), + MaxBlobsPerBlock: cast.ToUint64(entryMap["MAX_BLOBS_PER_BLOCK"]), + } + } + } + } + } + return spec } @@ -200,6 +216,11 @@ func (s *Spec) Validate() error { return nil } +// GetMaxBlobsPerBlock returns the maximum number of blobs that can be included in a block for a given epoch. +func (s *Spec) GetMaxBlobsPerBlock(epoch phase0.Epoch) uint64 { + return s.BlobSchedule.GetMaxBlobsPerBlock(epoch) +} + func dataVersionFromString(name string) (sp.DataVersion, error) { var v sp.DataVersion if err := json.Unmarshal([]byte(fmt.Sprintf("\"%s\"", name)), &v); err != nil {