From 4a8bd0ad00db775079a35b58886f1c2dcbf2e503 Mon Sep 17 00:00:00 2001 From: Josh Klopfenstein Date: Mon, 6 Oct 2025 12:00:31 -0700 Subject: [PATCH 1/5] beacon client: use new fusaka endpoint to get blobs Fallback to old endpoint on error. --- op-service/apis/beacon.go | 1 + op-service/sources/l1_beacon_client.go | 67 ++++++++++++++++++--- op-service/sources/l1_beacon_client_test.go | 34 ++++++++--- op-service/sources/mocks/BeaconClient.go | 60 ++++++++++++++++++ 4 files changed, 144 insertions(+), 18 deletions(-) diff --git a/op-service/apis/beacon.go b/op-service/apis/beacon.go index 4be95f53f6190..76f61ca01a3a3 100644 --- a/op-service/apis/beacon.go +++ b/op-service/apis/beacon.go @@ -11,6 +11,7 @@ type BeaconClient interface { NodeVersion(ctx context.Context) (string, error) ConfigSpec(ctx context.Context) (eth.APIConfigResponse, error) BeaconGenesis(ctx context.Context) (eth.APIGenesisResponse, error) + BeaconBlobs(ctx context.Context, slot uint64, hashes []eth.IndexedBlobHash) ([]*eth.Blob, error) BlobSideCarsClient } diff --git a/op-service/sources/l1_beacon_client.go b/op-service/sources/l1_beacon_client.go index 7400c7e948286..cc40a94abb9eb 100644 --- a/op-service/sources/l1_beacon_client.go +++ b/op-service/sources/l1_beacon_client.go @@ -25,6 +25,7 @@ const ( specMethod = "eth/v1/config/spec" genesisMethod = "eth/v1/beacon/genesis" sidecarsMethodPrefix = "eth/v1/beacon/blob_sidecars/" + blobsMethodPrefix = "eth/v1/beacon/blobs/" ) type L1BeaconClientConfig struct { @@ -100,6 +101,19 @@ func (cl *BeaconHTTPClient) BeaconGenesis(ctx context.Context) (eth.APIGenesisRe return genesisResp, nil } +func (cl *BeaconHTTPClient) BeaconBlobs(ctx context.Context, slot uint64, hashes []eth.IndexedBlobHash) ([]*eth.Blob, error) { + reqQuery := url.Values{} + for _, hash := range hashes { + reqQuery.Add("versioned_hashes", hash.Hash.Hex()) + } + reqPath := path.Join(blobsMethodPrefix, strconv.FormatUint(slot, 10)) + var blobsResp []*eth.Blob + if err := cl.apiReq(ctx, &blobsResp, reqPath, reqQuery); err != nil { + return nil, err + } + return blobsResp, nil +} + func (cl *BeaconHTTPClient) BeaconBlobSideCars(ctx context.Context, fetchAllSidecars bool, slot uint64, hashes []eth.IndexedBlobHash) (eth.APIGetBlobSidecarsResponse, error) { reqPath := path.Join(sidecarsMethodPrefix, strconv.FormatUint(slot, 10)) var reqQuery url.Values @@ -202,6 +216,18 @@ func (cl *L1BeaconClient) GetTimeToSlotFn(ctx context.Context) (TimeToSlotFn, er return cl.timeToSlotFn, nil } +func (cl *L1BeaconClient) timeToSlot(ctx context.Context, timestamp uint64) (uint64, error) { + slotFn, err := cl.GetTimeToSlotFn(ctx) + if err != nil { + return 0, fmt.Errorf("get time to slot fn: %w", err) + } + slot, err := slotFn(timestamp) + if err != nil { + return 0, fmt.Errorf("convert timestamp %d to slot number: %w", timestamp, err) + } + return slot, nil +} + func (cl *L1BeaconClient) fetchSidecars(ctx context.Context, slot uint64, hashes []eth.IndexedBlobHash) (eth.APIGetBlobSidecarsResponse, error) { var errs []error for i := 0; i < cl.pool.Len(); i++ { @@ -225,18 +251,21 @@ func (cl *L1BeaconClient) GetBlobSidecars(ctx context.Context, ref eth.L1BlockRe if len(hashes) == 0 { return []*eth.BlobSidecar{}, nil } - slotFn, err := cl.GetTimeToSlotFn(ctx) + slot, err := cl.timeToSlot(ctx, ref.Time) if err != nil { - return nil, fmt.Errorf("failed to get time to slot function: %w", err) + return nil, err } - slot, err := slotFn(ref.Time) + sidecars, err := cl.getBlobSidecars(ctx, slot, hashes) if err != nil { - return nil, fmt.Errorf("error in converting ref.Time to slot: %w", err) + return nil, fmt.Errorf("get blob sidecars for block %v: %w", ref, err) } + return sidecars, nil +} +func (cl *L1BeaconClient) getBlobSidecars(ctx context.Context, slot uint64, hashes []eth.IndexedBlobHash) ([]*eth.BlobSidecar, error) { resp, err := cl.fetchSidecars(ctx, slot, hashes) if err != nil { - return nil, fmt.Errorf("failed to fetch blob sidecars for slot %v block %v: %w", slot, ref, err) + return nil, fmt.Errorf("failed to fetch blob sidecars for slot %v: %w", slot, err) } apiscs := make([]*eth.APIBlobSidecar, 0, len(hashes)) @@ -267,17 +296,37 @@ func (cl *L1BeaconClient) GetBlobSidecars(ctx context.Context, ref eth.L1BlockRe // blob's validity by checking its proof against the commitment, and confirming the commitment // hashes to the expected value. Returns error if any blob is found invalid. func (cl *L1BeaconClient) GetBlobs(ctx context.Context, ref eth.L1BlockRef, hashes []eth.IndexedBlobHash) ([]*eth.Blob, error) { - blobSidecars, err := cl.GetBlobSidecars(ctx, ref, hashes) + if len(hashes) == 0 { + return []*eth.Blob{}, nil + } + slot, err := cl.timeToSlot(ctx, ref.Time) if err != nil { - return nil, fmt.Errorf("failed to get blob sidecars for L1BlockRef %s: %w", ref, err) + return nil, err } - blobs, err := blobsFromSidecars(blobSidecars, hashes) + blobs, err := cl.cl.BeaconBlobs(ctx, slot, hashes) if err != nil { - return nil, fmt.Errorf("failed to get blobs from sidecars for L1BlockRef %s: %w", ref, err) + // We would normally check for an explicit error like "method not found", but the Beacon + // API doesn't standardize such a response. Thus, we interpret all errors as + // "method not found" and fall back to fetching sidecars. + blobSidecars, err := cl.getBlobSidecars(ctx, slot, hashes) + if err != nil { + return nil, fmt.Errorf("failed to get blob sidecars for L1BlockRef %s: %w", ref, err) + } + blobs, err := blobsFromSidecars(blobSidecars, hashes) + if err != nil { + return nil, fmt.Errorf("failed to get blobs from sidecars for L1BlockRef %s: %w", ref, err) + } + return blobs, nil + } + for i, blob := range blobs { + if err := verifyBlob(blob, hashes[i].Hash); err != nil { + return nil, fmt.Errorf("blob %d failed verification: %w", i, err) + } } return blobs, nil } +// blobsFromSidecars pulls the blobs from the sidecars and verifies the commitments and indices. func blobsFromSidecars(blobSidecars []*eth.BlobSidecar, hashes []eth.IndexedBlobHash) ([]*eth.Blob, error) { if len(blobSidecars) != len(hashes) { return nil, fmt.Errorf("number of hashes and blobSidecars mismatch, %d != %d", len(hashes), len(blobSidecars)) diff --git a/op-service/sources/l1_beacon_client_test.go b/op-service/sources/l1_beacon_client_test.go index 0e39d192891be..e5122e883f02e 100644 --- a/op-service/sources/l1_beacon_client_test.go +++ b/op-service/sources/l1_beacon_client_test.go @@ -233,20 +233,36 @@ func TestBeaconClientBadProof(t *testing.T) { hashes := []eth.IndexedBlobHash{index0, index1, index2} sidecars := []*eth.BlobSidecar{sidecar0, sidecar1, sidecar2} + blobs := []*eth.Blob{&sidecar0.Blob, &sidecar1.Blob, &sidecar2.Blob} // invalidate proof sidecar1.KZGProof = eth.Bytes48(badProof) apiSidecars := toAPISideCars(sidecars) - ctx := context.Background() - p := mocks.NewBeaconClient(t) - - p.EXPECT().BeaconGenesis(ctx).Return(eth.APIGenesisResponse{Data: eth.ReducedGenesisData{GenesisTime: 10}}, nil) - p.EXPECT().ConfigSpec(ctx).Return(eth.APIConfigResponse{Data: eth.ReducedConfigData{SecondsPerSlot: 2}}, nil) - clientWithValidation := NewL1BeaconClient(p, L1BeaconClientConfig{}) - p.EXPECT().BeaconBlobSideCars(ctx, false, uint64(1), hashes).Return(eth.APIGetBlobSidecarsResponse{Data: apiSidecars}, nil) - _, err := clientWithValidation.GetBlobs(ctx, eth.L1BlockRef{Time: 12}, hashes) - assert.NoError(t, err) // The verification flow does not require a valid proof + t.Run("fallback to BeaconBlobSideCars", func(t *testing.T) { + ctx := context.Background() + p := mocks.NewBeaconClient(t) + p.EXPECT().BeaconGenesis(ctx).Return(eth.APIGenesisResponse{Data: eth.ReducedGenesisData{GenesisTime: 10}}, nil) + p.EXPECT().ConfigSpec(ctx).Return(eth.APIConfigResponse{Data: eth.ReducedConfigData{SecondsPerSlot: 2}}, nil) + clientWithValidation := NewL1BeaconClient(p, L1BeaconClientConfig{}) + ref := eth.L1BlockRef{Time: 12} + p.EXPECT().BeaconBlobs(ctx, uint64(1), hashes).Return(nil, errors.New("the sky is falling")) + p.EXPECT().BeaconBlobSideCars(ctx, false, uint64(1), hashes).Return(eth.APIGetBlobSidecarsResponse{Data: apiSidecars}, nil) + _, err := clientWithValidation.GetBlobs(ctx, ref, hashes) + assert.NoError(t, err) // The verification flow does not require a valid proof + }) + + t.Run("BeaconBlobs", func(t *testing.T) { + ctx := context.Background() + p := mocks.NewBeaconClient(t) + p.EXPECT().BeaconGenesis(ctx).Return(eth.APIGenesisResponse{Data: eth.ReducedGenesisData{GenesisTime: 10}}, nil) + p.EXPECT().ConfigSpec(ctx).Return(eth.APIConfigResponse{Data: eth.ReducedConfigData{SecondsPerSlot: 2}}, nil) + clientWithValidation := NewL1BeaconClient(p, L1BeaconClientConfig{}) + ref := eth.L1BlockRef{Time: 12} + p.EXPECT().BeaconBlobs(ctx, uint64(1), hashes).Return(blobs, nil) + _, err := clientWithValidation.GetBlobs(ctx, ref, hashes) + assert.NoError(t, err) // The verification flow does not require a valid proof + }) } func TestBeaconHTTPClient(t *testing.T) { diff --git a/op-service/sources/mocks/BeaconClient.go b/op-service/sources/mocks/BeaconClient.go index 89b07d0670c59..18c06b6f22e7b 100644 --- a/op-service/sources/mocks/BeaconClient.go +++ b/op-service/sources/mocks/BeaconClient.go @@ -81,6 +81,66 @@ func (_c *BeaconClient_BeaconBlobSideCars_Call) RunAndReturn(run func(context.Co return _c } +// BeaconBlobs provides a mock function with given fields: ctx, slot, hashes +func (_m *BeaconClient) BeaconBlobs(ctx context.Context, slot uint64, hashes []eth.IndexedBlobHash) ([]*eth.Blob, error) { + ret := _m.Called(ctx, slot, hashes) + + if len(ret) == 0 { + panic("no return value specified for BeaconBlobs") + } + + var r0 []*eth.Blob + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, uint64, []eth.IndexedBlobHash) ([]*eth.Blob, error)); ok { + return rf(ctx, slot, hashes) + } + if rf, ok := ret.Get(0).(func(context.Context, uint64, []eth.IndexedBlobHash) []*eth.Blob); ok { + r0 = rf(ctx, slot, hashes) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*eth.Blob) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, uint64, []eth.IndexedBlobHash) error); ok { + r1 = rf(ctx, slot, hashes) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// BeaconClient_BeaconBlobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BeaconBlobs' +type BeaconClient_BeaconBlobs_Call struct { + *mock.Call +} + +// BeaconBlobs is a helper method to define mock.On call +// - ctx context.Context +// - slot uint64 +// - hashes []eth.IndexedBlobHash +func (_e *BeaconClient_Expecter) BeaconBlobs(ctx interface{}, slot interface{}, hashes interface{}) *BeaconClient_BeaconBlobs_Call { + return &BeaconClient_BeaconBlobs_Call{Call: _e.mock.On("BeaconBlobs", ctx, slot, hashes)} +} + +func (_c *BeaconClient_BeaconBlobs_Call) Run(run func(ctx context.Context, slot uint64, hashes []eth.IndexedBlobHash)) *BeaconClient_BeaconBlobs_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uint64), args[2].([]eth.IndexedBlobHash)) + }) + return _c +} + +func (_c *BeaconClient_BeaconBlobs_Call) Return(_a0 []*eth.Blob, _a1 error) *BeaconClient_BeaconBlobs_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *BeaconClient_BeaconBlobs_Call) RunAndReturn(run func(context.Context, uint64, []eth.IndexedBlobHash) ([]*eth.Blob, error)) *BeaconClient_BeaconBlobs_Call { + _c.Call.Return(run) + return _c +} + // BeaconGenesis provides a mock function with given fields: ctx func (_m *BeaconClient) BeaconGenesis(ctx context.Context) (eth.APIGenesisResponse, error) { ret := _m.Called(ctx) From da52756104a1c4a583008b6e0d3c3cb3dc277e3c Mon Sep 17 00:00:00 2001 From: geoknee Date: Tue, 7 Oct 2025 10:46:24 +0100 Subject: [PATCH 2/5] unexport method --- op-service/sources/l1_beacon_client.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/op-service/sources/l1_beacon_client.go b/op-service/sources/l1_beacon_client.go index cc40a94abb9eb..b9d932d5b76e4 100644 --- a/op-service/sources/l1_beacon_client.go +++ b/op-service/sources/l1_beacon_client.go @@ -184,8 +184,8 @@ func NewL1BeaconClient(cl apis.BeaconClient, cfg L1BeaconClientConfig, fallbacks type TimeToSlotFn func(timestamp uint64) (uint64, error) -// GetTimeToSlotFn returns a function that converts a timestamp to a slot number. -func (cl *L1BeaconClient) GetTimeToSlotFn(ctx context.Context) (TimeToSlotFn, error) { +// getTimeToSlotFn returns a function that converts a timestamp to a slot number. +func (cl *L1BeaconClient) getTimeToSlotFn(ctx context.Context) (TimeToSlotFn, error) { cl.initLock.Lock() defer cl.initLock.Unlock() if cl.timeToSlotFn != nil { @@ -217,7 +217,7 @@ func (cl *L1BeaconClient) GetTimeToSlotFn(ctx context.Context) (TimeToSlotFn, er } func (cl *L1BeaconClient) timeToSlot(ctx context.Context, timestamp uint64) (uint64, error) { - slotFn, err := cl.GetTimeToSlotFn(ctx) + slotFn, err := cl.getTimeToSlotFn(ctx) if err != nil { return 0, fmt.Errorf("get time to slot fn: %w", err) } From be53dde2f14390b92d66bd696ca1f424c7b04e4e Mon Sep 17 00:00:00 2001 From: geoknee Date: Tue, 7 Oct 2025 10:46:55 +0100 Subject: [PATCH 3/5] tests: rename clientWithValidation -> client --- op-service/sources/l1_beacon_client_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/op-service/sources/l1_beacon_client_test.go b/op-service/sources/l1_beacon_client_test.go index e5122e883f02e..bff55fb3487a6 100644 --- a/op-service/sources/l1_beacon_client_test.go +++ b/op-service/sources/l1_beacon_client_test.go @@ -244,11 +244,11 @@ func TestBeaconClientBadProof(t *testing.T) { p := mocks.NewBeaconClient(t) p.EXPECT().BeaconGenesis(ctx).Return(eth.APIGenesisResponse{Data: eth.ReducedGenesisData{GenesisTime: 10}}, nil) p.EXPECT().ConfigSpec(ctx).Return(eth.APIConfigResponse{Data: eth.ReducedConfigData{SecondsPerSlot: 2}}, nil) - clientWithValidation := NewL1BeaconClient(p, L1BeaconClientConfig{}) + client := NewL1BeaconClient(p, L1BeaconClientConfig{}) ref := eth.L1BlockRef{Time: 12} p.EXPECT().BeaconBlobs(ctx, uint64(1), hashes).Return(nil, errors.New("the sky is falling")) p.EXPECT().BeaconBlobSideCars(ctx, false, uint64(1), hashes).Return(eth.APIGetBlobSidecarsResponse{Data: apiSidecars}, nil) - _, err := clientWithValidation.GetBlobs(ctx, ref, hashes) + _, err := client.GetBlobs(ctx, ref, hashes) assert.NoError(t, err) // The verification flow does not require a valid proof }) @@ -257,10 +257,10 @@ func TestBeaconClientBadProof(t *testing.T) { p := mocks.NewBeaconClient(t) p.EXPECT().BeaconGenesis(ctx).Return(eth.APIGenesisResponse{Data: eth.ReducedGenesisData{GenesisTime: 10}}, nil) p.EXPECT().ConfigSpec(ctx).Return(eth.APIConfigResponse{Data: eth.ReducedConfigData{SecondsPerSlot: 2}}, nil) - clientWithValidation := NewL1BeaconClient(p, L1BeaconClientConfig{}) + client := NewL1BeaconClient(p, L1BeaconClientConfig{}) ref := eth.L1BlockRef{Time: 12} p.EXPECT().BeaconBlobs(ctx, uint64(1), hashes).Return(blobs, nil) - _, err := clientWithValidation.GetBlobs(ctx, ref, hashes) + _, err := client.GetBlobs(ctx, ref, hashes) assert.NoError(t, err) // The verification flow does not require a valid proof }) } From ccbfaecd1fdfe53aaf3d99d4ebf9f2bfadead886 Mon Sep 17 00:00:00 2001 From: geoknee Date: Tue, 7 Oct 2025 10:47:19 +0100 Subject: [PATCH 4/5] remove comments --- op-service/sources/l1_beacon_client_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/op-service/sources/l1_beacon_client_test.go b/op-service/sources/l1_beacon_client_test.go index bff55fb3487a6..38c02b1de5184 100644 --- a/op-service/sources/l1_beacon_client_test.go +++ b/op-service/sources/l1_beacon_client_test.go @@ -249,7 +249,7 @@ func TestBeaconClientBadProof(t *testing.T) { p.EXPECT().BeaconBlobs(ctx, uint64(1), hashes).Return(nil, errors.New("the sky is falling")) p.EXPECT().BeaconBlobSideCars(ctx, false, uint64(1), hashes).Return(eth.APIGetBlobSidecarsResponse{Data: apiSidecars}, nil) _, err := client.GetBlobs(ctx, ref, hashes) - assert.NoError(t, err) // The verification flow does not require a valid proof + assert.NoError(t, err) }) t.Run("BeaconBlobs", func(t *testing.T) { @@ -261,7 +261,7 @@ func TestBeaconClientBadProof(t *testing.T) { ref := eth.L1BlockRef{Time: 12} p.EXPECT().BeaconBlobs(ctx, uint64(1), hashes).Return(blobs, nil) _, err := client.GetBlobs(ctx, ref, hashes) - assert.NoError(t, err) // The verification flow does not require a valid proof + assert.NoError(t, err) }) } From 4245b4aa27b70970c696fb311171a011352aa0f7 Mon Sep 17 00:00:00 2001 From: George Knee Date: Tue, 7 Oct 2025 10:48:02 +0100 Subject: [PATCH 5/5] Update op-service/sources/l1_beacon_client.go --- op-service/sources/l1_beacon_client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/op-service/sources/l1_beacon_client.go b/op-service/sources/l1_beacon_client.go index b9d932d5b76e4..c5aa3afbb8607 100644 --- a/op-service/sources/l1_beacon_client.go +++ b/op-service/sources/l1_beacon_client.go @@ -326,7 +326,7 @@ func (cl *L1BeaconClient) GetBlobs(ctx context.Context, ref eth.L1BlockRef, hash return blobs, nil } -// blobsFromSidecars pulls the blobs from the sidecars and verifies the commitments and indices. +// blobsFromSidecars pulls the blobs from the sidecars and verifies them against the supplied hashes. func blobsFromSidecars(blobSidecars []*eth.BlobSidecar, hashes []eth.IndexedBlobHash) ([]*eth.Blob, error) { if len(blobSidecars) != len(hashes) { return nil, fmt.Errorf("number of hashes and blobSidecars mismatch, %d != %d", len(hashes), len(blobSidecars))