diff --git a/beacon/blockchain/blob_executor.go b/beacon/blockchain/blob_executor.go new file mode 100644 index 0000000000..dfdeec30dc --- /dev/null +++ b/beacon/blockchain/blob_executor.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +package blockchain + +import ( + "context" + "fmt" + + datypes "github.com/berachain/beacon-kit/da/types" + "github.com/berachain/beacon-kit/log" +) + +// blobFetchExecutor handles the Byzantine-critical blob fetch and verification logic. +// This is the core component that ensures we only accept valid blobs from peers. +type blobFetchExecutor struct { + blobProcessor BlobProcessor + blobRequester BlobRequester + storageBackend StorageBackend + logger log.Logger +} + +// FetchBlobsAndVerify fetches, verifies, and stores blobs for a single request. +// It creates a verifier function that the BlobRequester uses to validate blobs. +// If verification fails, the BlobRequester will automatically try the next peer. +func (e *blobFetchExecutor) FetchBlobsAndVerify(ctx context.Context, req BlobFetchRequest) error { + e.logger.Info("Fetching blobs from peers", "slot", req.Header.Slot.Unwrap(), "expected_blobs", len(req.Commitments)) + + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + // Create a verifier function that validates blobs against the stored header and commitments. + // This is the Byzantine fault tolerance mechanism - if a peer sends invalid blobs, + // verification will fail and BlobRequester will try the next peer. + verifier := func(sidecars datypes.BlobSidecars) error { + return e.blobProcessor.VerifySidecars(ctx, sidecars, req.Header, req.Commitments) + } + + // Request blobs with verification - will try multiple peers if verification fails + fetchedBlobs, err := e.blobRequester.RequestBlobs(ctx, req.Header.Slot, verifier) + if err != nil { + return fmt.Errorf("failed to request valid blobs for slot %d: %w", req.Header.Slot.Unwrap(), err) + } + + // Process and store the validated blobs + err = e.blobProcessor.ProcessSidecars(e.storageBackend.AvailabilityStore(), fetchedBlobs) + if err != nil { + return fmt.Errorf("failed to process blobs for slot %d: %w", req.Header.Slot.Unwrap(), err) + } + + e.logger.Info("Successfully fetched and stored blobs", "slot", req.Header.Slot.Unwrap(), "count", len(fetchedBlobs)) + return nil +} diff --git a/beacon/blockchain/blob_executor_test.go b/beacon/blockchain/blob_executor_test.go new file mode 100644 index 0000000000..e2a1502743 --- /dev/null +++ b/beacon/blockchain/blob_executor_test.go @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +//nolint:testpackage // Testing internal components +package blockchain + +import ( + "testing" + + "cosmossdk.io/log" + "github.com/berachain/beacon-kit/beacon/blockchain/testhelpers" + "github.com/berachain/beacon-kit/da/blobreactor" + dastore "github.com/berachain/beacon-kit/da/store" + datypes "github.com/berachain/beacon-kit/da/types" + "github.com/berachain/beacon-kit/errors" + "github.com/berachain/beacon-kit/primitives/math" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// Test when peer sends invalid blobs (verification should reject them) +func TestBlobFetchExecutor_ByzantineBlobs_Rejected(t *testing.T) { + t.Parallel() + ctx := t.Context() + mockProcessor := &testhelpers.SimpleBlobProcessor{} + mockRequester := &testhelpers.SimpleBlobRequester{} + mockStorage := testhelpers.NewSimpleStorageBackend(&dastore.Store{}) + + executor := &blobFetchExecutor{ + blobProcessor: mockProcessor, + blobRequester: mockRequester, + storageBackend: mockStorage, + logger: log.NewNopLogger(), + } + + request := createTestBlobRequest(math.Slot(100), 2) + invalidBlobs := []*datypes.BlobSidecar{{Index: 0}, {Index: 1}} + + // Byzantine peer returns invalid blobs - KZG proof verification fails + verifyErr := errors.New("KZG proof verification failed") + mockProcessor.On("VerifySidecars", ctx, mock.Anything, request.Header, request.Commitments).Return(verifyErr) + mockRequester.On("RequestBlobs", ctx, math.Slot(100), mock.Anything).Return(invalidBlobs, verifyErr) + + err := executor.FetchBlobsAndVerify(ctx, request) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to request valid blobs") + + // ProcessSidecars must NOT be called with invalid blobs + mockProcessor.AssertNotCalled(t, "ProcessSidecars") +} + +// Test Verifier function is called (Byzantine protection mechanism) +func TestBlobFetchExecutor_VerifierCalled(t *testing.T) { + t.Parallel() + ctx := t.Context() + mockProcessor := &testhelpers.SimpleBlobProcessor{} + mockRequester := &testhelpers.SimpleBlobRequester{} + mockStorage := testhelpers.NewSimpleStorageBackend(&dastore.Store{}) + + executor := &blobFetchExecutor{ + blobProcessor: mockProcessor, + blobRequester: mockRequester, + storageBackend: mockStorage, + logger: log.NewNopLogger(), + } + + request := createTestBlobRequest(math.Slot(100), 1) + validBlobs := []*datypes.BlobSidecar{{Index: 0}} + + verifierCalled := false + mockProcessor.On("VerifySidecars", ctx, mock.Anything, request.Header, request.Commitments). + Run(func(_ mock.Arguments) { verifierCalled = true }). + Return(nil) + mockRequester.On("RequestBlobs", ctx, math.Slot(100), mock.Anything).Return(validBlobs, nil) + mockProcessor.On("ProcessSidecars", mockStorage.AvailabilityStore(), mock.Anything).Return(nil) + + err := executor.FetchBlobsAndVerify(ctx, request) + require.NoError(t, err) + require.True(t, verifierCalled, "Verifier must be called for Byzantine protection") +} + +// Test when all peers fail - no valid blobs available +func TestBlobFetchExecutor_AllPeersFailed(t *testing.T) { + t.Parallel() + ctx := t.Context() + mockProcessor := &testhelpers.SimpleBlobProcessor{} + mockRequester := &testhelpers.SimpleBlobRequester{} + mockStorage := testhelpers.NewSimpleStorageBackend(&dastore.Store{}) + + executor := &blobFetchExecutor{ + blobProcessor: mockProcessor, + blobRequester: mockRequester, + storageBackend: mockStorage, + logger: log.NewNopLogger(), + } + + request := createTestBlobRequest(math.Slot(100), 2) + + // All peers failed (timeout, byzantine, offline, etc.) + mockRequester.On("RequestBlobs", ctx, math.Slot(100), mock.Anything).Return(nil, blobreactor.ErrAllPeersFailed) + + err := executor.FetchBlobsAndVerify(ctx, request) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to request valid blobs") + + // No blobs should be stored + mockProcessor.AssertNotCalled(t, "ProcessSidecars") +} diff --git a/beacon/blockchain/blob_fetcher.go b/beacon/blockchain/blob_fetcher.go new file mode 100644 index 0000000000..c350856387 --- /dev/null +++ b/beacon/blockchain/blob_fetcher.go @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +package blockchain + +import ( + "context" + "path/filepath" + "sync" + "time" + + ctypes "github.com/berachain/beacon-kit/consensus-types/types" + "github.com/berachain/beacon-kit/errors" + "github.com/berachain/beacon-kit/log" + "github.com/berachain/beacon-kit/primitives/math" +) + +// BlobFetcherConfig contains configuration for the blob fetcher. +type BlobFetcherConfig struct { + // CheckInterval is how often to check the queue for pending requests + CheckInterval time.Duration + // RetryInterval is the minimum time between retry attempts per blob request + RetryInterval time.Duration + // MaxRetries is the maximum number of retry attempts per blob request before giving up and deleting it + MaxRetries int +} + +// DefaultBlobFetcherConfig returns the default configuration. +// +//nolint:mnd // Just defaults +func DefaultBlobFetcherConfig() BlobFetcherConfig { + return BlobFetcherConfig{ + CheckInterval: 10 * time.Second, + RetryInterval: 1 * time.Minute, + MaxRetries: 10, + } +} + +// blobFetcher handles asynchronous fetching of blobs in the background. +type blobFetcher struct { + logger log.Logger + chainSpec BlobFetcherChainSpec + queue *blobQueue // Queue for persistent requests + executor *blobFetchExecutor // Executor for fetch logic + config BlobFetcherConfig // Configuration + metrics *blobFetcherMetrics + + // We need to track current head slot so we know when blob download requests need to be pruned as they are outside the WithinDAPeriod + headSlotMu sync.RWMutex + headSlot math.Slot + + ctx context.Context + cancel context.CancelFunc + stopOnce sync.Once +} + +// NewBlobFetcher creates a new background blob fetcher. +func NewBlobFetcher( + dataDir string, + logger log.Logger, + blobProcessor BlobProcessor, + blobRequester BlobRequester, + storageBackend StorageBackend, + chainSpec BlobFetcherChainSpec, + config BlobFetcherConfig, + telemetrySink TelemetrySink, +) (BlobFetcher, error) { + metrics := newBlobFetcherMetrics(telemetrySink) + + queue, err := newBlobQueue(filepath.Join(dataDir, "blobs", "download_queue"), logger, metrics) + if err != nil { + return nil, err + } + + return &blobFetcher{ + logger: logger, + chainSpec: chainSpec, + queue: queue, + config: config, + metrics: metrics, + executor: &blobFetchExecutor{ + blobProcessor: blobProcessor, + blobRequester: blobRequester, + storageBackend: storageBackend, + logger: logger, + }, + }, nil +} + +// Start begins the background blob fetching process. +func (bf *blobFetcher) Start(ctx context.Context) { + bf.ctx, bf.cancel = context.WithCancel(ctx) + go bf.run() +} + +// Stop gracefully shuts down the blob fetcher. +func (bf *blobFetcher) Stop() { + bf.stopOnce.Do(func() { bf.cancel() }) +} + +// SetHeadSlot updates the head slot for blob fetching. +func (bf *blobFetcher) SetHeadSlot(slot math.Slot) { + bf.headSlotMu.Lock() + bf.headSlot = slot + bf.headSlotMu.Unlock() + + // Also update the reactor's head slot so it can respond correctly to peers + bf.executor.blobRequester.SetHeadSlot(slot) +} + +// QueueBlobRequest queues a request to fetch blobs for a specific block. +func (bf *blobFetcher) QueueBlobRequest(block *ctypes.BeaconBlock) error { + // Don't queue if no blobs expected + commitments := block.GetBody().GetBlobKzgCommitments() + if len(commitments) == 0 { + return nil + } + + // Create request with header and commitments needed for validation + request := BlobFetchRequest{ + Header: block.GetHeader(), + Commitments: commitments, + } + + slot := block.GetHeader().Slot + if err := bf.queue.Add(slot, request); err != nil { + return err + } + + bf.metrics.recordRequestQueued() + bf.logger.Info("Queued blob fetch request", "slot", slot.Unwrap(), "expected_blobs", len(commitments)) + + return nil +} + +func (bf *blobFetcher) run() { + // Ticker to periodically check for requests (both new and ready to retry) + ticker := time.NewTicker(bf.config.CheckInterval) + defer ticker.Stop() + + for { + select { + case <-bf.ctx.Done(): + bf.logger.Info("Blob fetcher shutting down") + return + + case <-ticker.C: + // Process all pending requests from disk + bf.processAllPendingRequests() + } + } +} + +// processAllPendingRequests reads and processes all queued requests from disk. +func (bf *blobFetcher) processAllPendingRequests() { + for { + select { + case <-bf.ctx.Done(): + return + default: + } + + bf.headSlotMu.RLock() + headSlot := bf.headSlot + bf.headSlotMu.RUnlock() + + request, filename, err := bf.queue.GetNext( + headSlot, bf.config.RetryInterval, bf.config.MaxRetries, bf.chainSpec.WithinDAPeriod) + if err != nil { + if errors.Is(err, errNoMoreRequests) { + return + } + bf.logger.Error("Failed to get next request", "error", err) + if filename != "" { + _ = bf.queue.Remove(filename) + } + continue + } + + err = bf.executor.FetchBlobsAndVerify(bf.ctx, request) + if err == nil { + // Successfully processed, remove the request file + _ = bf.queue.Remove(filename) + + bf.metrics.recordRequestComplete() + continue + } + + // Check if error is due to context cancellation (shutdown) + if errors.Is(err, context.Canceled) { + return + } + + bf.logger.Error("Failed to process blob fetch request", "slot", request.Header.Slot.Unwrap(), "error", err) + + // Update retry metadata and save back to file + if updateErr := bf.queue.UpdateRetry(filename, request); updateErr != nil { + bf.logger.Error("Failed to update retry metadata", "error", updateErr) + continue + } + + bf.metrics.recordRetry() + bf.logger.Warn("Blob fetch failed, will retry later", + "slot", request.Header.Slot.Unwrap(), + "failure_count", request.FailureCount+1) + } +} diff --git a/beacon/blockchain/blob_fetcher_metrics.go b/beacon/blockchain/blob_fetcher_metrics.go new file mode 100644 index 0000000000..d87cce3c93 --- /dev/null +++ b/beacon/blockchain/blob_fetcher_metrics.go @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +package blockchain + +// Metric reason constants for blob fetcher. +const ( + expiredReasonOutsideDA = "outside_da_period" + expiredReasonMaxRetries = "max_retries" +) + +// blobFetcherMetrics contains metrics for the blob fetcher queue and retry operations. +type blobFetcherMetrics struct { + sink TelemetrySink +} + +// newBlobFetcherMetrics creates a new blobFetcherMetrics instance. +func newBlobFetcherMetrics(sink TelemetrySink) *blobFetcherMetrics { + return &blobFetcherMetrics{sink: sink} +} + +// recordRetry increments counter when a blob request is retried after failure. +func (m *blobFetcherMetrics) recordRetry() { + m.sink.IncrementCounter("beacon_kit.blob_fetcher.retries_total") +} + +// recordRequestExpired increments counter when request expires before completion. +// Reason: "outside_da_period", "max_retries" +func (m *blobFetcherMetrics) recordRequestExpired(reason string) { + m.sink.IncrementCounter("beacon_kit.blob_fetcher.requests_expired_total", "reason", reason) +} + +// recordRequestComplete increments counter when request completes successfully. +func (m *blobFetcherMetrics) recordRequestComplete() { + m.sink.IncrementCounter("beacon_kit.blob_fetcher.requests_completed_total") +} + +// recordRequestQueued increments counter when a new request is added to queue. +func (m *blobFetcherMetrics) recordRequestQueued() { + m.sink.IncrementCounter("beacon_kit.blob_fetcher.requests_queued_total") +} + +// setQueueDepth sets the current depth of the blob fetcher queue. +func (m *blobFetcherMetrics) setQueueDepth(depth int) { + m.sink.SetGauge("beacon_kit.blob_fetcher.queue_depth", int64(depth)) +} diff --git a/beacon/blockchain/blob_queue.go b/beacon/blockchain/blob_queue.go new file mode 100644 index 0000000000..c4b07fd391 --- /dev/null +++ b/beacon/blockchain/blob_queue.go @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +package blockchain + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + ctypes "github.com/berachain/beacon-kit/consensus-types/types" + "github.com/berachain/beacon-kit/errors" + "github.com/berachain/beacon-kit/log" + "github.com/berachain/beacon-kit/primitives/common" + "github.com/berachain/beacon-kit/primitives/eip4844" + "github.com/berachain/beacon-kit/primitives/math" +) + +var ( + errNoMoreRequests = errors.New("no more requests in queue") +) + +// BlobFetchRequest contains the minimal data needed to fetch and validate blobs. +type BlobFetchRequest struct { + Header *ctypes.BeaconBlockHeader `json:"header"` + Commitments eip4844.KZGCommitments[common.ExecutionHash] `json:"commitments"` + LastRetryTime time.Time `json:"last_retry_time"` + FailureCount int `json:"failure_count"` +} + +// blobQueue handles persistent queue operations using the filesystem. +// This struct has no external dependencies and can be tested without mocks. +type blobQueue struct { + queueDir string + logger log.Logger + metrics *blobFetcherMetrics +} + +// newBlobQueue creates a new blob queue with the given directory. +// It creates the directory if it doesn't exist and cleans up orphaned temp files. +func newBlobQueue(queueDir string, logger log.Logger, metrics *blobFetcherMetrics) (*blobQueue, error) { + // Create queue directory + if err := os.MkdirAll(queueDir, 0750); err != nil { + return nil, fmt.Errorf("failed to create blob download queue directory: %w", err) + } + + // Clean up any leftover temp files in the unlikely event we crashed while writing a request + tmpFiles, _ := filepath.Glob(filepath.Join(queueDir, "*.tmp")) + for _, tmpFile := range tmpFiles { + _ = os.Remove(tmpFile) + } + + return &blobQueue{ + queueDir: queueDir, + logger: logger, + metrics: metrics, + }, nil +} + +// Add queues a new blob fetch request. +func (q *blobQueue) Add(slot math.Slot, request BlobFetchRequest) error { + // Serialize to JSON file with slot as filename + filename := filepath.Join(q.queueDir, fmt.Sprintf("%010d.json", slot.Unwrap())) + + // Check if request already exists for this slot + if _, err := os.Stat(filename); err == nil { + q.logger.Info("Blob fetch request already queued for slot, skipping", "slot", slot.Unwrap()) + return nil + } + + data, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("failed to marshal blob fetch request: %w", err) + } + + // Write the request to a tmp file first, then rename atomically. This prevents + // reading a partially written file causing JSON unmarshal errors. + tempFile := filename + ".tmp" + if writeErr := os.WriteFile(tempFile, data, 0600); writeErr != nil { + return fmt.Errorf("failed to write temp blob fetch request: %w", writeErr) + } + if renameErr := os.Rename(tempFile, filename); renameErr != nil { + _ = os.Remove(tempFile) + return fmt.Errorf("failed to rename blob fetch request: %w", renameErr) + } + + return nil +} + +// GetNext returns the next request that is ready to be processed. +// It skips requests outside the availability window and requests not ready for retry. +func (q *blobQueue) GetNext( + headSlot math.Slot, + retryInterval time.Duration, + maxRetries int, + withinDAPeriod func(block, current math.Slot) bool, +) (BlobFetchRequest, string, error) { + files, err := filepath.Glob(filepath.Join(q.queueDir, "*.json")) + if err != nil { + return BlobFetchRequest{}, "", fmt.Errorf("failed to read queue directory: %w", err) + } + + if len(files) == 0 { + return BlobFetchRequest{}, "", errNoMoreRequests + } + + // Update queue depth metric with current file count + q.metrics.setQueueDepth(len(files)) + + for _, filename := range files { + fileData, readErr := os.ReadFile(filename) // #nosec G304 // filename is constructed from queueDir + if readErr != nil { + return BlobFetchRequest{}, filename, fmt.Errorf("failed to read request file: %w", readErr) + } + + var request BlobFetchRequest + if err = json.Unmarshal(fileData, &request); err != nil { + // Non retryable error, rename to .corrupted for manual investigation + corruptedFile := filename + ".corrupted" + q.logger.Error("Failed to unmarshal request, marking as corrupted", + "file", filename, + "corrupted_file", corruptedFile, + "error", err) + if renameErr := os.Rename(filename, corruptedFile); renameErr != nil { + q.logger.Error("Failed to rename corrupted file, deleting instead", "file", filename, "error", renameErr) + _ = os.Remove(filename) + } + continue + } + + // Check if request is outside availability window + if headSlot > 0 && !withinDAPeriod(request.Header.Slot, headSlot) { + q.metrics.recordRequestExpired(expiredReasonOutsideDA) + q.logger.Warn("Request is outside availability window, deleting", + "slot", request.Header.Slot.Unwrap(), + "head_slot", headSlot.Unwrap(), + "failure_count", request.FailureCount) + _ = q.Remove(filename) + continue + } + + // Check if request has exceeded max retry limit + if request.FailureCount >= maxRetries { + q.metrics.recordRequestExpired(expiredReasonMaxRetries) + q.logger.Warn("Request exceeded max retry limit, deleting", + "slot", request.Header.Slot.Unwrap(), + "failure_count", request.FailureCount, + "max_retries", maxRetries) + _ = q.Remove(filename) + continue + } + + // Check if this request needs to wait before retry + if !request.LastRetryTime.IsZero() && time.Since(request.LastRetryTime) < retryInterval { + continue // Skip, not ready to retry yet + } + + return request, filename, nil + } + + return BlobFetchRequest{}, "", errNoMoreRequests +} + +// Remove deletes a request file from the queue. +func (q *blobQueue) Remove(filename string) error { + if err := os.Remove(filename); err != nil && !os.IsNotExist(err) { + q.logger.Error("Failed to delete request file", "file", filename, "error", err) + return err + } + return nil +} + +// UpdateRetry updates the retry metadata for a failed request. +func (q *blobQueue) UpdateRetry(filename string, request BlobFetchRequest) error { + request.FailureCount++ + request.LastRetryTime = time.Now() + + data, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + tempFile := filename + ".tmp" + if writeErr := os.WriteFile(tempFile, data, 0600); writeErr != nil { + return fmt.Errorf("failed to write temp request: %w", writeErr) + } + if renameErr := os.Rename(tempFile, filename); renameErr != nil { + _ = os.Remove(tempFile) + return fmt.Errorf("failed to rename request: %w", renameErr) + } + + return nil +} diff --git a/beacon/blockchain/blob_queue_test.go b/beacon/blockchain/blob_queue_test.go new file mode 100644 index 0000000000..fa96c4d69f --- /dev/null +++ b/beacon/blockchain/blob_queue_test.go @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +//nolint:testpackage // Testing internal components +package blockchain + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "cosmossdk.io/log" + ctypes "github.com/berachain/beacon-kit/consensus-types/types" + "github.com/berachain/beacon-kit/node-core/components/metrics" + "github.com/berachain/beacon-kit/primitives/common" + "github.com/berachain/beacon-kit/primitives/eip4844" + "github.com/berachain/beacon-kit/primitives/math" + "github.com/stretchr/testify/require" +) + +func createTestBlobRequest(slot math.Slot, blobCount int) BlobFetchRequest { + header := &ctypes.BeaconBlockHeader{Slot: slot} + commitments := make(eip4844.KZGCommitments[common.ExecutionHash], blobCount) + return BlobFetchRequest{Header: header, Commitments: commitments} +} + +// Test that successful write produces valid JSON and cleans up temp files +func TestBlobQueue_SuccessfulWrite(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + queue, err := newBlobQueue(tmpDir, log.NewNopLogger(), newBlobFetcherMetrics(metrics.NewNoOpTelemetrySink())) + require.NoError(t, err) + + slot := math.Slot(100) + err = queue.Add(slot, createTestBlobRequest(slot, 3)) + require.NoError(t, err) + + // Verify no temp file exists after successful write + tmpFile := filepath.Join(tmpDir, "0000000100.json.tmp") + _, err = os.Stat(tmpFile) + require.True(t, os.IsNotExist(err), "temp file should be cleaned up") + + // Verify final file is valid JSON + data, err := os.ReadFile(filepath.Join(tmpDir, "0000000100.json")) + require.NoError(t, err) + var request BlobFetchRequest + err = json.Unmarshal(data, &request) + require.NoError(t, err, "file should contain valid JSON") +} + +// Test that recent blob requests are skipped until retry interval passes +func TestBlobQueue_RetryLogic(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + queue, err := newBlobQueue(tmpDir, log.NewNopLogger(), newBlobFetcherMetrics(metrics.NewNoOpTelemetrySink())) + require.NoError(t, err) + + withinDA := func(_, _ math.Slot) bool { return true } + maxRetries := 72 + + // Request with recent retry should be skipped + request := createTestBlobRequest(math.Slot(100), 1) + request.LastRetryTime = time.Now() + data, marshalErr := json.Marshal(request) + require.NoError(t, marshalErr) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "0000000100.json"), data, 0600)) + + _, _, err = queue.GetNext(math.Slot(200), 5*time.Minute, maxRetries, withinDA) + require.Error(t, err) + require.Equal(t, errNoMoreRequests, err, "should skip request not ready for retry") + + // Request with old retry should be returned + request.LastRetryTime = time.Now().Add(-6 * time.Minute) + data, marshalErr = json.Marshal(request) + require.NoError(t, marshalErr) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "0000000100.json"), data, 0600)) + + got, _, err := queue.GetNext(math.Slot(200), 5*time.Minute, maxRetries, withinDA) + require.NoError(t, err, "should return request ready for retry") + require.Equal(t, math.Slot(100), got.Header.Slot) +} + +// Test that blob requests outside availability window are deleted +func TestBlobQueue_AvailabilityWindow(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + queue, err := newBlobQueue(tmpDir, log.NewNopLogger(), newBlobFetcherMetrics(metrics.NewNoOpTelemetrySink())) + require.NoError(t, err) + + // Add old request + slot := math.Slot(50) + err = queue.Add(slot, createTestBlobRequest(slot, 1)) + require.NoError(t, err) + + filename := filepath.Join(tmpDir, "0000000050.json") + _, err = os.Stat(filename) + require.NoError(t, err, "file should exist before cleanup") + + // GetNext with request outside DA window should delete it + withinDAPeriod := func(_, _ math.Slot) bool { return false } + maxRetries := 72 + _, _, err = queue.GetNext(math.Slot(1000), 1*time.Minute, maxRetries, withinDAPeriod) + require.Error(t, err) + require.Equal(t, errNoMoreRequests, err) + + // Verify file was deleted + _, err = os.Stat(filename) + require.True(t, os.IsNotExist(err), "old request should be deleted") +} + +// Test that failure count increments correctly for retry logic +func TestBlobQueue_UpdateRetry(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + queue, err := newBlobQueue(tmpDir, log.NewNopLogger(), newBlobFetcherMetrics(metrics.NewNoOpTelemetrySink())) + require.NoError(t, err) + + request := createTestBlobRequest(math.Slot(100), 2) + request.FailureCount = 3 + + err = queue.Add(math.Slot(100), request) + require.NoError(t, err) + + filename := filepath.Join(tmpDir, "0000000100.json") + err = queue.UpdateRetry(filename, request) + require.NoError(t, err) + + // Verify failure count incremented + data, readErr := os.ReadFile(filename) + require.NoError(t, readErr) + var updated BlobFetchRequest + require.NoError(t, json.Unmarshal(data, &updated)) + require.Equal(t, 4, updated.FailureCount, "failure count should increment") + require.False(t, updated.LastRetryTime.IsZero(), "retry time should be set") +} + +// Test that blob queue processes requests in order alphabetically by filename +func TestBlobQueue_ProcessingOrder(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + queue, err := newBlobQueue(tmpDir, log.NewNopLogger(), newBlobFetcherMetrics(metrics.NewNoOpTelemetrySink())) + require.NoError(t, err) + + withinDA := func(_, _ math.Slot) bool { return true } + maxRetries := 72 + + // Add requests out of order + slots := []math.Slot{200, 100, 150} + for _, slot := range slots { + require.NoError(t, queue.Add(slot, createTestBlobRequest(slot, 1))) + } + + // Should process in ascending slot order (lexicographic filename order) + expected := []math.Slot{100, 150, 200} + for _, expectedSlot := range expected { + got, filename, getErr := queue.GetNext(math.Slot(300), 1*time.Minute, maxRetries, withinDA) + require.NoError(t, getErr) + require.Equal(t, expectedSlot, got.Header.Slot) + require.NoError(t, queue.Remove(filename)) + } + + // Queue should be empty + _, _, err = queue.GetNext(math.Slot(300), 1*time.Minute, maxRetries, withinDA) + require.Error(t, err) + require.Equal(t, errNoMoreRequests, err) +} + +// Test that when blob requests exceed the max retry limit they are deleted +func TestBlobQueue_MaxRetryLimit(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + queue, err := newBlobQueue(tmpDir, log.NewNopLogger(), newBlobFetcherMetrics(metrics.NewNoOpTelemetrySink())) + require.NoError(t, err) + + withinDA := func(_, _ math.Slot) bool { return true } + maxRetries := 72 + + // Create request that has exceeded retry limit + request := createTestBlobRequest(math.Slot(100), 2) + request.FailureCount = maxRetries // At the limit + data, marshalErr := json.Marshal(request) + require.NoError(t, marshalErr) + + filename := filepath.Join(tmpDir, "0000000100.json") + require.NoError(t, os.WriteFile(filename, data, 0600)) + + // GetNext should delete the request and return errNoMoreRequests + _, _, err = queue.GetNext(math.Slot(200), 1*time.Minute, maxRetries, withinDA) + require.Error(t, err) + require.Equal(t, errNoMoreRequests, err) + + // Verify file was deleted + _, statErr := os.Stat(filename) + require.True(t, os.IsNotExist(statErr), "request should be deleted after exceeding retry limit") +} + +// Test that requests under retry limit are still processed +func TestBlobQueue_UnderRetryLimit(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + queue, err := newBlobQueue(tmpDir, log.NewNopLogger(), newBlobFetcherMetrics(metrics.NewNoOpTelemetrySink())) + require.NoError(t, err) + + withinDA := func(_, _ math.Slot) bool { return true } + maxRetries := 72 + + // Create request with failures but under the limit + request := createTestBlobRequest(math.Slot(100), 2) + request.FailureCount = maxRetries - 1 // One below limit + request.LastRetryTime = time.Now().Add(-10 * time.Minute) // Ready to retry + data, marshalErr := json.Marshal(request) + require.NoError(t, marshalErr) + + filename := filepath.Join(tmpDir, "0000000100.json") + require.NoError(t, os.WriteFile(filename, data, 0600)) + + // GetNext should return the request (not delete it) + got, gotFilename, err := queue.GetNext(math.Slot(200), 1*time.Minute, maxRetries, withinDA) + require.NoError(t, err) + require.Equal(t, math.Slot(100), got.Header.Slot) + require.Equal(t, filename, gotFilename) + require.Equal(t, maxRetries-1, got.FailureCount) +} + +// Test that corrupted JSON files are renamed to .corrupted and not processed +func TestBlobQueue_CorruptedFileHandling(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + queue, err := newBlobQueue(tmpDir, log.NewNopLogger(), newBlobFetcherMetrics(metrics.NewNoOpTelemetrySink())) + require.NoError(t, err) + + withinDA := func(_, _ math.Slot) bool { return true } + maxRetries := 72 + + // Create a corrupted JSON file (invalid JSON syntax) + corruptedFilename := filepath.Join(tmpDir, "0000000100.json") + corruptedData := []byte(`{"header":{"slot":100},"commitments":[INVALID JSON}`) + require.NoError(t, os.WriteFile(corruptedFilename, corruptedData, 0600)) + + // Create a valid request to ensure queue continues processing + validSlot := math.Slot(101) + require.NoError(t, queue.Add(validSlot, createTestBlobRequest(validSlot, 1))) + + // Verify corrupted file exists before processing + _, err = os.Stat(corruptedFilename) + require.NoError(t, err, "corrupted file should exist") + + // GetNext should skip corrupted file and process valid one + got, _, err := queue.GetNext(math.Slot(200), 1*time.Minute, maxRetries, withinDA) + require.NoError(t, err, "should process valid request despite corrupted file") + require.Equal(t, validSlot, got.Header.Slot) + + // Verify corrupted file was renamed to .corrupted + _, err = os.Stat(corruptedFilename) + require.True(t, os.IsNotExist(err), "original corrupted file should not exist") + + renamedFile := corruptedFilename + ".corrupted" + _, err = os.Stat(renamedFile) + require.NoError(t, err, "corrupted file should be renamed to .corrupted") +} diff --git a/beacon/blockchain/common.go b/beacon/blockchain/common.go index 1224e9894d..495f7faa39 100644 --- a/beacon/blockchain/common.go +++ b/beacon/blockchain/common.go @@ -27,42 +27,45 @@ import ( "github.com/berachain/beacon-kit/consensus/cometbft/service/encoding" "github.com/berachain/beacon-kit/da/types" "github.com/berachain/beacon-kit/primitives/math" + cmtabci "github.com/cometbft/cometbft/abci/types" ) -func (s *Service) ParseBeaconBlock(req encoding.ABCIRequest) ( +func (s *Service) ParseProcessProposalRequest(req *cmtabci.ProcessProposalRequest) ( *ctypes.SignedBeaconBlock, types.BlobSidecars, error, ) { - if countTx := len(req.GetTxs()); countTx > MaxConsensusTxsCount { - return nil, nil, fmt.Errorf("max expected %d, got %d: %w", - MaxConsensusTxsCount, countTx, - ErrTooManyConsensusTxs, - ) + blobConsensusEnabled := s.chainSpec.IsBlobConsensusEnabledAtHeight(req.Height) + + maxTxCount := MaxConsensusTxsCount + if blobConsensusEnabled { + maxTxCount = 1 // After BlobEnableHeight: only 1 tx expected (block), blobs in Blob field + } + + if len(req.GetTxs()) > maxTxCount { + return nil, nil, fmt.Errorf("max expected %d txs, got %d", maxTxCount, len(req.GetTxs())) } forkVersion := s.chainSpec.ActiveForkVersionForTimestamp(math.U64(req.GetTime().Unix())) //#nosec: G115 - // Decode signed block and sidecars. - signedBlk, sidecars, err := encoding.ExtractBlobsAndBlockFromRequest( - req, - BeaconBlockTxIndex, - BlobSidecarsTxIndex, - forkVersion, - ) + signedBlk, err := encoding.UnmarshalBeaconBlockFromABCIRequest(req.GetTxs(), BeaconBlockTxIndex, forkVersion) if err != nil { return nil, nil, err } + if signedBlk == nil { - s.logger.Warn( - "Aborting block verification - beacon block not found in proposal", - ) + s.logger.Warn("Aborting block verification - beacon block not found in proposal") return nil, nil, ErrNilBlk } - if sidecars == nil { - s.logger.Warn( - "Aborting block verification - blob sidecars not found in proposal", - ) - return nil, nil, ErrNilBlob + + // Extract sidecars using the common helper + sidecars, err := encoding.ExtractBlobSidecarsFromRequest( + req.GetTxs(), + req.GetBlob(), + req.Height, + s.chainSpec, + ) + if err != nil { + return nil, nil, fmt.Errorf("failed to extract blob sidecars at height %d: %w", req.Height, err) } return signedBlk, sidecars, nil diff --git a/beacon/blockchain/finalize_block.go b/beacon/blockchain/finalize_block.go index 78cbd8785b..a1f88e21a1 100644 --- a/beacon/blockchain/finalize_block.go +++ b/beacon/blockchain/finalize_block.go @@ -26,6 +26,7 @@ import ( "time" ctypes "github.com/berachain/beacon-kit/consensus-types/types" + "github.com/berachain/beacon-kit/consensus/cometbft/service/encoding" "github.com/berachain/beacon-kit/consensus/types" datypes "github.com/berachain/beacon-kit/da/types" "github.com/berachain/beacon-kit/primitives/crypto" @@ -39,12 +40,27 @@ import ( func (s *Service) FinalizeBlock( ctx sdk.Context, req *cmtabci.FinalizeBlockRequest, + blobs datypes.BlobSidecars, ) (transition.ValidatorUpdates, error) { + maxTxCount := MaxConsensusTxsCount + if s.chainSpec.IsBlobConsensusEnabledAtHeight(req.Height) { + maxTxCount = 1 + } + // STEP 1: Decode block and blobs. - signedBlk, blobs, err := s.ParseBeaconBlock(req) + if countTx := len(req.GetTxs()); countTx > maxTxCount { + return nil, fmt.Errorf("invalid tx count in FinalizeBlock at height %d: expected max %d, got %d", + req.Height, maxTxCount, countTx) + } + + forkVersion := s.chainSpec.ActiveForkVersionForTimestamp(math.U64(req.GetTime().Unix())) //#nosec: G115 + signedBlk, err := encoding.UnmarshalBeaconBlockFromABCIRequest( + req.GetTxs(), + BeaconBlockTxIndex, + forkVersion, + ) if err != nil { - s.logger.Error("Failed to decode block and blobs", "error", err) - return nil, fmt.Errorf("failed to decode block and blobs: %w", err) + return nil, fmt.Errorf("failed to decode block at height %d with fork version %s: %w", req.Height, forkVersion, err) } blk := signedBlk.GetBeaconBlock() st := s.storageBackend.StateFromContext(ctx) @@ -73,10 +89,7 @@ func (s *Service) FinalizeBlock( consensusBlk := types.NewConsensusBlock(blk, req.GetProposerAddress(), req.GetTime()) valUpdates, err := s.finalizeBeaconBlock(ctx, st, consensusBlk) if err != nil { - s.logger.Error("Failed to process verified beacon block", - "error", err, - ) - return nil, err + return nil, fmt.Errorf("failed finalizing beacon block at height %d: %w", req.Height, err) } // STEP 4: Post Finalizations cleanups. @@ -89,36 +102,55 @@ func (s *Service) FinalizeSidecars( blk *ctypes.BeaconBlock, blobs datypes.BlobSidecars, ) error { + processBlobsFunc := func(blobs datypes.BlobSidecars) error { + // Process the blobs which saves them to storage + if err := s.blobProcessor.ProcessSidecars(s.storageBackend.AvailabilityStore(), blobs); err != nil { + return fmt.Errorf("failed to process %d blob sidecars for slot %d: %w", len(blobs), blk.GetSlot(), err) + } + + // Final verification + if !s.storageBackend.AvailabilityStore().IsDataAvailable(ctx, blk.GetSlot(), blk.GetBody()) { + return fmt.Errorf("data not available after processing blobs for slot %d: %w", blk.GetSlot(), ErrDataNotAvailable) + } + + return nil + } + // SyncingToHeight is always the tip of the chain both during sync and when // caught up. We don't need to process sidecars unless they are within DA period. // //#nosec: G115 // SyncingToHeight will never be negative. - if s.chainSpec.WithinDAPeriod(blk.GetSlot(), math.Slot(syncingToHeight)) { - err := s.blobProcessor.ProcessSidecars( - s.storageBackend.AvailabilityStore(), - blobs, - ) - if err != nil { - s.logger.Error("Failed to process blob sidecars", "error", err) - return fmt.Errorf("failed to process blob sidecars: %w", err) - } - - // Ensure we can access the data using the commitments from the block. - if !s.storageBackend.AvailabilityStore().IsDataAvailable( - ctx, blk.GetSlot(), blk.GetBody(), - ) { - return ErrDataNotAvailable + if !s.chainSpec.WithinDAPeriod(blk.GetSlot(), math.Slot(syncingToHeight)) { + // Here outside Data Availability window. Just log if needed + if len(blobs) > 0 { + s.logger.Info( + "Skipping blob processing outside of Data Availability Period", + "slot", blk.GetSlot().Base10(), "head", syncingToHeight, + ) } return nil } - // Here outside Data Availability window. Just log if needed + // If blobs were passed in (normal consensus mode), use them if len(blobs) > 0 { - s.logger.Info( - "Skipping blob processing outside of Data Availability Period", - "slot", blk.GetSlot().Base10(), "head", syncingToHeight, - ) + return processBlobsFunc(blobs) } + + // Check the block to see if there should be blobs + expectedBlobs := len(blk.GetBody().GetBlobKzgCommitments()) + if expectedBlobs == 0 { + return nil // No blobs expected for this block + } + + // Queue the blob fetch request to be handled asynchronously + err := s.blobFetcher.QueueBlobRequest(blk) + if err != nil { + // TODO: should we log and continue here instead of erroring out? + return fmt.Errorf("failed to queue blob fetch request for slot %d: %w", blk.GetSlot().Unwrap(), err) + } + + // Return immediately without waiting for blobs + // The background fetcher will handle retrieval and storage return nil } @@ -141,6 +173,9 @@ func (s *Service) PostFinalizeBlockOps(ctx sdk.Context, blk *ctypes.BeaconBlock) return err } + // Update our head slot so other peers know at which height we are at. + s.blobFetcher.SetHeadSlot(slot) + // Prune the availability and deposit store. if err := s.processPruning(ctx, blk); err != nil { s.logger.Error("failed to processPruning", "error", err) diff --git a/beacon/blockchain/interfaces.go b/beacon/blockchain/interfaces.go index 2dbd174b68..0a7d104f49 100644 --- a/beacon/blockchain/interfaces.go +++ b/beacon/blockchain/interfaces.go @@ -26,8 +26,8 @@ import ( "github.com/berachain/beacon-kit/chain" ctypes "github.com/berachain/beacon-kit/consensus-types/types" + "github.com/berachain/beacon-kit/consensus/cometbft/service/blobreactor" "github.com/berachain/beacon-kit/consensus/cometbft/service/delay" - "github.com/berachain/beacon-kit/consensus/cometbft/service/encoding" dastore "github.com/berachain/beacon-kit/da/store" datypes "github.com/berachain/beacon-kit/da/types" engineprimitives "github.com/berachain/beacon-kit/engine-primitives/engine-primitives" @@ -45,6 +45,17 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" ) +// BlobRequester is the interface for requesting blobs from peers. +type BlobRequester interface { + // RequestBlobs fetches all blobs for a given slot from peers. + // The verifier function is called to validate blobs before returning. + // If verification fails, it tries the next peer until valid blobs are found. + // Returns all blob sidecars for the slot, or an error if none could be retrieved. + RequestBlobs(ctx context.Context, slot math.Slot, verifier func(datypes.BlobSidecars) error) ([]*datypes.BlobSidecar, error) + // SetHeadSlot updates the requester's view of the current blockchain head slot. + SetHeadSlot(slot math.Slot) +} + // ExecutionEngine is the interface for the execution engine. type ExecutionEngine interface { // NotifyNewPayload notifies the execution client of new payload. @@ -128,6 +139,9 @@ type TelemetrySink interface { // the provided key. IncrementCounter(key string, args ...string) + // SetGauge sets a gauge metric to the specified value. + SetGauge(key string, value int64, args ...string) + // MeasureSince measures the time since the provided start time, // identified by the provided keys. MeasureSince(key string, start time.Time, args ...string) @@ -139,7 +153,7 @@ type BlockchainI interface { context.Context, []byte, ) (transition.ValidatorUpdates, error) - ParseBeaconBlock(req encoding.ABCIRequest) ( + ParseProcessProposalRequest(*cmtabci.ProcessProposalRequest) ( *ctypes.SignedBeaconBlock, datypes.BlobSidecars, error, @@ -158,6 +172,7 @@ type BlockchainI interface { FinalizeBlock( sdk.Context, *cmtabci.FinalizeBlockRequest, + datypes.BlobSidecars, ) (transition.ValidatorUpdates, error) PostFinalizeBlockOps( sdk.Context, @@ -182,17 +197,34 @@ type BlobProcessor interface { ) error } +// BlobFetcher is the interface for asynchronously fetching blobs. +type BlobFetcher interface { + // Start begins the background blob fetching process. + Start(ctx context.Context) + // Stop gracefully shuts down the blob fetcher. + Stop() + // QueueBlobRequest queues a request to fetch blobs for a specific block. + QueueBlobRequest(block *ctypes.BeaconBlock) error + // SetHeadSlot updates the head slot for blob fetching. + SetHeadSlot(slot math.Slot) +} + type PruningChainSpec interface { MinEpochsForBlobsSidecarsRequest() math.Epoch SlotsPerEpoch() uint64 } +type BlobFetcherChainSpec interface { + WithinDAPeriod(block, current math.Slot) bool +} + type ServiceChainSpec interface { PruningChainSpec chain.BlobSpec chain.ForkSpec chain.ForkVersionSpec delay.ConfigGetter + blobreactor.ConfigGetter EpochsPerHistoricalVector() uint64 SlotToEpoch(slot math.Slot) math.Epoch diff --git a/beacon/blockchain/payload_test.go b/beacon/blockchain/payload_test.go index 710c8a3c0b..913f8e1bbc 100644 --- a/beacon/blockchain/payload_test.go +++ b/beacon/blockchain/payload_test.go @@ -275,6 +275,7 @@ func setupOptimisticPayloadTests(t *testing.T, cs chain.Spec) ( chain := blockchain.NewService( sb, nil, // blockchain.BlobProcessor unused in this test + nil, // blockchain.BlobRequester unused in this test nil, // deposit.Contract unused in this test logger, cs, diff --git a/beacon/blockchain/process_proposal.go b/beacon/blockchain/process_proposal.go index 2cd6b2b148..aaa7da79f7 100644 --- a/beacon/blockchain/process_proposal.go +++ b/beacon/blockchain/process_proposal.go @@ -46,7 +46,6 @@ import ( ) const ( - // BeaconBlockTxIndex represents the index of the beacon block transaction. // It is the first transaction in the tx list. BeaconBlockTxIndex uint = iota // BlobSidecarsTxIndex represents the index of the blob sidecar transaction. @@ -63,7 +62,7 @@ func (s *Service) ProcessProposal( req *cmtabci.ProcessProposalRequest, thisNodeAddress []byte, ) (transition.ValidatorUpdates, error) { - signedBlk, sidecars, err := s.ParseBeaconBlock(req) + signedBlk, sidecars, err := s.ParseProcessProposalRequest(req) if err != nil { s.logger.Error("Failed to decode block and blobs", "error", err) return nil, fmt.Errorf("failed to decode block and blobs: %w", err) diff --git a/beacon/blockchain/service.go b/beacon/blockchain/service.go index 8d5416b9fe..83a20ce5f4 100644 --- a/beacon/blockchain/service.go +++ b/beacon/blockchain/service.go @@ -37,6 +37,8 @@ type Service struct { storageBackend StorageBackend // blobProcessor is used for processing sidecars. blobProcessor BlobProcessor + // blobFetcher is used for fetching blobs during sync in the background. + blobFetcher BlobFetcher // depositContract is the contract interface for interacting with the // deposit contract. depositContract deposit.Contract @@ -74,6 +76,7 @@ type Service struct { func NewService( storageBackend StorageBackend, blobProcessor BlobProcessor, + blobFetcher BlobFetcher, depositContract deposit.Contract, logger log.Logger, chainSpec ServiceChainSpec, @@ -85,6 +88,7 @@ func NewService( return &Service{ storageBackend: storageBackend, blobProcessor: blobProcessor, + blobFetcher: blobFetcher, depositContract: depositContract, eth1FollowDistance: math.U64(chainSpec.Eth1FollowDistance()), failedBlocks: make(map[math.Slot]struct{}), @@ -105,6 +109,9 @@ func (s *Service) Name() string { // Start starts the blockchain service. func (s *Service) Start(ctx context.Context) error { + // Start the blob fetcher in the background. + s.blobFetcher.Start(ctx) + // Catchup deposits for failed blocks. TODO: remove. go s.depositCatchupFetcher(ctx) @@ -115,6 +122,8 @@ func (s *Service) Start(ctx context.Context) error { func (s *Service) Stop() error { s.logger.Info("Stopping blockchain service") + s.blobFetcher.Stop() + err := s.storageBackend.DepositStore().Close() if err != nil { s.logger.Error("failed to close deposit store", "err", err) diff --git a/beacon/blockchain/testhelpers/blob_processor_simple.go b/beacon/blockchain/testhelpers/blob_processor_simple.go new file mode 100644 index 0000000000..66f0fce6ec --- /dev/null +++ b/beacon/blockchain/testhelpers/blob_processor_simple.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +package testhelpers + +import ( + "context" + + ctypes "github.com/berachain/beacon-kit/consensus-types/types" + dastore "github.com/berachain/beacon-kit/da/store" + datypes "github.com/berachain/beacon-kit/da/types" + "github.com/berachain/beacon-kit/primitives/common" + "github.com/berachain/beacon-kit/primitives/eip4844" + "github.com/stretchr/testify/mock" +) + +// SimpleBlobProcessor is a testify mock for BlobProcessor interface. +// Used primarily for unit testing blob executor with controlled verification outcomes. +type SimpleBlobProcessor struct { + mock.Mock +} + +func (m *SimpleBlobProcessor) VerifySidecars( + ctx context.Context, + sidecars datypes.BlobSidecars, + blkHeader *ctypes.BeaconBlockHeader, + kzgCommitments eip4844.KZGCommitments[common.ExecutionHash], +) error { + return m.Called(ctx, sidecars, blkHeader, kzgCommitments).Error(0) +} + +func (m *SimpleBlobProcessor) ProcessSidecars(avs *dastore.Store, sidecars datypes.BlobSidecars) error { + return m.Called(avs, sidecars).Error(0) +} diff --git a/beacon/blockchain/testhelpers/blob_requester_simple.go b/beacon/blockchain/testhelpers/blob_requester_simple.go new file mode 100644 index 0000000000..a88313b2a8 --- /dev/null +++ b/beacon/blockchain/testhelpers/blob_requester_simple.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +package testhelpers + +import ( + "context" + + datypes "github.com/berachain/beacon-kit/da/types" + "github.com/berachain/beacon-kit/primitives/math" + "github.com/stretchr/testify/mock" +) + +// SimpleBlobRequester is a testify mock for BlobRequester interface. +// Used primarily for unit testing blob executor with controlled peer responses. +type SimpleBlobRequester struct { + mock.Mock +} + +func (m *SimpleBlobRequester) RequestBlobs( + ctx context.Context, + slot math.Slot, + verifier func(datypes.BlobSidecars) error, +) ([]*datypes.BlobSidecar, error) { + args := m.Called(ctx, slot, verifier) + if args.Get(0) != nil { + sidecars, ok := args.Get(0).([]*datypes.BlobSidecar) + if !ok { + return nil, args.Error(1) + } + // Call the verifier if provided (simulates Byzantine verification) + if verifier != nil { + if err := verifier(sidecars); err != nil { + return nil, err // Verifier rejected + } + } + return sidecars, args.Error(1) + } + return nil, args.Error(1) +} + +func (m *SimpleBlobRequester) SetHeadSlot(_ math.Slot) {} diff --git a/beacon/blockchain/testhelpers/storage_backend_simple.go b/beacon/blockchain/testhelpers/storage_backend_simple.go new file mode 100644 index 0000000000..4f11441cb5 --- /dev/null +++ b/beacon/blockchain/testhelpers/storage_backend_simple.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +package testhelpers + +import ( + "context" + + ctypes "github.com/berachain/beacon-kit/consensus-types/types" + dastore "github.com/berachain/beacon-kit/da/store" + statedb "github.com/berachain/beacon-kit/state-transition/core/state" + "github.com/berachain/beacon-kit/storage/block" + "github.com/berachain/beacon-kit/storage/deposit" +) + +// SimpleStorageBackend is a lightweight wrapper that uses real components where possible. +// This is useful for tests that need a real AvailabilityStore but don't need other storage backends. +type SimpleStorageBackend struct { + availStore *dastore.Store +} + +// NewSimpleStorageBackend creates a new simple storage backend with a real availability store. +func NewSimpleStorageBackend(availStore *dastore.Store) *SimpleStorageBackend { + return &SimpleStorageBackend{availStore: availStore} +} + +func (m *SimpleStorageBackend) AvailabilityStore() *dastore.Store { return m.availStore } +func (m *SimpleStorageBackend) StateFromContext(_ context.Context) *statedb.StateDB { + return nil +} +func (m *SimpleStorageBackend) DepositStore() deposit.StoreManager { return nil } +func (m *SimpleStorageBackend) BlockStore() *block.KVStore[*ctypes.BeaconBlock] { + return nil +} diff --git a/chain/data.go b/chain/data.go index d2fecd820c..fd378442d0 100644 --- a/chain/data.go +++ b/chain/data.go @@ -21,6 +21,7 @@ package chain import ( + "github.com/berachain/beacon-kit/consensus/cometbft/service/blobreactor" "github.com/berachain/beacon-kit/consensus/cometbft/service/delay" "github.com/berachain/beacon-kit/primitives/common" ) @@ -29,6 +30,7 @@ import ( // `mapstructure` tag are required. type SpecData struct { delay.Config `mapstructure:"block-delay-configuration"` + BlobConfig blobreactor.Config `mapstructure:"blob-reactor-configuration"` // Gwei value constants. // diff --git a/chain/spec.go b/chain/spec.go index 88c4120a0d..486a8d76ac 100644 --- a/chain/spec.go +++ b/chain/spec.go @@ -24,6 +24,7 @@ import ( "fmt" "time" + "github.com/berachain/beacon-kit/consensus/cometbft/service/blobreactor" "github.com/berachain/beacon-kit/consensus/cometbft/service/delay" "github.com/berachain/beacon-kit/errors" "github.com/berachain/beacon-kit/primitives/common" @@ -184,6 +185,7 @@ type WithdrawalsSpec interface { // Spec defines an interface for accessing chain-specific parameters. type Spec interface { delay.ConfigGetter + blobreactor.ConfigGetter DepositSpec BalancesSpec HysteresisSpec @@ -297,6 +299,20 @@ func (s spec) validate() error { } } + // Validate BlobReactor configuration + if s.Data.BlobConfig.ConsensusEnableHeight != 0 { + if s.Data.BlobConfig.ConsensusUpdateHeight >= s.Data.BlobConfig.ConsensusEnableHeight { + return fmt.Errorf( + "blob reactor parameters violation: ConsensusUpdateHeight %d must be smaller than ConsensusEnableHeight %d", + s.Data.BlobConfig.ConsensusUpdateHeight, s.Data.BlobConfig.ConsensusEnableHeight, + ) + } + // MaxBytes must be set when BlobReactor is enabled + if s.Data.BlobConfig.MaxBytes <= 0 { + return errors.New("blob reactor MaxBytes must be greater than 0 when enabled") + } + } + // TODO: Add more validation rules here. return nil } @@ -317,6 +333,22 @@ func (s spec) SbtConsensusEnableHeight() int64 { return s.Data.ConsensusEnableHeight } +func (s spec) BlobConsensusUpdateHeight() int64 { + return s.Data.BlobConfig.ConsensusUpdateHeight +} + +func (s spec) BlobConsensusEnableHeight() int64 { + return s.Data.BlobConfig.ConsensusEnableHeight +} + +func (s spec) BlobMaxBytes() int64 { + return s.Data.BlobConfig.MaxBytes +} + +func (s spec) IsBlobConsensusEnabledAtHeight(height int64) bool { + return s.BlobConsensusEnableHeight() > 0 && height >= s.BlobConsensusEnableHeight() +} + // MaxEffectiveBalance returns the maximum effective balance. func (s spec) MaxEffectiveBalance() math.Gwei { return math.Gwei(s.Data.MaxEffectiveBalance) diff --git a/cmd/beacond/defaults.go b/cmd/beacond/defaults.go index 76856413c4..bb2fea0324 100644 --- a/cmd/beacond/defaults.go +++ b/cmd/beacond/defaults.go @@ -33,6 +33,8 @@ func DefaultComponents() []any { components.ProvideBlsSigner, components.ProvideBlobProcessor, components.ProvideBlobProofVerifier, + components.ProvideBlobReactor, + components.ProvideBlobFetcher, components.ProvideChainService, components.ProvideNode, components.ProvideConfig, diff --git a/config/config.go b/config/config.go index 556b813a7e..128f087be0 100644 --- a/config/config.go +++ b/config/config.go @@ -26,6 +26,7 @@ import ( "github.com/berachain/beacon-kit/beacon/validator" "github.com/berachain/beacon-kit/config/template" viperlib "github.com/berachain/beacon-kit/config/viper" + "github.com/berachain/beacon-kit/da/blobreactor" "github.com/berachain/beacon-kit/da/kzg" "github.com/berachain/beacon-kit/errors" engineclient "github.com/berachain/beacon-kit/execution/client" @@ -61,6 +62,7 @@ func DefaultConfig() *Config { Validator: validator.DefaultConfig(), BlockStoreService: block.DefaultConfig(), NodeAPI: server.DefaultConfig(), + BlobReactor: blobreactor.DefaultConfig(), } } @@ -87,6 +89,8 @@ type Config struct { BlockStoreService block.Config `mapstructure:"block-store-service"` // NodeAPI is the configuration for the node API. NodeAPI server.Config `mapstructure:"node-api"` + // BlobReactor is the configuration for the blob reactor. + BlobReactor blobreactor.Config `mapstructure:"blob-reactor"` } // GetEngine returns the execution client configuration. diff --git a/config/spec/devnet.go b/config/spec/devnet.go index e82f991835..dfb296ff29 100644 --- a/config/spec/devnet.go +++ b/config/spec/devnet.go @@ -22,6 +22,7 @@ package spec import ( "github.com/berachain/beacon-kit/chain" + "github.com/berachain/beacon-kit/consensus/cometbft/service/blobreactor" "github.com/berachain/beacon-kit/primitives/common" "github.com/ethereum/go-ethereum/params" ) @@ -74,6 +75,13 @@ func DevnetChainSpecData() *chain.SpecData { specData.Config.ConsensusUpdateHeight = 1 specData.Config.ConsensusEnableHeight = 2 + //nolint:mnd // ok for now + specData.BlobConfig = blobreactor.Config{ + ConsensusUpdateHeight: 1, + ConsensusEnableHeight: 2, + MaxBytes: 819200, + } + // Fork timings are set to facilitate local testing across fork versions. specData.GenesisTime = devnetGenesisTime specData.Deneb1ForkTime = devnetDeneb1ForkTime diff --git a/config/spec/mainnet.go b/config/spec/mainnet.go index c9a076abef..5eae986a04 100644 --- a/config/spec/mainnet.go +++ b/config/spec/mainnet.go @@ -22,6 +22,7 @@ package spec import ( "github.com/berachain/beacon-kit/chain" + "github.com/berachain/beacon-kit/consensus/cometbft/service/blobreactor" "github.com/berachain/beacon-kit/consensus/cometbft/service/delay" "github.com/berachain/beacon-kit/primitives/bytes" "github.com/berachain/beacon-kit/primitives/common" @@ -179,6 +180,8 @@ func MainnetChainSpecData() *chain.SpecData { specData.Config.ConsensusUpdateHeight = mainnetSBTConsensusUpdateHeight specData.Config.ConsensusEnableHeight = mainnetSBTConsensusEnableHeight + specData.BlobConfig = blobreactor.DefaultConfig() + return specData } diff --git a/config/spec/testnet.go b/config/spec/testnet.go index 7046ad0225..1377853082 100644 --- a/config/spec/testnet.go +++ b/config/spec/testnet.go @@ -22,6 +22,7 @@ package spec import ( "github.com/berachain/beacon-kit/chain" + "github.com/berachain/beacon-kit/consensus/cometbft/service/blobreactor" ) // TestnetChainSpecData is the chain.SpecData for Berachain's public testnet, Bepolia. @@ -49,6 +50,8 @@ func TestnetChainSpecData() *chain.SpecData { // Timestamp of the Electra1 fork on Bepolia. specData.Electra1ForkTime = 1_754_496_000 + specData.BlobConfig = blobreactor.DefaultConfig() + return specData } diff --git a/config/template/template.go b/config/template/template.go index d6ff69f72b..21ecc29fb4 100644 --- a/config/template/template.go +++ b/config/template/template.go @@ -105,4 +105,8 @@ address = "{{ .BeaconKit.NodeAPI.Address }}" # Logging determines if the node API logging is enabled. logging = "{{ .BeaconKit.NodeAPI.Logging }}" + +[beacon-kit.blob-reactor] +# Request timeout for blob requests from peers +request-timeout = "{{ .BeaconKit.BlobReactor.RequestTimeout }}" ` diff --git a/consensus/cometbft/service/blobreactor/config.go b/consensus/cometbft/service/blobreactor/config.go new file mode 100644 index 0000000000..883eb0a434 --- /dev/null +++ b/consensus/cometbft/service/blobreactor/config.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +package blobreactor + +import "math" + +const ( + // Height at which consensus params are upgraded to use blobreactor + blobConsensusUpdateHeight int64 = 0 + + // Height to enable blobreactor + blobConsensusEnableHeight int64 = math.MaxInt64 + + // maxBytes is the maximum size of blob data in bytes for BlobReactor consensus params (e.g., 800KB). + blobMaxBytes int64 = 800 * 1024 // 800KB +) + +// ConfigGetter provides read access to BlobReactor configuration. +type ConfigGetter interface { + // BlobConsensusUpdateHeight returns the height at which BlobReactor consensus params are updated. + // This is when the parameters are set but not yet active. + BlobConsensusUpdateHeight() int64 + // BlobConsensusEnableHeight returns the height when P2P blob distribution via BlobReactor is enabled. + // A value of 0 means the BlobReactor is disabled. + BlobConsensusEnableHeight() int64 + // BlobMaxBytes returns the maximum size of blob data in bytes for BlobReactor consensus params. + BlobMaxBytes() int64 + // IsBlobConsensusEnabledAtHeight returns true if blob consensus is enabled for the given height. + // Returns false if BlobConsensusEnableHeight is 0 (disabled) or if height is before enable height. + IsBlobConsensusEnabledAtHeight(height int64) bool +} + +// Config contains configuration for the P2P BlobReactor component. +type Config struct { + // ConsensusUpdateHeight is the height at which BlobReactor consensus params are updated. + // This is when the parameters are set but not yet active. + ConsensusUpdateHeight int64 `mapstructure:"consensus-update-height"` + // ConsensusEnableHeight is the height when P2P blob distribution via BlobReactor is enabled. + // A value of 0 means the BlobReactor is disabled. + ConsensusEnableHeight int64 `mapstructure:"consensus-enable-height"` + // MaxBytes is the maximum size of blob data in bytes for BlobReactor consensus params (e.g., 800KB). + MaxBytes int64 `mapstructure:"max-bytes"` +} + +func DefaultConfig() Config { + return Config{ + ConsensusUpdateHeight: blobConsensusUpdateHeight, + ConsensusEnableHeight: blobConsensusEnableHeight, + MaxBytes: blobMaxBytes, + } +} diff --git a/consensus/cometbft/service/cache/cache.go b/consensus/cometbft/service/cache/cache.go index 8e3b35e764..38993c0ef3 100644 --- a/consensus/cometbft/service/cache/cache.go +++ b/consensus/cometbft/service/cache/cache.go @@ -79,6 +79,7 @@ type States interface { type Element struct { State *State ValUpdates transition.ValidatorUpdates + Blobs []byte } type candidateStates struct { diff --git a/consensus/cometbft/service/encoding/encoding.go b/consensus/cometbft/service/encoding/encoding.go index 4404cc957f..1abfceeded 100644 --- a/consensus/cometbft/service/encoding/encoding.go +++ b/consensus/cometbft/service/encoding/encoding.go @@ -24,40 +24,13 @@ import ( "fmt" ctypes "github.com/berachain/beacon-kit/consensus-types/types" + "github.com/berachain/beacon-kit/consensus/cometbft/service/blobreactor" datypes "github.com/berachain/beacon-kit/da/types" "github.com/berachain/beacon-kit/primitives/common" "github.com/berachain/beacon-kit/primitives/encoding/ssz" + cmtabci "github.com/cometbft/cometbft/abci/types" ) -// ExtractBlobsAndBlockFromRequest extracts the blobs and block from an ABCI -// request. -func ExtractBlobsAndBlockFromRequest( - req ABCIRequest, - beaconBlkIndex uint, - blobSidecarsIndex uint, - forkVersion common.Version, -) (*ctypes.SignedBeaconBlock, datypes.BlobSidecars, error) { - if req == nil { - return nil, nil, ErrNilABCIRequest - } - - blk, err := UnmarshalBeaconBlockFromABCIRequest( - req.GetTxs(), - beaconBlkIndex, - forkVersion, - ) - if err != nil { - return nil, nil, err - } - - blobs, err := UnmarshalBlobSidecarsFromABCIRequest( - req.GetTxs(), - blobSidecarsIndex, - ) - - return blk, blobs, err -} - // UnmarshalBeaconBlockFromABCIRequest extracts a beacon block from an ABCI // request. func UnmarshalBeaconBlockFromABCIRequest( @@ -93,24 +66,73 @@ func UnmarshalBeaconBlockFromABCIRequest( return block, nil } -// UnmarshalBlobSidecarsFromABCIRequest extracts blob sidecars from an ABCI -// request. -func UnmarshalBlobSidecarsFromABCIRequest( +// UnmarshalBlobSidecarsFromABCIRequest extracts blob sidecars from an ABCI FinalizeBlockRequest based on blob consensus parameters. +func UnmarshalBlobSidecarsFromABCIRequest(req *cmtabci.FinalizeBlockRequest, cfg blobreactor.ConfigGetter) (datypes.BlobSidecars, error) { + if req == nil { + return nil, ErrNilABCIRequest + } + + // If we are at or after BlobEnableHeight, then blobs must be retrieved from cache or via blobreactor p2p + if cfg.IsBlobConsensusEnabledAtHeight(req.Height) { + return datypes.BlobSidecars{}, nil + } + + // Otherwise, blobs are in Txs[1] if present in this block + txs := req.GetTxs() + if len(txs) <= 1 { + return datypes.BlobSidecars{}, nil + } + + sidecarBz := txs[1] + if len(sidecarBz) == 0 { + return datypes.BlobSidecars{}, nil + } + + var sidecars datypes.BlobSidecars + if err := ssz.Unmarshal(sidecarBz, &sidecars); err != nil { + return nil, fmt.Errorf("failed to unmarshal blobs from Txs[1]: %w", err) + } + + return sidecars, nil +} + +// ExtractBlobSidecarsFromRequest is a generic helper that extracts blob sidecars from either +// ProcessProposal or FinalizeBlock requests based on the blob consensus configuration. +// It handles the transition from storing blobs in Txs to using the BlobReactor. +func ExtractBlobSidecarsFromRequest( txs [][]byte, - bzIndex uint, + blobData []byte, + height int64, + cfg blobreactor.ConfigGetter, ) (datypes.BlobSidecars, error) { - if len(txs) == 0 || bzIndex >= uint(len(txs)) { - return nil, ErrNoBlobSidecarInRequest + // If we are at or after BlobEnableHeight, then we use blobData for blobs if present + if cfg.IsBlobConsensusEnabledAtHeight(height) { + if len(blobData) == 0 { + return datypes.BlobSidecars{}, nil + } + + var sidecars datypes.BlobSidecars + if err := ssz.Unmarshal(blobData, &sidecars); err != nil { + return nil, fmt.Errorf("failed to unmarshal blobs from Blob field at height %d: %w", height, err) + } + + return sidecars, nil } - sidecarBz := txs[bzIndex] - if sidecarBz == nil { - return nil, ErrNilBlobSidecarInRequest + // Otherwise, blobs are in Txs[1] if present + if len(txs) <= 1 { + return datypes.BlobSidecars{}, nil + } + + sidecarBz := txs[1] + if len(sidecarBz) == 0 { + return datypes.BlobSidecars{}, nil } var sidecars datypes.BlobSidecars if err := ssz.Unmarshal(sidecarBz, &sidecars); err != nil { - return nil, err + return nil, fmt.Errorf("failed to unmarshal blobs from Txs[1] at height %d: %w", height, err) } + return sidecars, nil } diff --git a/consensus/cometbft/service/finalize_block.go b/consensus/cometbft/service/finalize_block.go index a04f5ea48f..dd87428901 100644 --- a/consensus/cometbft/service/finalize_block.go +++ b/consensus/cometbft/service/finalize_block.go @@ -26,15 +26,21 @@ import ( "fmt" "time" - ctypes "github.com/berachain/beacon-kit/consensus-types/types" + "github.com/berachain/beacon-kit/beacon/blockchain" + "github.com/berachain/beacon-kit/consensus-types/types" "github.com/berachain/beacon-kit/consensus/cometbft/service/cache" "github.com/berachain/beacon-kit/consensus/cometbft/service/delay" + "github.com/berachain/beacon-kit/consensus/cometbft/service/encoding" datypes "github.com/berachain/beacon-kit/da/types" + "github.com/berachain/beacon-kit/primitives/encoding/ssz" + "github.com/berachain/beacon-kit/primitives/math" "github.com/berachain/beacon-kit/primitives/transition" cmtabci "github.com/cometbft/cometbft/abci/types" + cmttypes "github.com/cometbft/cometbft/types" "github.com/sourcegraph/conc/iter" ) +//nolint:gocognit,funlen,nestif // TODO cleanup this function. func (s *Service) finalizeBlock( ctx context.Context, req *cmtabci.FinalizeBlockRequest, @@ -43,6 +49,23 @@ func (s *Service) finalizeBlock( return nil, err } + getBlobsFunc := func(cachedBlobData []byte) (datypes.BlobSidecars, error) { + if len(cachedBlobData) != 0 { + var sidecars datypes.BlobSidecars + if err := ssz.Unmarshal(cachedBlobData, &sidecars); err != nil { + return nil, fmt.Errorf("finalize block: failed to unmarshal cached blob data: %w", err) + } + return sidecars, nil + } + + // not cached + sidecars, err := encoding.UnmarshalBlobSidecarsFromABCIRequest(req, s.chainSpec) + if err != nil { + return nil, fmt.Errorf("finalize block: failed parsing blobs from request: %w", err) + } + return sidecars, nil + } + // Check whether currently block hash is already available. If so // we may speed up block finalization. hash := string(req.Hash) @@ -52,21 +75,36 @@ func (s *Service) finalizeBlock( // This is because Genesis state is cached but not committed (and purged from s.cachedStates) // We handle the case outside of this switch, via the s.Blockchain.FinalizeBlock below. if req.Height > s.initialHeight { + s.logger.Info( + "FinalizeBlock using cached state", + "height", req.Height, + "hash", fmt.Sprintf("%X", req.Hash), + "cached_blob_size", len(cached.Blobs), + ) + if err = s.cachedStates.MarkAsFinal(hash); err != nil { return nil, fmt.Errorf("failed marking state as final, hash %s, height %d: %w", hash, req.Height, err) } finalState := cached.State - var ( - signedBlk *ctypes.SignedBeaconBlock - sidecars datypes.BlobSidecars - ) - signedBlk, sidecars, err = s.Blockchain.ParseBeaconBlock(req) + + forkVersion := s.chainSpec.ActiveForkVersionForTimestamp(math.U64(req.GetTime().Unix())) //#nosec: G115 + var signedBlk *types.SignedBeaconBlock + signedBlk, err = encoding.UnmarshalBeaconBlockFromABCIRequest(req.GetTxs(), blockchain.BeaconBlockTxIndex, forkVersion) if err != nil { return nil, fmt.Errorf("finalize block: failed parsing block: %w", err) } + if signedBlk == nil { + return nil, blockchain.ErrNilBlk + } blk := signedBlk.GetBeaconBlock() + var sidecars datypes.BlobSidecars + sidecars, err = getBlobsFunc(cached.Blobs) + if err != nil { + return nil, err + } + if err = s.Blockchain.FinalizeSidecars( finalState.Context(), req.SyncingToHeight, @@ -113,7 +151,13 @@ func (s *Service) finalizeBlock( return nil, fmt.Errorf("failed checking cached final state, hash %s, height %d: %w", hash, req.Height, err) } - valUpdates, err := s.Blockchain.FinalizeBlock(finalState.Context(), req) + sidecars, err := getBlobsFunc(nil) + if err != nil { + return nil, err + } + + // Then finalize the block with blobs + valUpdates, err := s.Blockchain.FinalizeBlock(finalState.Context(), req, sidecars) if err != nil { return nil, err } @@ -122,6 +166,7 @@ func (s *Service) finalizeBlock( s.cachedStates.SetCached(hash, &cache.Element{ State: finalState, ValUpdates: valUpdates, + Blobs: nil, }) if err = s.cachedStates.MarkAsFinal(hash); err != nil { return nil, fmt.Errorf("failed marking state as final, hash %s, height %d: %w", hash, req.Height, err) @@ -130,16 +175,15 @@ func (s *Service) finalizeBlock( return s.calculateFinalizeBlockResponse(req, valUpdates) } -//nolint:lll // long message on one line for readability. func (s *Service) nextBlockDelay(req *cmtabci.FinalizeBlockRequest) time.Duration { // c0. SBT is not enabled => use the old block delay. if s.cmtConsensusParams.Feature.SBTEnableHeight <= 0 { - return s.delayCfg.SbtConstBlockDelay() + return s.chainSpec.SbtConstBlockDelay() } // c1. current height < SBTEnableHeight => wait for the upgrade. if req.Height < s.cmtConsensusParams.Feature.SBTEnableHeight { - return s.delayCfg.SbtConstBlockDelay() + return s.chainSpec.SbtConstBlockDelay() } // c2. current height == SBTEnableHeight => initialize the block delay. @@ -149,7 +193,7 @@ func (s *Service) nextBlockDelay(req *cmtabci.FinalizeBlockRequest) time.Duratio InitialHeight: req.Height, PreviousBlockTime: req.Time, } - return s.delayCfg.SbtConstBlockDelay() + return s.chainSpec.SbtConstBlockDelay() } // c3. current height > SBTEnableHeight @@ -158,7 +202,7 @@ func (s *Service) nextBlockDelay(req *cmtabci.FinalizeBlockRequest) time.Duratio // The upgrade was successfully applied and the block delay is set. if s.blockDelay != nil { prevBlkTime := s.blockDelay.PreviousBlockTime // note it down before ComputeNext changes it - delay := s.blockDelay.ComputeNext(s.delayCfg, req.Time, req.Height) + delay := s.blockDelay.ComputeNext(s.chainSpec, req.Time, req.Height) s.logger.Debug("Stable block time", "previous block time", prevBlkTime.String(), "current block time", req.Time.String(), @@ -170,8 +214,13 @@ func (s *Service) nextBlockDelay(req *cmtabci.FinalizeBlockRequest) time.Duratio // // Looks like we've skipped SBTEnableHeight (probably restoring from the // snapshot) => panic. - panic(fmt.Sprintf("nil block delay at height %d past SBTEnableHeight %d. This is only possible w/ statesync, which is not supported by SBT atm", - req.Height, s.cmtConsensusParams.Feature.SBTEnableHeight)) + panic( + fmt.Sprintf( + "nil block delay at height %d past SBTEnableHeight %d. This is only possible w/ statesync, which is not supported by SBT atm", + req.Height, + s.cmtConsensusParams.Feature.SBTEnableHeight, + ), + ) } // workingHash gets the apphash that will be finalized in commit. @@ -249,11 +298,21 @@ func (s *Service) calculateFinalizeBlockResponse( valUpdates transition.ValidatorUpdates, ) (*cmtabci.FinalizeBlockResponse, error) { // Update Stable block time related data - if s.cmtConsensusParams.Feature.SBTEnableHeight == 0 && req.Height == s.delayCfg.SbtConsensusUpdateHeight() { - s.cmtConsensusParams.Feature.SBTEnableHeight = s.delayCfg.SbtConsensusEnableHeight() + if s.cmtConsensusParams.Feature.SBTEnableHeight == 0 && req.Height == s.chainSpec.SbtConsensusUpdateHeight() { + s.cmtConsensusParams.Feature.SBTEnableHeight = s.chainSpec.SbtConsensusEnableHeight() } nextBlockTime := s.nextBlockDelay(req) + // Update BlobReactor consensus parameters + if s.cmtConsensusParams.Feature.BlobEnableHeight == 0 && req.Height == s.chainSpec.BlobConsensusUpdateHeight() { + s.logger.Info("Setting blob consensus parameters", + "current_height", req.Height, + "blob_enable_height", s.chainSpec.BlobConsensusEnableHeight(), + "max_bytes", s.chainSpec.BlobMaxBytes()) + s.cmtConsensusParams.Feature.BlobEnableHeight = s.chainSpec.BlobConsensusEnableHeight() + s.cmtConsensusParams.Blob = cmttypes.BlobParams{MaxBytes: s.chainSpec.BlobMaxBytes()} + } + // This result format is expected by Comet. That actual execution will happen as part of the state transition. txsLen := len(req.Txs) txResults := make([]*cmtabci.ExecTxResult, txsLen) diff --git a/consensus/cometbft/service/interfaces.go b/consensus/cometbft/service/interfaces.go index f00cd84b6f..8d4a430c0e 100644 --- a/consensus/cometbft/service/interfaces.go +++ b/consensus/cometbft/service/interfaces.go @@ -22,8 +22,17 @@ package cometbft import ( "time" + + "github.com/cometbft/cometbft/p2p" ) +// BlobReactorI is an interface for the BlobReactor P2P component. +type BlobReactorI interface { + p2p.Reactor + // SetNodeKey sets the node Key for the reactor. + SetNodeKey(nodeKey string) +} + // TelemetrySink is an interface for sending metrics to a telemetry backend. type TelemetrySink interface { // IncrementCounter increments a counter for the given key. diff --git a/consensus/cometbft/service/prepare_proposal.go b/consensus/cometbft/service/prepare_proposal.go index 20be46095a..cf98c23285 100644 --- a/consensus/cometbft/service/prepare_proposal.go +++ b/consensus/cometbft/service/prepare_proposal.go @@ -84,7 +84,11 @@ func (s *Service) prepareProposal( return &cmtabci.PrepareProposalResponse{Txs: [][]byte{}}, nil } - return &cmtabci.PrepareProposalResponse{ - Txs: [][]byte{blkBz, sidecarsBz}, - }, nil + if !s.chainSpec.IsBlobConsensusEnabledAtHeight(req.Height) { + // Before BlobEnableHeight: return blobs as second transaction + return &cmtabci.PrepareProposalResponse{Txs: [][]byte{blkBz, sidecarsBz}}, nil + } + + // At and after BlobEnableHeight: return blobs in the Blob field + return &cmtabci.PrepareProposalResponse{Txs: [][]byte{blkBz}, Blob: sidecarsBz}, nil } diff --git a/consensus/cometbft/service/process_proposal.go b/consensus/cometbft/service/process_proposal.go index 18c6a7c494..bdc5e5b79e 100644 --- a/consensus/cometbft/service/process_proposal.go +++ b/consensus/cometbft/service/process_proposal.go @@ -61,6 +61,20 @@ func (s *Service) processProposal( ), ) + // Reject proposal if blobs are present before they are allowed + if s.chainSpec.BlobConsensusEnableHeight() > 0 && !s.chainSpec.IsBlobConsensusEnabledAtHeight(req.Height) { + if len(req.Blob) > 0 { + status := cmtabci.PROCESS_PROPOSAL_STATUS_REJECT + s.logger.Error( + "proposal contains blobs before BlobEnableHeight", + "height", req.Height, + "blob_enable_height", s.chainSpec.BlobConsensusEnableHeight(), + "blob_size", len(req.Blob), + ) + return &cmtabci.ProcessProposalResponse{Status: status}, nil + } + } + // errors to consensus indicate that the node was not able to understand // whether the block was valid or not. Viceversa, we signal that a block // is invalid by its status, but we do return nil error in such a case. @@ -86,13 +100,24 @@ func (s *Service) processProposal( // TODO: before Stable block time activation we keep caching off // to make sure chain does not get faster. Once activated, we can // active as if cache was always active. - if cache.IsStateCachingActive(s.delayCfg, math.Slot(req.Height)) { + if cache.IsStateCachingActive(s.chainSpec, math.Slot(req.Height)) { stateHash := string(req.Hash) + blobData := req.GetBlob() toCache := &cache.Element{ State: processProposalState, ValUpdates: valUpdates, + Blobs: blobData, } s.cachedStates.SetCached(stateHash, toCache) + + // Log caching details for debugging blob issues + s.logger.Info( + "ProcessProposal cached state and blobs", + "height", req.Height, + "hash", fmt.Sprintf("%X", req.Hash), + "blob_data_size", len(blobData), + "has_blobs", len(blobData) > 0, + ) } status := cmtabci.PROCESS_PROPOSAL_STATUS_ACCEPT return &cmtabci.ProcessProposalResponse{Status: status}, nil diff --git a/consensus/cometbft/service/service.go b/consensus/cometbft/service/service.go index de275a0c1a..eb12b285b2 100644 --- a/consensus/cometbft/service/service.go +++ b/consensus/cometbft/service/service.go @@ -58,7 +58,7 @@ type Service struct { node *node.Node nodeAddress cmtcrypto.Address - delayCfg delay.ConfigGetter + chainSpec chain.Spec // cmtConsensusParams are part of the blockchain state and // are agreed upon by all validators in the network. @@ -75,6 +75,7 @@ type Service struct { sm *statem.Manager Blockchain blockchain.BlockchainI BlockBuilder validator.BlockBuilderI + BlobReactor BlobReactorI // cachedStates tracks in memory the post block states // of blocks which were successfully verified. It allows @@ -112,6 +113,7 @@ func NewService( db dbm.DB, blockchain blockchain.BlockchainI, blockBuilder validator.BlockBuilderI, + blobReactor BlobReactorI, cs chain.Spec, cmtCfg *cmtcfg.Config, telemetrySink TelemetrySink, @@ -135,7 +137,8 @@ func NewService( sm: statem.NewManager(db, log), Blockchain: blockchain, BlockBuilder: blockBuilder, - delayCfg: cs, + BlobReactor: blobReactor, + chainSpec: cs, cmtConsensusParams: cmtConsensusParams, cmtCfg: cmtCfg, telemetrySink: telemetrySink, @@ -163,8 +166,14 @@ func NewService( // Make sure that SBT consensus parameters are duly set when the node restart. // Note that we can't rely on genesis.json having these parameters set right // because we introduced stable block time post (mainnet) genesis. - if lastBlockHeight >= s.delayCfg.SbtConsensusUpdateHeight() { - s.cmtConsensusParams.Feature.SBTEnableHeight = s.delayCfg.SbtConsensusEnableHeight() + if lastBlockHeight >= s.chainSpec.SbtConsensusUpdateHeight() { + s.cmtConsensusParams.Feature.SBTEnableHeight = s.chainSpec.SbtConsensusEnableHeight() + } + + // Make sure that blob consensus parameters are duly set when the node restarts. + if lastBlockHeight >= s.chainSpec.BlobConsensusUpdateHeight() { + s.cmtConsensusParams.Feature.BlobEnableHeight = s.chainSpec.BlobConsensusEnableHeight() + s.cmtConsensusParams.Blob.MaxBytes = s.chainSpec.BlobMaxBytes() } // Load block delay. @@ -205,6 +214,21 @@ func (s *Service) Start( } s.ResetAppCtx(ctx) + + // Enable BlobReactor only if BlobConsensusEnableHeight is set (not 0) + // The reactor will be used once the chain reaches that height + // TODO: consider making it noop until the update height is reached + var nodeOptions []node.Option + if s.chainSpec.BlobConsensusEnableHeight() > 0 { + s.logger.Info("Enabling BlobReactor for P2P blob distribution", "blob_enable_height", s.chainSpec.BlobConsensusEnableHeight()) + s.BlobReactor.SetNodeKey(string(nodeKey.ID())) + nodeOptions = []node.Option{ + node.CustomReactors(map[string]p2p.Reactor{ + "BlobReactor": s.BlobReactor, + }), + } + } + s.node, err = node.NewNode( ctx, cfg, @@ -215,6 +239,7 @@ func (s *Service) Start( cmtcfg.DefaultDBProvider, node.DefaultMetricsProvider(cfg.Instrumentation), servercmtlog.WrapCometLogger(s.logger), + nodeOptions..., ) if err != nil { return err diff --git a/da/blob/verifier.go b/da/blob/verifier.go index f4a926cb09..81bd064823 100644 --- a/da/blob/verifier.go +++ b/da/blob/verifier.go @@ -88,7 +88,7 @@ func (bv *verifier) verifySidecars( // This check happens outside the goroutines so that we do not // process the inclusion proofs before validating the index. if s.GetIndex() >= numSidecars { - return fmt.Errorf("invalid sidecar Index: %d", i) + return fmt.Errorf("invalid sidecar index: expected < %d, got %d at position %d", numSidecars, s.GetIndex(), i) } // Check BlobSidecar.Header equality with BeaconBlockHeader diff --git a/da/blobreactor/config.go b/da/blobreactor/config.go new file mode 100644 index 0000000000..d6e1eb824d --- /dev/null +++ b/da/blobreactor/config.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +package blobreactor + +import "time" + +const defaultRequestTimeout = 5 * time.Second + +// Config is the configuration for the blob reactor +type Config struct { + // RequestTimeout is the timeout for blob requests + RequestTimeout time.Duration `mapstructure:"request-timeout"` +} + +// DefaultConfig returns the default configuration +func DefaultConfig() Config { + return Config{ + RequestTimeout: defaultRequestTimeout, + } +} diff --git a/consensus/cometbft/service/encoding/interfaces.go b/da/blobreactor/errors.go similarity index 67% rename from consensus/cometbft/service/encoding/interfaces.go rename to da/blobreactor/errors.go index d6c9f869c3..d9c449d2ab 100644 --- a/consensus/cometbft/service/encoding/interfaces.go +++ b/da/blobreactor/errors.go @@ -13,18 +13,21 @@ // LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). // // TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON -// AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, // EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND // TITLE. -package encoding +package blobreactor -import "time" +import ( + "errors" +) -// ABCIRequest represents the interface for an ABCI request. -type ABCIRequest interface { - // GetTxs returns the transactions included in the request. - GetTxs() [][]byte - GetTime() time.Time -} +var ( + // ErrNoPeersAvailable indicates no peers are available for blob requests + ErrNoPeersAvailable = errors.New("no peers available for blob requests") + + // ErrAllPeersFailed indicates all peers failed to provide requested blobs + ErrAllPeersFailed = errors.New("all peers failed to provide blobs") +) diff --git a/da/blobreactor/interfaces.go b/da/blobreactor/interfaces.go new file mode 100644 index 0000000000..60f1b4628d --- /dev/null +++ b/da/blobreactor/interfaces.go @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +package blobreactor + +import "time" + +// BlobStore is a minimal interface for the BlobReactor to check and serve blobs. +// This matches the IndexDB interface from the AvailabilityStore. +type BlobStore interface { + // Has checks if a blob exists for the given index and key. + Has(index uint64, key []byte) (bool, error) + + // GetByIndex retrieves all raw blob data for a given index (slot). + GetByIndex(index uint64) ([][]byte, error) +} + +// TelemetrySink is an interface for emitting metrics. +type TelemetrySink interface { + // IncrementCounter increments a counter metric identified by the provided keys. + IncrementCounter(key string, args ...string) + + // SetGauge sets a gauge metric to the specified value. + SetGauge(key string, value int64, args ...string) + + // MeasureSince measures the time since the provided start time. + MeasureSince(key string, start time.Time, args ...string) +} diff --git a/da/blobreactor/messages.go b/da/blobreactor/messages.go new file mode 100644 index 0000000000..88f82bf216 --- /dev/null +++ b/da/blobreactor/messages.go @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +package blobreactor + +import ( + "encoding/binary" + "errors" + "fmt" + + "github.com/berachain/beacon-kit/primitives/common" + "github.com/berachain/beacon-kit/primitives/constraints" + "github.com/berachain/beacon-kit/primitives/math" + "github.com/cosmos/gogoproto/proto" + karalabessz "github.com/karalabe/ssz" +) + +// BlobMessage wraps our messages for CometBFT +// This implements proto.Message interface for CometBFT compatibility +type BlobMessage struct { + Data []byte `json:"data"` +} + +// Ensure BlobMessage implements proto.Message +var _ proto.Message = (*BlobMessage)(nil) + +// Reset implements proto.Message +func (m *BlobMessage) Reset() { + if m != nil { + m.Data = nil + } +} + +// String implements proto.Message +func (m *BlobMessage) String() string { + if m == nil { + return "nil" + } + return fmt.Sprintf("BlobMessage{Data: %d bytes}", len(m.Data)) +} + +// ProtoMessage implements proto.Message +func (m *BlobMessage) ProtoMessage() {} + +// Marshal implements encoding for CometBFT +func (m *BlobMessage) Marshal() ([]byte, error) { + if m == nil { + return nil, nil + } + return m.Data, nil +} + +// Unmarshal implements decoding for CometBFT +func (m *BlobMessage) Unmarshal(data []byte) error { + if m == nil { + return errors.New("cannot unmarshal into nil BlobMessage") + } + m.Data = make([]byte, len(data)) + copy(m.Data, data) + return nil +} + +// Size returns the size of the message +func (m *BlobMessage) Size() int { + if m == nil { + return 0 + } + return len(m.Data) +} + +// NewBlobMessage creates a new blob message from JSON data +func NewBlobMessage(data []byte) *BlobMessage { + return &BlobMessage{Data: data} +} + +// ============================================================================ +// SSZ Message Types for BlobReactor Protocol +// ============================================================================ + +// Ensure our types implement the necessary interfaces +var ( + _ karalabessz.StaticObject = (*BlobRequest)(nil) + _ constraints.SSZMarshallableRootable = (*BlobRequest)(nil) + _ constraints.SSZMarshallable = (*BlobResponse)(nil) +) + +// MessageType identifies the type of message being sent +type MessageType uint8 + +const ( + MessageTypeRequest MessageType = iota + MessageTypeResponse +) + +// BlobRequest requests all blobs for a specific slot +type BlobRequest struct { + Slot math.Slot + RequestID uint64 // Unique ID for request/response matching +} + +// DefineSSZ defines the SSZ encoding for BlobRequest +func (r *BlobRequest) DefineSSZ(c *karalabessz.Codec) { + karalabessz.DefineUint64(c, &r.Slot) + karalabessz.DefineUint64(c, &r.RequestID) +} + +// SizeSSZ returns the size of BlobRequest in SSZ encoding +// +//nolint:mnd // ok for now +func (*BlobRequest) SizeSSZ(*karalabessz.Sizer) uint32 { + return 16 // uint64 slot + uint64 requestID +} + +// MarshalSSZ marshals the BlobRequest to SSZ format +func (r *BlobRequest) MarshalSSZ() ([]byte, error) { + buf := make([]byte, karalabessz.Size(r)) + return buf, karalabessz.EncodeToBytes(buf, r) +} + +// HashTreeRoot computes the SSZ hash tree root +func (r *BlobRequest) HashTreeRoot() common.Root { + return karalabessz.HashSequential(r) +} + +// UnmarshalSSZ unmarshals BlobRequest from SSZ format +// +//nolint:mnd // ok for now +func (r *BlobRequest) UnmarshalSSZ(buf []byte) error { + if len(buf) < 16 { + return fmt.Errorf("insufficient data for BlobRequest: need 16 bytes, got %d", len(buf)) + } + r.Slot = math.Slot(binary.LittleEndian.Uint64(buf[0:8])) + r.RequestID = binary.LittleEndian.Uint64(buf[8:16]) + return nil +} + +// ValidateAfterDecodingSSZ validates the BlobRequest after SSZ decoding +func (*BlobRequest) ValidateAfterDecodingSSZ() error { + return nil +} + +const BlobResponseStaticSize uint32 = 28 + +// BlobResponse contains all blobs for the requested slot +type BlobResponse struct { + Slot math.Slot + RequestID uint64 // Echo back the request ID for matching + HeadSlot math.Slot + SidecarData []byte // Raw SSZ-encoded BlobSidecars (avoiding double marshal/unmarshal) +} + +// DefineSSZ defines the SSZ encoding for BlobResponse using karalabe/ssz codec +func (r *BlobResponse) DefineSSZ(c *karalabessz.Codec) { + // Define fixed-size fields + karalabessz.DefineUint64(c, &r.Slot) + karalabessz.DefineUint64(c, &r.RequestID) + karalabessz.DefineUint64(c, &r.HeadSlot) + + // Define dynamic field - offset first, then content + karalabessz.DefineDynamicBytesOffset(c, &r.SidecarData, defaultRecvMessageCapacity) + karalabessz.DefineDynamicBytesContent(c, &r.SidecarData, defaultRecvMessageCapacity) +} + +// SizeSSZ returns the SSZ encoded size in bytes +func (r *BlobResponse) SizeSSZ(_ *karalabessz.Sizer, fixed bool) uint32 { + var size = BlobResponseStaticSize + if fixed { + return size + } + + // Dynamic part: actual sidecar data length + size += uint32(len(r.SidecarData)) // #nosec G115 // length validated in ValidateAfterDecodingSSZ + return size +} + +// MarshalSSZ marshals the BlobResponse to SSZ format using the codec +func (r *BlobResponse) MarshalSSZ() ([]byte, error) { + buf := make([]byte, karalabessz.Size(r)) + return buf, karalabessz.EncodeToBytes(buf, r) +} + +// UnmarshalSSZ unmarshals BlobResponse from SSZ format using the codec +func (r *BlobResponse) UnmarshalSSZ(buf []byte) error { + return karalabessz.DecodeFromBytes(buf, r) +} + +// ValidateAfterDecodingSSZ validates the BlobResponse after SSZ decoding +func (r *BlobResponse) ValidateAfterDecodingSSZ() error { + // Validate sidecar data size + if len(r.SidecarData) > defaultRecvMessageCapacity { + return fmt.Errorf("sidecar data too large: %d bytes (max %d)", len(r.SidecarData), defaultRecvMessageCapacity) + } + return nil +} diff --git a/da/blobreactor/messages_test.go b/da/blobreactor/messages_test.go new file mode 100644 index 0000000000..bf64f73085 --- /dev/null +++ b/da/blobreactor/messages_test.go @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +package blobreactor_test + +import ( + "testing" + + "github.com/berachain/beacon-kit/da/blobreactor" + datypes "github.com/berachain/beacon-kit/da/types" + "github.com/berachain/beacon-kit/primitives/encoding/ssz" + "github.com/berachain/beacon-kit/primitives/math" + "github.com/stretchr/testify/require" +) + +func TestBlobRequest_SSZ(t *testing.T) { + t.Parallel() + original := &blobreactor.BlobRequest{Slot: 12345, RequestID: 67890} + + // Marshal + data, err := original.MarshalSSZ() + require.NoError(t, err) + require.Len(t, data, 16, "BlobRequest should be 16 bytes (8 for Slot + 8 for RequestID)") + + // Unmarshal + decoded := &blobreactor.BlobRequest{} + err = decoded.UnmarshalSSZ(data) + require.NoError(t, err) + require.Equal(t, original.Slot, decoded.Slot) +} + +func TestBlobResponse_SSZ(t *testing.T) { + t.Parallel() + t.Run("without sidecars", func(t *testing.T) { + original := &blobreactor.BlobResponse{ + Slot: 42, + RequestID: 123, + HeadSlot: 100, + SidecarData: nil, + } + + // Marshal + data, err := original.MarshalSSZ() + require.NoError(t, err) + require.GreaterOrEqual(t, len(data), 28, "BlobResponse should be at least 28 bytes (fixed size)") + + // Unmarshal + decoded := &blobreactor.BlobResponse{} + err = decoded.UnmarshalSSZ(data) + require.NoError(t, err) + require.Equal(t, original.Slot, decoded.Slot) + require.Equal(t, original.RequestID, decoded.RequestID) + require.Equal(t, original.HeadSlot, decoded.HeadSlot) + require.Empty(t, decoded.SidecarData) + }) + + t.Run("with sidecars", func(t *testing.T) { + // Create a test sidecar and marshal it to SSZ + sidecars := datypes.BlobSidecars{&datypes.BlobSidecar{Index: 0}} + sidecarData, err := sidecars.MarshalSSZ() + require.NoError(t, err) + + original := &blobreactor.BlobResponse{ + Slot: 100, + RequestID: 456, + HeadSlot: 200, + SidecarData: sidecarData, + } + + // Marshal + data, err := original.MarshalSSZ() + require.NoError(t, err) + require.Greater(t, len(data), 28, "BlobResponse with sidecars should be > 28 bytes") + + // Unmarshal + decoded := &blobreactor.BlobResponse{} + err = decoded.UnmarshalSSZ(data) + require.NoError(t, err) + require.Equal(t, original.Slot, decoded.Slot) + require.Equal(t, original.RequestID, decoded.RequestID) + require.Equal(t, original.HeadSlot, decoded.HeadSlot) + require.NotEmpty(t, decoded.SidecarData) + + // Verify we can unmarshal the sidecar data back + var decodedSidecars datypes.BlobSidecars + err = ssz.Unmarshal(decoded.SidecarData, &decodedSidecars) + require.NoError(t, err) + require.Len(t, decodedSidecars, 1) + }) +} + +func TestSSZ_InvalidData(t *testing.T) { + t.Parallel() + // Test that decoding fails gracefully with invalid data + t.Run("BlobRequest too short", func(t *testing.T) { + req := &blobreactor.BlobRequest{} + err := req.UnmarshalSSZ([]byte{1, 2, 3}) // Only 3 bytes, need 8 + require.Error(t, err) + }) + + t.Run("BlobResponse too short", func(t *testing.T) { + resp := &blobreactor.BlobResponse{} + err := resp.UnmarshalSSZ([]byte{1, 2, 3, 4, 5}) // Only 5 bytes, need at least 28 + require.Error(t, err) + }) +} + +func TestMessageWithTypePrefix(t *testing.T) { + t.Parallel() + // Test the complete message flow with type prefix + req := &blobreactor.BlobRequest{Slot: 1000} + + // Marshal SSZ + sszData, err := req.MarshalSSZ() + require.NoError(t, err) + + // Add message type prefix (simulating what the reactor does) + fullMsg := append([]byte{byte(blobreactor.MessageTypeRequest)}, sszData...) + + // Create BlobMessage wrapper + blobMsg := blobreactor.NewBlobMessage(fullMsg) + require.NotNil(t, blobMsg) + + // Extract and decode (simulating Receive method) + msgType := blobreactor.MessageType(blobMsg.Data[0]) + msgData := blobMsg.Data[1:] + + require.Equal(t, blobreactor.MessageTypeRequest, msgType) + + decoded := &blobreactor.BlobRequest{} + err = decoded.UnmarshalSSZ(msgData) + require.NoError(t, err) + require.Equal(t, math.Slot(1000), decoded.Slot) +} diff --git a/da/blobreactor/metrics.go b/da/blobreactor/metrics.go new file mode 100644 index 0000000000..7b1bc8b3cc --- /dev/null +++ b/da/blobreactor/metrics.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +package blobreactor + +import ( + "time" +) + +// Metric status constants for blob reactor requests. +const ( + statusSuccess = "success" + statusTimeout = "timeout" + statusPeerNotFound = "peer_not_found" + statusSendFailed = "send_failed" + statusAllPeersFailed = "all_peers_failed" + statusMarshalFailed = "marshal_failed" + statusInvalidResponse = "invalid_response" + statusVerifyFailed = "verification_failed" + messageTypeRequest = "request" + messageTypeResponse = "response" +) + +// blobReactorMetrics contains metrics for the blob reactor P2P operations. +type blobReactorMetrics struct { + sink TelemetrySink +} + +// newBlobReactorMetrics creates a new blobReactorMetrics instance. +func newBlobReactorMetrics(sink TelemetrySink) *blobReactorMetrics { + return &blobReactorMetrics{sink: sink} +} + +// recordOverallRequestComplete records completion of entire blob request (may try multiple peers). +func (m *blobReactorMetrics) recordOverallRequestComplete(status string, start time.Time) { + m.sink.IncrementCounter("beacon_kit.blobreactor.request_total", "status", status) + m.sink.MeasureSince("beacon_kit.blobreactor.request_duration", start, "status", status) +} + +// recordPeerAttempt records a single peer attempt with status (no duration to avoid high cardinality). +func (m *blobReactorMetrics) recordPeerAttempt(status string) { + m.sink.IncrementCounter("beacon_kit.blobreactor.peer_attempts_total", "status", status) +} + +// observeWorkerPoolFull increments counter when worker pool is full and messages are dropped. +func (m *blobReactorMetrics) observeWorkerPoolFull(messageType string) { + m.sink.IncrementCounter("beacon_kit.blobreactor.worker_pool_full_total", "message_type", messageType) +} + +// setActiveRequests sets gauge for currently active blob requests. +func (m *blobReactorMetrics) setActiveRequests(count int) { + m.sink.SetGauge("beacon_kit.blobreactor.active_requests", int64(count)) +} + +// setPeerPoolSize sets gauges for peer pool statistics. +func (m *blobReactorMetrics) setPeerPoolSize(available, total int) { + m.sink.SetGauge("beacon_kit.blobreactor.peers_available", int64(available)) + m.sink.SetGauge("beacon_kit.blobreactor.peers_total", int64(total)) +} diff --git a/da/blobreactor/reactor.go b/da/blobreactor/reactor.go new file mode 100644 index 0000000000..440211f78e --- /dev/null +++ b/da/blobreactor/reactor.go @@ -0,0 +1,610 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +package blobreactor + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "math/rand" + "sort" + "sync" + "sync/atomic" + "time" + + datypes "github.com/berachain/beacon-kit/da/types" + "github.com/berachain/beacon-kit/log" + "github.com/berachain/beacon-kit/primitives/encoding/ssz" + "github.com/berachain/beacon-kit/primitives/math" + "github.com/cometbft/cometbft/libs/service" + "github.com/cometbft/cometbft/p2p" +) + +const ( + // BlobChannel is our custom channel ID for blob requests/responses + BlobChannel = byte(0x70) + + // ReactorName is the registered name for the blob reactor in CometBFT's switch + ReactorName = "BLOBREACTOR" + + defaultSleepDuration = 100 * time.Millisecond + defaultPriority = 5 + defaultSendQueueCapacity = 100 + defaultRecvBufferCapacity = 1024 * 1024 + defaultRecvMessageCapacity = 1024 * 1024 + + defaultMaxRequestWorkers = 10 + + maxBlobsPerBlock = 6 +) + +// blobRequestError wraps an error with a status for metrics tracking. +type blobRequestError struct { + err error + status string +} + +func (e *blobRequestError) Error() string { + return e.err.Error() +} + +func (e *blobRequestError) Unwrap() error { + return e.err +} + +func newBlobRequestError(err error, status string) error { + return &blobRequestError{err: err, status: status} +} + +// BlobReactor handles P2P blob distribution for BeaconKit. +// It implements the CometBFT Reactor interface. +type BlobReactor struct { + service.BaseService + sw *p2p.Switch + + blobStore BlobStore // Storage backend for checking which blobs exist locally + logger log.Logger // Logger for the reactor + config Config // Config for the reactor + metrics *blobReactorMetrics + + // Track peers and our head slot + stateMu sync.RWMutex // Protects peers and headSlot + peers map[p2p.ID]struct{} + headSlot math.Slot // Our own head slot (updated by blockchain service) + nodeKey string // Our nodeKey (identity) + + // Concurrent request/response handling with per-request channels + responseMu sync.RWMutex + responseChans map[uint64]chan *BlobResponse // requestID -> response channel + + // Worker pool for controlled concurrency + requestWorkers chan struct{} // semaphore for limiting concurrent request handlers + workersWg sync.WaitGroup // tracks active worker goroutines + + // Request ID counter + nextRequestID atomic.Uint64 // atomic counter for generating unique request IDs + + // Shutdown flag to prevent new workers during stop + stopped atomic.Bool // set to true when OnStop begins +} + +// NewBlobReactor creates a new blob reactor with storage backend +func NewBlobReactor(blobStore BlobStore, logger log.Logger, cfg Config, sink TelemetrySink) *BlobReactor { + br := &BlobReactor{ + peers: make(map[p2p.ID]struct{}), + blobStore: blobStore, + logger: logger, + config: cfg, + metrics: newBlobReactorMetrics(sink), + responseChans: make(map[uint64]chan *BlobResponse), + requestWorkers: make(chan struct{}, defaultMaxRequestWorkers), + } + br.BaseService = *service.NewBaseService(nil, ReactorName, br) + return br +} + +// SetSwitch allows setting a switch. +func (br *BlobReactor) SetSwitch(sw *p2p.Switch) { + br.logger.Info("BlobReactor SetSwitch called", "switch", sw) + br.sw = sw +} + +// GetChannels returns the list of MConnection.ChannelDescriptor. +func (br *BlobReactor) GetChannels() []*p2p.ChannelDescriptor { + return []*p2p.ChannelDescriptor{ + { + ID: BlobChannel, + Priority: defaultPriority, + SendQueueCapacity: defaultSendQueueCapacity, + RecvBufferCapacity: defaultRecvBufferCapacity, + RecvMessageCapacity: defaultRecvMessageCapacity, + MessageType: &BlobMessage{}, + }, + } +} + +// InitPeer is called by the switch before the peer is started. Use it to +// initialize data for the peer (e.g. peer state). +func (br *BlobReactor) InitPeer(peer p2p.Peer) p2p.Peer { + br.AddPeer(peer) + return peer +} + +// AddPeer is called by the switch after the peer is added and successfully started. +func (br *BlobReactor) AddPeer(peer p2p.Peer) { + br.stateMu.Lock() + br.peers[peer.ID()] = struct{}{} + br.stateMu.Unlock() + + br.logger.Info("Added peer", "peer", peer.ID()) +} + +// RemovePeer is called by the switch when the peer is stopped (due to error or other reason). +func (br *BlobReactor) RemovePeer(peer p2p.Peer, reason interface{}) { + br.stateMu.Lock() + delete(br.peers, peer.ID()) + br.stateMu.Unlock() + + br.logger.Info("Removed peer", "peer", peer.ID(), "reason", reason) +} + +// spawnWorker attempts to spawn a worker goroutine to handle the given task. +// Returns true if worker was spawned, false if pool is full or reactor is stopped. +func (br *BlobReactor) spawnWorker(task func(), peerID p2p.ID, taskType string) { + select { + case br.requestWorkers <- struct{}{}: + // Double-check stopped flag after acquiring worker slot to prevent race + if br.stopped.Load() { + <-br.requestWorkers // Release slot + br.logger.Debug("Dropping message, reactor stopped during worker acquisition", "peer", peerID, "task_type", taskType) + return + } + br.workersWg.Add(1) + go func() { + defer func() { + <-br.requestWorkers + br.workersWg.Done() + }() + task() + }() + default: + br.logger.Warn("Worker pool full, dropping message", "peer", peerID, "task_type", taskType) + br.metrics.observeWorkerPoolFull(taskType) + } +} + +// Receive is called by the switch when an envelope is received from any connected +// peer on any of the channels registered by the reactor +func (br *BlobReactor) Receive(envelope p2p.Envelope) { + // Ignore messages if reactor is stopped + if br.stopped.Load() { + return + } + + br.logger.Info("Received message on BlobChannel", + "peer", envelope.Src.ID(), + "channel", envelope.ChannelID, + "peer_is_running", envelope.Src.IsRunning()) + + // Get the message from the envelope + blobMsg, ok := envelope.Message.(*BlobMessage) + if !ok { + br.logger.Error("Failed to cast message to BlobMessage", "peer", envelope.Src.ID(), "type", fmt.Sprintf("%T", envelope.Message)) + return + } + + // Validate message has minimum length + if len(blobMsg.Data) < 1 { + br.logger.Error("Received message too short", "size", len(blobMsg.Data), "peer", envelope.Src.ID()) + return + } + + msgType := MessageType(blobMsg.Data[0]) + msgData := blobMsg.Data[1:] + + br.logger.Info("Processing message", "type", msgType, "data_size", len(msgData), "peer", envelope.Src.ID()) + + switch msgType { + case MessageTypeRequest: + var req BlobRequest + if err := req.UnmarshalSSZ(msgData); err != nil { + br.logger.Error("Failed to unmarshal BlobRequest", "error", err, "peer", envelope.Src.ID()) + return + } + br.logger.Info("Received blob request", "slot", req.Slot.Unwrap(), "request_id", req.RequestID, "peer", envelope.Src.ID()) + + handleRequest := func() { + br.handleBlobRequest(envelope.Src, &req) + } + br.spawnWorker(handleRequest, envelope.Src.ID(), "request") + + case MessageTypeResponse: + var resp BlobResponse + if err := resp.UnmarshalSSZ(msgData); err != nil { + br.logger.Error("Failed to unmarshal BlobResponse", "error", err, "peer", envelope.Src.ID(), "data_size", len(msgData)) + return + } + br.logger.Info("Received blob response", + "slot", resp.Slot.Unwrap(), + "request_id", resp.RequestID, + "peer", envelope.Src.ID(), + "sidecar_data_size", len(resp.SidecarData)) + + handleResponse := func() { + br.handleBlobResponse(envelope.Src, &resp) + } + br.spawnWorker(handleResponse, envelope.Src.ID(), "response") + + default: + br.logger.Warn("Received unknown message type", "type", msgType, "peer", envelope.Src.ID()) + } +} + +func (br *BlobReactor) SetNodeKey(nodeKey string) { + br.nodeKey = nodeKey +} + +// SetHeadSlot updates the reactor's view of the current blockchain head slot. +func (br *BlobReactor) SetHeadSlot(slot math.Slot) { + br.stateMu.Lock() + br.headSlot = slot + br.stateMu.Unlock() +} + +// handleBlobRequest processes incoming blob requests and sends back blobs +func (br *BlobReactor) handleBlobRequest(peer p2p.Peer, req *BlobRequest) { + br.logger.Info("Received blob request", "slot", req.Slot.Unwrap(), "request_id", req.RequestID, "peer", peer.ID()) + + // Get our current head slot to include in response + br.stateMu.RLock() + headSlot := br.headSlot + br.stateMu.RUnlock() + + // Fetch blobs from storage - if not found or error, sidecarBzs will be nil + sidecarBzs, err := br.blobStore.GetByIndex(req.Slot.Unwrap()) + if err != nil { + br.logger.Error("Failed to fetch blobs from storage", "slot", req.Slot.Unwrap(), "request_id", req.RequestID, "error", err) + } + resp := &BlobResponse{ + Slot: req.Slot, + RequestID: req.RequestID, + HeadSlot: headSlot, + SidecarData: encodeBlobSidecarsSSZ(sidecarBzs), + } + + respBytes, err := resp.MarshalSSZ() + if err != nil { + br.logger.Error("Failed to marshal response", "slot", req.Slot.Unwrap(), "request_id", req.RequestID, "error", err) + return + } + + // Prepend message type + msgData := append([]byte{byte(MessageTypeResponse)}, respBytes...) + + // Send response back to peer + if !peer.Send(p2p.Envelope{ChannelID: BlobChannel, Message: NewBlobMessage(msgData)}) { + br.logger.Warn("Failed to send blob response", + "peer", peer.ID(), + "slot", req.Slot, + "request_id", req.RequestID, + "data_size", len(msgData)) + // If sending response failed, the caller will timeout and try another peer + return + } + + br.logger.Info("Sent blob response", + "slot", req.Slot.Unwrap(), + "request_id", req.RequestID, + "peer", peer.ID(), + "data_size", len(msgData), + ) +} + +// handleBlobResponse processes incoming blob responses +func (br *BlobReactor) handleBlobResponse(peer p2p.Peer, resp *BlobResponse) { + br.logger.Info("Received blob response", + "slot", resp.Slot.Unwrap(), + "request_id", resp.RequestID, + "peer", peer.ID(), + "data_size", len(resp.SidecarData), + "peer_head", resp.HeadSlot) + + // Look up and remove the response channel for this request ID + br.responseMu.Lock() + respChan, exists := br.responseChans[resp.RequestID] + if exists { + delete(br.responseChans, resp.RequestID) + } + br.responseMu.Unlock() + + if !exists { + br.logger.Info("No waiting channel for response (request may have timed out)", + "request_id", resp.RequestID, + "slot", resp.Slot.Unwrap()) + return + } + + // Try to deliver the response + select { + case respChan <- resp: + br.logger.Info("Delivered response to waiting request", "request_id", resp.RequestID, "slot", resp.Slot.Unwrap()) + default: + br.logger.Warn("Response channel full, dropping response", "request_id", resp.RequestID, "slot", resp.Slot.Unwrap()) + } +} + +// RequestBlobs fetches all blobs for a given slot from peers. +// Returns all blob sidecars for the slot, or an error if none could be retrieved. +// The context controls cancellation and timeout for the entire operation. +func (br *BlobReactor) RequestBlobs( + ctx context.Context, + slot math.Slot, + verifier func(datypes.BlobSidecars) error) ([]*datypes.BlobSidecar, error) { + br.logger.Info("RequestBlobs called", "slot", slot.Unwrap()) + + // Check if we have any peers at all + br.stateMu.RLock() + peerCount := len(br.peers) + br.stateMu.RUnlock() + + br.metrics.setPeerPoolSize(peerCount, peerCount) + + if peerCount == 0 { + br.logger.Error("No peers available for blob request", "slot", slot.Unwrap()) + return nil, ErrNoPeersAvailable + } + + // Track which peers we've already tried + triedPeers := make(map[p2p.ID]bool) + + start := time.Now() + + // Continue trying while we have untried peers + for { + // Check context before trying next peer + select { + case <-ctx.Done(): + br.logger.Warn("Request cancelled before all peers tried", "slot", slot.Unwrap(), "peers_tried", len(triedPeers)) + br.metrics.recordOverallRequestComplete(statusTimeout, start) + return nil, fmt.Errorf("request cancelled: %w", ctx.Err()) + default: + } + + // Select next untried peer + peerID := br.selectUntriedPeer(triedPeers) + if peerID == "" { + // No more peers to try + break + } + + // Mark this peer as tried + triedPeers[peerID] = true + + // Update available peers metric + br.metrics.setPeerPoolSize(peerCount-len(triedPeers), peerCount) + + // Try to request blobs from this peer + sidecars, err := br.requestBlobsFromPeer(ctx, peerID, slot) + if err != nil { + // Record per-peer failure with status + status := statusInvalidResponse + var reqErr *blobRequestError + if errors.As(err, &reqErr) { + status = reqErr.status + } + br.metrics.recordPeerAttempt(status) + br.logger.Warn("Failed to get blobs from peer", "peer", peerID, "error", err) + continue + } + + // Sort sidecars by index to ensure correct order + sort.Slice(sidecars, func(i, j int) bool { return sidecars[i].GetIndex() < sidecars[j].GetIndex() }) + + // Verify the blobs before returning + if verifyErr := verifier(sidecars); verifyErr != nil { + br.metrics.recordPeerAttempt(statusVerifyFailed) + br.logger.Warn("Blob verification failed, trying next peer", + "slot", slot.Unwrap(), + "count", len(sidecars), + "peer", peerID, + "error", verifyErr) + continue + } + + // Success - record both per-peer and overall metrics + br.metrics.recordPeerAttempt(statusSuccess) + br.metrics.recordOverallRequestComplete(statusSuccess, start) + br.logger.Info("Successfully retrieved and verified blobs", "slot", slot.Unwrap(), "peer", peerID, "count", len(sidecars)) + return sidecars, nil + } + + br.logger.Error("Failed to retrieve blobs from all peers", "slot", slot.Unwrap(), "peers_tried", len(triedPeers)) + br.metrics.recordOverallRequestComplete(statusAllPeersFailed, start) + return nil, ErrAllPeersFailed +} + +// selectUntriedPeer returns a random untried peer, or empty string if all peers have been tried. +func (br *BlobReactor) selectUntriedPeer(triedPeers map[p2p.ID]bool) p2p.ID { + br.stateMu.RLock() + defer br.stateMu.RUnlock() + + // Build list of untried peers + var untried []p2p.ID + for peerID := range br.peers { + if !triedPeers[peerID] { + untried = append(untried, peerID) + } + } + + if len(untried) == 0 { + return "" // All peers tried or no peers available + } + + // Return random untried peer to distribute load + return untried[rand.Intn(len(untried))] // #nosec G404 // weak rng is acceptable for peer selection +} + +// requestBlobsFromPeer sends a blob request to a specific peer and waits for response. +func (br *BlobReactor) requestBlobsFromPeer(ctx context.Context, peerID p2p.ID, slot math.Slot) (datypes.BlobSidecars, error) { + peer := br.sw.Peers().Get(peerID) + if peer == nil { + return nil, newBlobRequestError(fmt.Errorf("peer %s not found", peerID), statusPeerNotFound) + } + + if !peer.IsRunning() { + return nil, newBlobRequestError(fmt.Errorf("peer %s not running", peerID), statusPeerNotFound) + } + + // Generate unique request ID + requestID := br.nextRequestID.Add(1) + + req := &BlobRequest{ + Slot: slot, + RequestID: requestID, + } + + var err error + reqBytes, err := req.MarshalSSZ() + if err != nil { + return nil, newBlobRequestError(fmt.Errorf("failed to marshal request from peer %s: %w", peerID, err), statusMarshalFailed) + } + + // Create a dedicated response channel for this request + respChan := make(chan *BlobResponse, 1) + + // Register the response channel and track active requests + br.responseMu.Lock() + br.responseChans[requestID] = respChan + activeRequests := len(br.responseChans) + br.responseMu.Unlock() + br.metrics.setActiveRequests(activeRequests) + + cleanup := func() { + br.logger.Debug("Cleaning up response channel", "request_id", requestID) + br.responseMu.Lock() + delete(br.responseChans, requestID) + br.metrics.setActiveRequests(len(br.responseChans)) + br.responseMu.Unlock() + } + defer cleanup() + + msgData := append([]byte{byte(MessageTypeRequest)}, reqBytes...) + if !peer.Send(p2p.Envelope{ChannelID: BlobChannel, Message: NewBlobMessage(msgData)}) { + return nil, newBlobRequestError(fmt.Errorf("failed to send blob request to peer %s", peerID), statusSendFailed) + } + + br.logger.Info("Sent blob request, waiting for response", "slot", slot.Unwrap(), "peer", peerID, "request_id", requestID) + + // Wait for response with timeout, respecting parent context + timeoutCtx, cancel := context.WithTimeout(ctx, br.config.RequestTimeout) + defer cancel() + + select { + case resp := <-respChan: + br.logger.Info("Received response", "slot", resp.Slot.Unwrap(), "peer", peerID, "data_size", len(resp.SidecarData)) + + if resp.Slot != slot { + err = fmt.Errorf("peer %s returned wrong slot: expected %d, got %d", peerID, slot.Unwrap(), resp.Slot.Unwrap()) + return nil, newBlobRequestError(err, statusInvalidResponse) + } + + if resp.HeadSlot < resp.Slot { + err = fmt.Errorf("peer %s head (%d) not at requested slot (%d)", peerID, resp.HeadSlot.Unwrap(), resp.Slot.Unwrap()) + return nil, newBlobRequestError(err, statusInvalidResponse) + } + + if len(resp.SidecarData) > defaultRecvMessageCapacity { + err = fmt.Errorf( + "peer %s sent oversized response: %d bytes (max %d)", + peerID, + len(resp.SidecarData), + defaultRecvMessageCapacity, + ) + return nil, newBlobRequestError(err, statusInvalidResponse) + } + + var sidecars datypes.BlobSidecars + if len(resp.SidecarData) > 0 { + if err = ssz.Unmarshal(resp.SidecarData, &sidecars); err != nil { + err = fmt.Errorf("failed to unmarshal sidecars from peer %s: %w", peerID, err) + return nil, newBlobRequestError(err, statusInvalidResponse) + } + } + + if len(sidecars) > maxBlobsPerBlock { + err = fmt.Errorf("peer %s sent too many blobs: %d (max %d)", peerID, len(sidecars), maxBlobsPerBlock) + return nil, newBlobRequestError(err, statusInvalidResponse) + } + + return sidecars, nil + + case <-timeoutCtx.Done(): + if ctx.Err() != nil { + return nil, newBlobRequestError(fmt.Errorf("request cancelled from peer %s: %w", peerID, ctx.Err()), statusTimeout) + } + err = fmt.Errorf("request timed out from peer %s after %v", peerID, br.config.RequestTimeout) + return nil, newBlobRequestError(err, statusTimeout) + } +} + +func (br *BlobReactor) OnStart() error { + br.logger.Info("Starting BlobReactor", "node_key", br.nodeKey) + return nil +} + +func (br *BlobReactor) OnStop() { + br.logger.Info("Stopping BlobReactor", "node_key", br.nodeKey) + + // Set stop flag to prevent new workers from being spawned + // This must happen before waiting for existing workers + br.stopped.Store(true) + + // Wait for all worker goroutines to complete + br.workersWg.Wait() + + br.logger.Info("BlobReactor stopped, all workers completed") +} + +// encodeBlobSidecarsSSZ takes multiple SSZ-encoded BlobSidecar bytes and combines them +// into a single SSZ-encoded BlobSidecars (slice) format. +// The encoding is: 4-byte offset (always 4) + concatenated sidecars. +// +//nolint:mnd // ok for now +func encodeBlobSidecarsSSZ(sidecarBzs [][]byte) []byte { + totalSize := 4 + for _, data := range sidecarBzs { + totalSize += len(data) + } + + result := make([]byte, totalSize) + + // Write offset (4) in little-endian - data starts after the offset + binary.LittleEndian.PutUint32(result[0:4], 4) + + // Concatenate all sidecars after the offset (if any) + pos := 4 + for _, data := range sidecarBzs { + pos += copy(result[pos:], data) + } + + return result +} diff --git a/da/blobreactor/reactor_test.go b/da/blobreactor/reactor_test.go new file mode 100644 index 0000000000..e78678ea4e --- /dev/null +++ b/da/blobreactor/reactor_test.go @@ -0,0 +1,422 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +//nolint:paralleltest // Tests cannot run in parallel due to race condition in CometBFT's p2p.MakeConnectedSwitches +package blobreactor_test + +import ( + "errors" + "testing" + "time" + + "cosmossdk.io/log" + ctypes "github.com/berachain/beacon-kit/consensus-types/types" + "github.com/berachain/beacon-kit/da/blobreactor" + datypes "github.com/berachain/beacon-kit/da/types" + "github.com/berachain/beacon-kit/primitives/common" + "github.com/berachain/beacon-kit/primitives/math" + "github.com/cometbft/cometbft/config" + "github.com/cometbft/cometbft/p2p" + "github.com/stretchr/testify/require" +) + +type stubBlobStore struct { + blobs map[uint64][][]byte // slot -> blob data + delay time.Duration // optional delay to simulate slow responses +} + +func newStubBlobStore() *stubBlobStore { + return &stubBlobStore{ + blobs: make(map[uint64][][]byte), + } +} + +func newSlowStubBlobStore(delay time.Duration) *stubBlobStore { + return &stubBlobStore{ + blobs: make(map[uint64][][]byte), + delay: delay, + } +} + +func (m *stubBlobStore) Has(_ uint64, _ []byte) (bool, error) { + return false, nil +} + +func (m *stubBlobStore) GetByIndex(index uint64) ([][]byte, error) { + // Simulate slow response if delay is set + if m.delay > 0 { + time.Sleep(m.delay) + } + + if blobs, ok := m.blobs[index]; ok { + return blobs, nil + } + + return nil, errors.New("blobs not found") +} + +func (m *stubBlobStore) setBlobs(slot uint64, sidecars datypes.BlobSidecars) error { + sidecarBzs := make([][]byte, len(sidecars)) + for i, sidecar := range sidecars { + data, err := sidecar.MarshalSSZ() + if err != nil { + return err + } + sidecarBzs[i] = data + } + m.blobs[slot] = sidecarBzs + return nil +} + +func createTestSidecars(t *testing.T, count int) datypes.BlobSidecars { + t.Helper() + + sidecars := make([]*datypes.BlobSidecar, count) + for i := range count { + sidecars[i] = &datypes.BlobSidecar{ + Index: uint64(i), + SignedBeaconBlockHeader: &ctypes.SignedBeaconBlockHeader{ + Header: &ctypes.BeaconBlockHeader{}, + }, + InclusionProof: make([]common.Root, ctypes.KZGInclusionProofDepth), + } + } + + return sidecars +} + +func newTestReactor(t *testing.T, store blobreactor.BlobStore, config blobreactor.Config) *blobreactor.BlobReactor { + t.Helper() + logger := log.NewTestLogger(t) + reactor := blobreactor.NewBlobReactor(store, logger, config, noOpTelemetrySink{}) + return reactor +} + +type noOpTelemetrySink struct{} + +func (noOpTelemetrySink) IncrementCounter(string, ...string) {} +func (noOpTelemetrySink) SetGauge(string, int64, ...string) {} +func (noOpTelemetrySink) MeasureSince(string, time.Time, ...string) {} + +func makeTestP2PConfig(t *testing.T) *config.P2PConfig { + t.Helper() + p2pConfig := config.DefaultP2PConfig() + p2pConfig.ListenAddress = "tcp://127.0.0.1:0" // Use random port + return p2pConfig +} + +func makeConnectedReactors( + t *testing.T, n int, stores []*stubBlobStore, configs []blobreactor.Config, +) ([]*blobreactor.BlobReactor, []*p2p.Switch) { + t.Helper() + + tempDir := t.TempDir() + + p2pConfig := makeTestP2PConfig(t) + p2pConfig.RootDir = tempDir + + reactors := make([]*blobreactor.BlobReactor, n) + for i := range n { + reactors[i] = newTestReactor(t, stores[i], configs[i]) + } + + initSwitch := func(i int, sw *p2p.Switch) *p2p.Switch { + sw.AddReactor(blobreactor.ReactorName, reactors[i]) + return sw + } + switches := p2p.MakeConnectedSwitches(p2pConfig, n, initSwitch, p2p.Connect2Switches) + + return reactors, switches +} + +func stopSwitches(switches []*p2p.Switch) { + for _, s := range switches { + _ = s.Stop() + } +} + +// Test basic connectivity and just request blobs from a single peer +func TestBlobReactor_BasicRequest(t *testing.T) { + slot := math.Slot(123) + requestingStore := newStubBlobStore() + servingStore := newStubBlobStore() + + // Serving store has blobs + blobs := createTestSidecars(t, 2) + err := servingStore.setBlobs(slot.Unwrap(), blobs) + require.NoError(t, err) + + stores := []*stubBlobStore{requestingStore, servingStore} + configs := []blobreactor.Config{ + {RequestTimeout: 5 * time.Second}, + {RequestTimeout: 5 * time.Second}, + } + + reactors, switches := makeConnectedReactors(t, 2, stores, configs) + defer stopSwitches(switches) + + for _, r := range reactors { + r.SetHeadSlot(slot + 10) + } + + verifier := func(_ datypes.BlobSidecars) error { return nil } + + sidecars, err := reactors[0].RequestBlobs(t.Context(), slot, verifier) + + require.NoError(t, err) + require.NotNil(t, sidecars) + require.Len(t, sidecars, 2) +} + +// Test peer retry when first peer has no blobs and second peer succeeds +func TestBlobReactor_PeerRetry(t *testing.T) { + slot := math.Slot(123) + requestingStore := newStubBlobStore() + unavailableStore := newStubBlobStore() // Empty - will return error + validStore := newStubBlobStore() + + // Only valid store has blobs + validBlobs := createTestSidecars(t, 2) + err := validStore.setBlobs(slot.Unwrap(), validBlobs) + require.NoError(t, err) + + stores := []*stubBlobStore{requestingStore, unavailableStore, validStore} + configs := []blobreactor.Config{ + {RequestTimeout: 5 * time.Second}, + {RequestTimeout: 5 * time.Second}, + {RequestTimeout: 5 * time.Second}, + } + + reactors, switches := makeConnectedReactors(t, 3, stores, configs) + defer stopSwitches(switches) + + // Set head slots so blobs are considered available + for _, r := range reactors { + r.SetHeadSlot(slot + 10) + } + + // Verifier accepts valid blobs + verifier := func(sidecars datypes.BlobSidecars) error { + if len(sidecars) != 2 { + return errors.New("expected 2 blobs") + } + return nil + } + + sidecars, err := reactors[0].RequestBlobs(t.Context(), slot, verifier) + + // Should succeed despite one peer not having blobs + require.NoError(t, err) + require.NotNil(t, sidecars) + require.Len(t, sidecars, 2) +} + +// Test when all peers fail to provide valid blobs +func TestBlobReactor_AllPeersFailed(t *testing.T) { + slot := math.Slot(456) + + // Create stores: requesting (empty), peer (empty - no blobs) + requestingStore := newStubBlobStore() + peerStore := newStubBlobStore() + + stores := []*stubBlobStore{requestingStore, peerStore} + configs := []blobreactor.Config{ + {RequestTimeout: 500 * time.Millisecond}, + {RequestTimeout: 500 * time.Millisecond}, + } + + reactors, switches := makeConnectedReactors(t, 2, stores, configs) + defer stopSwitches(switches) + + for _, r := range reactors { + r.SetHeadSlot(slot + 10) + } + + verifier := func(sidecars datypes.BlobSidecars) error { + if len(sidecars) == 0 { + return errors.New("expected blobs but got none") + } + return nil + } + + sidecars, err := reactors[0].RequestBlobs(t.Context(), slot, verifier) + + require.Error(t, err) + require.ErrorIs(t, err, blobreactor.ErrAllPeersFailed) + require.Nil(t, sidecars) +} + +// Test request timeout when peer responds too slowly +func TestBlobReactor_RequestTimeout(t *testing.T) { + slot := math.Slot(789) + + requestingStore := newStubBlobStore() + slowPeerStore := newSlowStubBlobStore(500 * time.Millisecond) + + stores := []*stubBlobStore{requestingStore, slowPeerStore} + configs := []blobreactor.Config{ + {RequestTimeout: 200 * time.Millisecond}, + {RequestTimeout: 200 * time.Millisecond}, + } + + reactors, switches := makeConnectedReactors(t, 2, stores, configs) + defer stopSwitches(switches) + + for _, r := range reactors { + r.SetHeadSlot(slot + 10) + } + + verifier := func(_ datypes.BlobSidecars) error { + return nil + } + + start := time.Now() + sidecars, err := reactors[0].RequestBlobs(t.Context(), slot, verifier) + elapsed := time.Since(start) + + require.Error(t, err) + require.ErrorIs(t, err, blobreactor.ErrAllPeersFailed) + require.Nil(t, sidecars) + + // Elapsed time should be at least 200ms (request timeout) and less than 500ms + require.Greater(t, elapsed, 190*time.Millisecond, "Should wait for request timeout") + require.Less(t, elapsed, 490*time.Millisecond, "Should timeout before peer responds") +} + +// Test concurrent requests route responses correctly +func TestBlobReactor_ConcurrentRequests(t *testing.T) { + requestingStore := newStubBlobStore() + servingStore := newStubBlobStore() + + // Set up blobs for multiple slots + slot1, slot2, slot3 := math.Slot(123), math.Slot(234), math.Slot(345) + + blobs1 := createTestSidecars(t, 1) + err := servingStore.setBlobs(slot1.Unwrap(), blobs1) + require.NoError(t, err) + + blobs2 := createTestSidecars(t, 1) + err = servingStore.setBlobs(slot2.Unwrap(), blobs2) + require.NoError(t, err) + + blobs3 := createTestSidecars(t, 1) + err = servingStore.setBlobs(slot3.Unwrap(), blobs3) + require.NoError(t, err) + + stores := []*stubBlobStore{requestingStore, servingStore} + configs := []blobreactor.Config{ + {RequestTimeout: 2 * time.Second}, + {RequestTimeout: 2 * time.Second}, + } + + reactors, switches := makeConnectedReactors(t, 2, stores, configs) + defer stopSwitches(switches) + + maxSlot := slot3 + 10 + for _, r := range reactors { + r.SetHeadSlot(maxSlot) + } + + verifier := func(_ datypes.BlobSidecars) error { + return nil + } + + // Start 3 concurrent requests + type requestResult struct { + slot math.Slot + sidecars []*datypes.BlobSidecar + err error + } + results := make(chan requestResult, 3) + + for _, slot := range []math.Slot{slot1, slot2, slot3} { + slot := slot + go func() { + sc, reqErr := reactors[0].RequestBlobs(t.Context(), slot, verifier) + results <- requestResult{slot, sc, reqErr} + }() + } + + // Collect all results + receivedSlots := make(map[math.Slot]bool) + for range 3 { + select { + case result := <-results: + require.NoError(t, result.err, "Request for slot %d failed", result.slot) + require.NotNil(t, result.sidecars) + receivedSlots[result.slot] = true + case <-time.After(5 * time.Second): + t.Fatal("Timeout waiting for concurrent request results") + } + } + + // Verify all requests succeeded with correct slots + require.True(t, receivedSlots[slot1]) + require.True(t, receivedSlots[slot2]) + require.True(t, receivedSlots[slot3]) +} + +// Test that verifier correctly rejects then accepts blobs +func TestBlobReactor_VerifierFunctionality(t *testing.T) { + slot := math.Slot(567) + requestingStore := newStubBlobStore() + servingStore := newStubBlobStore() + + validBlobs := createTestSidecars(t, 2) + err := servingStore.setBlobs(slot.Unwrap(), validBlobs) + require.NoError(t, err) + + stores := []*stubBlobStore{requestingStore, servingStore} + configs := []blobreactor.Config{ + {RequestTimeout: 2 * time.Second}, + {RequestTimeout: 2 * time.Second}, + } + + reactors, switches := makeConnectedReactors(t, 2, stores, configs) + defer stopSwitches(switches) + + for _, r := range reactors { + r.SetHeadSlot(slot + 10) + } + + // First request: verifier rejects all blobs + verifierReject := func(_ datypes.BlobSidecars) error { + return errors.New("verification failed") + } + + sidecars, err := reactors[0].RequestBlobs(t.Context(), slot, verifierReject) + + require.Error(t, err) + require.Nil(t, sidecars) + + // Second request: verifier accepts blobs + verifierAccept := func(sidecars datypes.BlobSidecars) error { + if len(sidecars) != 2 { + return errors.New("expected 2 blobs") + } + return nil + } + + sidecars, err = reactors[0].RequestBlobs(t.Context(), slot, verifierAccept) + + require.NoError(t, err) + require.NotNil(t, sidecars) + require.Len(t, sidecars, 2) +} diff --git a/da/store/store.go b/da/store/store.go index b4f7470b01..cc5890cc90 100644 --- a/da/store/store.go +++ b/da/store/store.go @@ -22,6 +22,7 @@ package store import ( "context" + "fmt" ctypes "github.com/berachain/beacon-kit/consensus-types/types" "github.com/berachain/beacon-kit/da/types" @@ -56,9 +57,16 @@ func (s *Store) IsDataAvailable( slot math.Slot, body *ctypes.BeaconBlockBody, ) bool { - for _, commitment := range body.GetBlobKzgCommitments() { - // Check if the block data is available in the IndexDB - blockData, err := s.IndexDB.Has(slot.Unwrap(), commitment[:]) + // We need to check each commitment with its corresponding index + // Since commitments can be duplicated, we check by index order + for i, commitment := range body.GetBlobKzgCommitments() { + // Validate index is within byte range + if i > 255 { //nolint:mnd // 255 is max value for byte + s.logger.Error("Blob index exceeds maximum value of 255", "index", i) + return false + } + // Check if the block data is available in the IndexDB with the index appended + blockData, err := s.IndexDB.Has(slot.Unwrap(), append(commitment[:], byte(i))) if err != nil || !blockData { return false } @@ -95,13 +103,20 @@ func (s *Store) Persist(sidecars types.BlobSidecars) error { if sidecar == nil { return ErrAttemptedToStoreNilSidecar } + + // Validate index is within byte range to prevent overflow + index := sidecar.GetIndex() + if index > 255 { //nolint:mnd // 255 is max value for byte + return fmt.Errorf("blob index %d exceeds maximum value of 255", index) + } + bz, err := sidecar.MarshalSSZ() if err != nil { return err } slot = sidecar.GetBeaconBlockHeader().GetSlot() - err = s.IndexDB.Set(slot.Unwrap(), sidecar.KzgCommitment[:], bz) - + // Include blob index in the key to prevent overwrites when KZG commitments are duplicated + err = s.IndexDB.Set(slot.Unwrap(), append(sidecar.KzgCommitment[:], byte(index)), bz) if err != nil { return err } diff --git a/go.mod b/go.mod index cfb2c9717d..e55aada94b 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/berachain/beacon-kit go 1.25.3 replace ( - github.com/cometbft/cometbft => github.com/berachain/cometbft v1.0.1-0.20251015081901-8394c619874a - github.com/cometbft/cometbft/api => github.com/berachain/cometbft/api v1.0.1-0.20251015081901-8394c619874a + github.com/cometbft/cometbft => github.com/alesforz/cometbft v0.0.0-20250916204208-b48cac03c4e5 + github.com/cometbft/cometbft/api => github.com/alesforz/cometbft/api v0.0.0-20250916204208-b48cac03c4e5 github.com/cosmos/cosmos-sdk => github.com/cosmos/cosmos-sdk v0.52.0-rc.1 github.com/ethereum/go-ethereum => github.com/berachain/bera-geth v1.16.3-0.20251030205931-33cdc637de2d github.com/karalabe/ssz => github.com/berachain/karalabe-ssz v0.3.0-alpha.0 @@ -24,6 +24,7 @@ require ( github.com/cosmos/cosmos-db v1.1.3 github.com/cosmos/cosmos-sdk v0.53.0 github.com/cosmos/go-bip39 v1.0.0 + github.com/cosmos/gogoproto v1.7.0 github.com/crate-crypto/go-kzg-4844 v1.1.0 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/go-faster/xor v1.0.0 @@ -108,7 +109,6 @@ require ( github.com/cosmos/btcutil v1.0.5 // indirect github.com/cosmos/cosmos-proto v1.0.0-beta.5 // indirect github.com/cosmos/gogogateway v1.2.0 // indirect - github.com/cosmos/gogoproto v1.7.0 // indirect github.com/cosmos/iavl v1.3.4 // indirect github.com/cosmos/ledger-cosmos-go v0.13.3 // indirect github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect diff --git a/go.sum b/go.sum index 7a3a144915..15ce5e04f7 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,10 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/alesforz/cometbft v0.0.0-20250916204208-b48cac03c4e5 h1:vyfqKPX38qlkCP07+Ca6i26WhsabumDFl978lbc1vbc= +github.com/alesforz/cometbft v0.0.0-20250916204208-b48cac03c4e5/go.mod h1:AB3j5W0TQmQSuwRdzWvIBQPAKquFo7VnckwETlAsuwk= +github.com/alesforz/cometbft/api v0.0.0-20250916204208-b48cac03c4e5 h1:n2Uqca+d5yVrBiuqot/Bud22z4rBJdDGc4NYlio4Dtk= +github.com/alesforz/cometbft/api v0.0.0-20250916204208-b48cac03c4e5/go.mod h1:QaK8NCB4rHDs0MdS+L+QOQsL4UM3YJ9OMCNrH+CGdA0= github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156 h1:eMwmnE/GDgah4HI848JfFxHt+iPb26b4zyfspmqY0/8= github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= @@ -78,10 +82,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/berachain/bera-geth v1.16.3-0.20251030205931-33cdc637de2d h1:ikO+o1WPTO5MHmjmI9m34JjI7pahkScjuOzlJaesJV0= github.com/berachain/bera-geth v1.16.3-0.20251030205931-33cdc637de2d/go.mod h1:U8+OCIcIGEScedKpVU89CNwFJWdyDLD/TWolw/Gcj18= -github.com/berachain/cometbft v1.0.1-0.20251015081901-8394c619874a h1:+SbQdFLflXw5Bpg0uPci2Mv37OyFWggx9nKTEumBuAU= -github.com/berachain/cometbft v1.0.1-0.20251015081901-8394c619874a/go.mod h1:AB3j5W0TQmQSuwRdzWvIBQPAKquFo7VnckwETlAsuwk= -github.com/berachain/cometbft/api v1.0.1-0.20251015081901-8394c619874a h1:LZt0HFTZ8Jv2GF2tQiMQWSZD73ciBbWpfLyU9LBlnLc= -github.com/berachain/cometbft/api v1.0.1-0.20251015081901-8394c619874a/go.mod h1:QaK8NCB4rHDs0MdS+L+QOQsL4UM3YJ9OMCNrH+CGdA0= github.com/berachain/karalabe-ssz v0.3.0-alpha.0 h1:SVMU5PSuMB2fgmFTf1rSBY9rEHpQv24DJcqxSrD7jf8= github.com/berachain/karalabe-ssz v0.3.0-alpha.0/go.mod h1:7BZG/jckt43eKw7sl/AF6gTcL0oxgFPme39m54v8rDI= github.com/bgentry/speakeasy v0.2.0 h1:tgObeVOf8WAvtuAX6DhJ4xks4CFNwPDZiqzGqIHE51E= diff --git a/kurtosis/src/services/spamoor/launcher.star b/kurtosis/src/services/spamoor/launcher.star index 6840b58710..ee26c7c8f6 100644 --- a/kurtosis/src/services/spamoor/launcher.star +++ b/kurtosis/src/services/spamoor/launcher.star @@ -20,10 +20,13 @@ def get_config(funding_account, rpc_endpoint): rpc_endpoint, ) + # Add delay to prevent rpc endpoint not being ready + cmd_with_delay = "sleep 30 && " + blob_cmd + return ServiceConfig( image = IMAGE_NAME, entrypoint = ENTRYPOINT_ARGS, - cmd = [blob_cmd], + cmd = [cmd_with_delay], min_cpu = MIN_CPU, max_cpu = MAX_CPU, min_memory = MIN_MEMORY, diff --git a/node-api/handlers/beacon/blobs.go b/node-api/handlers/beacon/blobs.go index 6d07c01f9d..0a78c698e0 100644 --- a/node-api/handlers/beacon/blobs.go +++ b/node-api/handlers/beacon/blobs.go @@ -26,6 +26,7 @@ import ( "github.com/berachain/beacon-kit/node-api/handlers" "github.com/berachain/beacon-kit/node-api/handlers/beacon/types" + handlertypes "github.com/berachain/beacon-kit/node-api/handlers/types" "github.com/berachain/beacon-kit/node-api/handlers/utils" "github.com/berachain/beacon-kit/primitives/math" ) @@ -93,6 +94,18 @@ func (h *Handler) GetBlobSidecars(c handlers.Context) (any, error) { return nil, err } + // If no blobs found, check if we expect blobs for this slot. If so, it means that + // we are still waiting for the blobs to be fetched via BlobReactor. + if len(blobSidecars) == 0 { + st, _, stateErr := h.backend.StateAndSlotFromHeight(int64(slot)) //#nosec: G115 // practically safe + if stateErr == nil { + payloadHeader, headerErr := st.GetLatestExecutionPayloadHeader() + if headerErr == nil && payloadHeader.GetBlobGasUsed() > 0 { + return nil, fmt.Errorf("blobs pending: %w", handlertypes.ErrNotFound) + } + } + } + // Create a map of requested indices for O(1) index lookups. isRequestIndex := make(map[uint64]bool) for _, idx := range indices { diff --git a/node-api/handlers/beacon/validators.go b/node-api/handlers/beacon/validators.go index 1faec6c387..2c55ff065d 100644 --- a/node-api/handlers/beacon/validators.go +++ b/node-api/handlers/beacon/validators.go @@ -117,7 +117,12 @@ func (h *Handler) getValidator(height int64, validatorID string) (*beacontypes.V balance, err := st.GetBalance(index) if err != nil { - return nil, fmt.Errorf("failed to get validator balance for validator pubkey %s and index %d: %w", validator.GetPubkey(), index, err) + return nil, fmt.Errorf( + "failed to get validator balance for validator pubkey %s and index %d: %w", + validator.GetPubkey(), + index, + err, + ) } status, err := validator.Status(h.cs.SlotToEpoch(resolvedSlot)) if err != nil { diff --git a/node-core/components/blob_fetcher.go b/node-core/components/blob_fetcher.go new file mode 100644 index 0000000000..b23e2b55dd --- /dev/null +++ b/node-core/components/blob_fetcher.go @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +package components + +import ( + "path/filepath" + + "cosmossdk.io/depinject" + "github.com/berachain/beacon-kit/beacon/blockchain" + "github.com/berachain/beacon-kit/chain" + "github.com/berachain/beacon-kit/config" + "github.com/berachain/beacon-kit/da/blobreactor" + "github.com/berachain/beacon-kit/log/phuslu" + "github.com/berachain/beacon-kit/node-core/components/metrics" + "github.com/berachain/beacon-kit/node-core/components/storage" + "github.com/cosmos/cosmos-sdk/client/flags" + "github.com/spf13/cast" +) + +// BlobFetcherInput is the input for the ProvideBlobFetcher function. +type BlobFetcherInput struct { + depinject.In + + BlobProcessor BlobProcessor + BlobReactor *blobreactor.BlobReactor + ChainSpec chain.Spec + Logger *phuslu.Logger + StorageBackend *storage.Backend + TelemetrySink *metrics.TelemetrySink + AppOpts config.AppOptions +} + +// ProvideBlobFetcher provides the blob fetcher for asynchronous blob retrieval. +func ProvideBlobFetcher(in BlobFetcherInput) (blockchain.BlobFetcher, error) { + return blockchain.NewBlobFetcher( + filepath.Join(cast.ToString(in.AppOpts.Get(flags.FlagHome)), "data"), + in.Logger.With("service", "blob-fetcher"), + in.BlobProcessor, + in.BlobReactor, + in.StorageBackend, + in.ChainSpec, + blockchain.DefaultBlobFetcherConfig(), + in.TelemetrySink, + ) +} diff --git a/node-core/components/blob_reactor.go b/node-core/components/blob_reactor.go new file mode 100644 index 0000000000..9e69c745b9 --- /dev/null +++ b/node-core/components/blob_reactor.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +package components + +import ( + "cosmossdk.io/depinject" + "github.com/berachain/beacon-kit/config" + "github.com/berachain/beacon-kit/da/blobreactor" + "github.com/berachain/beacon-kit/log/phuslu" + "github.com/berachain/beacon-kit/node-core/components/metrics" + "github.com/berachain/beacon-kit/node-core/components/storage" +) + +// BlobReactorInput is the input for the ProvideBlobReactor function. +type BlobReactorInput struct { + depinject.In + + Config *config.Config + Logger *phuslu.Logger + StorageBackend *storage.Backend + TelemetrySink *metrics.TelemetrySink +} + +// ProvideBlobReactor provides the blob reactor for P2P communication. +func ProvideBlobReactor(in BlobReactorInput) *blobreactor.BlobReactor { + cfg := in.Config.BlobReactor + + // Guard against missing config fields - apply defaults for zero values + if cfg.RequestTimeout == 0 { + cfg.RequestTimeout = blobreactor.DefaultConfig().RequestTimeout + } + + return blobreactor.NewBlobReactor( + in.StorageBackend.AvailabilityStore().IndexDB, + in.Logger.With("service", "blob-reactor"), + cfg, + in.TelemetrySink, + ) +} diff --git a/node-core/components/chain_service.go b/node-core/components/chain_service.go index 7581c93d24..63beb4888a 100644 --- a/node-core/components/chain_service.go +++ b/node-core/components/chain_service.go @@ -42,6 +42,7 @@ type ChainServiceInput struct { StateProcessor StateProcessor StorageBackend *storage.Backend BlobProcessor BlobProcessor + BlobFetcher blockchain.BlobFetcher TelemetrySink *metrics.TelemetrySink BeaconDepositContract deposit.Contract } @@ -51,6 +52,7 @@ func ProvideChainService(in ChainServiceInput) *blockchain.Service { return blockchain.NewService( in.StorageBackend, in.BlobProcessor, + in.BlobFetcher, in.BeaconDepositContract, in.Logger.With("service", "blockchain"), in.ChainSpec, diff --git a/node-core/components/cometbft_service.go b/node-core/components/cometbft_service.go index 3dce738714..017f85620b 100644 --- a/node-core/components/cometbft_service.go +++ b/node-core/components/cometbft_service.go @@ -26,6 +26,7 @@ import ( "github.com/berachain/beacon-kit/chain" "github.com/berachain/beacon-kit/config" cometbft "github.com/berachain/beacon-kit/consensus/cometbft/service" + "github.com/berachain/beacon-kit/da/blobreactor" "github.com/berachain/beacon-kit/log/phuslu" "github.com/berachain/beacon-kit/node-core/builder" "github.com/berachain/beacon-kit/node-core/components/metrics" @@ -38,6 +39,7 @@ func ProvideCometBFTService( logger *phuslu.Logger, blockchain blockchain.BlockchainI, blockBuilder validator.BlockBuilderI, + blobReactor *blobreactor.BlobReactor, db dbm.DB, cs chain.Spec, cmtCfg *cmtcfg.Config, @@ -49,6 +51,7 @@ func ProvideCometBFTService( db, blockchain, blockBuilder, + blobReactor, cs, cmtCfg, telemetrySink, diff --git a/testing/e2e/config/defaults.go b/testing/e2e/config/defaults.go index 7742273416..e4b3055037 100644 --- a/testing/e2e/config/defaults.go +++ b/testing/e2e/config/defaults.go @@ -34,6 +34,13 @@ const ( ClientValidator2 = "cl-validator-beaconkit-2" ClientValidator3 = "cl-validator-beaconkit-3" ClientValidator4 = "cl-validator-beaconkit-4" + + NumFullNodes = 4 + + ClientFullNode0 = "cl-full-beaconkit-0" + ClientFullNode1 = "cl-full-beaconkit-1" + ClientFullNode2 = "cl-full-beaconkit-2" + ClientFullNode3 = "cl-full-beaconkit-3" ) // DefaultE2ETestConfig provides a default configuration for end-to-end tests, diff --git a/testing/e2e/e2e_blob_sync_test.go b/testing/e2e/e2e_blob_sync_test.go new file mode 100644 index 0000000000..1525a8e936 --- /dev/null +++ b/testing/e2e/e2e_blob_sync_test.go @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +package e2e_test + +import ( + "bytes" + "context" + "encoding/binary" + "math/big" + "time" + + "github.com/attestantio/go-eth2-client/api" + "github.com/berachain/beacon-kit/testing/e2e/config" + "github.com/berachain/beacon-kit/testing/e2e/suite" + "github.com/berachain/beacon-kit/testing/e2e/suite/types/tx" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + coretypes "github.com/ethereum/go-ethereum/core/types" +) + +const ( + // NumBlocksWithBlobs is the number of blocks with blob transactions to create before restarting the syncing node. + NumBlocksWithBlobs = 10 +) + +// TestBlobSync validates that a node can sync from behind and fetch blobs from other peers via the blob reactor. +// This test does the following steps: +// 1. Stop a full node to simulate it being offline +// 2. Produce several blocks with blob transactions while the node is down +// 3. Restart the full node so it needs to catch up +// 4. Verify that the node successfully fetches blobs from peers via P2P +func (s *BeaconKitE2ESuite) TestBlobSync() { + ctx, cancel := context.WithTimeout(s.Ctx(), suite.DefaultE2ETestTimeout) + defer cancel() + + // We use full node 0 for this test + fullNodeELService := "el-full-reth-0" + fullNodeCLService := config.ClientFullNode0 + + // 1. Stop a full node to simulate it being offline + // + s.Logger().Info("Stopping full node to simulate being offline", "service", fullNodeCLService) + err := s.StopService(ctx, fullNodeCLService) + s.Require().NoError(err, "failed to stop full node consensus client") + err = s.StopService(ctx, fullNodeELService) + s.Require().NoError(err, "failed to stop full node execution client") + s.Logger().Info("Full node stopped, now producing blocks with blobs while it's offline") + + // Set up connection to a validator's consensus client to produce blocks with blobs + // while the full node is offline. Lets use validator 0 for this. + // + client0 := s.ConsensusClients()[config.ClientValidator0] + s.Require().NotNil(client0) + s.Require().NoError(client0.Connect(ctx)) + + // Get initial block number before submitting blob transactions + initialBlockNum, err := s.JSONRPCBalancer().BlockNumber(ctx) + s.Require().NoError(err) + s.Logger().Info("Initial block number", "block", initialBlockNum) + + // Prepare transaction parameters + sender := s.TestAccounts()[0] + chainID, err := s.JSONRPCBalancer().ChainID(ctx) + s.Require().NoError(err) + tip, err := s.JSONRPCBalancer().SuggestGasTipCap(ctx) + s.Require().NoError(err) + gasFee, err := s.JSONRPCBalancer().SuggestGasPrice(ctx) + s.Require().NoError(err) + nonce, err := s.JSONRPCBalancer().NonceAt(ctx, sender.Address(), new(big.Int).SetUint64(initialBlockNum)) + s.Require().NoError(err) + + // 2. Produce several blocks with blob transactions while the node is down + // + var ( + blobTxs = make([]*coretypes.Transaction, 0) + receipts = make([]*coretypes.Receipt, 0) + currentNonce = nonce + lastBlobBlockNum uint64 + ) + for blockIdx := range NumBlocksWithBlobs { + // Each block can have 1-6 blob sidecars + numBlobsInBlock := uint64((blockIdx % 6) + 1) + + s.Logger().Info("Creating block with blobs", "block_index", blockIdx, "num_blobs", numBlobsInBlock) + for blobIdx := range numBlobsInBlock { + // Create unique blob data for each transaction + blobData := make([]byte, 8) + binary.LittleEndian.PutUint64(blobData, currentNonce) + + // Craft blob-carrying transaction + blobTx := tx.New4844Tx( + currentNonce, nil, 1000000, + chainID, tip, gasFee, big.NewInt(0), + []byte{0x01, 0x02, 0x03, 0x04}, + big.NewInt(1), blobData, + coretypes.AccessList{}, + ) + + // Sign and submit the transaction + blobTx, err = sender.SignTx(chainID, blobTx) + s.Require().NoError(err) + s.Logger().Info("Submitting blob transaction", + "tx_hash", blobTx.Hash().Hex(), + "nonce", currentNonce, + "block_index", blockIdx, + "blob_index", blobIdx) + + err = s.JSONRPCBalancer().SendTransaction(ctx, blobTx) + s.Require().NoError(err) + blobTxs = append(blobTxs, blobTx) + + // Wait for this transaction to be mined + receipt, errWait := bind.WaitMined(ctx, s.JSONRPCBalancer(), blobTx) + s.Require().NoError(errWait) + s.Require().Equal(coretypes.ReceiptStatusSuccessful, receipt.Status) + receipts = append(receipts, receipt) + + // Track the highest block number + if receipt.BlockNumber.Uint64() > lastBlobBlockNum { + lastBlobBlockNum = receipt.BlockNumber.Uint64() + } + + s.Logger().Info("Blob transaction mined", + "tx_hash", blobTx.Hash().Hex(), + "block", receipt.BlockNumber.Uint64(), + "block_index", blockIdx, + "blob_index", blobIdx) + + currentNonce++ + } + } + + // 3. Restart the full node so it needs to catch up + // + err = s.StartService(ctx, fullNodeCLService) + s.Require().NoError(err, "failed to start full node consensus client") + err = s.StartService(ctx, fullNodeELService) + s.Require().NoError(err, "failed to start full node execution client") + s.Logger().Info("Full node restarted, waiting for it to sync to last blob block...", "last_blob_block", lastBlobBlockNum) + s.Require().NoError(s.WaitForFinalizedBlockNumber(lastBlobBlockNum)) + + // After catching up, the full node may need to wait a bit more for the blob fetcher to detect missing blobs + s.Logger().Info("Waiting for blob fetcher to process queued blob requests...") + time.Sleep(20 * time.Second) + + // 4. Verify that the node successfully fetches blobs from peers via P2P + // + s.Logger().Info("Setting up full node consensus client to verify blob sync...") + err = s.SetupFullNodeConsensusClients() + s.Require().NoError(err, "failed to setup full node consensus clients") + fullNodeClient := s.FullNodeClients()[fullNodeCLService] + s.Require().NotNil(fullNodeClient, "full node consensus client is nil") + s.Logger().Info("Connecting to full node's consensus client...") + err = fullNodeClient.Connect(ctx) + s.Require().NoError(err, "failed to connect to full node consensus client") + + // Verify all blobs are accessible from the full node's node-api + for i, receipt := range receipts { + s.Logger().Info("Verifying blob availability on full node", + "block", receipt.BlockNumber.Uint64(), + "tx_index", i, + "full_node", fullNodeCLService) + + // Fetch blob sidecars from the full node's node-api + response, errAPI := fullNodeClient.BlobSidecars(ctx, &api.BlobSidecarsOpts{Block: receipt.BlockNumber.String()}) + s.Require().NoError(errAPI, "failed to fetch blob sidecars from full node for block %s", receipt.BlockNumber.String()) + s.Require().NotNil(response) + s.Require().NotEmpty(response.Data, "no blob sidecars found on full node for block %s", receipt.BlockNumber.String()) + + // Verify each blob commitment matches what was originally submitted + sidecar := blobTxs[i].BlobTxSidecar() + s.Require().NotNil(sidecar, "blob transaction %d missing sidecar", i) + for j, commitment := range sidecar.Commitments { + found := false + for _, blob := range response.Data { + if bytes.Equal(blob.KZGCommitment[:], commitment[:]) { + s.Require().Equal(sidecar.Blobs[j][:], blob.Blob[:], "blob data mismatch on full node for tx %d blob %d", i, j) + found = true + break + } + } + s.Require().True(found, "blob commitment not found on full node for tx %d blob %d", i, j) + } + + s.Logger().Info("Blob verified successfully on full node", + "block", receipt.BlockNumber.Uint64(), + "tx_hash", blobTxs[i].Hash().Hex(), + "num_blobs", len(sidecar.Commitments)) + } + + s.Logger().Info("Blob sync test completed successfully - full node fetched all blobs via P2P", + "blocks_with_blobs", NumBlocksWithBlobs, + "total_transactions", len(receipts), + "full_node_synced", fullNodeCLService) +} diff --git a/testing/e2e/e2e_inflation_test.go b/testing/e2e/e2e_inflation_test.go index 48b0e41355..9de5d330e3 100644 --- a/testing/e2e/e2e_inflation_test.go +++ b/testing/e2e/e2e_inflation_test.go @@ -38,6 +38,10 @@ func (s *BeaconKitE2ESuite) TestEVMInflation() { chainspec, err := spec.DevnetChainSpec() s.Require().NoError(err) + // Get the current finalized block to start from (in case other tests ran first) + startBlock, err := s.JSONRPCBalancer().BlockNumber(s.Ctx()) + s.Require().NoError(err) + var ( inflationPerBlock uint64 inflationAddress common.ExecutionAddress @@ -49,8 +53,9 @@ func (s *BeaconKitE2ESuite) TestEVMInflation() { forkSlot int64 onceOnFork sync.Once ) - // Arbitrarily run test for 2 epochs. - for blkNum := range int64(2 * chainspec.SlotsPerEpoch()) { + // Arbitrarily run test for 2 epochs from the current block. + for i := range int64(2 * chainspec.SlotsPerEpoch()) { + blkNum := int64(startBlock) + i err = s.WaitForFinalizedBlockNumber(uint64(blkNum)) s.Require().NoError(err) payload, errBlk := s.JSONRPCBalancer().BlockByNumber(s.Ctx(), big.NewInt(blkNum)) diff --git a/testing/e2e/suite/node_control.go b/testing/e2e/suite/node_control.go new file mode 100644 index 0000000000..4c631eb220 --- /dev/null +++ b/testing/e2e/suite/node_control.go @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +package suite + +import ( + "context" + "fmt" + + "github.com/kurtosis-tech/kurtosis/api/golang/core/lib/starlark_run_config" +) + +// StopService stops a running service in the Kurtosis enclave. +func (s *KurtosisE2ESuite) StopService(ctx context.Context, serviceName string) error { + s.logger.Info("Stopping service", "service", serviceName) + + script := fmt.Sprintf(` +def run(plan): + plan.stop_service("%s") +`, serviceName) + + result, err := s.enclave.RunStarlarkScriptBlocking(ctx, script, starlark_run_config.NewRunStarlarkConfig()) + if err != nil { + return fmt.Errorf("failed to stop service %s: %w", serviceName, err) + } + + if result.ExecutionError != nil { + return fmt.Errorf("error stopping service %s: %s", serviceName, result.ExecutionError.String()) + } + + if len(result.ValidationErrors) > 0 { + return fmt.Errorf("validation error stopping service %s: %s", serviceName, result.ValidationErrors[0].String()) + } + + s.logger.Info("Service stopped successfully", "service", serviceName) + return nil +} + +// StartService starts a stopped service in the Kurtosis enclave. +func (s *KurtosisE2ESuite) StartService(ctx context.Context, serviceName string) error { + s.logger.Info("Starting service", "service", serviceName) + + script := fmt.Sprintf(` +def run(plan): + plan.start_service("%s") +`, serviceName) + + result, err := s.enclave.RunStarlarkScriptBlocking(ctx, script, starlark_run_config.NewRunStarlarkConfig()) + if err != nil { + return fmt.Errorf("failed to start service %s: %w", serviceName, err) + } + + if result.ExecutionError != nil { + return fmt.Errorf("error starting service %s: %s", serviceName, result.ExecutionError.String()) + } + + if len(result.ValidationErrors) > 0 { + return fmt.Errorf("validation error starting service %s: %s", serviceName, result.ValidationErrors[0].String()) + } + + s.logger.Info("Service started successfully", "service", serviceName) + return nil +} + +// RestartService stops and then starts a service. +func (s *KurtosisE2ESuite) RestartService(ctx context.Context, serviceName string) error { + if err := s.StopService(ctx, serviceName); err != nil { + return err + } + + return s.StartService(ctx, serviceName) +} diff --git a/testing/e2e/suite/setup.go b/testing/e2e/suite/setup.go index 6bca983727..664ac3b577 100644 --- a/testing/e2e/suite/setup.go +++ b/testing/e2e/suite/setup.go @@ -139,8 +139,6 @@ func (s *KurtosisE2ESuite) SetupSuiteWithOptions(opts ...Option) { } // SetupConsensusClients sets up the consensus clients for the validator nodes. -// -// TODO: set up consensus clients for full nodes as well. func (s *KurtosisE2ESuite) SetupConsensusClients() error { s.consensusClients = make(map[string]*types.ConsensusClient, config.NumValidators) @@ -188,6 +186,49 @@ func (s *KurtosisE2ESuite) SetupConsensusClients() error { return nil } +// SetupFullNodeConsensusClients sets up consensus clients for full nodes. +func (s *KurtosisE2ESuite) SetupFullNodeConsensusClients() error { + s.fullNodeClients = make(map[string]*types.ConsensusClient, config.NumFullNodes) + + var ( + sCtx *services.ServiceContext + res *enclaves.StarlarkRunResult + err error + ) + for i := range config.NumFullNodes { + var clientName string + //nolint:mnd // its okay. + switch i % config.NumFullNodes { + case 0: + clientName = config.ClientFullNode0 + case 1: + clientName = config.ClientFullNode1 + case 2: + clientName = config.ClientFullNode2 + case 3: + clientName = config.ClientFullNode3 + } + sCtx, err = s.Enclave().GetServiceContext(clientName) + if err != nil { + return err + } + + wrappedCtx := types.NewWrappedServiceContext(sCtx, s.Enclave().RunStarlarkScriptBlocking) + s.fullNodeClients[clientName] = types.NewConsensusClient(wrappedCtx) + if res, err = s.fullNodeClients[clientName].Start(context.Background(), s.Enclave()); err != nil { + return err + } + if res.ExecutionError != nil { + return errors.New(res.ExecutionError.String()) + } + if len(res.ValidationErrors) > 0 { + return errors.New(res.ValidationErrors[0].String()) + } + } + + return nil +} + // SetupJSONRPCBalancer sets up the load balancer for the test suite. // // TODO: set up execution clients for all validators and full nodes. @@ -392,6 +433,12 @@ func (s *KurtosisE2ESuite) TearDownSuite() { s.Require().Nil(res.ExecutionError, "Error stopping consensus client") s.Require().Empty(res.ValidationErrors, "Error stopping consensus client") } + for _, client := range s.fullNodeClients { + res, err := client.Stop(s.ctx) + s.Require().NoError(err, "Error stopping full node client") + s.Require().Nil(res.ExecutionError, "Error stopping full node client") + s.Require().Empty(res.ValidationErrors, "Error stopping full node client") + } s.Require().NoError(s.kCtx.DestroyEnclave(s.ctx, "e2e-test-enclave")) } diff --git a/testing/e2e/suite/suite.go b/testing/e2e/suite/suite.go index e14f8e6681..fd5f0e957a 100644 --- a/testing/e2e/suite/suite.go +++ b/testing/e2e/suite/suite.go @@ -47,6 +47,7 @@ type KurtosisE2ESuite struct { enclave *enclaves.EnclaveContext consensusClients map[string]*types.ConsensusClient + fullNodeClients map[string]*types.ConsensusClient // executionClients map[string]*types.ExecutionClient // TODO: enable. loadBalancer *types.LoadBalancer @@ -59,6 +60,11 @@ func (s *KurtosisE2ESuite) ConsensusClients() map[string]*types.ConsensusClient return s.consensusClients } +// FullNodeClients returns the full node consensus clients associated with the KurtosisE2ESuite. +func (s *KurtosisE2ESuite) FullNodeClients() map[string]*types.ConsensusClient { + return s.fullNodeClients +} + // Ctx returns the context associated with the KurtosisE2ESuite. // This context is used throughout the suite to control the flow of operations, // including timeouts and cancellations. diff --git a/testing/files/spec.toml b/testing/files/spec.toml index fa46e9559d..d0618e4301 100644 --- a/testing/files/spec.toml +++ b/testing/files/spec.toml @@ -73,3 +73,8 @@ target-block-time = 2_000_000_000 const-block-delay = 500_000_000 consensus-update-height = 1 consensus-enable-height = 2 + +[blob-reactor-configuration] +consensus-update-height = 1 +consensus-enable-height = 2 +max-bytes = 819200 diff --git a/testing/networks/80069/spec.toml b/testing/networks/80069/spec.toml index 8cfed4cda2..ece098942a 100644 --- a/testing/networks/80069/spec.toml +++ b/testing/networks/80069/spec.toml @@ -73,3 +73,8 @@ target-block-time = 2_000_000_000 const-block-delay = 500_000_000 consensus-update-height = 7_768_334 consensus-enable-height = 7_768_335 + +[blob-reactor-configuration] +consensus-update-height = 0 +consensus-enable-height = 9_223_372_036_854_775_807 +max-bytes = 819200 diff --git a/testing/networks/80094/spec.toml b/testing/networks/80094/spec.toml index c436a4854a..263f2a625c 100644 --- a/testing/networks/80094/spec.toml +++ b/testing/networks/80094/spec.toml @@ -72,4 +72,9 @@ max-block-delay = 300_000_000_000 target-block-time = 2_000_000_000 const-block-delay = 500_000_000 consensus-update-height = 9_983_085 -consensus-enable-height = 9_983_086 \ No newline at end of file +consensus-enable-height = 9_983_086 + +[blob-reactor-configuration] +consensus-update-height = 0 +consensus-enable-height = 9_223_372_036_854_775_807 +max-bytes = 819200 diff --git a/testing/simulated/blob_fetcher_integration_test.go b/testing/simulated/blob_fetcher_integration_test.go new file mode 100644 index 0000000000..1af4695645 --- /dev/null +++ b/testing/simulated/blob_fetcher_integration_test.go @@ -0,0 +1,230 @@ +//go:build simulated + +// SPDX-License-Identifier: BUSL-1.1 +// +// Copyright (C) 2025, Berachain Foundation. All rights reserved. +// Use of this software is governed by the Business Source License included +// in the LICENSE file of this repository and at www.mariadb.com/bsl11. +// +// ANY USE OF THE LICENSED WORK IN VIOLATION OF THIS LICENSE WILL AUTOMATICALLY +// TERMINATE YOUR RIGHTS UNDER THIS LICENSE FOR THE CURRENT AND ALL OTHER +// VERSIONS OF THE LICENSED WORK. +// +// THIS LICENSE DOES NOT GRANT YOU ANY RIGHT IN ANY TRADEMARK OR LOGO OF +// LICENSOR OR ITS AFFILIATES (PROVIDED THAT YOU MAY USE A TRADEMARK OR LOGO OF +// LICENSOR AS EXPRESSLY REQUIRED BY THIS LICENSE). +// +// TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +// AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +// EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +// TITLE. + +package simulated_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "cosmossdk.io/log" + "github.com/berachain/beacon-kit/beacon/blockchain" + "github.com/berachain/beacon-kit/beacon/blockchain/testhelpers" + ctypes "github.com/berachain/beacon-kit/consensus-types/types" + dablob "github.com/berachain/beacon-kit/da/blob" + "github.com/berachain/beacon-kit/da/blobreactor" + dastore "github.com/berachain/beacon-kit/da/store" + datypes "github.com/berachain/beacon-kit/da/types" + "github.com/berachain/beacon-kit/node-core/components/metrics" + "github.com/berachain/beacon-kit/primitives/common" + "github.com/berachain/beacon-kit/primitives/crypto" + "github.com/berachain/beacon-kit/primitives/eip4844" + "github.com/berachain/beacon-kit/primitives/math" + "github.com/berachain/beacon-kit/primitives/version" + "github.com/berachain/beacon-kit/storage/filedb" + "github.com/berachain/beacon-kit/testing/simulated" + cmtconfig "github.com/cometbft/cometbft/config" + "github.com/cometbft/cometbft/p2p" + "github.com/stretchr/testify/require" +) + +// TestBlobFetcher_MultiNodeFetch tests Node1 fetching blobs from Node2 via P2P blob reactor +func (s *SimulatedSuite) TestBlobFetcher_MultiNodeFetch() { + // Initialize the chain state. + s.InitializeChain(s.T()) + + // Move chain forward one block + nodeAddress, err := s.SimComet.GetNodeAddress() + s.Require().NoError(err) + startTime := time.Now() + _, _, _ = s.MoveChainToHeight(s.T(), 1, 1, nodeAddress, startTime) + + // Create test blobs + testSlot := math.Slot(100) + blobs := []*eip4844.Blob{{1, 2, 3}, {4, 5, 6}} + + // Create sidecars, block, and commitments + sidecars, block, commitments := createTestSidecars(s.T(), s, blobs, testSlot) + s.Require().Len(sidecars, 2) + s.Require().Len(commitments, 2) + + // Setup two nodes: Node1 (empty) and Node2 (has blobs) + node1HomeDir := filepath.Join(os.TempDir(), "node1_multinode_test") + node2HomeDir := filepath.Join(os.TempDir(), "node2_multinode_test") + defer os.RemoveAll(node1HomeDir) + defer os.RemoveAll(node2HomeDir) + + node1Store := createBlobStore(node1HomeDir) + node2Store := createBlobStore(node2HomeDir) + s.Require().NoError(node2Store.Persist(sidecars)) + + node1Reactor := blobreactor.NewBlobReactor( + node1Store, + log.NewNopLogger(), + blobreactor.Config{RequestTimeout: 5 * time.Second}, + metrics.NewNoOpTelemetrySink(), + ) + node2Reactor := blobreactor.NewBlobReactor( + node2Store, + log.NewNopLogger(), + blobreactor.Config{RequestTimeout: 5 * time.Second}, + metrics.NewNoOpTelemetrySink(), + ) + + // Connect via P2P + switches := setupP2PReactors([]*blobreactor.BlobReactor{node1Reactor, node2Reactor}) + defer func() { + for _, sw := range switches { + _ = sw.Stop() + } + }() + + // Create and start Node1's blob fetcher + node1Fetcher, err := blockchain.NewBlobFetcher( + filepath.Join(node1HomeDir, "data"), + log.NewNopLogger(), + s.TestNode.BlobProcessor, + node1Reactor, + testhelpers.NewSimpleStorageBackend(node1Store), + s.TestNode.ChainSpec, + blockchain.BlobFetcherConfig{ + CheckInterval: 100 * time.Millisecond, + RetryInterval: 200 * time.Millisecond, + MaxRetries: 3, + }, + metrics.NewNoOpTelemetrySink(), + ) + s.Require().NoError(err) + node1Fetcher.Start(s.CtxApp) + + // Set head slots on both nodes (within DA period) + node1Reactor.SetHeadSlot(testSlot + 10) + node2Reactor.SetHeadSlot(testSlot + 10) + node1Fetcher.SetHeadSlot(testSlot + 10) + + // Queue blob request, wait for it to be downloaded and validate + s.Require().NoError(node1Fetcher.QueueBlobRequest(block)) + time.Sleep(500 * time.Millisecond) + storedSidecars, err := node1Store.GetBlobSidecars(testSlot) + s.Require().NoError(err) + s.Require().Len(storedSidecars, 2) + verifyBlobCommitments(s.T(), storedSidecars, commitments) + s.Require().True(assertQueueLength(node1HomeDir, 0), "queue should be empty after successful fetch") + + node1Fetcher.Stop() +} + +// Helper to check queue state +func assertQueueLength(homeDir string, expected int) bool { + queueDir := filepath.Join(homeDir, "data", "blobs", "download_queue") + files, err := os.ReadDir(queueDir) + if err != nil { + return expected == 0 + } + + jsonFiles := 0 + for _, f := range files { + if strings.HasSuffix(f.Name(), ".json") { + jsonFiles++ + } + } + return jsonFiles == expected +} + +// Helper to create test sidecars with minimal viable data for testing +func createTestSidecars(t *testing.T, s *SimulatedSuite, blobs []*eip4844.Blob, slot math.Slot) ( + datypes.BlobSidecars, *ctypes.BeaconBlock, []eip4844.KZGCommitment, +) { + proofs, commitments := simulated.GetProofAndCommitmentsForBlobs(s.Require(), blobs, s.TestNode.KZGVerifier) + + block, err := ctypes.NewBeaconBlockWithVersion(slot, 0, common.Root{}, version.Deneb()) + s.Require().NoError(err) + block.Body.SetBlobKzgCommitments( + eip4844.KZGCommitments[common.ExecutionHash](commitments), + ) + signedHeader := ctypes.NewSignedBeaconBlockHeader(block.GetHeader(), crypto.BLSSignature{}) + + sidecarFactory := dablob.NewSidecarFactory(metrics.NewNoOpTelemetrySink()) + sidecars := make(datypes.BlobSidecars, len(blobs)) + for i := range blobs { + inclusionProof, err := sidecarFactory.BuildKZGInclusionProof(block.Body, math.U64(i)) + s.Require().NoError(err) + + sidecars[i] = &datypes.BlobSidecar{ + Index: uint64(i), + Blob: *blobs[i], + KzgCommitment: commitments[i], + KzgProof: proofs[i], + SignedBeaconBlockHeader: signedHeader, + InclusionProof: inclusionProof, + } + } + + return sidecars, block, commitments +} + +// Helper to create a blob availability store for testing +func createBlobStore(homeDir string) *dastore.Store { + return dastore.New( + filedb.NewRangeDB( + filedb.NewDB( + filedb.WithRootDirectory(filepath.Join(homeDir, "data", "blobs")), + filedb.WithFileExtension("ssz"), + filedb.WithDirectoryPermissions(os.ModePerm), + filedb.WithLogger(log.NewNopLogger()), + ), + ), + log.NewNopLogger(), + ) +} + +// Helper to setup P2P connected reactors +func setupP2PReactors(reactors []*blobreactor.BlobReactor) []*p2p.Switch { + p2pConfig := cmtconfig.DefaultP2PConfig() + p2pConfig.ListenAddress = "tcp://127.0.0.1:0" + + initSwitch := func(i int, sw *p2p.Switch) *p2p.Switch { + sw.AddReactor(blobreactor.ReactorName, reactors[i]) + return sw + } + return p2p.MakeConnectedSwitches(p2pConfig, len(reactors), initSwitch, p2p.Connect2Switches) +} + +// Helper to verify blobs match expected commitments +func verifyBlobCommitments(t *testing.T, sidecars datypes.BlobSidecars, expectedCommitments []eip4844.KZGCommitment) { + t.Helper() + require := require.New(t) + + sidecarsByIndex := make(map[uint64]*datypes.BlobSidecar) + for _, sidecar := range sidecars { + sidecarsByIndex[sidecar.Index] = sidecar + } + + for i, expectedCommitment := range expectedCommitments { + sidecar, exists := sidecarsByIndex[uint64(i)] + require.True(exists, "sidecar index %d should exist", i) + require.Equal(expectedCommitment, sidecar.KzgCommitment, "commitment mismatch at index %d", i) + } +} diff --git a/testing/simulated/components.go b/testing/simulated/components.go index 6265c17a85..47f27dddf9 100644 --- a/testing/simulated/components.go +++ b/testing/simulated/components.go @@ -40,6 +40,8 @@ func FixedComponents(t *testing.T) []any { components.ProvideBlsSigner, components.ProvideBlobProcessor, components.ProvideBlobProofVerifier, + components.ProvideBlobReactor, + components.ProvideBlobFetcher, components.ProvideChainService, components.ProvideNode, components.ProvideConfig, diff --git a/testing/simulated/malicious_proposer_test.go b/testing/simulated/malicious_proposer_test.go index b47238b04e..3222807c9e 100644 --- a/testing/simulated/malicious_proposer_test.go +++ b/testing/simulated/malicious_proposer_test.go @@ -557,7 +557,7 @@ func testBuildInvalidBlock( pubkey, err := blsSigner.GetPubKey() r.NoError(err) - signedBlk, sidecars, err := builder.SimComet.Comet.Blockchain.ParseBeaconBlock( + signedBlk, sidecars, err := builder.SimComet.Comet.Blockchain.ParseProcessProposalRequest( &types.ProcessProposalRequest{ Txs: PrepReq.Txs, Height: PrepReq.Height, diff --git a/testing/simulated/pectra_fork_test.go b/testing/simulated/pectra_fork_test.go index b26fe4de32..9b9b9966a6 100644 --- a/testing/simulated/pectra_fork_test.go +++ b/testing/simulated/pectra_fork_test.go @@ -91,7 +91,7 @@ func (s *PectraForkSuite) SetupTest() { s.Geth.ElHandle = elHandle rethNode := execution.NewRethNode(s.Reth.HomeDir, execution.ValidRethImage()) - rethHandle, rethAuthRPC, elRPC := rethNode.Start(s.T(), path.Base(elGenesisPath)) + rethHandle, rethAuthRPC, rethElRPC := rethNode.Start(s.T(), path.Base(elGenesisPath)) s.Reth.ElHandle = rethHandle // Prepare a logger backed by a buffer to capture logs for assertions. @@ -121,7 +121,7 @@ func (s *PectraForkSuite) SetupTest() { TempHomeDir: s.Reth.HomeDir, CometConfig: cometConfig, AuthRPC: rethAuthRPC, - ClientRPC: elRPC, + ClientRPC: rethElRPC, Logger: rethLogger, AppOpts: viper.New(), Components: components, @@ -221,10 +221,9 @@ func (s *PectraForkSuite) TestTimestampFork_ELAndCLInSync_IsSuccessful() { // set consensus time for the next block to match // the timestamp of the payload built optimistically. forkVersion := s.Geth.TestNode.ChainSpec.ActiveForkVersionForTimestamp(math.U64(consensusTime.Unix())) //#nosec: G115 - blk, _, err := encoding.ExtractBlobsAndBlockFromRequest( - processRequest, + blk, err := encoding.UnmarshalBeaconBlockFromABCIRequest( + processRequest.GetTxs(), blockchain.BeaconBlockTxIndex, - blockchain.BlobSidecarsTxIndex, forkVersion, ) s.Require().NoError(err) diff --git a/testing/simulated/simcomet.go b/testing/simulated/simcomet.go index 40e1375399..292358f963 100644 --- a/testing/simulated/simcomet.go +++ b/testing/simulated/simcomet.go @@ -56,6 +56,7 @@ func ProvideSimComet( logger *phuslu.Logger, blockchain blockchain.BlockchainI, blockBuilder validator.BlockBuilderI, + blobReactor cometbft.BlobReactorI, db dbm.DB, cs chain.Spec, cmtCfg *cmtcfg.Config, @@ -67,6 +68,7 @@ func ProvideSimComet( db, blockchain, blockBuilder, + blobReactor, cs, cmtCfg, telemetrySink, diff --git a/testing/simulated/testnode.go b/testing/simulated/testnode.go index 8c14f24629..b20d5ab704 100644 --- a/testing/simulated/testnode.go +++ b/testing/simulated/testnode.go @@ -78,6 +78,9 @@ type TestNode struct { ServiceRegistry *service.Registry KZGVerifier kzg.BlobProofVerifier ContractBackend *ethclient.Client + BlobFetcher blockchain.BlobFetcher + BlobReactor blockchain.BlobRequester + BlobProcessor blockchain.BlobProcessor } // NewTestNode Uses the testnet chainspec. @@ -133,6 +136,9 @@ func buildNode( stateProcessor *core.StateProcessor serviceRegistry *service.Registry kzgVerifier kzg.BlobProofVerifier + blobFetcher blockchain.BlobFetcher + blobReactor blockchain.BlobRequester + blobProcessor blockchain.BlobProcessor ) // build all node components using depinject @@ -158,6 +164,9 @@ func buildNode( &stateProcessor, &serviceRegistry, &kzgVerifier, + &blobFetcher, + &blobReactor, + &blobProcessor, ); err != nil { panic(err) } @@ -179,6 +188,9 @@ func buildNode( StateProcessor: stateProcessor, ServiceRegistry: serviceRegistry, KZGVerifier: kzgVerifier, + BlobFetcher: blobFetcher, + BlobReactor: blobReactor, + BlobProcessor: blobProcessor, } } diff --git a/testing/simulated/utils.go b/testing/simulated/utils.go index ef24242d9e..3bde32a26e 100644 --- a/testing/simulated/utils.go +++ b/testing/simulated/utils.go @@ -256,10 +256,9 @@ func (s *SharedAccessors) MoveChainToHeight( // set consensus time for the next block to match // the timestamp of the payload built optimistically. forkVersion := s.TestNode.ChainSpec.ActiveForkVersionForTimestamp(math.U64(proposalTime.Unix())) //#nosec: G115 - blk, _, err := encoding.ExtractBlobsAndBlockFromRequest( - processReq, + blk, err := encoding.UnmarshalBeaconBlockFromABCIRequest( + processReq.GetTxs(), blockchain.BeaconBlockTxIndex, - blockchain.BlobSidecarsTxIndex, forkVersion, ) require.NoError(t, err)