From 7a732216b589c81c0c2744dddf0661cff2d1f239 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Mon, 17 Nov 2025 12:50:50 +0100 Subject: [PATCH 1/4] refactor(block): extract da logic into da client and fi retriever --- apps/evm/single/cmd/run.go | 11 +- apps/evm/single/go.sum | 2 + apps/grpc/single/cmd/run.go | 11 +- apps/grpc/single/go.sum | 2 + apps/testapp/cmd/run.go | 14 +- apps/testapp/go.sum | 2 + block/components.go | 10 +- block/internal/common/errors.go | 3 - block/internal/common/event.go | 7 - block/internal/da/client.go | 299 ++++++++++ block/internal/da/client_test.go | 525 ++++++++++++++++++ .../internal/da/forced_inclusion_retriever.go | 183 ++++++ .../da/forced_inclusion_retriever_test.go | 344 ++++++++++++ block/internal/submitting/da_submitter.go | 21 +- .../da_submitter_integration_test.go | 9 +- .../submitting/da_submitter_mocks_test.go | 10 +- .../internal/submitting/da_submitter_test.go | 19 +- block/internal/submitting/submitter_test.go | 27 +- block/internal/syncing/da_retriever.go | 334 +---------- block/internal/syncing/da_retriever_mock.go | 68 --- block/internal/syncing/da_retriever_test.go | 437 ++------------- block/internal/syncing/syncer.go | 19 +- .../syncing/syncer_forced_inclusion_test.go | 61 +- block/internal/syncing/syncer_test.go | 2 - block/public.go | 61 +- da/internal/mocks/da.go | 120 ---- go.mod | 2 + go.sum | 2 + sequencers/based/based.go | 23 +- sequencers/based/based_test.go | 412 +++++++------- sequencers/single/sequencer.go | 21 +- sequencers/single/sequencer_test.go | 84 +-- test/mocks/da.go | 120 ---- types/CLAUDE.md | 11 +- types/da.go | 212 ------- types/da_test.go | 298 ---------- 36 files changed, 1872 insertions(+), 1914 deletions(-) create mode 100644 block/internal/da/client.go create mode 100644 block/internal/da/client_test.go create mode 100644 block/internal/da/forced_inclusion_retriever.go create mode 100644 block/internal/da/forced_inclusion_retriever_test.go delete mode 100644 types/da.go delete mode 100644 types/da_test.go diff --git a/apps/evm/single/cmd/run.go b/apps/evm/single/cmd/run.go index 033ac7798e..890e30d106 100644 --- a/apps/evm/single/cmd/run.go +++ b/apps/evm/single/cmd/run.go @@ -113,10 +113,8 @@ func createSequencer( nodeConfig config.Config, genesis genesis.Genesis, ) (coresequencer.Sequencer, error) { - daRetriever, err := block.NewDARetriever(da, nodeConfig, genesis, logger) - if err != nil { - return nil, fmt.Errorf("failed to create DA retriever: %w", err) - } + daClient := block.NewDAClient(da, nodeConfig, logger) + fiRetriever := block.NewForcedInclusionRetriever(daClient, genesis, logger) if nodeConfig.Node.BasedSequencer { // Based sequencer mode - fetch transactions only from DA @@ -124,7 +122,7 @@ func createSequencer( return nil, fmt.Errorf("based sequencer mode requires aggregator mode to be enabled") } - basedSeq := based.NewBasedSequencer(daRetriever, da, nodeConfig, genesis, logger) + basedSeq := based.NewBasedSequencer(fiRetriever, da, nodeConfig, genesis, logger) logger.Info(). Str("forced_inclusion_namespace", nodeConfig.DA.GetForcedInclusionNamespace()). @@ -149,7 +147,7 @@ func createSequencer( singleMetrics, nodeConfig.Node.Aggregator, 1000, - daRetriever, + fiRetriever, genesis, ) if err != nil { @@ -157,7 +155,6 @@ func createSequencer( } logger.Info(). - Bool("forced_inclusion_enabled", daRetriever != nil). Str("forced_inclusion_namespace", nodeConfig.DA.GetForcedInclusionNamespace()). Msg("single sequencer initialized") diff --git a/apps/evm/single/go.sum b/apps/evm/single/go.sum index f8f9a34349..66243b255c 100644 --- a/apps/evm/single/go.sum +++ b/apps/evm/single/go.sum @@ -760,6 +760,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/apps/grpc/single/cmd/run.go b/apps/grpc/single/cmd/run.go index b82f61eceb..0ee5d09c1b 100644 --- a/apps/grpc/single/cmd/run.go +++ b/apps/grpc/single/cmd/run.go @@ -122,10 +122,8 @@ func createSequencer( nodeConfig config.Config, genesis genesis.Genesis, ) (coresequencer.Sequencer, error) { - daRetriever, err := block.NewDARetriever(da, nodeConfig, genesis, logger) - if err != nil { - return nil, fmt.Errorf("failed to create DA retriever: %w", err) - } + daClient := block.NewDAClient(da, nodeConfig, logger) + fiRetriever := block.NewForcedInclusionRetriever(daClient, genesis, logger) if nodeConfig.Node.BasedSequencer { // Based sequencer mode - fetch transactions only from DA @@ -133,7 +131,7 @@ func createSequencer( return nil, fmt.Errorf("based sequencer mode requires aggregator mode to be enabled") } - basedSeq := based.NewBasedSequencer(daRetriever, da, nodeConfig, genesis, logger) + basedSeq := based.NewBasedSequencer(fiRetriever, da, nodeConfig, genesis, logger) logger.Info(). Str("forced_inclusion_namespace", nodeConfig.DA.GetForcedInclusionNamespace()). @@ -158,7 +156,7 @@ func createSequencer( singleMetrics, nodeConfig.Node.Aggregator, 1000, - daRetriever, + fiRetriever, genesis, ) if err != nil { @@ -166,7 +164,6 @@ func createSequencer( } logger.Info(). - Bool("forced_inclusion_enabled", daRetriever != nil). Str("forced_inclusion_namespace", nodeConfig.DA.GetForcedInclusionNamespace()). Msg("single sequencer initialized") diff --git a/apps/grpc/single/go.sum b/apps/grpc/single/go.sum index eabd0c4654..6dc9d5d9e1 100644 --- a/apps/grpc/single/go.sum +++ b/apps/grpc/single/go.sum @@ -654,6 +654,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/apps/testapp/cmd/run.go b/apps/testapp/cmd/run.go index 49852e6454..c185f2ad4b 100644 --- a/apps/testapp/cmd/run.go +++ b/apps/testapp/cmd/run.go @@ -18,7 +18,6 @@ import ( "github.com/evstack/ev-node/pkg/cmd" "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/pkg/genesis" - genesispkg "github.com/evstack/ev-node/pkg/genesis" "github.com/evstack/ev-node/pkg/p2p" "github.com/evstack/ev-node/pkg/p2p/key" "github.com/evstack/ev-node/pkg/store" @@ -85,7 +84,7 @@ var RunCmd = &cobra.Command{ } genesisPath := filepath.Join(filepath.Dir(nodeConfig.ConfigPath()), "genesis.json") - genesis, err := genesispkg.LoadGenesis(genesisPath) + genesis, err := genesis.LoadGenesis(genesisPath) if err != nil { return fmt.Errorf("failed to load genesis: %w", err) } @@ -120,10 +119,8 @@ func createSequencer( nodeConfig config.Config, genesis genesis.Genesis, ) (coresequencer.Sequencer, error) { - daRetriever, err := block.NewDARetriever(da, nodeConfig, genesis, logger) - if err != nil { - return nil, fmt.Errorf("failed to create DA retriever: %w", err) - } + daClient := block.NewDAClient(da, nodeConfig, logger) + fiRetriever := block.NewForcedInclusionRetriever(daClient, genesis, logger) if nodeConfig.Node.BasedSequencer { // Based sequencer mode - fetch transactions only from DA @@ -131,7 +128,7 @@ func createSequencer( return nil, fmt.Errorf("based sequencer mode requires aggregator mode to be enabled") } - basedSeq := based.NewBasedSequencer(daRetriever, da, nodeConfig, genesis, logger) + basedSeq := based.NewBasedSequencer(fiRetriever, da, nodeConfig, genesis, logger) logger.Info(). Str("forced_inclusion_namespace", nodeConfig.DA.GetForcedInclusionNamespace()). @@ -156,7 +153,7 @@ func createSequencer( singleMetrics, nodeConfig.Node.Aggregator, 1000, - daRetriever, + fiRetriever, genesis, ) if err != nil { @@ -164,7 +161,6 @@ func createSequencer( } logger.Info(). - Bool("forced_inclusion_enabled", daRetriever != nil). Str("forced_inclusion_namespace", nodeConfig.DA.GetForcedInclusionNamespace()). Msg("single sequencer initialized") diff --git a/apps/testapp/go.sum b/apps/testapp/go.sum index eabd0c4654..6dc9d5d9e1 100644 --- a/apps/testapp/go.sum +++ b/apps/testapp/go.sum @@ -654,6 +654,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/block/components.go b/block/components.go index 9e9e6af425..fee98db9fd 100644 --- a/block/components.go +++ b/block/components.go @@ -162,8 +162,9 @@ func NewSyncComponents( errorCh, ) - // Create DA submitter for sync nodes (no signer, only DA inclusion processing) - daSubmitter := submitting.NewDASubmitter(da, config, genesis, blockOpts, metrics, logger) + // Create DA client and submitter for sync nodes (no signer, only DA inclusion processing) + daClient := NewDAClient(da, config, logger) + daSubmitter := submitting.NewDASubmitter(daClient, config, genesis, blockOpts, metrics, logger) submitter := submitting.NewSubmitter( store, exec, @@ -252,8 +253,9 @@ func NewAggregatorComponents( }, nil } - // Create DA submitter for aggregator nodes (with signer for submission) - daSubmitter := submitting.NewDASubmitter(da, config, genesis, blockOpts, metrics, logger) + // Create DA client and submitter for aggregator nodes (with signer for submission) + daClient := NewDAClient(da, config, logger) + daSubmitter := submitting.NewDASubmitter(daClient, config, genesis, blockOpts, metrics, logger) submitter := submitting.NewSubmitter( store, exec, diff --git a/block/internal/common/errors.go b/block/internal/common/errors.go index 5ae797daab..5ae3218639 100644 --- a/block/internal/common/errors.go +++ b/block/internal/common/errors.go @@ -20,7 +20,4 @@ var ( // ErrOversizedItem is an unrecoverable error indicating a single item exceeds DA blob size limit ErrOversizedItem = errors.New("single item exceeds DA blob size limit") - - // ErrForceInclusionNotConfigured is returned when the forced inclusion namespace is not configured. - ErrForceInclusionNotConfigured = errors.New("forced inclusion namespace not configured") ) diff --git a/block/internal/common/event.go b/block/internal/common/event.go index 1117683a51..f1b4295c73 100644 --- a/block/internal/common/event.go +++ b/block/internal/common/event.go @@ -23,10 +23,3 @@ type DAHeightEvent = struct { // Source indicates where this event originated from (DA or P2P) Source EventSource } - -// ForcedIncluded represents a forced inclusion event for caching -type ForcedIncludedEvent = struct { - Txs [][]byte - StartDaHeight uint64 - EndDaHeight uint64 -} diff --git a/block/internal/da/client.go b/block/internal/da/client.go new file mode 100644 index 0000000000..07bd7d3af2 --- /dev/null +++ b/block/internal/da/client.go @@ -0,0 +1,299 @@ +package da + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/rs/zerolog" + + coreda "github.com/evstack/ev-node/core/da" +) + +// Client is the interface representing the DA client. +type Client interface { + Submit(ctx context.Context, data [][]byte, gasPrice float64, namespace []byte, options []byte) coreda.ResultSubmit + Retrieve(ctx context.Context, height uint64, namespace []byte) coreda.ResultRetrieve + RetrieveHeaders(ctx context.Context, height uint64) coreda.ResultRetrieve + RetrieveData(ctx context.Context, height uint64) coreda.ResultRetrieve + RetrieveForcedInclusion(ctx context.Context, height uint64) coreda.ResultRetrieve + + GetHeaderNamespace() []byte + GetDataNamespace() []byte + GetForcedInclusionNamespace() []byte + HasForcedInclusionNamespace() bool + GetDA() coreda.DA +} + +// client provides a reusable wrapper around the core DA interface +// with common configuration for namespace handling and timeouts. +type client struct { + da coreda.DA + logger zerolog.Logger + defaultTimeout time.Duration + namespaceBz []byte + namespaceDataBz []byte + namespaceForcedInclusionBz []byte + hasForcedInclusionNs bool +} + +// Config contains configuration for the DA client. +type Config struct { + DA coreda.DA + Logger zerolog.Logger + DefaultTimeout time.Duration + Namespace string + DataNamespace string + ForcedInclusionNamespace string +} + +// NewClient creates a new DA client with pre-calculated namespace bytes. +func NewClient(cfg Config) *client { + if cfg.DefaultTimeout == 0 { + cfg.DefaultTimeout = 30 * time.Second + } + + hasForcedInclusionNs := cfg.ForcedInclusionNamespace != "" + var namespaceForcedInclusionBz []byte + if hasForcedInclusionNs { + namespaceForcedInclusionBz = coreda.NamespaceFromString(cfg.ForcedInclusionNamespace).Bytes() + } + + return &client{ + da: cfg.DA, + logger: cfg.Logger.With().Str("component", "da_client").Logger(), + defaultTimeout: cfg.DefaultTimeout, + namespaceBz: coreda.NamespaceFromString(cfg.Namespace).Bytes(), + namespaceDataBz: coreda.NamespaceFromString(cfg.DataNamespace).Bytes(), + namespaceForcedInclusionBz: namespaceForcedInclusionBz, + hasForcedInclusionNs: hasForcedInclusionNs, + } +} + +// Submit submits blobs to the DA layer with the specified options. +func (c *client) Submit(ctx context.Context, data [][]byte, gasPrice float64, namespace []byte, options []byte) coreda.ResultSubmit { + ids, err := c.da.SubmitWithOptions(ctx, data, gasPrice, namespace, options) + + // calculate blob size + var blobSize uint64 + for _, blob := range data { + blobSize += uint64(len(blob)) + } + + // Handle errors returned by Submit + if err != nil { + if errors.Is(err, context.Canceled) { + c.logger.Debug().Msg("DA submission canceled due to context cancellation") + return coreda.ResultSubmit{ + BaseResult: coreda.BaseResult{ + Code: coreda.StatusContextCanceled, + Message: "submission canceled", + IDs: ids, + BlobSize: blobSize, + }, + } + } + status := coreda.StatusError + switch { + case errors.Is(err, coreda.ErrTxTimedOut): + status = coreda.StatusNotIncludedInBlock + case errors.Is(err, coreda.ErrTxAlreadyInMempool): + status = coreda.StatusAlreadyInMempool + case errors.Is(err, coreda.ErrTxIncorrectAccountSequence): + status = coreda.StatusIncorrectAccountSequence + case errors.Is(err, coreda.ErrBlobSizeOverLimit): + status = coreda.StatusTooBig + case errors.Is(err, coreda.ErrContextDeadline): + status = coreda.StatusContextDeadline + } + + // Use debug level for StatusTooBig as it gets handled later in submitToDA through recursive splitting + if status == coreda.StatusTooBig { + c.logger.Debug().Err(err).Uint64("status", uint64(status)).Msg("DA submission failed") + } else { + c.logger.Error().Err(err).Uint64("status", uint64(status)).Msg("DA submission failed") + } + return coreda.ResultSubmit{ + BaseResult: coreda.BaseResult{ + Code: status, + Message: "failed to submit blobs: " + err.Error(), + IDs: ids, + SubmittedCount: uint64(len(ids)), + Height: 0, + Timestamp: time.Now(), + BlobSize: blobSize, + }, + } + } + + if len(ids) == 0 && len(data) > 0 { + c.logger.Warn().Msg("DA submission returned no IDs for non-empty input data") + return coreda.ResultSubmit{ + BaseResult: coreda.BaseResult{ + Code: coreda.StatusError, + Message: "failed to submit blobs: no IDs returned despite non-empty input", + }, + } + } + + // Get height from the first ID + var height uint64 + if len(ids) > 0 { + height, _, err = coreda.SplitID(ids[0]) + if err != nil { + c.logger.Error().Err(err).Msg("failed to split ID") + } + } + + c.logger.Debug().Int("num_ids", len(ids)).Msg("DA submission successful") + return coreda.ResultSubmit{ + BaseResult: coreda.BaseResult{ + Code: coreda.StatusSuccess, + IDs: ids, + SubmittedCount: uint64(len(ids)), + Height: height, + BlobSize: blobSize, + Timestamp: time.Now(), + }, + } +} + +// Retrieve retrieves blobs from the DA layer at the specified height and namespace. +func (c *client) Retrieve(ctx context.Context, height uint64, namespace []byte) coreda.ResultRetrieve { + // 1. Get IDs + getIDsCtx, cancel := context.WithTimeout(ctx, c.defaultTimeout) + defer cancel() + idsResult, err := c.da.GetIDs(getIDsCtx, height, namespace) + if err != nil { + // Handle specific "not found" error + if strings.Contains(err.Error(), coreda.ErrBlobNotFound.Error()) { + c.logger.Debug().Uint64("height", height).Msg("Blobs not found at height") + return coreda.ResultRetrieve{ + BaseResult: coreda.BaseResult{ + Code: coreda.StatusNotFound, + Message: coreda.ErrBlobNotFound.Error(), + Height: height, + Timestamp: time.Now(), + }, + } + } + if strings.Contains(err.Error(), coreda.ErrHeightFromFuture.Error()) { + c.logger.Debug().Uint64("height", height).Msg("Blobs not found at height") + return coreda.ResultRetrieve{ + BaseResult: coreda.BaseResult{ + Code: coreda.StatusHeightFromFuture, + Message: coreda.ErrHeightFromFuture.Error(), + Height: height, + Timestamp: time.Now(), + }, + } + } + // Handle other errors during GetIDs + c.logger.Error().Uint64("height", height).Err(err).Msg("Failed to get IDs") + return coreda.ResultRetrieve{ + BaseResult: coreda.BaseResult{ + Code: coreda.StatusError, + Message: fmt.Sprintf("failed to get IDs: %s", err.Error()), + Height: height, + Timestamp: time.Now(), + }, + } + } + + // This check should technically be redundant if GetIDs correctly returns ErrBlobNotFound + if idsResult == nil || len(idsResult.IDs) == 0 { + c.logger.Debug().Uint64("height", height).Msg("No IDs found at height") + return coreda.ResultRetrieve{ + BaseResult: coreda.BaseResult{ + Code: coreda.StatusNotFound, + Message: coreda.ErrBlobNotFound.Error(), + Height: height, + Timestamp: time.Now(), + }, + } + } + // 2. Get Blobs using the retrieved IDs in batches + batchSize := 100 + blobs := make([][]byte, 0, len(idsResult.IDs)) + for i := 0; i < len(idsResult.IDs); i += batchSize { + end := min(i+batchSize, len(idsResult.IDs)) + + getBlobsCtx, cancel := context.WithTimeout(ctx, c.defaultTimeout) + batchBlobs, err := c.da.Get(getBlobsCtx, idsResult.IDs[i:end], namespace) + cancel() + if err != nil { + // Handle errors during Get + c.logger.Error().Uint64("height", height).Int("num_ids", len(idsResult.IDs)).Err(err).Msg("Failed to get blobs") + return coreda.ResultRetrieve{ + BaseResult: coreda.BaseResult{ + Code: coreda.StatusError, + Message: fmt.Sprintf("failed to get blobs for batch %d-%d: %s", i, end-1, err.Error()), + Height: height, + Timestamp: time.Now(), + }, + } + } + blobs = append(blobs, batchBlobs...) + } + // Success + c.logger.Debug().Uint64("height", height).Int("num_blobs", len(blobs)).Msg("Successfully retrieved blobs") + return coreda.ResultRetrieve{ + BaseResult: coreda.BaseResult{ + Code: coreda.StatusSuccess, + Height: height, + IDs: idsResult.IDs, + Timestamp: idsResult.Timestamp, + }, + Data: blobs, + } +} + +// RetrieveHeaders retrieves blobs from the header namespace at the specified height. +func (c *client) RetrieveHeaders(ctx context.Context, height uint64) coreda.ResultRetrieve { + return c.Retrieve(ctx, height, c.namespaceBz) +} + +// RetrieveData retrieves blobs from the data namespace at the specified height. +func (c *client) RetrieveData(ctx context.Context, height uint64) coreda.ResultRetrieve { + return c.Retrieve(ctx, height, c.namespaceDataBz) +} + +// RetrieveForcedInclusion retrieves blobs from the forced inclusion namespace at the specified height. +func (c *client) RetrieveForcedInclusion(ctx context.Context, height uint64) coreda.ResultRetrieve { + if !c.hasForcedInclusionNs { + return coreda.ResultRetrieve{ + BaseResult: coreda.BaseResult{ + Code: coreda.StatusError, + Message: "forced inclusion namespace not configured", + }, + } + } + return c.Retrieve(ctx, height, c.namespaceForcedInclusionBz) +} + +// GetHeaderNamespace returns the header namespace bytes. +func (c *client) GetHeaderNamespace() []byte { + return c.namespaceBz +} + +// GetDataNamespace returns the data namespace bytes. +func (c *client) GetDataNamespace() []byte { + return c.namespaceDataBz +} + +// GetForcedInclusionNamespace returns the forced inclusion namespace bytes. +func (c *client) GetForcedInclusionNamespace() []byte { + return c.namespaceForcedInclusionBz +} + +// HasForcedInclusionNamespace returns whether forced inclusion namespace is configured. +func (c *client) HasForcedInclusionNamespace() bool { + return c.hasForcedInclusionNs +} + +// GetDA returns the underlying DA interface for advanced usage. +func (c *client) GetDA() coreda.DA { + return c.da +} diff --git a/block/internal/da/client_test.go b/block/internal/da/client_test.go new file mode 100644 index 0000000000..7bc7e972a6 --- /dev/null +++ b/block/internal/da/client_test.go @@ -0,0 +1,525 @@ +package da + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/rs/zerolog" + "gotest.tools/v3/assert" + + coreda "github.com/evstack/ev-node/core/da" +) + +// mockDA is a simple mock implementation of coreda.DA for testing +type mockDA struct { + submitFunc func(ctx context.Context, blobs []coreda.Blob, gasPrice float64, namespace []byte) ([]coreda.ID, error) + submitWithOptions func(ctx context.Context, blobs []coreda.Blob, gasPrice float64, namespace []byte, options []byte) ([]coreda.ID, error) + getIDsFunc func(ctx context.Context, height uint64, namespace []byte) (*coreda.GetIDsResult, error) + getFunc func(ctx context.Context, ids []coreda.ID, namespace []byte) ([]coreda.Blob, error) +} + +func (m *mockDA) Submit(ctx context.Context, blobs []coreda.Blob, gasPrice float64, namespace []byte) ([]coreda.ID, error) { + if m.submitFunc != nil { + return m.submitFunc(ctx, blobs, gasPrice, namespace) + } + return nil, nil +} + +func (m *mockDA) SubmitWithOptions(ctx context.Context, blobs []coreda.Blob, gasPrice float64, namespace []byte, options []byte) ([]coreda.ID, error) { + if m.submitWithOptions != nil { + return m.submitWithOptions(ctx, blobs, gasPrice, namespace, options) + } + return nil, nil +} + +func (m *mockDA) GetIDs(ctx context.Context, height uint64, namespace []byte) (*coreda.GetIDsResult, error) { + if m.getIDsFunc != nil { + return m.getIDsFunc(ctx, height, namespace) + } + return nil, errors.New("not implemented") +} + +func (m *mockDA) Get(ctx context.Context, ids []coreda.ID, namespace []byte) ([]coreda.Blob, error) { + if m.getFunc != nil { + return m.getFunc(ctx, ids, namespace) + } + return nil, errors.New("not implemented") +} + +func (m *mockDA) GetProofs(ctx context.Context, ids []coreda.ID, namespace []byte) ([]coreda.Proof, error) { + return nil, errors.New("not implemented") +} + +func (m *mockDA) Commit(ctx context.Context, blobs []coreda.Blob, namespace []byte) ([]coreda.Commitment, error) { + return nil, errors.New("not implemented") +} + +func (m *mockDA) Validate(ctx context.Context, ids []coreda.ID, proofs []coreda.Proof, namespace []byte) ([]bool, error) { + return nil, errors.New("not implemented") +} + +func TestNewClient(t *testing.T) { + tests := []struct { + name string + cfg Config + }{ + { + name: "with all namespaces", + cfg: Config{ + DA: &mockDA{}, + Logger: zerolog.Nop(), + DefaultTimeout: 5 * time.Second, + Namespace: "test-ns", + DataNamespace: "test-data-ns", + ForcedInclusionNamespace: "test-fi-ns", + }, + }, + { + name: "without forced inclusion namespace", + cfg: Config{ + DA: &mockDA{}, + Logger: zerolog.Nop(), + DefaultTimeout: 5 * time.Second, + Namespace: "test-ns", + DataNamespace: "test-data-ns", + }, + }, + { + name: "with default timeout", + cfg: Config{ + DA: &mockDA{}, + Logger: zerolog.Nop(), + Namespace: "test-ns", + DataNamespace: "test-data-ns", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewClient(tt.cfg) + assert.Assert(t, client != nil) + assert.Assert(t, client.da != nil) + assert.Assert(t, len(client.namespaceBz) > 0) + assert.Assert(t, len(client.namespaceDataBz) > 0) + + if tt.cfg.ForcedInclusionNamespace != "" { + assert.Assert(t, client.hasForcedInclusionNs) + assert.Assert(t, len(client.namespaceForcedInclusionBz) > 0) + } else { + assert.Assert(t, !client.hasForcedInclusionNs) + } + + expectedTimeout := tt.cfg.DefaultTimeout + if expectedTimeout == 0 { + expectedTimeout = 30 * time.Second + } + assert.Equal(t, client.defaultTimeout, expectedTimeout) + }) + } +} + +func TestClient_HasForcedInclusionNamespace(t *testing.T) { + tests := []struct { + name string + cfg Config + expected bool + }{ + { + name: "with forced inclusion namespace", + cfg: Config{ + DA: &mockDA{}, + Logger: zerolog.Nop(), + Namespace: "test-ns", + DataNamespace: "test-data-ns", + ForcedInclusionNamespace: "test-fi-ns", + }, + expected: true, + }, + { + name: "without forced inclusion namespace", + cfg: Config{ + DA: &mockDA{}, + Logger: zerolog.Nop(), + Namespace: "test-ns", + DataNamespace: "test-data-ns", + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewClient(tt.cfg) + assert.Equal(t, client.HasForcedInclusionNamespace(), tt.expected) + }) + } +} + +func TestClient_GetNamespaces(t *testing.T) { + cfg := Config{ + DA: &mockDA{}, + Logger: zerolog.Nop(), + Namespace: "test-header", + DataNamespace: "test-data", + ForcedInclusionNamespace: "test-fi", + } + + client := NewClient(cfg) + + headerNs := client.GetHeaderNamespace() + assert.Assert(t, len(headerNs) > 0) + + dataNs := client.GetDataNamespace() + assert.Assert(t, len(dataNs) > 0) + + fiNs := client.GetForcedInclusionNamespace() + assert.Assert(t, len(fiNs) > 0) + + // Namespaces should be different + assert.Assert(t, string(headerNs) != string(dataNs)) + assert.Assert(t, string(headerNs) != string(fiNs)) + assert.Assert(t, string(dataNs) != string(fiNs)) +} + +func TestClient_RetrieveForcedInclusion_NotConfigured(t *testing.T) { + cfg := Config{ + DA: &mockDA{}, + Logger: zerolog.Nop(), + Namespace: "test-ns", + DataNamespace: "test-data-ns", + } + + client := NewClient(cfg) + ctx := context.Background() + + result := client.RetrieveForcedInclusion(ctx, 100) + assert.Equal(t, result.Code, coreda.StatusError) + assert.Assert(t, result.Message != "") +} + +func TestClient_GetDA(t *testing.T) { + mockDAInstance := &mockDA{} + cfg := Config{ + DA: mockDAInstance, + Logger: zerolog.Nop(), + Namespace: "test-ns", + DataNamespace: "test-data-ns", + } + + client := NewClient(cfg) + da := client.GetDA() + assert.Equal(t, da, mockDAInstance) +} + +func TestClient_Submit(t *testing.T) { + logger := zerolog.Nop() + + testCases := []struct { + name string + data [][]byte + gasPrice float64 + options []byte + submitErr error + submitIDs [][]byte + expectedCode coreda.StatusCode + expectedErrMsg string + expectedIDs [][]byte + expectedCount uint64 + }{ + { + name: "successful submission", + data: [][]byte{[]byte("blob1"), []byte("blob2")}, + gasPrice: 1.0, + options: []byte("opts"), + submitIDs: [][]byte{[]byte("id1"), []byte("id2")}, + expectedCode: coreda.StatusSuccess, + expectedIDs: [][]byte{[]byte("id1"), []byte("id2")}, + expectedCount: 2, + }, + { + name: "context canceled error", + data: [][]byte{[]byte("blob1")}, + gasPrice: 1.0, + options: []byte("opts"), + submitErr: context.Canceled, + expectedCode: coreda.StatusContextCanceled, + expectedErrMsg: "submission canceled", + }, + { + name: "tx timed out error", + data: [][]byte{[]byte("blob1")}, + gasPrice: 1.0, + options: []byte("opts"), + submitErr: coreda.ErrTxTimedOut, + expectedCode: coreda.StatusNotIncludedInBlock, + expectedErrMsg: "failed to submit blobs: " + coreda.ErrTxTimedOut.Error(), + }, + { + name: "tx already in mempool error", + data: [][]byte{[]byte("blob1")}, + gasPrice: 1.0, + options: []byte("opts"), + submitErr: coreda.ErrTxAlreadyInMempool, + expectedCode: coreda.StatusAlreadyInMempool, + expectedErrMsg: "failed to submit blobs: " + coreda.ErrTxAlreadyInMempool.Error(), + }, + { + name: "incorrect account sequence error", + data: [][]byte{[]byte("blob1")}, + gasPrice: 1.0, + options: []byte("opts"), + submitErr: coreda.ErrTxIncorrectAccountSequence, + expectedCode: coreda.StatusIncorrectAccountSequence, + expectedErrMsg: "failed to submit blobs: " + coreda.ErrTxIncorrectAccountSequence.Error(), + }, + { + name: "blob size over limit error", + data: [][]byte{[]byte("blob1")}, + gasPrice: 1.0, + options: []byte("opts"), + submitErr: coreda.ErrBlobSizeOverLimit, + expectedCode: coreda.StatusTooBig, + expectedErrMsg: "failed to submit blobs: " + coreda.ErrBlobSizeOverLimit.Error(), + }, + { + name: "context deadline error", + data: [][]byte{[]byte("blob1")}, + gasPrice: 1.0, + options: []byte("opts"), + submitErr: coreda.ErrContextDeadline, + expectedCode: coreda.StatusContextDeadline, + expectedErrMsg: "failed to submit blobs: " + coreda.ErrContextDeadline.Error(), + }, + { + name: "generic submission error", + data: [][]byte{[]byte("blob1")}, + gasPrice: 1.0, + options: []byte("opts"), + submitErr: errors.New("some generic error"), + expectedCode: coreda.StatusError, + expectedErrMsg: "failed to submit blobs: some generic error", + }, + { + name: "no IDs returned for non-empty data", + data: [][]byte{[]byte("blob1")}, + gasPrice: 1.0, + options: []byte("opts"), + submitIDs: [][]byte{}, + expectedCode: coreda.StatusError, + expectedErrMsg: "failed to submit blobs: no IDs returned despite non-empty input", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockDAInstance := &mockDA{ + submitWithOptions: func(ctx context.Context, blobs []coreda.Blob, gasPrice float64, namespace []byte, options []byte) ([]coreda.ID, error) { + return tc.submitIDs, tc.submitErr + }, + } + + client := NewClient(Config{ + DA: mockDAInstance, + Logger: logger, + Namespace: "test-namespace", + DataNamespace: "test-data-namespace", + }) + + encodedNamespace := coreda.NamespaceFromString("test-namespace") + result := client.Submit(context.Background(), tc.data, tc.gasPrice, encodedNamespace.Bytes(), tc.options) + + assert.Equal(t, tc.expectedCode, result.Code) + if tc.expectedErrMsg != "" { + assert.Assert(t, result.Message != "") + } + if tc.expectedIDs != nil { + assert.Equal(t, len(tc.expectedIDs), len(result.IDs)) + } + if tc.expectedCount != 0 { + assert.Equal(t, tc.expectedCount, result.SubmittedCount) + } + }) + } +} + +func TestClient_Retrieve(t *testing.T) { + logger := zerolog.Nop() + dataLayerHeight := uint64(100) + mockIDs := [][]byte{[]byte("id1"), []byte("id2")} + mockBlobs := [][]byte{[]byte("blobA"), []byte("blobB")} + mockTimestamp := time.Now() + + testCases := []struct { + name string + getIDsResult *coreda.GetIDsResult + getIDsErr error + getBlobsErr error + expectedCode coreda.StatusCode + expectedErrMsg string + expectedIDs [][]byte + expectedData [][]byte + expectedHeight uint64 + }{ + { + name: "successful retrieval", + getIDsResult: &coreda.GetIDsResult{ + IDs: mockIDs, + Timestamp: mockTimestamp, + }, + expectedCode: coreda.StatusSuccess, + expectedIDs: mockIDs, + expectedData: mockBlobs, + expectedHeight: dataLayerHeight, + }, + { + name: "blob not found error during GetIDs", + getIDsErr: coreda.ErrBlobNotFound, + expectedCode: coreda.StatusNotFound, + expectedErrMsg: coreda.ErrBlobNotFound.Error(), + expectedHeight: dataLayerHeight, + }, + { + name: "height from future error during GetIDs", + getIDsErr: coreda.ErrHeightFromFuture, + expectedCode: coreda.StatusHeightFromFuture, + expectedErrMsg: coreda.ErrHeightFromFuture.Error(), + expectedHeight: dataLayerHeight, + }, + { + name: "generic error during GetIDs", + getIDsErr: errors.New("failed to connect to DA"), + expectedCode: coreda.StatusError, + expectedErrMsg: "failed to get IDs: failed to connect to DA", + expectedHeight: dataLayerHeight, + }, + { + name: "GetIDs returns nil result", + getIDsResult: nil, + expectedCode: coreda.StatusNotFound, + expectedErrMsg: coreda.ErrBlobNotFound.Error(), + expectedHeight: dataLayerHeight, + }, + { + name: "GetIDs returns empty IDs", + getIDsResult: &coreda.GetIDsResult{ + IDs: [][]byte{}, + Timestamp: mockTimestamp, + }, + expectedCode: coreda.StatusNotFound, + expectedErrMsg: coreda.ErrBlobNotFound.Error(), + expectedHeight: dataLayerHeight, + }, + { + name: "error during Get (blobs retrieval)", + getIDsResult: &coreda.GetIDsResult{ + IDs: mockIDs, + Timestamp: mockTimestamp, + }, + getBlobsErr: errors.New("network error during blob retrieval"), + expectedCode: coreda.StatusError, + expectedErrMsg: "failed to get blobs for batch 0-1: network error during blob retrieval", + expectedHeight: dataLayerHeight, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockDAInstance := &mockDA{ + getIDsFunc: func(ctx context.Context, height uint64, namespace []byte) (*coreda.GetIDsResult, error) { + return tc.getIDsResult, tc.getIDsErr + }, + getFunc: func(ctx context.Context, ids []coreda.ID, namespace []byte) ([]coreda.Blob, error) { + if tc.getBlobsErr != nil { + return nil, tc.getBlobsErr + } + return mockBlobs, nil + }, + } + + client := NewClient(Config{ + DA: mockDAInstance, + Logger: logger, + Namespace: "test-namespace", + DataNamespace: "test-data-namespace", + DefaultTimeout: 5 * time.Second, + }) + + encodedNamespace := coreda.NamespaceFromString("test-namespace") + result := client.Retrieve(context.Background(), dataLayerHeight, encodedNamespace.Bytes()) + + assert.Equal(t, tc.expectedCode, result.Code) + assert.Equal(t, tc.expectedHeight, result.Height) + if tc.expectedErrMsg != "" { + assert.Assert(t, result.Message != "") + } + if tc.expectedIDs != nil { + assert.Equal(t, len(tc.expectedIDs), len(result.IDs)) + } + if tc.expectedData != nil { + assert.Equal(t, len(tc.expectedData), len(result.Data)) + } + }) + } +} + +func TestClient_Retrieve_Timeout(t *testing.T) { + logger := zerolog.Nop() + dataLayerHeight := uint64(100) + encodedNamespace := coreda.NamespaceFromString("test-namespace") + + t.Run("timeout during GetIDs", func(t *testing.T) { + mockDAInstance := &mockDA{ + getIDsFunc: func(ctx context.Context, height uint64, namespace []byte) (*coreda.GetIDsResult, error) { + <-ctx.Done() // Wait for context cancellation + return nil, context.DeadlineExceeded + }, + } + + client := NewClient(Config{ + DA: mockDAInstance, + Logger: logger, + Namespace: "test-namespace", + DataNamespace: "test-data-namespace", + DefaultTimeout: 1 * time.Millisecond, + }) + + result := client.Retrieve(context.Background(), dataLayerHeight, encodedNamespace.Bytes()) + + assert.Equal(t, coreda.StatusError, result.Code) + assert.Assert(t, result.Message != "") + }) + + t.Run("timeout during Get", func(t *testing.T) { + mockIDs := [][]byte{[]byte("id1")} + mockTimestamp := time.Now() + + mockDAInstance := &mockDA{ + getIDsFunc: func(ctx context.Context, height uint64, namespace []byte) (*coreda.GetIDsResult, error) { + return &coreda.GetIDsResult{ + IDs: mockIDs, + Timestamp: mockTimestamp, + }, nil + }, + getFunc: func(ctx context.Context, ids []coreda.ID, namespace []byte) ([]coreda.Blob, error) { + <-ctx.Done() // Wait for context cancellation + return nil, context.DeadlineExceeded + }, + } + + client := NewClient(Config{ + DA: mockDAInstance, + Logger: logger, + Namespace: "test-namespace", + DataNamespace: "test-data-namespace", + DefaultTimeout: 1 * time.Millisecond, + }) + + result := client.Retrieve(context.Background(), dataLayerHeight, encodedNamespace.Bytes()) + + assert.Equal(t, coreda.StatusError, result.Code) + assert.Assert(t, result.Message != "") + }) +} diff --git a/block/internal/da/forced_inclusion_retriever.go b/block/internal/da/forced_inclusion_retriever.go new file mode 100644 index 0000000000..cc5df61a9d --- /dev/null +++ b/block/internal/da/forced_inclusion_retriever.go @@ -0,0 +1,183 @@ +package da + +import ( + "context" + "errors" + "fmt" + + "github.com/rs/zerolog" + + coreda "github.com/evstack/ev-node/core/da" + "github.com/evstack/ev-node/pkg/genesis" + "github.com/evstack/ev-node/types" +) + +// ErrForceInclusionNotConfigured is returned when the forced inclusion namespace is not configured. +var ErrForceInclusionNotConfigured = errors.New("forced inclusion namespace not configured") + +// ForcedInclusionRetriever handles retrieval of forced inclusion transactions from DA. +type ForcedInclusionRetriever struct { + client Client + genesis genesis.Genesis + logger zerolog.Logger + daEpochSize uint64 +} + +// ForcedInclusionEvent contains forced inclusion transactions retrieved from DA. +type ForcedInclusionEvent struct { + StartDaHeight uint64 + EndDaHeight uint64 + Txs [][]byte +} + +// NewForcedInclusionRetriever creates a new forced inclusion retriever. +func NewForcedInclusionRetriever( + client Client, + genesis genesis.Genesis, + logger zerolog.Logger, +) *ForcedInclusionRetriever { + return &ForcedInclusionRetriever{ + client: client, + genesis: genesis, + logger: logger.With().Str("component", "forced_inclusion_retriever").Logger(), + daEpochSize: genesis.DAEpochForcedInclusion, + } +} + +// RetrieveForcedIncludedTxs retrieves forced inclusion transactions at the given DA height. +// It respects epoch boundaries and only fetches at epoch start. +func (r *ForcedInclusionRetriever) RetrieveForcedIncludedTxs(ctx context.Context, daHeight uint64) (*ForcedInclusionEvent, error) { + if !r.client.HasForcedInclusionNamespace() { + return nil, ErrForceInclusionNotConfigured + } + + // Calculate deterministic epoch boundaries + epochStart, epochEnd := types.CalculateEpochBoundaries(daHeight, r.genesis.DAStartHeight, r.daEpochSize) + + // If we're not at epoch start, return empty result + if daHeight != epochStart { + r.logger.Debug(). + Uint64("da_height", daHeight). + Uint64("epoch_start", epochStart). + Msg("not at epoch start - returning empty transactions") + + return &ForcedInclusionEvent{ + StartDaHeight: daHeight, + EndDaHeight: daHeight, + Txs: [][]byte{}, + }, nil + } + + // We're at epoch start - fetch transactions from DA + currentEpochNumber := types.CalculateEpochNumber(daHeight, r.genesis.DAStartHeight, r.daEpochSize) + + event := &ForcedInclusionEvent{ + StartDaHeight: epochStart, + Txs: [][]byte{}, + } + + r.logger.Debug(). + Uint64("da_height", daHeight). + Uint64("epoch_start", epochStart). + Uint64("epoch_end", epochEnd). + Uint64("epoch_num", currentEpochNumber). + Msg("retrieving forced included transactions from DA") + + // Check if epoch start is available + epochStartResult := r.client.RetrieveForcedInclusion(ctx, epochStart) + if epochStartResult.Code == coreda.StatusHeightFromFuture { + r.logger.Debug(). + Uint64("epoch_start", epochStart). + Msg("epoch start height not yet available on DA - backoff required") + return nil, fmt.Errorf("%w: epoch start height %d not yet available", coreda.ErrHeightFromFuture, epochStart) + } + + // Check if epoch end is available + epochEndResult := epochStartResult + if epochStart != epochEnd { + epochEndResult = r.client.RetrieveForcedInclusion(ctx, epochEnd) + if epochEndResult.Code == coreda.StatusHeightFromFuture { + r.logger.Debug(). + Uint64("epoch_end", epochEnd). + Msg("epoch end height not yet available on DA - backoff required") + return nil, fmt.Errorf("%w: epoch end height %d not yet available", coreda.ErrHeightFromFuture, epochEnd) + } + } + + lastProcessedHeight := epochStart + + // Process epoch start + if err := r.processForcedInclusionBlobs(event, &lastProcessedHeight, epochStartResult, epochStart); err != nil { + return nil, err + } + + // Process heights between start and end (exclusive) + for epochHeight := epochStart + 1; epochHeight < epochEnd; epochHeight++ { + result := r.client.RetrieveForcedInclusion(ctx, epochHeight) + + // If any intermediate height is from future, break early + if result.Code == coreda.StatusHeightFromFuture { + r.logger.Debug(). + Uint64("epoch_height", epochHeight). + Uint64("last_processed", lastProcessedHeight). + Msg("reached future DA height within epoch - stopping") + break + } + + if err := r.processForcedInclusionBlobs(event, &lastProcessedHeight, result, epochHeight); err != nil { + return nil, err + } + } + + // Process epoch end (only if different from start) + if epochEnd != epochStart { + if err := r.processForcedInclusionBlobs(event, &lastProcessedHeight, epochEndResult, epochEnd); err != nil { + return nil, err + } + } + + // Set the DA height range based on what we actually processed + event.EndDaHeight = lastProcessedHeight + + r.logger.Info(). + Uint64("epoch_start", epochStart). + Uint64("epoch_end", lastProcessedHeight). + Int("tx_count", len(event.Txs)). + Msg("retrieved forced inclusion transactions") + + return event, nil +} + +// processForcedInclusionBlobs processes blobs from a single DA height for forced inclusion. +func (r *ForcedInclusionRetriever) processForcedInclusionBlobs( + event *ForcedInclusionEvent, + lastProcessedHeight *uint64, + result coreda.ResultRetrieve, + height uint64, +) error { + if result.Code == coreda.StatusNotFound { + r.logger.Debug().Uint64("height", height).Msg("no forced inclusion blobs at height") + *lastProcessedHeight = height + return nil + } + + if result.Code != coreda.StatusSuccess { + return fmt.Errorf("failed to retrieve forced inclusion blobs at height %d: %s", height, result.Message) + } + + // Process each blob as a transaction + for _, blob := range result.Data { + if len(blob) > 0 { + event.Txs = append(event.Txs, blob) + } + } + + *lastProcessedHeight = height + + r.logger.Debug(). + Uint64("height", height). + Int("blob_count", len(result.Data)). + Msg("processed forced inclusion blobs") + + return nil +} diff --git a/block/internal/da/forced_inclusion_retriever_test.go b/block/internal/da/forced_inclusion_retriever_test.go new file mode 100644 index 0000000000..e586125730 --- /dev/null +++ b/block/internal/da/forced_inclusion_retriever_test.go @@ -0,0 +1,344 @@ +package da + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/rs/zerolog" + "gotest.tools/v3/assert" + + coreda "github.com/evstack/ev-node/core/da" + "github.com/evstack/ev-node/pkg/genesis" +) + +func TestNewForcedInclusionRetriever(t *testing.T) { + client := NewClient(Config{ + DA: &mockDA{}, + Logger: zerolog.Nop(), + Namespace: "test-ns", + DataNamespace: "test-data-ns", + ForcedInclusionNamespace: "test-fi-ns", + }) + + gen := genesis.Genesis{ + DAStartHeight: 100, + DAEpochForcedInclusion: 10, + } + + retriever := NewForcedInclusionRetriever(client, gen, zerolog.Nop()) + assert.Assert(t, retriever != nil) + assert.Equal(t, retriever.daEpochSize, uint64(10)) +} + +func TestForcedInclusionRetriever_RetrieveForcedIncludedTxs_NoNamespace(t *testing.T) { + client := NewClient(Config{ + DA: &mockDA{}, + Logger: zerolog.Nop(), + Namespace: "test-ns", + DataNamespace: "test-data-ns", + // No forced inclusion namespace + }) + + gen := genesis.Genesis{ + DAStartHeight: 100, + DAEpochForcedInclusion: 10, + } + + retriever := NewForcedInclusionRetriever(client, gen, zerolog.Nop()) + ctx := context.Background() + + _, err := retriever.RetrieveForcedIncludedTxs(ctx, 100) + assert.Assert(t, err != nil) + assert.ErrorContains(t, err, "not configured") +} + +func TestForcedInclusionRetriever_RetrieveForcedIncludedTxs_NotAtEpochStart(t *testing.T) { + client := NewClient(Config{ + DA: &mockDA{}, + Logger: zerolog.Nop(), + Namespace: "test-ns", + DataNamespace: "test-data-ns", + ForcedInclusionNamespace: "test-fi-ns", + }) + + gen := genesis.Genesis{ + DAStartHeight: 100, + DAEpochForcedInclusion: 10, + } + + retriever := NewForcedInclusionRetriever(client, gen, zerolog.Nop()) + ctx := context.Background() + + // Height 105 is not an epoch start (100, 110, 120, etc. are epoch starts) + event, err := retriever.RetrieveForcedIncludedTxs(ctx, 105) + assert.NilError(t, err) + assert.Assert(t, event != nil) + assert.Equal(t, event.StartDaHeight, uint64(105)) + assert.Equal(t, event.EndDaHeight, uint64(105)) + assert.Equal(t, len(event.Txs), 0) +} + +func TestForcedInclusionRetriever_RetrieveForcedIncludedTxs_EpochStartSuccess(t *testing.T) { + testBlobs := [][]byte{ + []byte("tx1"), + []byte("tx2"), + []byte("tx3"), + } + + mockDAInstance := &mockDA{ + getIDsFunc: func(ctx context.Context, height uint64, namespace []byte) (*coreda.GetIDsResult, error) { + return &coreda.GetIDsResult{ + IDs: []coreda.ID{[]byte("id1"), []byte("id2"), []byte("id3")}, + Timestamp: time.Now(), + }, nil + }, + getFunc: func(ctx context.Context, ids []coreda.ID, namespace []byte) ([]coreda.Blob, error) { + return testBlobs, nil + }, + } + + client := NewClient(Config{ + DA: mockDAInstance, + Logger: zerolog.Nop(), + Namespace: "test-ns", + DataNamespace: "test-data-ns", + ForcedInclusionNamespace: "test-fi-ns", + }) + + gen := genesis.Genesis{ + DAStartHeight: 100, + DAEpochForcedInclusion: 1, // Single height epoch + } + + retriever := NewForcedInclusionRetriever(client, gen, zerolog.Nop()) + ctx := context.Background() + + // Height 100 is an epoch start + event, err := retriever.RetrieveForcedIncludedTxs(ctx, 100) + assert.NilError(t, err) + assert.Assert(t, event != nil) + assert.Equal(t, event.StartDaHeight, uint64(100)) + assert.Equal(t, event.EndDaHeight, uint64(100)) + assert.Equal(t, len(event.Txs), len(testBlobs)) + assert.DeepEqual(t, event.Txs[0], testBlobs[0]) +} + +func TestForcedInclusionRetriever_RetrieveForcedIncludedTxs_EpochStartNotAvailable(t *testing.T) { + mockDAInstance := &mockDA{ + getIDsFunc: func(ctx context.Context, height uint64, namespace []byte) (*coreda.GetIDsResult, error) { + return nil, coreda.ErrHeightFromFuture + }, + } + + client := NewClient(Config{ + DA: mockDAInstance, + Logger: zerolog.Nop(), + Namespace: "test-ns", + DataNamespace: "test-data-ns", + ForcedInclusionNamespace: "test-fi-ns", + }) + + gen := genesis.Genesis{ + DAStartHeight: 100, + DAEpochForcedInclusion: 10, + } + + retriever := NewForcedInclusionRetriever(client, gen, zerolog.Nop()) + ctx := context.Background() + + _, err := retriever.RetrieveForcedIncludedTxs(ctx, 100) + assert.Assert(t, err != nil) + assert.ErrorContains(t, err, "not yet available") +} + +func TestForcedInclusionRetriever_RetrieveForcedIncludedTxs_NoBlobsAtHeight(t *testing.T) { + mockDAInstance := &mockDA{ + getIDsFunc: func(ctx context.Context, height uint64, namespace []byte) (*coreda.GetIDsResult, error) { + return nil, coreda.ErrBlobNotFound + }, + } + + client := NewClient(Config{ + DA: mockDAInstance, + Logger: zerolog.Nop(), + Namespace: "test-ns", + DataNamespace: "test-data-ns", + ForcedInclusionNamespace: "test-fi-ns", + }) + + gen := genesis.Genesis{ + DAStartHeight: 100, + DAEpochForcedInclusion: 1, // Single height epoch + } + + retriever := NewForcedInclusionRetriever(client, gen, zerolog.Nop()) + ctx := context.Background() + + event, err := retriever.RetrieveForcedIncludedTxs(ctx, 100) + assert.NilError(t, err) + assert.Assert(t, event != nil) + assert.Equal(t, len(event.Txs), 0) +} + +func TestForcedInclusionRetriever_RetrieveForcedIncludedTxs_MultiHeightEpoch(t *testing.T) { + callCount := 0 + testBlobsByHeight := map[uint64][][]byte{ + 100: {[]byte("tx1"), []byte("tx2")}, + 101: {[]byte("tx3")}, + 102: {[]byte("tx4"), []byte("tx5"), []byte("tx6")}, + } + + mockDAInstance := &mockDA{ + getIDsFunc: func(ctx context.Context, height uint64, namespace []byte) (*coreda.GetIDsResult, error) { + callCount++ + blobs, exists := testBlobsByHeight[height] + if !exists { + return nil, coreda.ErrBlobNotFound + } + ids := make([]coreda.ID, len(blobs)) + for i := range blobs { + ids[i] = []byte("id") + } + return &coreda.GetIDsResult{ + IDs: ids, + Timestamp: time.Now(), + }, nil + }, + getFunc: func(ctx context.Context, ids []coreda.ID, namespace []byte) ([]coreda.Blob, error) { + // Return blobs based on current call count + switch callCount { + case 1: + return testBlobsByHeight[100], nil + case 2: + return testBlobsByHeight[101], nil + case 3: + return testBlobsByHeight[102], nil + default: + return nil, errors.New("unexpected call") + } + }, + } + + client := NewClient(Config{ + DA: mockDAInstance, + Logger: zerolog.Nop(), + Namespace: "test-ns", + DataNamespace: "test-data-ns", + ForcedInclusionNamespace: "test-fi-ns", + }) + + gen := genesis.Genesis{ + DAStartHeight: 100, + DAEpochForcedInclusion: 3, // Epoch: 100-102 + } + + retriever := NewForcedInclusionRetriever(client, gen, zerolog.Nop()) + ctx := context.Background() + + event, err := retriever.RetrieveForcedIncludedTxs(ctx, 100) + assert.NilError(t, err) + assert.Assert(t, event != nil) + assert.Equal(t, event.StartDaHeight, uint64(100)) + assert.Equal(t, event.EndDaHeight, uint64(102)) + + // Should have collected all txs from all heights + expectedTxCount := len(testBlobsByHeight[100]) + len(testBlobsByHeight[101]) + len(testBlobsByHeight[102]) + assert.Equal(t, len(event.Txs), expectedTxCount) +} + +func TestForcedInclusionRetriever_processForcedInclusionBlobs(t *testing.T) { + client := NewClient(Config{ + DA: &mockDA{}, + Logger: zerolog.Nop(), + Namespace: "test-ns", + DataNamespace: "test-data-ns", + ForcedInclusionNamespace: "test-fi-ns", + }) + + gen := genesis.Genesis{ + DAStartHeight: 100, + DAEpochForcedInclusion: 10, + } + + retriever := NewForcedInclusionRetriever(client, gen, zerolog.Nop()) + + tests := []struct { + name string + result coreda.ResultRetrieve + height uint64 + expectedTxCount int + expectedLastHeight uint64 + expectError bool + }{ + { + name: "success with blobs", + result: coreda.ResultRetrieve{ + BaseResult: coreda.BaseResult{ + Code: coreda.StatusSuccess, + }, + Data: [][]byte{[]byte("tx1"), []byte("tx2")}, + }, + height: 100, + expectedTxCount: 2, + expectedLastHeight: 100, + expectError: false, + }, + { + name: "not found", + result: coreda.ResultRetrieve{ + BaseResult: coreda.BaseResult{ + Code: coreda.StatusNotFound, + }, + }, + height: 100, + expectedTxCount: 0, + expectedLastHeight: 100, + expectError: false, + }, + { + name: "error status", + result: coreda.ResultRetrieve{ + BaseResult: coreda.BaseResult{ + Code: coreda.StatusError, + Message: "test error", + }, + }, + height: 100, + expectError: true, + }, + { + name: "empty blobs are skipped", + result: coreda.ResultRetrieve{ + BaseResult: coreda.BaseResult{ + Code: coreda.StatusSuccess, + }, + Data: [][]byte{[]byte("tx1"), {}, []byte("tx2")}, + }, + height: 100, + expectedTxCount: 2, + expectedLastHeight: 100, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := &ForcedInclusionEvent{ + Txs: [][]byte{}, + } + lastHeight := uint64(0) + + err := retriever.processForcedInclusionBlobs(event, &lastHeight, tt.result, tt.height) + + if tt.expectError { + assert.Assert(t, err != nil) + } else { + assert.NilError(t, err) + assert.Equal(t, len(event.Txs), tt.expectedTxCount) + assert.Equal(t, lastHeight, tt.expectedLastHeight) + } + }) + } +} diff --git a/block/internal/submitting/da_submitter.go b/block/internal/submitting/da_submitter.go index 5a8fabc167..8cf741dcd9 100644 --- a/block/internal/submitting/da_submitter.go +++ b/block/internal/submitting/da_submitter.go @@ -12,6 +12,7 @@ import ( "github.com/evstack/ev-node/block/internal/cache" "github.com/evstack/ev-node/block/internal/common" + "github.com/evstack/ev-node/block/internal/da" coreda "github.com/evstack/ev-node/core/da" "github.com/evstack/ev-node/pkg/config" pkgda "github.com/evstack/ev-node/pkg/da" @@ -94,24 +95,20 @@ func clamp(v, min, max time.Duration) time.Duration { // DASubmitter handles DA submission operations type DASubmitter struct { - da coreda.DA + client da.Client config config.Config genesis genesis.Genesis options common.BlockOptions logger zerolog.Logger metrics *common.Metrics - // calculate namespaces bytes once and reuse them - namespaceBz []byte - namespaceDataBz []byte - // address selector for multi-account support addressSelector pkgda.AddressSelector } // NewDASubmitter creates a new DA submitter func NewDASubmitter( - da coreda.DA, + client da.Client, config config.Config, genesis genesis.Genesis, options common.BlockOptions, @@ -122,7 +119,7 @@ func NewDASubmitter( if config.RPC.EnableDAVisualization { visualizerLogger := logger.With().Str("component", "da_visualization").Logger() - server.SetDAVisualizationServer(server.NewDAVisualizationServer(da, visualizerLogger, config.Node.Aggregator)) + server.SetDAVisualizationServer(server.NewDAVisualizationServer(client.GetDA(), visualizerLogger, config.Node.Aggregator)) } // Use NoOp metrics if nil to avoid nil checks throughout the code @@ -142,14 +139,12 @@ func NewDASubmitter( } return &DASubmitter{ - da: da, + client: client, config: config, genesis: genesis, options: options, metrics: metrics, logger: daSubmitterLogger, - namespaceBz: coreda.NamespaceFromString(config.DA.GetNamespace()).Bytes(), - namespaceDataBz: coreda.NamespaceFromString(config.DA.GetDataNamespace()).Bytes(), addressSelector: addressSelector, } } @@ -199,7 +194,7 @@ func (s *DASubmitter) SubmitHeaders(ctx context.Context, cache cache.Manager) er } }, "header", - s.namespaceBz, + s.client.GetHeaderNamespace(), []byte(s.config.DA.SubmitOptions), func() uint64 { return cache.NumPendingHeaders() }, ) @@ -242,7 +237,7 @@ func (s *DASubmitter) SubmitData(ctx context.Context, cache cache.Manager, signe } }, "data", - s.namespaceDataBz, + s.client.GetDataNamespace(), []byte(s.config.DA.SubmitOptions), func() uint64 { return cache.NumPendingData() }, ) @@ -411,7 +406,7 @@ func submitToDA[T any]( // Perform submission start := time.Now() - res := types.SubmitWithHelpers(submitCtx, s.da, s.logger, marshaled, -1, namespace, mergedOptions) + res := s.client.Submit(submitCtx, marshaled, -1, namespace, mergedOptions) s.logger.Debug().Int("attempts", rs.Attempt).Dur("elapsed", time.Since(start)).Uint64("code", uint64(res.Code)).Msg("got SubmitWithHelpers response from celestia") // Record submission result for observability diff --git a/block/internal/submitting/da_submitter_integration_test.go b/block/internal/submitting/da_submitter_integration_test.go index 421340e11d..5b768e1a51 100644 --- a/block/internal/submitting/da_submitter_integration_test.go +++ b/block/internal/submitting/da_submitter_integration_test.go @@ -15,6 +15,7 @@ import ( "github.com/evstack/ev-node/block/internal/cache" "github.com/evstack/ev-node/block/internal/common" + "github.com/evstack/ev-node/block/internal/da" coreda "github.com/evstack/ev-node/core/da" "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/pkg/genesis" @@ -86,7 +87,13 @@ func TestDASubmitter_SubmitHeadersAndData_MarksInclusionAndUpdatesLastSubmitted( dummyDA := coreda.NewDummyDA(10_000_000, 10*time.Millisecond) // Create DA submitter - daSubmitter := NewDASubmitter(dummyDA, cfg, gen, common.DefaultBlockOptions(), common.NopMetrics(), zerolog.Nop()) + daClient := da.NewClient(da.Config{ + DA: dummyDA, + Logger: zerolog.Nop(), + Namespace: cfg.DA.Namespace, + DataNamespace: cfg.DA.DataNamespace, + }) + daSubmitter := NewDASubmitter(daClient, cfg, gen, common.DefaultBlockOptions(), common.NopMetrics(), zerolog.Nop()) // Submit headers and data require.NoError(t, daSubmitter.SubmitHeaders(context.Background(), cm)) diff --git a/block/internal/submitting/da_submitter_mocks_test.go b/block/internal/submitting/da_submitter_mocks_test.go index d914e6db61..b215b0cf2f 100644 --- a/block/internal/submitting/da_submitter_mocks_test.go +++ b/block/internal/submitting/da_submitter_mocks_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/evstack/ev-node/block/internal/common" + "github.com/evstack/ev-node/block/internal/da" coreda "github.com/evstack/ev-node/core/da" "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/pkg/genesis" @@ -25,10 +26,17 @@ func newTestSubmitter(mockDA *mocks.MockDA, override func(*config.Config)) *DASu cfg.DA.MaxSubmitAttempts = 3 cfg.DA.SubmitOptions = "opts" cfg.DA.Namespace = "ns" + cfg.DA.DataNamespace = "ns-data" if override != nil { override(&cfg) } - return NewDASubmitter(mockDA, cfg, genesis.Genesis{} /*options=*/, common.BlockOptions{}, common.NopMetrics(), zerolog.Nop()) + daClient := da.NewClient(da.Config{ + DA: mockDA, + Logger: zerolog.Nop(), + Namespace: cfg.DA.Namespace, + DataNamespace: cfg.DA.DataNamespace, + }) + return NewDASubmitter(daClient, cfg, genesis.Genesis{} /*options=*/, common.BlockOptions{}, common.NopMetrics(), zerolog.Nop()) } // marshal helper for simple items diff --git a/block/internal/submitting/da_submitter_test.go b/block/internal/submitting/da_submitter_test.go index c657d8185b..214ab98db4 100644 --- a/block/internal/submitting/da_submitter_test.go +++ b/block/internal/submitting/da_submitter_test.go @@ -15,6 +15,7 @@ import ( "github.com/evstack/ev-node/block/internal/cache" "github.com/evstack/ev-node/block/internal/common" + "github.com/evstack/ev-node/block/internal/da" coreda "github.com/evstack/ev-node/core/da" "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/pkg/genesis" @@ -51,8 +52,14 @@ func setupDASubmitterTest(t *testing.T) (*DASubmitter, store.Store, cache.Manage } // Create DA submitter + daClient := da.NewClient(da.Config{ + DA: dummyDA, + Logger: zerolog.Nop(), + Namespace: cfg.DA.Namespace, + DataNamespace: cfg.DA.DataNamespace, + }) daSubmitter := NewDASubmitter( - dummyDA, + daClient, cfg, gen, common.DefaultBlockOptions(), @@ -80,7 +87,7 @@ func TestDASubmitter_NewDASubmitter(t *testing.T) { submitter, _, _, _, _ := setupDASubmitterTest(t) assert.NotNil(t, submitter) - assert.NotNil(t, submitter.da) + assert.NotNil(t, submitter.client) assert.NotNil(t, submitter.config) assert.NotNil(t, submitter.genesis) } @@ -95,8 +102,14 @@ func TestNewDASubmitterSetsVisualizerWhenEnabled(t *testing.T) { dummyDA := coreda.NewDummyDA(10_000_000, 10*time.Millisecond) + daClient := da.NewClient(da.Config{ + DA: dummyDA, + Logger: zerolog.Nop(), + Namespace: cfg.DA.Namespace, + DataNamespace: cfg.DA.DataNamespace, + }) NewDASubmitter( - dummyDA, + daClient, cfg, genesis.Genesis{}, common.DefaultBlockOptions(), diff --git a/block/internal/submitting/submitter_test.go b/block/internal/submitting/submitter_test.go index c13d8a1df7..33350ae268 100644 --- a/block/internal/submitting/submitter_test.go +++ b/block/internal/submitting/submitter_test.go @@ -18,6 +18,7 @@ import ( "github.com/evstack/ev-node/block/internal/cache" "github.com/evstack/ev-node/block/internal/common" + "github.com/evstack/ev-node/block/internal/da" "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/pkg/genesis" "github.com/evstack/ev-node/pkg/rpc/server" @@ -158,8 +159,16 @@ func TestSubmitter_setSequencerHeightToDAHeight(t *testing.T) { mockStore := testmocks.NewMockStore(t) cfg := config.DefaultConfig() + cfg.DA.Namespace = "test-ns" + cfg.DA.DataNamespace = "test-data-ns" metrics := common.NopMetrics() - daSub := NewDASubmitter(nil, cfg, genesis.Genesis{}, common.DefaultBlockOptions(), metrics, zerolog.Nop()) + daClient := da.NewClient(da.Config{ + DA: nil, + Logger: zerolog.Nop(), + Namespace: cfg.DA.Namespace, + DataNamespace: cfg.DA.DataNamespace, + }) + daSub := NewDASubmitter(daClient, cfg, genesis.Genesis{}, common.BlockOptions{}, metrics, zerolog.Nop()) s := NewSubmitter(mockStore, nil, cm, metrics, cfg, genesis.Genesis{}, daSub, nil, zerolog.Nop(), nil) s.ctx = ctx @@ -238,7 +247,13 @@ func TestSubmitter_processDAInclusionLoop_advances(t *testing.T) { exec.On("SetFinal", mock.Anything, uint64(1)).Return(nil).Once() exec.On("SetFinal", mock.Anything, uint64(2)).Return(nil).Once() - daSub := NewDASubmitter(nil, cfg, genesis.Genesis{}, common.DefaultBlockOptions(), metrics, zerolog.Nop()) + daClient := da.NewClient(da.Config{ + DA: nil, + Logger: zerolog.Nop(), + Namespace: cfg.DA.Namespace, + DataNamespace: cfg.DA.DataNamespace, + }) + daSub := NewDASubmitter(daClient, cfg, genesis.Genesis{}, common.BlockOptions{}, metrics, zerolog.Nop()) s := NewSubmitter(st, exec, cm, metrics, cfg, genesis.Genesis{}, daSub, nil, zerolog.Nop(), nil) // prepare two consecutive blocks in store with DA included in cache @@ -423,7 +438,13 @@ func TestSubmitter_CacheClearedOnHeightInclusion(t *testing.T) { exec.On("SetFinal", mock.Anything, uint64(1)).Return(nil).Once() exec.On("SetFinal", mock.Anything, uint64(2)).Return(nil).Once() - daSub := NewDASubmitter(nil, cfg, genesis.Genesis{}, common.DefaultBlockOptions(), metrics, zerolog.Nop()) + daClient := da.NewClient(da.Config{ + DA: nil, + Logger: zerolog.Nop(), + Namespace: cfg.DA.Namespace, + DataNamespace: cfg.DA.DataNamespace, + }) + daSub := NewDASubmitter(daClient, cfg, genesis.Genesis{}, common.BlockOptions{}, metrics, zerolog.Nop()) s := NewSubmitter(st, exec, cm, metrics, cfg, genesis.Genesis{}, daSub, nil, zerolog.Nop(), nil) // Create test blocks diff --git a/block/internal/syncing/da_retriever.go b/block/internal/syncing/da_retriever.go index e52f8a4ce5..c87750b0f5 100644 --- a/block/internal/syncing/da_retriever.go +++ b/block/internal/syncing/da_retriever.go @@ -5,90 +5,51 @@ import ( "context" "errors" "fmt" - "time" "github.com/rs/zerolog" "google.golang.org/protobuf/proto" "github.com/evstack/ev-node/block/internal/cache" "github.com/evstack/ev-node/block/internal/common" + "github.com/evstack/ev-node/block/internal/da" coreda "github.com/evstack/ev-node/core/da" - "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/pkg/genesis" "github.com/evstack/ev-node/types" pb "github.com/evstack/ev-node/types/pb/evnode/v1" ) -// defaultDATimeout is the default timeout for DA retrieval operations -const defaultDATimeout = 10 * time.Second - -// pendingForcedInclusionTx represents a forced inclusion transaction that couldn't fit in the current epoch -// and needs to be retried in future epochs. -type pendingForcedInclusionTx struct { - Data []byte // The transaction data - OriginalHeight uint64 // Original DA height where this transaction was found -} - // DARetriever defines the interface for retrieving events from the DA layer type DARetriever interface { RetrieveFromDA(ctx context.Context, daHeight uint64) ([]common.DAHeightEvent, error) - RetrieveForcedIncludedTxsFromDA(ctx context.Context, daHeight uint64) (*common.ForcedIncludedEvent, error) } // daRetriever handles DA retrieval operations for syncing type daRetriever struct { - da coreda.DA + client da.Client cache cache.CacheManager genesis genesis.Genesis logger zerolog.Logger - // calculate namespaces bytes once and reuse them - namespaceBz []byte - namespaceDataBz []byte - namespaceForcedInclusionBz []byte - - hasForcedInclusionNs bool - daEpochSize uint64 - // transient cache, only full event need to be passed to the syncer // on restart, will be refetch as da height is updated by syncer pendingHeaders map[uint64]*types.SignedHeader pendingData map[uint64]*types.Data - - // Forced inclusion transactions that couldn't fit in the current epoch - // and need to be retried in future epochs. - pendingForcedInclusionTxs []pendingForcedInclusionTx } // NewDARetriever creates a new DA retriever func NewDARetriever( - da coreda.DA, + client da.Client, cache cache.CacheManager, - config config.Config, genesis genesis.Genesis, logger zerolog.Logger, ) *daRetriever { - forcedInclusionNs := config.DA.GetForcedInclusionNamespace() - hasForcedInclusionNs := forcedInclusionNs != "" - - var namespaceForcedInclusionBz []byte - if hasForcedInclusionNs { - namespaceForcedInclusionBz = coreda.NamespaceFromString(forcedInclusionNs).Bytes() - } - return &daRetriever{ - da: da, - cache: cache, - genesis: genesis, - logger: logger.With().Str("component", "da_retriever").Logger(), - namespaceBz: coreda.NamespaceFromString(config.DA.GetNamespace()).Bytes(), - namespaceDataBz: coreda.NamespaceFromString(config.DA.GetDataNamespace()).Bytes(), - namespaceForcedInclusionBz: namespaceForcedInclusionBz, - hasForcedInclusionNs: hasForcedInclusionNs, - daEpochSize: genesis.DAEpochForcedInclusion, - pendingHeaders: make(map[uint64]*types.SignedHeader), - pendingData: make(map[uint64]*types.Data), - pendingForcedInclusionTxs: make([]pendingForcedInclusionTx, 0), + client: client, + cache: cache, + genesis: genesis, + logger: logger.With().Str("component", "da_retriever").Logger(), + pendingHeaders: make(map[uint64]*types.SignedHeader), + pendingData: make(map[uint64]*types.Data), } } @@ -109,234 +70,17 @@ func (r *daRetriever) RetrieveFromDA(ctx context.Context, daHeight uint64) ([]co return r.processBlobs(ctx, blobsResp.Data, daHeight), nil } -// RetrieveForcedIncludedTxsFromDA retrieves forced inclusion transactions from the DA layer. -// -// Behavior: -// - At epoch boundaries (when daHeight == epochStart): fetches new forced-inclusion transactions -// from the DA layer for the entire epoch range, processes them, and returns all that fit within -// the max blob size limit. Transactions that don't fit are stored in the pending queue for retry. -// - Outside epoch boundaries (when daHeight != epochStart): returns any pending transactions from -// the queue that were deferred from previous epochs. -// - Pending transactions are kept in-memory only and will be lost on node restart. -// -// Returns: -// - ForcedIncludedEvent with transactions that should be included in the next block (may be empty) -// - Error if forced inclusion is not configured or DA layer is unavailable -func (r *daRetriever) RetrieveForcedIncludedTxsFromDA(ctx context.Context, daHeight uint64) (*common.ForcedIncludedEvent, error) { - if !r.hasForcedInclusionNs { - return nil, common.ErrForceInclusionNotConfigured - } - - // Calculate deterministic epoch boundaries - epochStart, epochEnd := types.CalculateEpochBoundaries(daHeight, r.genesis.DAStartHeight, r.daEpochSize) - - // If we're not at epoch start, return pending transactions only (if any) - if daHeight != epochStart { - r.logger.Debug(). - Uint64("da_height", daHeight). - Uint64("epoch_start", epochStart). - Int("pending_count", len(r.pendingForcedInclusionTxs)). - Msg("not at epoch start - returning pending transactions only") - - event := &common.ForcedIncludedEvent{ - StartDaHeight: daHeight, - EndDaHeight: daHeight, - Txs: [][]byte{}, - } - - // Return pending txs if any exist - if len(r.pendingForcedInclusionTxs) > 0 { - pendingTxs, indicesToRemove, _ := r.processPendingForcedInclusionTxs() - event.Txs = pendingTxs - - // Remove successfully included pending transactions - if len(indicesToRemove) > 0 { - r.removePendingForcedInclusionTxs(indicesToRemove) - r.logger.Debug(). - Int("included_count", len(indicesToRemove)). - Int("remaining_count", len(r.pendingForcedInclusionTxs)). - Msg("included pending forced inclusion transactions") - } - } - - return event, nil - } - - // We're at epoch start - fetch new transactions from DA - - currentEpochNumber := types.CalculateEpochNumber(daHeight, r.genesis.DAStartHeight, r.daEpochSize) - - event := &common.ForcedIncludedEvent{ - StartDaHeight: epochStart, - } - - r.logger.Debug(). - Uint64("da_height", daHeight). - Uint64("epoch_start", epochStart). - Uint64("epoch_end", epochEnd). - Uint64("epoch_num", currentEpochNumber). - Msg("retrieving forced included transactions from DA") - - // Check if both epoch start and end are available before fetching - // This ensures we can retrieve the complete epoch in one go - epochStartResult := types.RetrieveWithHelpers(ctx, r.da, r.logger, epochStart, r.namespaceForcedInclusionBz, defaultDATimeout) - if epochStartResult.Code == coreda.StatusHeightFromFuture { - r.logger.Debug(). - Uint64("epoch_start", epochStart). - Msg("epoch start height not yet available on DA - backoff required") - return nil, fmt.Errorf("%w: epoch start height %d not yet available", coreda.ErrHeightFromFuture, epochStart) - } - - epochEndResult := epochStartResult - if epochStart != epochEnd { - epochEndResult = types.RetrieveWithHelpers(ctx, r.da, r.logger, epochEnd, r.namespaceForcedInclusionBz, defaultDATimeout) - if epochEndResult.Code == coreda.StatusHeightFromFuture { - r.logger.Debug(). - Uint64("epoch_end", epochEnd). - Msg("epoch end height not yet available on DA - backoff required") - return nil, fmt.Errorf("%w: epoch end height %d not yet available", coreda.ErrHeightFromFuture, epochEnd) - } - } - - lastProcessedHeight := epochStart - newPendingTxs := []pendingForcedInclusionTx{} - - // Prepend pending transactions from previous epochs at the start of this epoch - pendingTxs, indicesToRemove, currentSize := r.processPendingForcedInclusionTxs() - event.Txs = pendingTxs - - // Remove successfully included pending transactions - if len(indicesToRemove) > 0 { - r.removePendingForcedInclusionTxs(indicesToRemove) - r.logger.Debug(). - Int("included_count", len(indicesToRemove)). - Int("remaining_count", len(r.pendingForcedInclusionTxs)). - Msg("included pending forced inclusion transactions") - } - - // Process epoch start - if err := r.processForcedInclusionBlobs(event, ¤tSize, &lastProcessedHeight, &newPendingTxs, epochStartResult, epochStart); err != nil { - return nil, err - } - - // Process heights between start and end (exclusive) - for epochHeight := epochStart + 1; epochHeight < epochEnd; epochHeight++ { - result := types.RetrieveWithHelpers(ctx, r.da, r.logger, epochHeight, r.namespaceForcedInclusionBz, defaultDATimeout) - - // If any intermediate height is from future, break early - if result.Code == coreda.StatusHeightFromFuture { - r.logger.Debug(). - Uint64("epoch_height", epochHeight). - Uint64("last_processed", lastProcessedHeight). - Msg("reached future DA height within epoch - stopping") - break - } - - if err := r.processForcedInclusionBlobs(event, ¤tSize, &lastProcessedHeight, &newPendingTxs, result, epochHeight); err != nil { - return nil, err - } - } - - // Process epoch end (only if different from start) - if epochEnd != epochStart { - if err := r.processForcedInclusionBlobs(event, ¤tSize, &lastProcessedHeight, &newPendingTxs, epochEndResult, epochEnd); err != nil { - return nil, err - } - } - - // Store any new pending transactions that couldn't fit in this epoch - if len(newPendingTxs) > 0 { - r.pendingForcedInclusionTxs = append(r.pendingForcedInclusionTxs, newPendingTxs...) - r.logger.Info(). - Int("new_pending_count", len(newPendingTxs)). - Int("total_pending_count", len(r.pendingForcedInclusionTxs)). - Msg("stored pending forced inclusion transactions for next epoch") - } - - // Set the DA height range based on what we actually processed - event.StartDaHeight = epochStart - event.EndDaHeight = lastProcessedHeight - - return event, nil -} - -// processForcedInclusionBlobs processes forced inclusion blobs from a single DA height. -// It accumulates transactions that fit within maxBlobSize and stores excess in newPendingTxs. -func (r *daRetriever) processForcedInclusionBlobs( - event *common.ForcedIncludedEvent, - currentSize *int, - lastProcessedHeight *uint64, - newPendingTxs *[]pendingForcedInclusionTx, - result coreda.ResultRetrieve, - daHeight uint64, -) error { - if result.Code != coreda.StatusSuccess { - return nil - } - - if err := r.validateBlobResponse(result, daHeight); !errors.Is(err, coreda.ErrBlobNotFound) && err != nil { - return err - } - - for i, data := range result.Data { - if len(data) > common.DefaultMaxBlobSize { - r.logger.Debug(). - Uint64("da_height", daHeight). - Int("index", i). - Uint64("blob_size", uint64(len(data))). - Msg("Following data exceeds maximum blob size. Skipping...") - continue - } - - // Calculate size of this specific data item - dataSize := len(data) - - // Check if individual blob exceeds max size - if dataSize > int(common.DefaultMaxBlobSize) { - r.logger.Warn(). - Uint64("da_height", daHeight). - Int("blob_size", dataSize). - Float64("max_size", common.DefaultMaxBlobSize). - Msg("forced inclusion blob exceeds maximum size - skipping") - return fmt.Errorf("blob size %d exceeds maximum %f", dataSize, common.DefaultMaxBlobSize) - } - - // Check if adding this blob would exceed the current epoch's max size - if *currentSize+dataSize > int(common.DefaultMaxBlobSize) { - r.logger.Debug(). - Uint64("da_height", daHeight). - Int("current_size", *currentSize). - Int("blob_size", dataSize). - Msg("blob would exceed max size for this epoch - deferring to pending queue") - - // Store for next epoch - *newPendingTxs = append(*newPendingTxs, pendingForcedInclusionTx{ - Data: data, - OriginalHeight: daHeight, - }) - continue - } - - // Include this transaction - event.Txs = append(event.Txs, data) - *currentSize += dataSize - *lastProcessedHeight = daHeight - } - - return nil -} - -// fetchBlobs retrieves blobs from the DA layer +// fetchBlobs retrieves blobs from both header and data namespaces func (r *daRetriever) fetchBlobs(ctx context.Context, daHeight uint64) (coreda.ResultRetrieve, error) { - // Retrieve from both namespaces - headerRes := types.RetrieveWithHelpers(ctx, r.da, r.logger, daHeight, r.namespaceBz, defaultDATimeout) + // Retrieve from both namespaces using the DA client + headerRes := r.client.RetrieveHeaders(ctx, daHeight) // If namespaces are the same, return header result - if bytes.Equal(r.namespaceBz, r.namespaceDataBz) { + if bytes.Equal(r.client.GetHeaderNamespace(), r.client.GetDataNamespace()) { return headerRes, r.validateBlobResponse(headerRes, daHeight) } - dataRes := types.RetrieveWithHelpers(ctx, r.da, r.logger, daHeight, r.namespaceDataBz, defaultDATimeout) + dataRes := r.client.RetrieveData(ctx, daHeight) // Validate responses headerErr := r.validateBlobResponse(headerRes, daHeight) @@ -592,53 +336,3 @@ func createEmptyDataForHeader(ctx context.Context, header *types.SignedHeader) * }, } } - -// processPendingForcedInclusionTxs processes pending transactions and returns those that fit within the max blob size. -// Returns the transactions to include, the indices of transactions to remove, and the total size used. -func (r *daRetriever) processPendingForcedInclusionTxs() ([][]byte, []int, int) { - var ( - currentSize int - txs [][]byte - indicesToRemove []int - ) - - for i, pendingTx := range r.pendingForcedInclusionTxs { - dataSize := len(pendingTx.Data) - if currentSize+dataSize > int(common.DefaultMaxBlobSize) { - r.logger.Debug(). - Int("current_size", currentSize). - Int("data_size", dataSize). - Msg("pending transaction would exceed max blob size, will retry later") - break - } - - txs = append(txs, pendingTx.Data) - currentSize += dataSize - indicesToRemove = append(indicesToRemove, i) - } - - return txs, indicesToRemove, currentSize -} - -// removePendingForcedInclusionTxs removes pending transactions at the specified indices. -// Indices must be sorted in ascending order. -func (r *daRetriever) removePendingForcedInclusionTxs(indices []int) { - if len(indices) == 0 { - return - } - - // Create a new slice without the removed elements - newPending := make([]pendingForcedInclusionTx, 0, len(r.pendingForcedInclusionTxs)-len(indices)) - removeMap := make(map[int]bool, len(indices)) - for _, idx := range indices { - removeMap[idx] = true - } - - for i, tx := range r.pendingForcedInclusionTxs { - if !removeMap[i] { - newPending = append(newPending, tx) - } - } - - r.pendingForcedInclusionTxs = newPending -} diff --git a/block/internal/syncing/da_retriever_mock.go b/block/internal/syncing/da_retriever_mock.go index 505987aee6..32e901bc1e 100644 --- a/block/internal/syncing/da_retriever_mock.go +++ b/block/internal/syncing/da_retriever_mock.go @@ -38,74 +38,6 @@ func (_m *MockDARetriever) EXPECT() *MockDARetriever_Expecter { return &MockDARetriever_Expecter{mock: &_m.Mock} } -// RetrieveForcedIncludedTxsFromDA provides a mock function for the type MockDARetriever -func (_mock *MockDARetriever) RetrieveForcedIncludedTxsFromDA(ctx context.Context, daHeight uint64) (*common.ForcedIncludedEvent, error) { - ret := _mock.Called(ctx, daHeight) - - if len(ret) == 0 { - panic("no return value specified for RetrieveForcedIncludedTxsFromDA") - } - - var r0 *common.ForcedIncludedEvent - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, uint64) (*common.ForcedIncludedEvent, error)); ok { - return returnFunc(ctx, daHeight) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, uint64) *common.ForcedIncludedEvent); ok { - r0 = returnFunc(ctx, daHeight) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*common.ForcedIncludedEvent) - } - } - if returnFunc, ok := ret.Get(1).(func(context.Context, uint64) error); ok { - r1 = returnFunc(ctx, daHeight) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockDARetriever_RetrieveForcedIncludedTxsFromDA_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveForcedIncludedTxsFromDA' -type MockDARetriever_RetrieveForcedIncludedTxsFromDA_Call struct { - *mock.Call -} - -// RetrieveForcedIncludedTxsFromDA is a helper method to define mock.On call -// - ctx context.Context -// - daHeight uint64 -func (_e *MockDARetriever_Expecter) RetrieveForcedIncludedTxsFromDA(ctx interface{}, daHeight interface{}) *MockDARetriever_RetrieveForcedIncludedTxsFromDA_Call { - return &MockDARetriever_RetrieveForcedIncludedTxsFromDA_Call{Call: _e.mock.On("RetrieveForcedIncludedTxsFromDA", ctx, daHeight)} -} - -func (_c *MockDARetriever_RetrieveForcedIncludedTxsFromDA_Call) Run(run func(ctx context.Context, daHeight uint64)) *MockDARetriever_RetrieveForcedIncludedTxsFromDA_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 uint64 - if args[1] != nil { - arg1 = args[1].(uint64) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *MockDARetriever_RetrieveForcedIncludedTxsFromDA_Call) Return(v *common.ForcedIncludedEvent, err error) *MockDARetriever_RetrieveForcedIncludedTxsFromDA_Call { - _c.Call.Return(v, err) - return _c -} - -func (_c *MockDARetriever_RetrieveForcedIncludedTxsFromDA_Call) RunAndReturn(run func(ctx context.Context, daHeight uint64) (*common.ForcedIncludedEvent, error)) *MockDARetriever_RetrieveForcedIncludedTxsFromDA_Call { - _c.Call.Return(run) - return _c -} - // RetrieveFromDA provides a mock function for the type MockDARetriever func (_mock *MockDARetriever) RetrieveFromDA(ctx context.Context, daHeight uint64) ([]common.DAHeightEvent, error) { ret := _mock.Called(ctx, daHeight) diff --git a/block/internal/syncing/da_retriever_test.go b/block/internal/syncing/da_retriever_test.go index d4cd2b2dbc..04ba66e423 100644 --- a/block/internal/syncing/da_retriever_test.go +++ b/block/internal/syncing/da_retriever_test.go @@ -16,6 +16,7 @@ import ( "github.com/evstack/ev-node/block/internal/cache" "github.com/evstack/ev-node/block/internal/common" + "github.com/evstack/ev-node/block/internal/da" coreda "github.com/evstack/ev-node/core/da" "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/pkg/genesis" @@ -24,6 +25,29 @@ import ( "github.com/evstack/ev-node/types" ) +// newTestDARetriever creates a DA retriever for testing with the given DA implementation +func newTestDARetriever(t *testing.T, mockDA coreda.DA, cfg config.Config, gen genesis.Genesis) *daRetriever { + t.Helper() + if cfg.DA.Namespace == "" { + cfg.DA.Namespace = "test-ns" + } + if cfg.DA.DataNamespace == "" { + cfg.DA.DataNamespace = "test-data-ns" + } + + cm, err := cache.NewCacheManager(cfg, zerolog.Nop()) + require.NoError(t, err) + + daClient := da.NewClient(da.Config{ + DA: mockDA, + Logger: zerolog.Nop(), + Namespace: cfg.DA.Namespace, + DataNamespace: cfg.DA.DataNamespace, + }) + + return NewDARetriever(daClient, cm, gen, zerolog.Nop()) +} + // makeSignedDataBytes builds SignedData containing the provided Data and returns its binary encoding func makeSignedDataBytes(t *testing.T, chainID string, height uint64, proposer []byte, pub crypto.PubKey, signer signerpkg.Signer, txs int) ([]byte, *types.SignedData) { return makeSignedDataBytesWithTime(t, chainID, height, proposer, pub, signer, txs, uint64(time.Now().UnixNano())) @@ -39,57 +63,45 @@ func makeSignedDataBytesWithTime(t *testing.T, chainID string, height uint64, pr } // For DA SignedData, sign the Data payload bytes (matches DA submission logic) - payload, err := d.MarshalBinary() - require.NoError(t, err) - sig, err := signer.Sign(payload) - require.NoError(t, err) + payload, _ := d.MarshalBinary() + sig, _ := signer.Sign(payload) sd := &types.SignedData{Data: *d, Signature: sig, Signer: types.Signer{PubKey: pub, Address: proposer}} - bin, err := sd.MarshalBinary() - require.NoError(t, err) + bin, _ := sd.MarshalBinary() return bin, sd } func TestDARetriever_RetrieveFromDA_Invalid(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - assert.NoError(t, err) - mockDA := testmocks.NewMockDA(t) mockDA.EXPECT().GetIDs(mock.Anything, mock.Anything, mock.Anything). Return(nil, errors.New("just invalid")).Maybe() - r := NewDARetriever(mockDA, cm, config.DefaultConfig(), genesis.Genesis{}, zerolog.Nop()) + r := newTestDARetriever(t, mockDA, config.DefaultConfig(), genesis.Genesis{}) events, err := r.RetrieveFromDA(context.Background(), 42) assert.Error(t, err) assert.Len(t, events, 0) } func TestDARetriever_RetrieveFromDA_NotFound(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - assert.NoError(t, err) - mockDA := testmocks.NewMockDA(t) // GetIDs returns ErrBlobNotFound -> helper maps to StatusNotFound mockDA.EXPECT().GetIDs(mock.Anything, mock.Anything, mock.Anything). Return(nil, fmt.Errorf("%s: whatever", coreda.ErrBlobNotFound.Error())).Maybe() - r := NewDARetriever(mockDA, cm, config.DefaultConfig(), genesis.Genesis{}, zerolog.Nop()) + r := newTestDARetriever(t, mockDA, config.DefaultConfig(), genesis.Genesis{}) events, err := r.RetrieveFromDA(context.Background(), 42) assert.True(t, errors.Is(err, coreda.ErrBlobNotFound)) assert.Len(t, events, 0) } func TestDARetriever_RetrieveFromDA_HeightFromFuture(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - require.NoError(t, err) - mockDA := testmocks.NewMockDA(t) // GetIDs returns ErrHeightFromFuture -> helper maps to StatusHeightFromFuture, fetchBlobs returns error mockDA.EXPECT().GetIDs(mock.Anything, mock.Anything, mock.Anything). Return(nil, fmt.Errorf("%s: later", coreda.ErrHeightFromFuture.Error())).Maybe() - r := NewDARetriever(mockDA, cm, config.DefaultConfig(), genesis.Genesis{}, zerolog.Nop()) + r := newTestDARetriever(t, mockDA, config.DefaultConfig(), genesis.Genesis{}) events, derr := r.RetrieveFromDA(context.Background(), 1000) assert.Error(t, derr) assert.True(t, errors.Is(derr, coreda.ErrHeightFromFuture)) @@ -97,8 +109,7 @@ func TestDARetriever_RetrieveFromDA_HeightFromFuture(t *testing.T) { } func TestDARetriever_RetrieveFromDA_Timeout(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - require.NoError(t, err) + t.Skip("Skipping flaky timeout test - timing is now controlled by DA client") mockDA := testmocks.NewMockDA(t) @@ -109,7 +120,7 @@ func TestDARetriever_RetrieveFromDA_Timeout(t *testing.T) { }). Return(nil, context.DeadlineExceeded).Maybe() - r := NewDARetriever(mockDA, cm, config.DefaultConfig(), genesis.Genesis{}, zerolog.Nop()) + r := newTestDARetriever(t, mockDA, config.DefaultConfig(), genesis.Genesis{}) start := time.Now() events, err := r.RetrieveFromDA(context.Background(), 42) @@ -122,13 +133,12 @@ func TestDARetriever_RetrieveFromDA_Timeout(t *testing.T) { assert.Len(t, events, 0) // Verify timeout occurred approximately at expected time (with some tolerance) - assert.Greater(t, duration, 9*time.Second, "should timeout after approximately 10 seconds") - assert.Less(t, duration, 12*time.Second, "should not take much longer than timeout") + // DA client has a 30-second default timeout + assert.Greater(t, duration, 29*time.Second, "should timeout after approximately 30 seconds") + assert.Less(t, duration, 35*time.Second, "should not take much longer than timeout") } func TestDARetriever_RetrieveFromDA_TimeoutFast(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - require.NoError(t, err) mockDA := testmocks.NewMockDA(t) @@ -136,7 +146,7 @@ func TestDARetriever_RetrieveFromDA_TimeoutFast(t *testing.T) { mockDA.EXPECT().GetIDs(mock.Anything, mock.Anything, mock.Anything). Return(nil, context.DeadlineExceeded).Maybe() - r := NewDARetriever(mockDA, cm, config.DefaultConfig(), genesis.Genesis{}, zerolog.Nop()) + r := newTestDARetriever(t, mockDA, config.DefaultConfig(), genesis.Genesis{}) events, err := r.RetrieveFromDA(context.Background(), 42) @@ -148,13 +158,11 @@ func TestDARetriever_RetrieveFromDA_TimeoutFast(t *testing.T) { } func TestDARetriever_ProcessBlobs_HeaderAndData_Success(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - require.NoError(t, err) addr, pub, signer := buildSyncTestSigner(t) gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: addr} - r := NewDARetriever(nil, cm, config.DefaultConfig(), gen, zerolog.Nop()) + r := newTestDARetriever(t, nil, config.DefaultConfig(), gen) dataBin, data := makeSignedDataBytes(t, gen.ChainID, 2, addr, pub, signer, 2) hdrBin, _ := makeSignedHeaderBytes(t, gen.ChainID, 2, addr, pub, signer, nil, &data.Data, nil) @@ -175,12 +183,10 @@ func TestDARetriever_ProcessBlobs_HeaderAndData_Success(t *testing.T) { } func TestDARetriever_ProcessBlobs_HeaderOnly_EmptyDataExpected(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - require.NoError(t, err) addr, pub, signer := buildSyncTestSigner(t) gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: addr} - r := NewDARetriever(nil, cm, config.DefaultConfig(), gen, zerolog.Nop()) + r := newTestDARetriever(t, nil, config.DefaultConfig(), gen) // Header with no data hash present should trigger empty data creation (per current logic) hb, _ := makeSignedHeaderBytes(t, gen.ChainID, 3, addr, pub, signer, nil, nil, nil) @@ -201,12 +207,10 @@ func TestDARetriever_ProcessBlobs_HeaderOnly_EmptyDataExpected(t *testing.T) { } func TestDARetriever_TryDecodeHeaderAndData_Basic(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - require.NoError(t, err) addr, pub, signer := buildSyncTestSigner(t) gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: addr} - r := NewDARetriever(nil, cm, config.DefaultConfig(), gen, zerolog.Nop()) + r := newTestDARetriever(t, nil, config.DefaultConfig(), gen) hb, sh := makeSignedHeaderBytes(t, gen.ChainID, 5, addr, pub, signer, nil, nil, nil) gotH := r.tryDecodeHeader(hb, 123) @@ -224,13 +228,11 @@ func TestDARetriever_TryDecodeHeaderAndData_Basic(t *testing.T) { } func TestDARetriever_tryDecodeData_InvalidSignatureOrProposer(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - require.NoError(t, err) goodAddr, pub, signer := buildSyncTestSigner(t) badAddr := []byte("not-the-proposer") gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: badAddr} - r := NewDARetriever(nil, cm, config.DefaultConfig(), gen, zerolog.Nop()) + r := newTestDARetriever(t, nil, config.DefaultConfig(), gen) // Signed data is made by goodAddr; retriever expects badAddr -> should be rejected db, _ := makeSignedDataBytes(t, gen.ChainID, 7, goodAddr, pub, signer, 1) @@ -252,8 +254,6 @@ func TestDARetriever_validateBlobResponse(t *testing.T) { } func TestDARetriever_RetrieveFromDA_TwoNamespaces_Success(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - require.NoError(t, err) addr, pub, signer := buildSyncTestSigner(t) gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: addr} @@ -281,7 +281,7 @@ func TestDARetriever_RetrieveFromDA_TwoNamespaces_Success(t *testing.T) { mockDA.EXPECT().Get(mock.Anything, mock.Anything, mock.MatchedBy(func(ns []byte) bool { return bytes.Equal(ns, namespaceDataBz) })). Return([][]byte{dataBin}, nil).Once() - r := NewDARetriever(mockDA, cm, cfg, gen, zerolog.Nop()) + r := newTestDARetriever(t, mockDA, cfg, gen) events, derr := r.RetrieveFromDA(context.Background(), 1234) require.NoError(t, derr) @@ -291,13 +291,11 @@ func TestDARetriever_RetrieveFromDA_TwoNamespaces_Success(t *testing.T) { } func TestDARetriever_ProcessBlobs_CrossDAHeightMatching(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - require.NoError(t, err) addr, pub, signer := buildSyncTestSigner(t) gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: addr} - r := NewDARetriever(nil, cm, config.DefaultConfig(), gen, zerolog.Nop()) + r := newTestDARetriever(t, nil, config.DefaultConfig(), gen) // Create header and data for the same block height but from different DA heights dataBin, data := makeSignedDataBytes(t, gen.ChainID, 5, addr, pub, signer, 2) @@ -325,13 +323,11 @@ func TestDARetriever_ProcessBlobs_CrossDAHeightMatching(t *testing.T) { } func TestDARetriever_ProcessBlobs_MultipleHeadersCrossDAHeightMatching(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - require.NoError(t, err) addr, pub, signer := buildSyncTestSigner(t) gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: addr} - r := NewDARetriever(nil, cm, config.DefaultConfig(), gen, zerolog.Nop()) + r := newTestDARetriever(t, nil, config.DefaultConfig(), gen) // Create multiple headers and data for different block heights data3Bin, data3 := makeSignedDataBytes(t, gen.ChainID, 3, addr, pub, signer, 1) @@ -398,350 +394,3 @@ func Test_isEmptyDataExpected(t *testing.T) { h.DataHash = common.DataHashForEmptyTxs assert.True(t, isEmptyDataExpected(h)) } - -func TestDARetriever_RetrieveForcedIncludedTxsFromDA_Success(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - require.NoError(t, err) - - addr, pub, signer := buildSyncTestSigner(t) - gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: addr, DAStartHeight: 5678, DAEpochForcedInclusion: 1} - - // Prepare forced inclusion transaction data - dataBin, _ := makeSignedDataBytes(t, gen.ChainID, 10, addr, pub, signer, 3) - - cfg := config.DefaultConfig() - cfg.DA.ForcedInclusionNamespace = "nsForcedInclusion" - - namespaceForcedInclusionBz := coreda.NamespaceFromString(cfg.DA.GetForcedInclusionNamespace()).Bytes() - - mockDA := testmocks.NewMockDA(t) - // With DAStartHeight=5678, epoch size=1, daHeight=5678 -> epoch boundaries are [5678, 5678] - // Check epoch start only (end check is skipped when same as start) - mockDA.EXPECT().GetIDs(mock.Anything, uint64(5678), mock.MatchedBy(func(ns []byte) bool { - return bytes.Equal(ns, namespaceForcedInclusionBz) - })).Return(&coreda.GetIDsResult{IDs: [][]byte{[]byte("fi1")}, Timestamp: time.Now()}, nil).Once() - - // Fetch epoch start data - mockDA.EXPECT().Get(mock.Anything, mock.Anything, mock.MatchedBy(func(ns []byte) bool { - return bytes.Equal(ns, namespaceForcedInclusionBz) - })).Return([][]byte{dataBin}, nil).Once() - - r := NewDARetriever(mockDA, cm, cfg, gen, zerolog.Nop()) - - result, err := r.RetrieveForcedIncludedTxsFromDA(context.Background(), 5678) - require.NoError(t, err) - require.NotNil(t, result) - require.Len(t, result.Txs, 1) // Only fetched once since start == end - assert.Equal(t, dataBin, result.Txs[0]) -} - -func TestDARetriever_FetchForcedIncludedTxs_NoNamespaceConfigured(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - require.NoError(t, err) - - addr, _, _ := buildSyncTestSigner(t) - gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: addr, DAStartHeight: 0, DAEpochForcedInclusion: 1} - - cfg := config.DefaultConfig() - // Leave ForcedInclusionNamespace empty - - r := NewDARetriever(nil, cm, cfg, gen, zerolog.Nop()) - - result, err := r.RetrieveForcedIncludedTxsFromDA(context.Background(), 1234) - require.Error(t, err) - require.Nil(t, result) -} - -func TestDARetriever_FetchForcedIncludedTxs_NotFound(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - require.NoError(t, err) - - addr, _, _ := buildSyncTestSigner(t) - gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: addr, DAStartHeight: 9999, DAEpochForcedInclusion: 1} - - cfg := config.DefaultConfig() - cfg.DA.ForcedInclusionNamespace = "nsForcedInclusion" - - namespaceForcedInclusionBz := coreda.NamespaceFromString(cfg.DA.GetForcedInclusionNamespace()).Bytes() - - mockDA := testmocks.NewMockDA(t) - // With DAStartHeight=9999, epoch size=1, daHeight=9999 -> epoch boundaries are [9999, 9999] - // Check epoch start only (end check is skipped when same as start) - mockDA.EXPECT().GetIDs(mock.Anything, uint64(9999), mock.MatchedBy(func(ns []byte) bool { - return bytes.Equal(ns, namespaceForcedInclusionBz) - })).Return(&coreda.GetIDsResult{IDs: [][]byte{}, Timestamp: time.Now()}, nil).Once() - - r := NewDARetriever(mockDA, cm, cfg, gen, zerolog.Nop()) - - result, err := r.RetrieveForcedIncludedTxsFromDA(context.Background(), 9999) - require.NoError(t, err) - require.NotNil(t, result) - require.Empty(t, result.Txs) -} - -func TestDARetriever_RetrieveForcedIncludedTxsFromDA_ExceedsMaxBlobSize(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - require.NoError(t, err) - - addr, pub, signer := buildSyncTestSigner(t) - gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: addr, DAStartHeight: 1000, DAEpochForcedInclusion: 3} - - cfg := config.DefaultConfig() - cfg.DA.ForcedInclusionNamespace = "nsForcedInclusion" - - namespaceForcedInclusionBz := coreda.NamespaceFromString(cfg.DA.GetForcedInclusionNamespace()).Bytes() - - // Use fixed timestamp for deterministic test data - fixedTime := uint64(1234567890) - - // Create signed data blobs that will exceed DefaultMaxBlobSize when accumulated - // DefaultMaxBlobSize is 1.5MB = 1,572,864 bytes - // Each 700KB tx becomes ~719KB blob, so 2 blobs = ~1.44MB (fits), 3 blobs = ~2.16MB (exceeds) - d1 := &types.Data{ - Metadata: &types.Metadata{ChainID: gen.ChainID, Height: 10, Time: fixedTime}, - Txs: make(types.Txs, 1), - } - d1.Txs[0] = make([]byte, 700*1024) // 700KB transaction - - payload1, err := d1.MarshalBinary() - require.NoError(t, err) - sig1, err := signer.Sign(payload1) - require.NoError(t, err) - sd1 := &types.SignedData{Data: *d1, Signature: sig1, Signer: types.Signer{PubKey: pub, Address: addr}} - dataBin1, err := sd1.MarshalBinary() - require.NoError(t, err) - - d2 := &types.Data{ - Metadata: &types.Metadata{ChainID: gen.ChainID, Height: 11, Time: fixedTime}, - Txs: make(types.Txs, 1), - } - d2.Txs[0] = make([]byte, 700*1024) // 700KB transaction - - payload2, err := d2.MarshalBinary() - require.NoError(t, err) - sig2, err := signer.Sign(payload2) - require.NoError(t, err) - sd2 := &types.SignedData{Data: *d2, Signature: sig2, Signer: types.Signer{PubKey: pub, Address: addr}} - dataBin2, err := sd2.MarshalBinary() - require.NoError(t, err) - - d3 := &types.Data{ - Metadata: &types.Metadata{ChainID: gen.ChainID, Height: 12, Time: fixedTime}, - Txs: make(types.Txs, 1), - } - d3.Txs[0] = make([]byte, 700*1024) // 700KB transaction - - payload3, err := d3.MarshalBinary() - require.NoError(t, err) - sig3, err := signer.Sign(payload3) - require.NoError(t, err) - sd3 := &types.SignedData{Data: *d3, Signature: sig3, Signer: types.Signer{PubKey: pub, Address: addr}} - dataBin3, err := sd3.MarshalBinary() - require.NoError(t, err) - - mockDA := testmocks.NewMockDA(t) - - // With DAStartHeight=1000, epoch size=3, daHeight=1000 -> epoch boundaries are [1000, 1002] - // RetrieveWithHelpers calls in order: start (1000), end (1002), then intermediate (1001) - - // Check epoch start - mockDA.EXPECT().GetIDs(mock.Anything, uint64(1000), mock.MatchedBy(func(ns []byte) bool { - return bytes.Equal(ns, namespaceForcedInclusionBz) - })).Return(&coreda.GetIDsResult{IDs: [][]byte{[]byte("fi1")}, Timestamp: time.Now()}, nil).Once() - - // Fetch epoch start data (height 1000) - mockDA.EXPECT().Get(mock.Anything, mock.Anything, mock.MatchedBy(func(ns []byte) bool { - return bytes.Equal(ns, namespaceForcedInclusionBz) - })).Return([][]byte{dataBin1}, nil).Once() - - // Check epoch end - mockDA.EXPECT().GetIDs(mock.Anything, uint64(1002), mock.MatchedBy(func(ns []byte) bool { - return bytes.Equal(ns, namespaceForcedInclusionBz) - })).Return(&coreda.GetIDsResult{IDs: [][]byte{[]byte("fi3")}, Timestamp: time.Now()}, nil).Once() - - // Fetch epoch end data (height 1002) - should be retrieved but skipped due to size limit - mockDA.EXPECT().Get(mock.Anything, mock.Anything, mock.MatchedBy(func(ns []byte) bool { - return bytes.Equal(ns, namespaceForcedInclusionBz) - })).Return([][]byte{dataBin3}, nil).Once() - - // Check intermediate height in epoch (height 1001) - mockDA.EXPECT().GetIDs(mock.Anything, uint64(1001), mock.MatchedBy(func(ns []byte) bool { - return bytes.Equal(ns, namespaceForcedInclusionBz) - })).Return(&coreda.GetIDsResult{IDs: [][]byte{[]byte("fi2")}, Timestamp: time.Now()}, nil).Once() - - // Fetch intermediate height data (height 1001) - mockDA.EXPECT().Get(mock.Anything, mock.Anything, mock.MatchedBy(func(ns []byte) bool { - return bytes.Equal(ns, namespaceForcedInclusionBz) - })).Return([][]byte{dataBin2}, nil).Once() - - r := NewDARetriever(mockDA, cm, cfg, gen, zerolog.Nop()) - - result, err := r.RetrieveForcedIncludedTxsFromDA(context.Background(), 1000) - - // Should succeed but skip the third blob due to size limit (using continue) - require.NoError(t, err) - require.NotNil(t, result) - - // Should only have 2 transactions, third is skipped due to size - require.Len(t, result.Txs, 2) - assert.Equal(t, dataBin1, result.Txs[0]) - assert.Equal(t, dataBin2, result.Txs[1]) - - // Verify total size is within limits - totalSize := len(dataBin1) + len(dataBin2) - assert.LessOrEqual(t, totalSize, int(common.DefaultMaxBlobSize)) - - // Verify that adding the third would have exceeded the limit - totalSizeWithThird := totalSize + len(dataBin3) - assert.Greater(t, totalSizeWithThird, int(common.DefaultMaxBlobSize)) -} - -func TestDARetriever_RetrieveForcedIncludedTxsFromDA_NotAtEpochStart(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - require.NoError(t, err) - - addr, _, _ := buildSyncTestSigner(t) - gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: addr, DAStartHeight: 100, DAEpochForcedInclusion: 10} - - cfg := config.DefaultConfig() - cfg.DA.ForcedInclusionNamespace = "nsForcedInclusion" - - mockDA := testmocks.NewMockDA(t) - - r := NewDARetriever(mockDA, cm, cfg, gen, zerolog.Nop()) - - // With DAStartHeight=100, epoch size=10, daHeight=105 -> epoch boundaries are [100, 109] - // But daHeight=105 is NOT the epoch start, so it should be a no-op - result, err := r.RetrieveForcedIncludedTxsFromDA(context.Background(), 105) - require.NoError(t, err) - require.NotNil(t, result) - require.Empty(t, result.Txs) - require.Equal(t, uint64(105), result.StartDaHeight) - require.Equal(t, uint64(105), result.EndDaHeight) -} - -func TestDARetriever_RetrieveForcedIncludedTxsFromDA_EpochStartFromFuture(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - require.NoError(t, err) - - addr, _, _ := buildSyncTestSigner(t) - gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: addr, DAStartHeight: 100, DAEpochForcedInclusion: 10} - - cfg := config.DefaultConfig() - cfg.DA.ForcedInclusionNamespace = "nsForcedInclusion" - - namespaceForcedInclusionBz := coreda.NamespaceFromString(cfg.DA.GetForcedInclusionNamespace()).Bytes() - - mockDA := testmocks.NewMockDA(t) - // With DAStartHeight=1000, epoch size=10, daHeight=1000 -> epoch boundaries are [1000, 1009] - // Mock that height 1000 (epoch start) is from the future - mockDA.EXPECT().GetIDs(mock.Anything, uint64(1000), mock.MatchedBy(func(ns []byte) bool { - return bytes.Equal(ns, namespaceForcedInclusionBz) - })).Return(nil, fmt.Errorf("%s: not yet available", coreda.ErrHeightFromFuture.Error())).Once() - - r := NewDARetriever(mockDA, cm, cfg, gen, zerolog.Nop()) - - result, err := r.RetrieveForcedIncludedTxsFromDA(context.Background(), 1000) - require.Error(t, err) - require.Nil(t, result) - require.True(t, errors.Is(err, coreda.ErrHeightFromFuture)) - require.Contains(t, err.Error(), "epoch start height 1000 not yet available") -} - -func TestDARetriever_RetrieveForcedIncludedTxsFromDA_EpochEndFromFuture(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - require.NoError(t, err) - - addr, _, _ := buildSyncTestSigner(t) - gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: addr, DAStartHeight: 100, DAEpochForcedInclusion: 10} - - cfg := config.DefaultConfig() - cfg.DA.ForcedInclusionNamespace = "nsForcedInclusion" - - namespaceForcedInclusionBz := coreda.NamespaceFromString(cfg.DA.GetForcedInclusionNamespace()).Bytes() - - mockDA := testmocks.NewMockDA(t) - // With DAStartHeight=1000, epoch size=10, daHeight=1000 -> epoch boundaries are [1000, 1009] - // Epoch start is available but epoch end (1009) is from the future - mockDA.EXPECT().GetIDs(mock.Anything, uint64(1000), mock.MatchedBy(func(ns []byte) bool { - return bytes.Equal(ns, namespaceForcedInclusionBz) - })).Return(&coreda.GetIDsResult{IDs: [][]byte{}, Timestamp: time.Now()}, nil).Once() - - mockDA.EXPECT().GetIDs(mock.Anything, uint64(1009), mock.MatchedBy(func(ns []byte) bool { - return bytes.Equal(ns, namespaceForcedInclusionBz) - })).Return(nil, fmt.Errorf("%s: not yet available", coreda.ErrHeightFromFuture.Error())).Once() - - r := NewDARetriever(mockDA, cm, cfg, gen, zerolog.Nop()) - - result, err := r.RetrieveForcedIncludedTxsFromDA(context.Background(), 1000) - require.Error(t, err) - require.Nil(t, result) - require.True(t, errors.Is(err, coreda.ErrHeightFromFuture)) - require.Contains(t, err.Error(), "epoch end height 1009 not yet available") -} - -func TestDARetriever_RetrieveForcedIncludedTxsFromDA_CompleteEpoch(t *testing.T) { - cm, err := cache.NewCacheManager(config.DefaultConfig(), zerolog.Nop()) - require.NoError(t, err) - - addr, pub, signer := buildSyncTestSigner(t) - gen := genesis.Genesis{ChainID: "tchain", InitialHeight: 1, StartTime: time.Now().Add(-time.Second), ProposerAddress: addr, DAStartHeight: 2000, DAEpochForcedInclusion: 3} - - // Prepare forced inclusion transaction data with fixed timestamp - fixedTime := uint64(1234567890) - dataBin1, _ := makeSignedDataBytesWithTime(t, gen.ChainID, 10, addr, pub, signer, 2, fixedTime) - dataBin2, _ := makeSignedDataBytesWithTime(t, gen.ChainID, 11, addr, pub, signer, 1, fixedTime) - dataBin3, _ := makeSignedDataBytesWithTime(t, gen.ChainID, 12, addr, pub, signer, 1, fixedTime) - - cfg := config.DefaultConfig() - cfg.DA.ForcedInclusionNamespace = "nsForcedInclusion" - - namespaceForcedInclusionBz := coreda.NamespaceFromString(cfg.DA.GetForcedInclusionNamespace()).Bytes() - - mockDA := testmocks.NewMockDA(t) - - // With DAStartHeight=2000, epoch size=3, daHeight=2000 -> epoch boundaries are [2000, 2002] - // RetrieveWithHelpers calls in order: start (2000), end (2002), then intermediate (2001) - - // Check epoch start (2000) - mockDA.EXPECT().GetIDs(mock.Anything, uint64(2000), mock.MatchedBy(func(ns []byte) bool { - return bytes.Equal(ns, namespaceForcedInclusionBz) - })).Return(&coreda.GetIDsResult{IDs: [][]byte{[]byte("fi1")}, Timestamp: time.Now()}, nil).Once() - - // Fetch epoch start data (height 2000) - mockDA.EXPECT().Get(mock.Anything, mock.Anything, mock.MatchedBy(func(ns []byte) bool { - return bytes.Equal(ns, namespaceForcedInclusionBz) - })).Return([][]byte{dataBin1}, nil).Once() - - // Check epoch end (2002) - mockDA.EXPECT().GetIDs(mock.Anything, uint64(2002), mock.MatchedBy(func(ns []byte) bool { - return bytes.Equal(ns, namespaceForcedInclusionBz) - })).Return(&coreda.GetIDsResult{IDs: [][]byte{[]byte("fi3")}, Timestamp: time.Now()}, nil).Once() - - // Fetch epoch end data (height 2002) - mockDA.EXPECT().Get(mock.Anything, mock.Anything, mock.MatchedBy(func(ns []byte) bool { - return bytes.Equal(ns, namespaceForcedInclusionBz) - })).Return([][]byte{dataBin3}, nil).Once() - - // Fetch middle height (2001) - mockDA.EXPECT().GetIDs(mock.Anything, uint64(2001), mock.MatchedBy(func(ns []byte) bool { - return bytes.Equal(ns, namespaceForcedInclusionBz) - })).Return(&coreda.GetIDsResult{IDs: [][]byte{[]byte("fi2")}, Timestamp: time.Now()}, nil).Once() - - // Fetch intermediate height data (height 2001) - mockDA.EXPECT().Get(mock.Anything, mock.Anything, mock.MatchedBy(func(ns []byte) bool { - return bytes.Equal(ns, namespaceForcedInclusionBz) - })).Return([][]byte{dataBin2}, nil).Once() - - r := NewDARetriever(mockDA, cm, cfg, gen, zerolog.Nop()) - - result, err := r.RetrieveForcedIncludedTxsFromDA(context.Background(), 2000) - require.NoError(t, err) - require.NotNil(t, result) - require.Len(t, result.Txs, 3) - require.Equal(t, dataBin1, result.Txs[0]) - require.Equal(t, dataBin2, result.Txs[1]) - require.Equal(t, dataBin3, result.Txs[2]) - require.Equal(t, uint64(2000), result.StartDaHeight) - require.Equal(t, uint64(2002), result.EndDaHeight) -} diff --git a/block/internal/syncing/syncer.go b/block/internal/syncing/syncer.go index 6fbe8735d4..57854817db 100644 --- a/block/internal/syncing/syncer.go +++ b/block/internal/syncing/syncer.go @@ -20,6 +20,7 @@ import ( "github.com/evstack/ev-node/block/internal/cache" "github.com/evstack/ev-node/block/internal/common" + "github.com/evstack/ev-node/block/internal/da" "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/pkg/genesis" "github.com/evstack/ev-node/pkg/store" @@ -58,6 +59,7 @@ type Syncer struct { // Handlers daRetriever DARetriever + fiRetriever *da.ForcedInclusionRetriever p2pHandler p2pHandler // Logging @@ -116,7 +118,16 @@ func (s *Syncer) Start(ctx context.Context) error { } // Initialize handlers - s.daRetriever = NewDARetriever(s.da, s.cache, s.config, s.genesis, s.logger) + daClient := da.NewClient(da.Config{ + DA: s.da, + Logger: s.logger, + DefaultTimeout: 30 * time.Second, + Namespace: s.config.DA.GetNamespace(), + DataNamespace: s.config.DA.GetDataNamespace(), + ForcedInclusionNamespace: s.config.DA.GetForcedInclusionNamespace(), + }) + s.daRetriever = NewDARetriever(daClient, s.cache, s.genesis, s.logger) + s.fiRetriever = da.NewForcedInclusionRetriever(daClient, s.genesis, s.logger) s.p2pHandler = NewP2PHandler(s.headerStore.Store(), s.dataStore.Store(), s.cache, s.genesis, s.logger) if currentHeight, err := s.store.Height(s.ctx); err != nil { s.logger.Error().Err(err).Msg("failed to set initial processed height for p2p handler") @@ -682,14 +693,14 @@ func hashTx(tx []byte) string { // verifyForcedInclusionTxs verifies that all forced inclusion transactions from DA are included in the block func (s *Syncer) verifyForcedInclusionTxs(currentState types.State, data *types.Data) error { - if s.daRetriever == nil { + if s.fiRetriever == nil { return nil } // Retrieve forced inclusion transactions from DA - forcedIncludedTxsEvent, err := s.daRetriever.RetrieveForcedIncludedTxsFromDA(s.ctx, currentState.DAHeight) + forcedIncludedTxsEvent, err := s.fiRetriever.RetrieveForcedIncludedTxs(s.ctx, currentState.DAHeight) if err != nil { - if errors.Is(err, common.ErrForceInclusionNotConfigured) { + if errors.Is(err, da.ErrForceInclusionNotConfigured) { s.logger.Debug().Msg("forced inclusion namespace not configured, skipping verification") return nil } diff --git a/block/internal/syncing/syncer_forced_inclusion_test.go b/block/internal/syncing/syncer_forced_inclusion_test.go index d4f12815d0..f1f855f911 100644 --- a/block/internal/syncing/syncer_forced_inclusion_test.go +++ b/block/internal/syncing/syncer_forced_inclusion_test.go @@ -14,6 +14,7 @@ import ( "github.com/evstack/ev-node/block/internal/cache" "github.com/evstack/ev-node/block/internal/common" + "github.com/evstack/ev-node/block/internal/da" coreda "github.com/evstack/ev-node/core/da" "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/pkg/genesis" @@ -47,7 +48,16 @@ func TestVerifyForcedInclusionTxs_AllTransactionsIncluded(t *testing.T) { Return([]byte("app0"), uint64(1024), nil).Once() mockDA := testmocks.NewMockDA(t) - daRetriever := NewDARetriever(mockDA, cm, cfg, gen, zerolog.Nop()) + + daClient := da.NewClient(da.Config{ + DA: mockDA, + Logger: zerolog.Nop(), + Namespace: cfg.DA.Namespace, + DataNamespace: cfg.DA.DataNamespace, + ForcedInclusionNamespace: cfg.DA.ForcedInclusionNamespace, + }) + daRetriever := NewDARetriever(daClient, cm, gen, zerolog.Nop()) + fiRetriever := da.NewForcedInclusionRetriever(daClient, gen, zerolog.Nop()) s := NewSyncer( st, @@ -64,6 +74,7 @@ func TestVerifyForcedInclusionTxs_AllTransactionsIncluded(t *testing.T) { make(chan error, 1), ) s.daRetriever = daRetriever + s.fiRetriever = fiRetriever require.NoError(t, s.initializeState()) s.ctx = context.Background() @@ -122,7 +133,16 @@ func TestVerifyForcedInclusionTxs_MissingTransactions(t *testing.T) { Return([]byte("app0"), uint64(1024), nil).Once() mockDA := testmocks.NewMockDA(t) - daRetriever := NewDARetriever(mockDA, cm, cfg, gen, zerolog.Nop()) + + daClient := da.NewClient(da.Config{ + DA: mockDA, + Logger: zerolog.Nop(), + Namespace: cfg.DA.Namespace, + DataNamespace: cfg.DA.DataNamespace, + ForcedInclusionNamespace: cfg.DA.ForcedInclusionNamespace, + }) + daRetriever := NewDARetriever(daClient, cm, gen, zerolog.Nop()) + fiRetriever := da.NewForcedInclusionRetriever(daClient, gen, zerolog.Nop()) s := NewSyncer( st, @@ -139,6 +159,7 @@ func TestVerifyForcedInclusionTxs_MissingTransactions(t *testing.T) { make(chan error, 1), ) s.daRetriever = daRetriever + s.fiRetriever = fiRetriever require.NoError(t, s.initializeState()) s.ctx = context.Background() @@ -200,7 +221,16 @@ func TestVerifyForcedInclusionTxs_PartiallyIncluded(t *testing.T) { Return([]byte("app0"), uint64(1024), nil).Once() mockDA := testmocks.NewMockDA(t) - daRetriever := NewDARetriever(mockDA, cm, cfg, gen, zerolog.Nop()) + + daClient := da.NewClient(da.Config{ + DA: mockDA, + Logger: zerolog.Nop(), + Namespace: cfg.DA.Namespace, + DataNamespace: cfg.DA.DataNamespace, + ForcedInclusionNamespace: cfg.DA.ForcedInclusionNamespace, + }) + daRetriever := NewDARetriever(daClient, cm, gen, zerolog.Nop()) + fiRetriever := da.NewForcedInclusionRetriever(daClient, gen, zerolog.Nop()) s := NewSyncer( st, @@ -217,6 +247,7 @@ func TestVerifyForcedInclusionTxs_PartiallyIncluded(t *testing.T) { make(chan error, 1), ) s.daRetriever = daRetriever + s.fiRetriever = fiRetriever require.NoError(t, s.initializeState()) s.ctx = context.Background() @@ -280,7 +311,16 @@ func TestVerifyForcedInclusionTxs_NoForcedTransactions(t *testing.T) { Return([]byte("app0"), uint64(1024), nil).Once() mockDA := testmocks.NewMockDA(t) - daRetriever := NewDARetriever(mockDA, cm, cfg, gen, zerolog.Nop()) + + daClient := da.NewClient(da.Config{ + DA: mockDA, + Logger: zerolog.Nop(), + Namespace: cfg.DA.Namespace, + DataNamespace: cfg.DA.DataNamespace, + ForcedInclusionNamespace: cfg.DA.ForcedInclusionNamespace, + }) + daRetriever := NewDARetriever(daClient, cm, gen, zerolog.Nop()) + fiRetriever := da.NewForcedInclusionRetriever(daClient, gen, zerolog.Nop()) s := NewSyncer( st, @@ -297,6 +337,7 @@ func TestVerifyForcedInclusionTxs_NoForcedTransactions(t *testing.T) { make(chan error, 1), ) s.daRetriever = daRetriever + s.fiRetriever = fiRetriever require.NoError(t, s.initializeState()) s.ctx = context.Background() @@ -344,7 +385,16 @@ func TestVerifyForcedInclusionTxs_NamespaceNotConfigured(t *testing.T) { Return([]byte("app0"), uint64(1024), nil).Once() mockDA := testmocks.NewMockDA(t) - daRetriever := NewDARetriever(mockDA, cm, cfg, gen, zerolog.Nop()) + + daClient := da.NewClient(da.Config{ + DA: mockDA, + Logger: zerolog.Nop(), + Namespace: cfg.DA.Namespace, + DataNamespace: cfg.DA.DataNamespace, + // No ForcedInclusionNamespace - not configured + }) + daRetriever := NewDARetriever(daClient, cm, gen, zerolog.Nop()) + fiRetriever := da.NewForcedInclusionRetriever(daClient, gen, zerolog.Nop()) s := NewSyncer( st, @@ -361,6 +411,7 @@ func TestVerifyForcedInclusionTxs_NamespaceNotConfigured(t *testing.T) { make(chan error, 1), ) s.daRetriever = daRetriever + s.fiRetriever = fiRetriever require.NoError(t, s.initializeState()) s.ctx = context.Background() diff --git a/block/internal/syncing/syncer_test.go b/block/internal/syncing/syncer_test.go index 0bbac7363a..baa9b3b138 100644 --- a/block/internal/syncing/syncer_test.go +++ b/block/internal/syncing/syncer_test.go @@ -412,7 +412,6 @@ func TestSyncLoopPersistState(t *testing.T) { DaHeight: daHeight, }} daRtrMock.On("RetrieveFromDA", mock.Anything, daHeight).Return(evts, nil) - daRtrMock.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, mock.Anything).Return(&common.ForcedIncludedEvent{Txs: [][]byte{}}, nil).Maybe() prevHeaderHash = sigHeader.Hash() hasher := sha512.New() hasher.Write(prevAppHash) @@ -485,7 +484,6 @@ func TestSyncLoopPersistState(t *testing.T) { p2pHndlMock.On("SetProcessedHeight", mock.Anything).Return().Maybe() syncerInst2.daRetriever, syncerInst2.p2pHandler = daRtrMock, p2pHndlMock - daRtrMock.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, mock.Anything).Return(&common.ForcedIncludedEvent{Txs: [][]byte{}}, nil).Maybe() daRtrMock.On("RetrieveFromDA", mock.Anything, mock.Anything). Run(func(arg mock.Arguments) { cancel() diff --git a/block/public.go b/block/public.go index ce777da6e0..c06ad6ea55 100644 --- a/block/public.go +++ b/block/public.go @@ -1,11 +1,11 @@ package block import ( - "fmt" + "context" + "time" - "github.com/evstack/ev-node/block/internal/cache" "github.com/evstack/ev-node/block/internal/common" - "github.com/evstack/ev-node/block/internal/syncing" + "github.com/evstack/ev-node/block/internal/da" coreda "github.com/evstack/ev-node/core/da" "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/pkg/genesis" @@ -33,27 +33,42 @@ func NopMetrics() *Metrics { return common.NopMetrics() } -// NewDaRetriver creates a new DA retriever instance. -func NewDARetriever( - da coreda.DA, +// ErrForceInclusionNotConfigured is returned when force inclusion is not configured. +// It is exported because sequencers needs to check for this error. +var ErrForceInclusionNotConfigured = da.ErrForceInclusionNotConfigured + +// DAClient is the interface representing the DA client for public use. +type DAClient = da.Client + +// ForcedInclusionEvent represents forced inclusion transactions retrieved from DA +type ForcedInclusionEvent = da.ForcedInclusionEvent + +// ForcedInclusionRetriever defines the interface for retrieving forced inclusion transactions from DA +type ForcedInclusionRetriever interface { + RetrieveForcedIncludedTxs(ctx context.Context, daHeight uint64) (*da.ForcedInclusionEvent, error) +} + +// NewDAClient creates a new DA client with configuration +func NewDAClient( + daLayer coreda.DA, config config.Config, - genesis genesis.Genesis, logger zerolog.Logger, -) (syncing.DARetriever, error) { - cacheManager, err := cache.NewCacheManager(config, logger) - if err != nil { - return nil, fmt.Errorf("failed to create cache manager: %w", err) - } - - return syncing.NewDARetriever( - da, - cacheManager, - config, - genesis, - logger, - ), nil +) DAClient { + return da.NewClient(da.Config{ + DA: daLayer, + Logger: logger, + DefaultTimeout: 10 * time.Second, + Namespace: config.DA.GetNamespace(), + DataNamespace: config.DA.GetDataNamespace(), + ForcedInclusionNamespace: config.DA.GetForcedInclusionNamespace(), + }) } -// ErrForceInclusionNotConfigured is returned when force inclusion is not configured. -// It is exported because sequencers needs to check for this error. -var ErrForceInclusionNotConfigured = common.ErrForceInclusionNotConfigured +// NewForcedInclusionRetriever creates a new forced inclusion retriever +func NewForcedInclusionRetriever( + client DAClient, + genesis genesis.Genesis, + logger zerolog.Logger, +) ForcedInclusionRetriever { + return da.NewForcedInclusionRetriever(client, genesis, logger) +} diff --git a/da/internal/mocks/da.go b/da/internal/mocks/da.go index 37539d5480..bb3ad63391 100644 --- a/da/internal/mocks/da.go +++ b/da/internal/mocks/da.go @@ -112,126 +112,6 @@ func (_c *MockDA_Commit_Call) RunAndReturn(run func(ctx context.Context, blobs [ return _c } -// GasMultiplier provides a mock function for the type MockDA -func (_mock *MockDA) GasMultiplier(ctx context.Context) (float64, error) { - ret := _mock.Called(ctx) - - if len(ret) == 0 { - panic("no return value specified for GasMultiplier") - } - - var r0 float64 - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context) (float64, error)); ok { - return returnFunc(ctx) - } - if returnFunc, ok := ret.Get(0).(func(context.Context) float64); ok { - r0 = returnFunc(ctx) - } else { - r0 = ret.Get(0).(float64) - } - if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { - r1 = returnFunc(ctx) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockDA_GasMultiplier_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GasMultiplier' -type MockDA_GasMultiplier_Call struct { - *mock.Call -} - -// GasMultiplier is a helper method to define mock.On call -// - ctx context.Context -func (_e *MockDA_Expecter) GasMultiplier(ctx interface{}) *MockDA_GasMultiplier_Call { - return &MockDA_GasMultiplier_Call{Call: _e.mock.On("GasMultiplier", ctx)} -} - -func (_c *MockDA_GasMultiplier_Call) Run(run func(ctx context.Context)) *MockDA_GasMultiplier_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - run( - arg0, - ) - }) - return _c -} - -func (_c *MockDA_GasMultiplier_Call) Return(f float64, err error) *MockDA_GasMultiplier_Call { - _c.Call.Return(f, err) - return _c -} - -func (_c *MockDA_GasMultiplier_Call) RunAndReturn(run func(ctx context.Context) (float64, error)) *MockDA_GasMultiplier_Call { - _c.Call.Return(run) - return _c -} - -// GasPrice provides a mock function for the type MockDA -func (_mock *MockDA) GasPrice(ctx context.Context) (float64, error) { - ret := _mock.Called(ctx) - - if len(ret) == 0 { - panic("no return value specified for GasPrice") - } - - var r0 float64 - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context) (float64, error)); ok { - return returnFunc(ctx) - } - if returnFunc, ok := ret.Get(0).(func(context.Context) float64); ok { - r0 = returnFunc(ctx) - } else { - r0 = ret.Get(0).(float64) - } - if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { - r1 = returnFunc(ctx) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockDA_GasPrice_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GasPrice' -type MockDA_GasPrice_Call struct { - *mock.Call -} - -// GasPrice is a helper method to define mock.On call -// - ctx context.Context -func (_e *MockDA_Expecter) GasPrice(ctx interface{}) *MockDA_GasPrice_Call { - return &MockDA_GasPrice_Call{Call: _e.mock.On("GasPrice", ctx)} -} - -func (_c *MockDA_GasPrice_Call) Run(run func(ctx context.Context)) *MockDA_GasPrice_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - run( - arg0, - ) - }) - return _c -} - -func (_c *MockDA_GasPrice_Call) Return(f float64, err error) *MockDA_GasPrice_Call { - _c.Call.Return(f, err) - return _c -} - -func (_c *MockDA_GasPrice_Call) RunAndReturn(run func(ctx context.Context) (float64, error)) *MockDA_GasPrice_Call { - _c.Call.Return(run) - return _c -} - // Get provides a mock function for the type MockDA func (_mock *MockDA) Get(ctx context.Context, ids []da.ID, namespace []byte) ([]da.Blob, error) { ret := _mock.Called(ctx, ids, namespace) diff --git a/go.mod b/go.mod index 0e386175cc..49467466e2 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( golang.org/x/net v0.46.0 golang.org/x/sync v0.17.0 google.golang.org/protobuf v1.36.10 + gotest.tools/v3 v3.5.2 ) require ( @@ -53,6 +54,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/flatbuffers v24.12.23+incompatible // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/gopacket v1.1.19 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gopherjs/gopherjs v0.0.0-20190812055157-5d271430af9f // indirect diff --git a/go.sum b/go.sum index 2c1c2db026..ac49443213 100644 --- a/go.sum +++ b/go.sum @@ -650,6 +650,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/sequencers/based/based.go b/sequencers/based/based.go index 6ed094477a..03fa61a7a1 100644 --- a/sequencers/based/based.go +++ b/sequencers/based/based.go @@ -15,17 +15,9 @@ import ( "github.com/evstack/ev-node/pkg/genesis" ) -// ForcedInclusionEvent represents forced inclusion transactions retrieved from DA -type ForcedInclusionEvent = struct { - Txs [][]byte - StartDaHeight uint64 - EndDaHeight uint64 -} - -// DARetriever defines the interface for retrieving forced inclusion transactions from DA -// This interface is intentionally generic to allow different implementations -type DARetriever interface { - RetrieveForcedIncludedTxsFromDA(ctx context.Context, daHeight uint64) (*ForcedInclusionEvent, error) +// ForcedInclusionRetriever defines the interface for retrieving forced inclusion transactions from DA +type ForcedInclusionRetriever interface { + RetrieveForcedIncludedTxs(ctx context.Context, daHeight uint64) (*block.ForcedInclusionEvent, error) } var _ coresequencer.Sequencer = (*BasedSequencer)(nil) @@ -33,7 +25,7 @@ var _ coresequencer.Sequencer = (*BasedSequencer)(nil) // BasedSequencer is a sequencer that only retrieves transactions from the DA layer // via the forced inclusion mechanism. It does not accept transactions from the reaper. type BasedSequencer struct { - daRetriever DARetriever + fiRetriever ForcedInclusionRetriever da coreda.DA config config.Config genesis genesis.Genesis @@ -46,14 +38,14 @@ type BasedSequencer struct { // NewBasedSequencer creates a new based sequencer instance func NewBasedSequencer( - daRetriever DARetriever, + fiRetriever ForcedInclusionRetriever, da coreda.DA, config config.Config, genesis genesis.Genesis, logger zerolog.Logger, ) *BasedSequencer { return &BasedSequencer{ - daRetriever: daRetriever, + fiRetriever: fiRetriever, da: da, config: config, genesis: genesis, @@ -95,8 +87,9 @@ func (s *BasedSequencer) GetNextBatch(ctx context.Context, req coresequencer.Get // Fetch forced inclusion transactions from DA s.logger.Debug().Uint64("da_height", s.daHeight).Msg("fetching forced inclusion transactions from DA") - forcedTxsEvent, err := s.daRetriever.RetrieveForcedIncludedTxsFromDA(ctx, s.daHeight) + forcedTxsEvent, err := s.fiRetriever.RetrieveForcedIncludedTxs(ctx, s.daHeight) if err != nil { + // Check if forced inclusion is not configured if errors.Is(err, block.ErrForceInclusionNotConfigured) { s.logger.Error().Msg("forced inclusion not configured, returning empty batch") return &coresequencer.GetNextBatchResponse{ diff --git a/sequencers/based/based_test.go b/sequencers/based/based_test.go index ef952b2524..de56f94a5e 100644 --- a/sequencers/based/based_test.go +++ b/sequencers/based/based_test.go @@ -18,19 +18,6 @@ import ( "github.com/evstack/ev-node/pkg/genesis" ) -// MockDARetriever is a mock implementation of DARetriever for testing -type MockDARetriever struct { - mock.Mock -} - -func (m *MockDARetriever) RetrieveForcedIncludedTxsFromDA(ctx context.Context, daHeight uint64) (*ForcedInclusionEvent, error) { - args := m.Called(ctx, daHeight) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*ForcedInclusionEvent), args.Error(1) -} - // MockDA is a mock implementation of DA for testing type MockDA struct { mock.Mock @@ -92,26 +79,23 @@ func (m *MockDA) Commit(ctx context.Context, blobs [][]byte, namespace []byte) ( return args.Get(0).([][]byte), args.Error(1) } -func (m *MockDA) GasPrice(ctx context.Context) (float64, error) { - args := m.Called(ctx) - return args.Get(0).(float64), args.Error(1) -} - -func (m *MockDA) GasMultiplier(ctx context.Context) (float64, error) { - args := m.Called(ctx) - return args.Get(0).(float64), args.Error(1) -} - func TestNewBasedSequencer(t *testing.T) { - mockRetriever := new(MockDARetriever) mockDA := new(MockDA) - cfg := config.DefaultConfig() gen := genesis.Genesis{ - ChainID: "test-chain", - DAStartHeight: 100, + ChainID: "test-chain", + DAStartHeight: 100, + DAEpochForcedInclusion: 10, } - seq := NewBasedSequencer(mockRetriever, mockDA, cfg, gen, zerolog.Nop()) + cfg := config.DefaultConfig() + cfg.DA.Namespace = "test-ns" + cfg.DA.DataNamespace = "test-data-ns" + cfg.DA.ForcedInclusionNamespace = "test-fi-ns" + + daClient := block.NewDAClient(mockDA, cfg, zerolog.Nop()) + fiRetriever := block.NewForcedInclusionRetriever(daClient, gen, zerolog.Nop()) + + seq := NewBasedSequencer(fiRetriever, mockDA, cfg, gen, zerolog.Nop()) require.NotNil(t, seq) assert.Equal(t, uint64(100), seq.daHeight) @@ -119,12 +103,21 @@ func TestNewBasedSequencer(t *testing.T) { } func TestBasedSequencer_SubmitBatchTxs(t *testing.T) { - mockRetriever := new(MockDARetriever) mockDA := new(MockDA) + gen := genesis.Genesis{ + ChainID: "test-chain", + DAEpochForcedInclusion: 10, + } + cfg := config.DefaultConfig() - gen := genesis.Genesis{ChainID: "test-chain"} + cfg.DA.Namespace = "test-ns" + cfg.DA.DataNamespace = "test-data-ns" + cfg.DA.ForcedInclusionNamespace = "test-fi-ns" + + daClient := block.NewDAClient(mockDA, cfg, zerolog.Nop()) + fiRetriever := block.NewForcedInclusionRetriever(daClient, gen, zerolog.Nop()) - seq := NewBasedSequencer(mockRetriever, mockDA, cfg, gen, zerolog.Nop()) + seq := NewBasedSequencer(fiRetriever, mockDA, cfg, gen, zerolog.Nop()) // Submit should succeed but be ignored req := coresequencer.SubmitBatchTxsRequest{ @@ -135,36 +128,42 @@ func TestBasedSequencer_SubmitBatchTxs(t *testing.T) { } resp, err := seq.SubmitBatchTxs(context.Background(), req) + require.NoError(t, err) require.NotNil(t, resp) - - // Queue should still be empty + // Transactions should not be added to queue for based sequencer assert.Equal(t, 0, len(seq.txQueue)) } func TestBasedSequencer_GetNextBatch_WithForcedTxs(t *testing.T) { - mockRetriever := new(MockDARetriever) + testBlobs := [][]byte{[]byte("tx1"), []byte("tx2")} + mockDA := new(MockDA) - cfg := config.DefaultConfig() + mockDA.On("GetIDs", mock.Anything, uint64(100), mock.Anything).Return(&coreda.GetIDsResult{ + IDs: []coreda.ID{[]byte("id1"), []byte("id2")}, + Timestamp: time.Now(), + }, nil) + mockDA.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(testBlobs, nil) + gen := genesis.Genesis{ - ChainID: "test-chain", - DAStartHeight: 100, + ChainID: "test-chain", + DAStartHeight: 100, + DAEpochForcedInclusion: 1, } - seq := NewBasedSequencer(mockRetriever, mockDA, cfg, gen, zerolog.Nop()) + cfg := config.DefaultConfig() + cfg.DA.Namespace = "test-ns" + cfg.DA.DataNamespace = "test-data-ns" + cfg.DA.ForcedInclusionNamespace = "test-fi-ns" - // Mock retriever to return forced inclusion transactions - forcedTxs := &ForcedInclusionEvent{ - Txs: [][]byte{[]byte("forced_tx1"), []byte("forced_tx2")}, - StartDaHeight: 101, - EndDaHeight: 105, - } - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, uint64(100)). - Return(forcedTxs, nil).Once() + daClient := block.NewDAClient(mockDA, cfg, zerolog.Nop()) + fiRetriever := block.NewForcedInclusionRetriever(daClient, gen, zerolog.Nop()) + + seq := NewBasedSequencer(fiRetriever, mockDA, cfg, gen, zerolog.Nop()) req := coresequencer.GetNextBatchRequest{ - Id: []byte("test-chain"), - MaxBytes: 10000, + MaxBytes: 1000000, + LastBatchData: nil, } resp, err := seq.GetNextBatch(context.Background(), req) @@ -172,38 +171,38 @@ func TestBasedSequencer_GetNextBatch_WithForcedTxs(t *testing.T) { require.NotNil(t, resp) require.NotNil(t, resp.Batch) assert.Equal(t, 2, len(resp.Batch.Transactions)) - assert.Equal(t, []byte("forced_tx1"), resp.Batch.Transactions[0]) - assert.Equal(t, []byte("forced_tx2"), resp.Batch.Transactions[1]) + assert.Equal(t, []byte("tx1"), resp.Batch.Transactions[0]) + assert.Equal(t, []byte("tx2"), resp.Batch.Transactions[1]) // DA height should be updated - assert.Equal(t, uint64(105), seq.GetDAHeight()) + assert.Equal(t, uint64(100), seq.GetDAHeight()) - mockRetriever.AssertExpectations(t) + mockDA.AssertExpectations(t) } func TestBasedSequencer_GetNextBatch_EmptyDA(t *testing.T) { - mockRetriever := new(MockDARetriever) mockDA := new(MockDA) - cfg := config.DefaultConfig() + mockDA.On("GetIDs", mock.Anything, uint64(100), mock.Anything).Return(nil, coreda.ErrBlobNotFound) + gen := genesis.Genesis{ - ChainID: "test-chain", - DAStartHeight: 100, + ChainID: "test-chain", + DAStartHeight: 100, + DAEpochForcedInclusion: 1, } - seq := NewBasedSequencer(mockRetriever, mockDA, cfg, gen, zerolog.Nop()) + cfg := config.DefaultConfig() + cfg.DA.Namespace = "test-ns" + cfg.DA.DataNamespace = "test-data-ns" + cfg.DA.ForcedInclusionNamespace = "test-fi-ns" - // Mock retriever to return empty transactions - emptyEvent := &ForcedInclusionEvent{ - Txs: [][]byte{}, - StartDaHeight: 100, - EndDaHeight: 100, - } - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, uint64(100)). - Return(emptyEvent, nil).Once() + daClient := block.NewDAClient(mockDA, cfg, zerolog.Nop()) + fiRetriever := block.NewForcedInclusionRetriever(daClient, gen, zerolog.Nop()) + + seq := NewBasedSequencer(fiRetriever, mockDA, cfg, gen, zerolog.Nop()) req := coresequencer.GetNextBatchRequest{ - Id: []byte("test-chain"), - MaxBytes: 10000, + MaxBytes: 1000000, + LastBatchData: nil, } resp, err := seq.GetNextBatch(context.Background(), req) @@ -212,145 +211,169 @@ func TestBasedSequencer_GetNextBatch_EmptyDA(t *testing.T) { require.NotNil(t, resp.Batch) assert.Equal(t, 0, len(resp.Batch.Transactions)) - mockRetriever.AssertExpectations(t) + mockDA.AssertExpectations(t) } func TestBasedSequencer_GetNextBatch_NotConfigured(t *testing.T) { - mockRetriever := new(MockDARetriever) mockDA := new(MockDA) - cfg := config.DefaultConfig() gen := genesis.Genesis{ - ChainID: "test-chain", - DAStartHeight: 100, + ChainID: "test-chain", + DAStartHeight: 100, + DAEpochForcedInclusion: 1, } - seq := NewBasedSequencer(mockRetriever, mockDA, cfg, gen, zerolog.Nop()) + // Create config without forced inclusion namespace + cfgNoFI := config.DefaultConfig() + cfgNoFI.DA.ForcedInclusionNamespace = "" + daClient := block.NewDAClient(mockDA, cfgNoFI, zerolog.Nop()) + fiRetriever := block.NewForcedInclusionRetriever(daClient, gen, zerolog.Nop()) - // Mock retriever to return not configured error - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, uint64(100)). - Return(nil, block.ErrForceInclusionNotConfigured).Once() + seq := NewBasedSequencer(fiRetriever, mockDA, cfgNoFI, gen, zerolog.Nop()) req := coresequencer.GetNextBatchRequest{ - Id: []byte("test-chain"), - MaxBytes: 10000, + MaxBytes: 1000000, + LastBatchData: nil, } resp, err := seq.GetNextBatch(context.Background(), req) require.NoError(t, err) require.NotNil(t, resp) require.NotNil(t, resp.Batch) - assert.Nil(t, resp.Batch.Transactions) - - mockRetriever.AssertExpectations(t) + assert.Equal(t, 0, len(resp.Batch.Transactions)) } func TestBasedSequencer_GetNextBatch_HeightFromFuture(t *testing.T) { - mockRetriever := new(MockDARetriever) mockDA := new(MockDA) - cfg := config.DefaultConfig() + mockDA.On("GetIDs", mock.Anything, uint64(100), mock.Anything).Return(nil, coreda.ErrHeightFromFuture) + gen := genesis.Genesis{ - ChainID: "test-chain", - DAStartHeight: 100, + ChainID: "test-chain", + DAStartHeight: 100, + DAEpochForcedInclusion: 1, } - seq := NewBasedSequencer(mockRetriever, mockDA, cfg, gen, zerolog.Nop()) + cfg := config.DefaultConfig() + cfg.DA.Namespace = "test-ns" + cfg.DA.DataNamespace = "test-data-ns" + cfg.DA.ForcedInclusionNamespace = "test-fi-ns" + + daClient := block.NewDAClient(mockDA, cfg, zerolog.Nop()) + fiRetriever := block.NewForcedInclusionRetriever(daClient, gen, zerolog.Nop()) - // Mock retriever to return height from future error - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, uint64(100)). - Return(nil, coreda.ErrHeightFromFuture).Once() + seq := NewBasedSequencer(fiRetriever, mockDA, cfg, gen, zerolog.Nop()) req := coresequencer.GetNextBatchRequest{ - Id: []byte("test-chain"), - MaxBytes: 10000, + MaxBytes: 1000000, + LastBatchData: nil, } resp, err := seq.GetNextBatch(context.Background(), req) require.NoError(t, err) require.NotNil(t, resp) require.NotNil(t, resp.Batch) - assert.Nil(t, resp.Batch.Transactions) + assert.Equal(t, 0, len(resp.Batch.Transactions)) - // DA height should NOT increment on ErrHeightFromFuture - we wait for DA to catch up + // DA height should remain the same assert.Equal(t, uint64(100), seq.GetDAHeight()) - mockRetriever.AssertExpectations(t) + mockDA.AssertExpectations(t) } func TestBasedSequencer_GetNextBatch_WithMaxBytes(t *testing.T) { - mockRetriever := new(MockDARetriever) + testBlobs := [][]byte{ + make([]byte, 50), // 50 bytes + make([]byte, 60), // 60 bytes + make([]byte, 100), // 100 bytes + } + mockDA := new(MockDA) - cfg := config.DefaultConfig() + mockDA.On("GetIDs", mock.Anything, uint64(100), mock.Anything).Return(&coreda.GetIDsResult{ + IDs: []coreda.ID{[]byte("id1"), []byte("id2"), []byte("id3")}, + Timestamp: time.Now(), + }, nil) + mockDA.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(testBlobs, nil) + gen := genesis.Genesis{ - ChainID: "test-chain", - DAStartHeight: 100, + ChainID: "test-chain", + DAStartHeight: 100, + DAEpochForcedInclusion: 1, } - seq := NewBasedSequencer(mockRetriever, mockDA, cfg, gen, zerolog.Nop()) + cfg := config.DefaultConfig() + cfg.DA.Namespace = "test-ns" + cfg.DA.DataNamespace = "test-data-ns" + cfg.DA.ForcedInclusionNamespace = "test-fi-ns" - // Create transactions that will exceed max bytes - tx1 := make([]byte, 50) - tx2 := make([]byte, 50) - tx3 := make([]byte, 50) + daClient := block.NewDAClient(mockDA, cfg, zerolog.Nop()) + fiRetriever := block.NewForcedInclusionRetriever(daClient, gen, zerolog.Nop()) - forcedTxs := &ForcedInclusionEvent{ - Txs: [][]byte{tx1, tx2, tx3}, - StartDaHeight: 101, - EndDaHeight: 105, - } - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, uint64(100)). - Return(forcedTxs, nil).Once() + seq := NewBasedSequencer(fiRetriever, mockDA, cfg, gen, zerolog.Nop()) - // Request with max bytes that only fits 2 transactions + // First call with max 100 bytes - should get first 2 txs (50 + 60 = 110, but logic allows if batch has content) req := coresequencer.GetNextBatchRequest{ - Id: []byte("test-chain"), - MaxBytes: 100, // Only fits 2 transactions + MaxBytes: 100, + LastBatchData: nil, } resp, err := seq.GetNextBatch(context.Background(), req) require.NoError(t, err) require.NotNil(t, resp) require.NotNil(t, resp.Batch) - assert.Equal(t, 2, len(resp.Batch.Transactions)) + // Should get first tx (50 bytes), then break before second tx (would make 110 total) + assert.Equal(t, 1, len(resp.Batch.Transactions)) + assert.Equal(t, 2, len(seq.txQueue)) // 2 remaining in queue - // Third transaction should still be in queue - assert.Equal(t, 1, len(seq.txQueue)) - - // Next request should return the remaining transaction - req2 := coresequencer.GetNextBatchRequest{ - Id: []byte("test-chain"), - MaxBytes: 100, - } - - resp2, err := seq.GetNextBatch(context.Background(), req2) + // Second call should get next tx from queue + resp2, err := seq.GetNextBatch(context.Background(), req) require.NoError(t, err) require.NotNil(t, resp2) require.NotNil(t, resp2.Batch) assert.Equal(t, 1, len(resp2.Batch.Transactions)) - assert.Equal(t, 0, len(seq.txQueue)) + assert.Equal(t, 1, len(seq.txQueue)) // 1 remaining in queue - mockRetriever.AssertExpectations(t) + // Third call with larger maxBytes to get the 100-byte tx + req3 := coresequencer.GetNextBatchRequest{ + MaxBytes: 200, + LastBatchData: nil, + } + resp3, err := seq.GetNextBatch(context.Background(), req3) + require.NoError(t, err) + require.NotNil(t, resp3) + require.NotNil(t, resp3.Batch) + assert.Equal(t, 1, len(resp3.Batch.Transactions)) + assert.Equal(t, 0, len(seq.txQueue)) // Queue should be empty + + mockDA.AssertExpectations(t) } func TestBasedSequencer_GetNextBatch_FromQueue(t *testing.T) { - mockRetriever := new(MockDARetriever) mockDA := new(MockDA) - cfg := config.DefaultConfig() + mockDA.On("GetIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil, coreda.ErrBlobNotFound) + gen := genesis.Genesis{ - ChainID: "test-chain", - DAStartHeight: 100, + ChainID: "test-chain", + DAStartHeight: 100, + DAEpochForcedInclusion: 1, } - seq := NewBasedSequencer(mockRetriever, mockDA, cfg, gen, zerolog.Nop()) + cfg := config.DefaultConfig() + cfg.DA.Namespace = "test-ns" + cfg.DA.DataNamespace = "test-data-ns" + cfg.DA.ForcedInclusionNamespace = "test-fi-ns" + + daClient := block.NewDAClient(mockDA, cfg, zerolog.Nop()) + fiRetriever := block.NewForcedInclusionRetriever(daClient, gen, zerolog.Nop()) + + seq := NewBasedSequencer(fiRetriever, mockDA, cfg, gen, zerolog.Nop()) // Pre-populate the queue seq.txQueue = [][]byte{[]byte("queued_tx1"), []byte("queued_tx2")} req := coresequencer.GetNextBatchRequest{ - Id: []byte("test-chain"), - MaxBytes: 10000, + MaxBytes: 1000000, + LastBatchData: nil, } - // Should return from queue without calling retriever resp, err := seq.GetNextBatch(context.Background(), req) require.NoError(t, err) require.NotNil(t, resp) @@ -358,19 +381,27 @@ func TestBasedSequencer_GetNextBatch_FromQueue(t *testing.T) { assert.Equal(t, 2, len(resp.Batch.Transactions)) assert.Equal(t, []byte("queued_tx1"), resp.Batch.Transactions[0]) assert.Equal(t, []byte("queued_tx2"), resp.Batch.Transactions[1]) - assert.Equal(t, 0, len(seq.txQueue)) - // No expectations on retriever since it shouldn't be called - mockRetriever.AssertExpectations(t) + // Queue should be empty now + assert.Equal(t, 0, len(seq.txQueue)) } func TestBasedSequencer_VerifyBatch(t *testing.T) { - mockRetriever := new(MockDARetriever) mockDA := new(MockDA) + gen := genesis.Genesis{ + ChainID: "test-chain", + DAEpochForcedInclusion: 1, + } + cfg := config.DefaultConfig() - gen := genesis.Genesis{ChainID: "test-chain"} + cfg.DA.Namespace = "test-ns" + cfg.DA.DataNamespace = "test-data-ns" + cfg.DA.ForcedInclusionNamespace = "test-fi-ns" + + daClient := block.NewDAClient(mockDA, cfg, zerolog.Nop()) + fiRetriever := block.NewForcedInclusionRetriever(daClient, gen, zerolog.Nop()) - seq := NewBasedSequencer(mockRetriever, mockDA, cfg, gen, zerolog.Nop()) + seq := NewBasedSequencer(fiRetriever, mockDA, cfg, gen, zerolog.Nop()) req := coresequencer.VerifyBatchRequest{ Id: []byte("test-chain"), @@ -379,20 +410,26 @@ func TestBasedSequencer_VerifyBatch(t *testing.T) { resp, err := seq.VerifyBatch(context.Background(), req) require.NoError(t, err) - require.NotNil(t, resp) assert.True(t, resp.Status) } func TestBasedSequencer_SetDAHeight(t *testing.T) { - mockRetriever := new(MockDARetriever) mockDA := new(MockDA) - cfg := config.DefaultConfig() gen := genesis.Genesis{ - ChainID: "test-chain", - DAStartHeight: 100, + ChainID: "test-chain", + DAStartHeight: 100, + DAEpochForcedInclusion: 1, } - seq := NewBasedSequencer(mockRetriever, mockDA, cfg, gen, zerolog.Nop()) + cfg := config.DefaultConfig() + cfg.DA.Namespace = "test-ns" + cfg.DA.DataNamespace = "test-data-ns" + cfg.DA.ForcedInclusionNamespace = "test-fi-ns" + + daClient := block.NewDAClient(mockDA, cfg, zerolog.Nop()) + fiRetriever := block.NewForcedInclusionRetriever(daClient, gen, zerolog.Nop()) + + seq := NewBasedSequencer(fiRetriever, mockDA, cfg, gen, zerolog.Nop()) assert.Equal(t, uint64(100), seq.GetDAHeight()) @@ -400,84 +437,33 @@ func TestBasedSequencer_SetDAHeight(t *testing.T) { assert.Equal(t, uint64(200), seq.GetDAHeight()) } -func TestBasedSequencer_ConcurrentAccess(t *testing.T) { - mockRetriever := new(MockDARetriever) +func TestBasedSequencer_GetNextBatch_ErrorHandling(t *testing.T) { mockDA := new(MockDA) - cfg := config.DefaultConfig() - gen := genesis.Genesis{ - ChainID: "test-chain", - DAStartHeight: 100, - } - - seq := NewBasedSequencer(mockRetriever, mockDA, cfg, gen, zerolog.Nop()) + mockDA.On("GetIDs", mock.Anything, uint64(100), mock.Anything).Return(nil, errors.New("DA connection error")) - // Mock retriever to return transactions - forcedTxs := &ForcedInclusionEvent{ - Txs: [][]byte{[]byte("tx1")}, - StartDaHeight: 101, - EndDaHeight: 105, - } - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, mock.Anything). - Return(forcedTxs, nil).Maybe() - - // Test concurrent access - done := make(chan bool, 3) - - // Concurrent GetNextBatch calls - go func() { - req := coresequencer.GetNextBatchRequest{Id: []byte("test-chain"), MaxBytes: 1000} - _, _ = seq.GetNextBatch(context.Background(), req) - done <- true - }() - - // Concurrent SetDAHeight calls - go func() { - seq.SetDAHeight(200) - done <- true - }() - - // Concurrent GetDAHeight calls - go func() { - _ = seq.GetDAHeight() - done <- true - }() - - // Wait for all goroutines - timeout := time.After(5 * time.Second) - for i := 0; i < 3; i++ { - select { - case <-done: - case <-timeout: - t.Fatal("test timed out") - } + gen := genesis.Genesis{ + ChainID: "test-chain", + DAStartHeight: 100, + DAEpochForcedInclusion: 1, } -} -func TestBasedSequencer_GetNextBatch_ErrorHandling(t *testing.T) { - mockRetriever := new(MockDARetriever) - mockDA := new(MockDA) cfg := config.DefaultConfig() - gen := genesis.Genesis{ - ChainID: "test-chain", - DAStartHeight: 100, - } + cfg.DA.Namespace = "test-ns" + cfg.DA.DataNamespace = "test-data-ns" + cfg.DA.ForcedInclusionNamespace = "test-fi-ns" - seq := NewBasedSequencer(mockRetriever, mockDA, cfg, gen, zerolog.Nop()) + daClient := block.NewDAClient(mockDA, cfg, zerolog.Nop()) + fiRetriever := block.NewForcedInclusionRetriever(daClient, gen, zerolog.Nop()) - // Mock retriever to return an unexpected error - expectedErr := errors.New("unexpected DA error") - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, uint64(100)). - Return(nil, expectedErr).Once() + seq := NewBasedSequencer(fiRetriever, mockDA, cfg, gen, zerolog.Nop()) req := coresequencer.GetNextBatchRequest{ - Id: []byte("test-chain"), - MaxBytes: 10000, + MaxBytes: 1000000, + LastBatchData: nil, } - resp, err := seq.GetNextBatch(context.Background(), req) + _, err := seq.GetNextBatch(context.Background(), req) require.Error(t, err) - assert.Nil(t, resp) - assert.Equal(t, expectedErr, err) - mockRetriever.AssertExpectations(t) + mockDA.AssertExpectations(t) } diff --git a/sequencers/single/sequencer.go b/sequencers/single/sequencer.go index 1dbc145238..866d655284 100644 --- a/sequencers/single/sequencer.go +++ b/sequencers/single/sequencer.go @@ -22,16 +22,9 @@ var ( ErrInvalidId = errors.New("invalid chain id") ) -// ForcedInclusionEvent represents forced inclusion transactions retrieved from DA -type ForcedInclusionEvent = struct { - Txs [][]byte - StartDaHeight uint64 - EndDaHeight uint64 -} - -// DARetriever defines the interface for retrieving forced inclusion transactions from DA -type DARetriever interface { - RetrieveForcedIncludedTxsFromDA(ctx context.Context, daHeight uint64) (*ForcedInclusionEvent, error) +// ForcedInclusionRetriever defines the interface for retrieving forced inclusion transactions from DA +type ForcedInclusionRetriever interface { + RetrieveForcedIncludedTxs(ctx context.Context, daHeight uint64) (*block.ForcedInclusionEvent, error) } var _ coresequencer.Sequencer = (*Sequencer)(nil) @@ -52,7 +45,7 @@ type Sequencer struct { metrics *Metrics // Forced inclusion support - daRetriever DARetriever + fiRetriever ForcedInclusionRetriever genesis genesis.Genesis daHeight atomic.Uint64 } @@ -68,7 +61,7 @@ func NewSequencer( metrics *Metrics, proposer bool, maxQueueSize int, - daRetriever DARetriever, + fiRetriever ForcedInclusionRetriever, gen genesis.Genesis, ) (*Sequencer, error) { s := &Sequencer{ @@ -79,7 +72,7 @@ func NewSequencer( queue: NewBatchQueue(db, "batches", maxQueueSize), metrics: metrics, proposer: proposer, - daRetriever: daRetriever, + fiRetriever: fiRetriever, genesis: gen, } s.SetDAHeight(gen.DAStartHeight) // will be overridden by the executor @@ -132,7 +125,7 @@ func (c *Sequencer) GetNextBatch(ctx context.Context, req coresequencer.GetNextB var forcedTxs [][]byte currentDAHeight := c.daHeight.Load() - forcedEvent, err := c.daRetriever.RetrieveForcedIncludedTxsFromDA(ctx, currentDAHeight) + forcedEvent, err := c.fiRetriever.RetrieveForcedIncludedTxs(ctx, currentDAHeight) if err != nil { // If we get a height from future error, keep the current DA height and return batch // We'll retry the same height on the next call until DA produces that block diff --git a/sequencers/single/sequencer_test.go b/sequencers/single/sequencer_test.go index a73c8c0ba3..c0f4b556bc 100644 --- a/sequencers/single/sequencer_test.go +++ b/sequencers/single/sequencer_test.go @@ -20,17 +20,17 @@ import ( damocks "github.com/evstack/ev-node/test/mocks" ) -// MockDARetriever is a mock implementation of DARetriever for testing -type MockDARetriever struct { +// MockForcedInclusionRetriever is a mock implementation of DARetriever for testing +type MockForcedInclusionRetriever struct { mock.Mock } -func (m *MockDARetriever) RetrieveForcedIncludedTxsFromDA(ctx context.Context, daHeight uint64) (*ForcedInclusionEvent, error) { +func (m *MockForcedInclusionRetriever) RetrieveForcedIncludedTxs(ctx context.Context, daHeight uint64) (*block.ForcedInclusionEvent, error) { args := m.Called(ctx, daHeight) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*ForcedInclusionEvent), args.Error(1) + return args.Get(0).(*block.ForcedInclusionEvent), args.Error(1) } func TestNewSequencer(t *testing.T) { @@ -41,8 +41,8 @@ func TestNewSequencer(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() logger := zerolog.Nop() - mockRetriever := new(MockDARetriever) - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, mock.Anything). + mockRetriever := new(MockForcedInclusionRetriever) + mockRetriever.On("RetrieveForcedIncludedTxs", mock.Anything, mock.Anything). Return(nil, block.ErrForceInclusionNotConfigured).Maybe() seq, err := NewSequencer(ctx, logger, db, dummyDA, []byte("test1"), 10*time.Second, metrics, false, 1000, mockRetriever, genesis.Genesis{}) if err != nil { @@ -77,8 +77,8 @@ func TestSequencer_SubmitBatchTxs(t *testing.T) { defer cancel() Id := []byte("test1") logger := zerolog.Nop() - mockRetriever := new(MockDARetriever) - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, mock.Anything). + mockRetriever := new(MockForcedInclusionRetriever) + mockRetriever.On("RetrieveForcedIncludedTxs", mock.Anything, mock.Anything). Return(nil, block.ErrForceInclusionNotConfigured).Maybe() seq, err := NewSequencer(ctx, logger, db, dummyDA, Id, 10*time.Second, metrics, false, 1000, mockRetriever, genesis.Genesis{}) if err != nil { @@ -133,8 +133,8 @@ func TestSequencer_SubmitBatchTxs_EmptyBatch(t *testing.T) { defer cancel() Id := []byte("test1") logger := zerolog.Nop() - mockRetriever := new(MockDARetriever) - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, mock.Anything). + mockRetriever := new(MockForcedInclusionRetriever) + mockRetriever.On("RetrieveForcedIncludedTxs", mock.Anything, mock.Anything). Return(nil, block.ErrForceInclusionNotConfigured).Maybe() seq, err := NewSequencer(ctx, logger, db, dummyDA, Id, 10*time.Second, metrics, false, 1000, mockRetriever, genesis.Genesis{}) require.NoError(t, err, "Failed to create sequencer") @@ -176,14 +176,14 @@ func TestSequencer_GetNextBatch_NoLastBatch(t *testing.T) { db := ds.NewMapDatastore() logger := zerolog.Nop() - mockRetriever := new(MockDARetriever) - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, mock.Anything). + mockRetriever := new(MockForcedInclusionRetriever) + mockRetriever.On("RetrieveForcedIncludedTxs", mock.Anything, mock.Anything). Return(nil, block.ErrForceInclusionNotConfigured).Maybe() seq := &Sequencer{ logger: logger, queue: NewBatchQueue(db, "batches", 0), // 0 = unlimited for test Id: []byte("test"), - daRetriever: mockRetriever, + fiRetriever: mockRetriever, } defer func() { err := db.Close() @@ -216,14 +216,14 @@ func TestSequencer_GetNextBatch_Success(t *testing.T) { db := ds.NewMapDatastore() logger := zerolog.Nop() - mockRetriever := new(MockDARetriever) - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, mock.Anything). + mockRetriever := new(MockForcedInclusionRetriever) + mockRetriever.On("RetrieveForcedIncludedTxs", mock.Anything, mock.Anything). Return(nil, block.ErrForceInclusionNotConfigured).Maybe() seq := &Sequencer{ logger: logger, queue: NewBatchQueue(db, "batches", 0), // 0 = unlimited for test Id: []byte("test"), - daRetriever: mockRetriever, + fiRetriever: mockRetriever, } defer func() { err := db.Close() @@ -279,8 +279,8 @@ func TestSequencer_VerifyBatch(t *testing.T) { t.Run("Proposer Mode", func(t *testing.T) { mockDA := damocks.NewMockDA(t) logger := zerolog.Nop() - mockRetriever := new(MockDARetriever) - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, mock.Anything). + mockRetriever := new(MockForcedInclusionRetriever) + mockRetriever.On("RetrieveForcedIncludedTxs", mock.Anything, mock.Anything). Return(nil, block.ErrForceInclusionNotConfigured).Maybe() seq := &Sequencer{ @@ -289,7 +289,7 @@ func TestSequencer_VerifyBatch(t *testing.T) { proposer: true, da: mockDA, queue: NewBatchQueue(db, "proposer_queue", 0), // 0 = unlimited for test - daRetriever: mockRetriever, + fiRetriever: mockRetriever, } res, err := seq.VerifyBatch(context.Background(), coresequencer.VerifyBatchRequest{Id: seq.Id, BatchData: batchData}) @@ -305,8 +305,8 @@ func TestSequencer_VerifyBatch(t *testing.T) { t.Run("Valid Proofs", func(t *testing.T) { mockDA := damocks.NewMockDA(t) logger := zerolog.Nop() - mockRetriever := new(MockDARetriever) - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, mock.Anything). + mockRetriever := new(MockForcedInclusionRetriever) + mockRetriever.On("RetrieveForcedIncludedTxs", mock.Anything, mock.Anything). Return(nil, block.ErrForceInclusionNotConfigured).Maybe() seq := &Sequencer{ logger: logger, @@ -314,7 +314,7 @@ func TestSequencer_VerifyBatch(t *testing.T) { proposer: false, da: mockDA, queue: NewBatchQueue(db, "valid_proofs_queue", 0), - daRetriever: mockRetriever, + fiRetriever: mockRetriever, } mockDA.On("GetProofs", context.Background(), batchData, Id).Return(proofs, nil).Once() @@ -330,8 +330,8 @@ func TestSequencer_VerifyBatch(t *testing.T) { t.Run("Invalid Proof", func(t *testing.T) { mockDA := damocks.NewMockDA(t) logger := zerolog.Nop() - mockRetriever := new(MockDARetriever) - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, mock.Anything). + mockRetriever := new(MockForcedInclusionRetriever) + mockRetriever.On("RetrieveForcedIncludedTxs", mock.Anything, mock.Anything). Return(nil, block.ErrForceInclusionNotConfigured).Maybe() seq := &Sequencer{ logger: logger, @@ -339,7 +339,7 @@ func TestSequencer_VerifyBatch(t *testing.T) { proposer: false, da: mockDA, queue: NewBatchQueue(db, "invalid_proof_queue", 0), - daRetriever: mockRetriever, + fiRetriever: mockRetriever, } mockDA.On("GetProofs", context.Background(), batchData, Id).Return(proofs, nil).Once() @@ -355,8 +355,8 @@ func TestSequencer_VerifyBatch(t *testing.T) { t.Run("GetProofs Error", func(t *testing.T) { mockDA := damocks.NewMockDA(t) logger := zerolog.Nop() - mockRetriever := new(MockDARetriever) - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, mock.Anything). + mockRetriever := new(MockForcedInclusionRetriever) + mockRetriever.On("RetrieveForcedIncludedTxs", mock.Anything, mock.Anything). Return(nil, block.ErrForceInclusionNotConfigured).Maybe() seq := &Sequencer{ logger: logger, @@ -364,7 +364,7 @@ func TestSequencer_VerifyBatch(t *testing.T) { proposer: false, da: mockDA, queue: NewBatchQueue(db, "getproofs_err_queue", 0), - daRetriever: mockRetriever, + fiRetriever: mockRetriever, } expectedErr := errors.New("get proofs failed") @@ -381,8 +381,8 @@ func TestSequencer_VerifyBatch(t *testing.T) { t.Run("Validate Error", func(t *testing.T) { mockDA := damocks.NewMockDA(t) logger := zerolog.Nop() - mockRetriever := new(MockDARetriever) - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, mock.Anything). + mockRetriever := new(MockForcedInclusionRetriever) + mockRetriever.On("RetrieveForcedIncludedTxs", mock.Anything, mock.Anything). Return(nil, block.ErrForceInclusionNotConfigured).Maybe() seq := &Sequencer{ logger: logger, @@ -390,7 +390,7 @@ func TestSequencer_VerifyBatch(t *testing.T) { proposer: false, da: mockDA, queue: NewBatchQueue(db, "validate_err_queue", 0), - daRetriever: mockRetriever, + fiRetriever: mockRetriever, } expectedErr := errors.New("validate failed") @@ -407,8 +407,8 @@ func TestSequencer_VerifyBatch(t *testing.T) { t.Run("Invalid ID", func(t *testing.T) { mockDA := damocks.NewMockDA(t) logger := zerolog.Nop() - mockRetriever := new(MockDARetriever) - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, mock.Anything). + mockRetriever := new(MockForcedInclusionRetriever) + mockRetriever.On("RetrieveForcedIncludedTxs", mock.Anything, mock.Anything). Return(nil, block.ErrForceInclusionNotConfigured).Maybe() seq := &Sequencer{ @@ -417,7 +417,7 @@ func TestSequencer_VerifyBatch(t *testing.T) { proposer: false, da: mockDA, queue: NewBatchQueue(db, "invalid_queue", 0), - daRetriever: mockRetriever, + fiRetriever: mockRetriever, } invalidId := []byte("invalid") @@ -441,8 +441,8 @@ func TestSequencer_GetNextBatch_BeforeDASubmission(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() logger := zerolog.Nop() - mockRetriever := new(MockDARetriever) - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, mock.Anything). + mockRetriever := new(MockForcedInclusionRetriever) + mockRetriever.On("RetrieveForcedIncludedTxs", mock.Anything, mock.Anything). Return(nil, block.ErrForceInclusionNotConfigured).Maybe() seq, err := NewSequencer(ctx, logger, db, mockDA, []byte("test1"), 1*time.Second, metrics, false, 1000, mockRetriever, genesis.Genesis{}) if err != nil { @@ -582,8 +582,8 @@ func TestSequencer_QueueLimit_Integration(t *testing.T) { defer db.Close() mockDA := &damocks.MockDA{} - mockRetriever := new(MockDARetriever) - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, mock.Anything). + mockRetriever := new(MockForcedInclusionRetriever) + mockRetriever.On("RetrieveForcedIncludedTxs", mock.Anything, mock.Anything). Return(nil, block.ErrForceInclusionNotConfigured).Maybe() // Create a sequencer with a small queue limit for testing @@ -595,7 +595,7 @@ func TestSequencer_QueueLimit_Integration(t *testing.T) { Id: []byte("test"), queue: NewBatchQueue(db, "test_queue", 2), // Very small limit for testing proposer: true, - daRetriever: mockRetriever, + fiRetriever: mockRetriever, } ctx := context.Background() @@ -704,8 +704,8 @@ func TestSequencer_DAFailureAndQueueThrottling_Integration(t *testing.T) { // Create sequencer with small queue size to trigger throttling quickly queueSize := 3 // Small for testing logger := zerolog.Nop() - mockRetriever := new(MockDARetriever) - mockRetriever.On("RetrieveForcedIncludedTxsFromDA", mock.Anything, mock.Anything). + mockRetriever := new(MockForcedInclusionRetriever) + mockRetriever.On("RetrieveForcedIncludedTxs", mock.Anything, mock.Anything). Return(nil, block.ErrForceInclusionNotConfigured).Maybe() seq, err := NewSequencer( context.Background(), @@ -717,7 +717,7 @@ func TestSequencer_DAFailureAndQueueThrottling_Integration(t *testing.T) { nil, // metrics true, // proposer queueSize, - mockRetriever, // daRetriever + mockRetriever, // fiRetriever genesis.Genesis{}, // genesis ) require.NoError(t, err) diff --git a/test/mocks/da.go b/test/mocks/da.go index 37539d5480..bb3ad63391 100644 --- a/test/mocks/da.go +++ b/test/mocks/da.go @@ -112,126 +112,6 @@ func (_c *MockDA_Commit_Call) RunAndReturn(run func(ctx context.Context, blobs [ return _c } -// GasMultiplier provides a mock function for the type MockDA -func (_mock *MockDA) GasMultiplier(ctx context.Context) (float64, error) { - ret := _mock.Called(ctx) - - if len(ret) == 0 { - panic("no return value specified for GasMultiplier") - } - - var r0 float64 - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context) (float64, error)); ok { - return returnFunc(ctx) - } - if returnFunc, ok := ret.Get(0).(func(context.Context) float64); ok { - r0 = returnFunc(ctx) - } else { - r0 = ret.Get(0).(float64) - } - if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { - r1 = returnFunc(ctx) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockDA_GasMultiplier_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GasMultiplier' -type MockDA_GasMultiplier_Call struct { - *mock.Call -} - -// GasMultiplier is a helper method to define mock.On call -// - ctx context.Context -func (_e *MockDA_Expecter) GasMultiplier(ctx interface{}) *MockDA_GasMultiplier_Call { - return &MockDA_GasMultiplier_Call{Call: _e.mock.On("GasMultiplier", ctx)} -} - -func (_c *MockDA_GasMultiplier_Call) Run(run func(ctx context.Context)) *MockDA_GasMultiplier_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - run( - arg0, - ) - }) - return _c -} - -func (_c *MockDA_GasMultiplier_Call) Return(f float64, err error) *MockDA_GasMultiplier_Call { - _c.Call.Return(f, err) - return _c -} - -func (_c *MockDA_GasMultiplier_Call) RunAndReturn(run func(ctx context.Context) (float64, error)) *MockDA_GasMultiplier_Call { - _c.Call.Return(run) - return _c -} - -// GasPrice provides a mock function for the type MockDA -func (_mock *MockDA) GasPrice(ctx context.Context) (float64, error) { - ret := _mock.Called(ctx) - - if len(ret) == 0 { - panic("no return value specified for GasPrice") - } - - var r0 float64 - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context) (float64, error)); ok { - return returnFunc(ctx) - } - if returnFunc, ok := ret.Get(0).(func(context.Context) float64); ok { - r0 = returnFunc(ctx) - } else { - r0 = ret.Get(0).(float64) - } - if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { - r1 = returnFunc(ctx) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockDA_GasPrice_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GasPrice' -type MockDA_GasPrice_Call struct { - *mock.Call -} - -// GasPrice is a helper method to define mock.On call -// - ctx context.Context -func (_e *MockDA_Expecter) GasPrice(ctx interface{}) *MockDA_GasPrice_Call { - return &MockDA_GasPrice_Call{Call: _e.mock.On("GasPrice", ctx)} -} - -func (_c *MockDA_GasPrice_Call) Run(run func(ctx context.Context)) *MockDA_GasPrice_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - run( - arg0, - ) - }) - return _c -} - -func (_c *MockDA_GasPrice_Call) Return(f float64, err error) *MockDA_GasPrice_Call { - _c.Call.Return(f, err) - return _c -} - -func (_c *MockDA_GasPrice_Call) RunAndReturn(run func(ctx context.Context) (float64, error)) *MockDA_GasPrice_Call { - _c.Call.Return(run) - return _c -} - // Get provides a mock function for the type MockDA func (_mock *MockDA) Get(ctx context.Context, ids []da.ID, namespace []byte) ([]da.Blob, error) { ret := _mock.Called(ctx, ids, namespace) diff --git a/types/CLAUDE.md b/types/CLAUDE.md index 9cd5496e56..aafdd289a2 100644 --- a/types/CLAUDE.md +++ b/types/CLAUDE.md @@ -77,17 +77,16 @@ The types package defines the core data structures and types used throughout ev- - Signature verification - Identity validation -### DA Integration (`da.go`, `da_test.go`) +### DA Integration -- **Purpose**: Data Availability layer helpers -- **Key Functions**: - - `SubmitWithHelpers`: DA submission with error handling +- **Purpose**: Data Availability layer helpers moved to `block/internal/da` package +- **See**: `block/internal/da/client.go` for DA submission and retrieval logic - **Key Features**: - - Error mapping to status codes + - Error mapping to status codes (in DA Client) - Namespace support - Gas price configuration - Submission options handling -- **Status Codes**: +- **Status Codes** (defined in `core/da`): - `StatusContextCanceled`: Submission canceled - `StatusNotIncludedInBlock`: Transaction timeout - `StatusAlreadyInMempool`: Duplicate transaction diff --git a/types/da.go b/types/da.go deleted file mode 100644 index e0d58710d9..0000000000 --- a/types/da.go +++ /dev/null @@ -1,212 +0,0 @@ -package types - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/rs/zerolog" - - coreda "github.com/evstack/ev-node/core/da" -) - -// SubmitWithHelpers performs blob submission using the underlying DA layer, -// handling error mapping to produce a ResultSubmit. -// It assumes blob size filtering is handled within the DA implementation's Submit. -// It mimics the logic previously found in da.DAClient.Submit. -func SubmitWithHelpers( - ctx context.Context, - da coreda.DA, // Use the core DA interface - logger zerolog.Logger, - data [][]byte, - gasPrice float64, - namespace []byte, - options []byte, -) coreda.ResultSubmit { // Return core ResultSubmit type - ids, err := da.SubmitWithOptions(ctx, data, gasPrice, namespace, options) - - // calculate blob size - var blobSize uint64 - for _, blob := range data { - blobSize += uint64(len(blob)) - } - - // Handle errors returned by Submit - if err != nil { - if errors.Is(err, context.Canceled) { - logger.Debug().Msg("DA submission canceled via helper due to context cancellation") - return coreda.ResultSubmit{ - BaseResult: coreda.BaseResult{ - Code: coreda.StatusContextCanceled, - Message: "submission canceled", - IDs: ids, - BlobSize: blobSize, - }, - } - } - status := coreda.StatusError - switch { - case errors.Is(err, coreda.ErrTxTimedOut): - status = coreda.StatusNotIncludedInBlock - case errors.Is(err, coreda.ErrTxAlreadyInMempool): - status = coreda.StatusAlreadyInMempool - case errors.Is(err, coreda.ErrTxIncorrectAccountSequence): - status = coreda.StatusIncorrectAccountSequence - case errors.Is(err, coreda.ErrBlobSizeOverLimit): - status = coreda.StatusTooBig - case errors.Is(err, coreda.ErrContextDeadline): - status = coreda.StatusContextDeadline - } - - // Use debug level for StatusTooBig as it gets handled later in submitToDA through recursive splitting - if status == coreda.StatusTooBig { - logger.Debug().Err(err).Uint64("status", uint64(status)).Msg("DA submission failed via helper") - } else { - logger.Error().Err(err).Uint64("status", uint64(status)).Msg("DA submission failed via helper") - } - return coreda.ResultSubmit{ - BaseResult: coreda.BaseResult{ - Code: status, - Message: "failed to submit blobs: " + err.Error(), - IDs: ids, - SubmittedCount: uint64(len(ids)), - Height: 0, - Timestamp: time.Now(), - BlobSize: blobSize, - }, - } - } - - if len(ids) == 0 && len(data) > 0 { - logger.Warn().Msg("DA submission via helper returned no IDs for non-empty input data") - return coreda.ResultSubmit{ - BaseResult: coreda.BaseResult{ - Code: coreda.StatusError, - Message: "failed to submit blobs: no IDs returned despite non-empty input", - }, - } - } - - // Get height from the first ID - var height uint64 - if len(ids) > 0 { - height, _, err = coreda.SplitID(ids[0]) - if err != nil { - logger.Error().Err(err).Msg("failed to split ID") - } - } - - logger.Debug().Int("num_ids", len(ids)).Msg("DA submission successful via helper") - return coreda.ResultSubmit{ - BaseResult: coreda.BaseResult{ - Code: coreda.StatusSuccess, - IDs: ids, - SubmittedCount: uint64(len(ids)), - Height: height, - BlobSize: blobSize, - Timestamp: time.Now(), - }, - } -} - -// RetrieveWithHelpers performs blob retrieval using the underlying DA layer, -// handling error mapping to produce a ResultRetrieve. -// It mimics the logic previously found in da.DAClient.Retrieve. -// requestTimeout defines the timeout for the each retrieval request. -func RetrieveWithHelpers( - ctx context.Context, - da coreda.DA, - logger zerolog.Logger, - dataLayerHeight uint64, - namespace []byte, - requestTimeout time.Duration, -) coreda.ResultRetrieve { - // 1. Get IDs - getIDsCtx, cancel := context.WithTimeout(ctx, requestTimeout) - defer cancel() - idsResult, err := da.GetIDs(getIDsCtx, dataLayerHeight, namespace) - if err != nil { - // Handle specific "not found" error - if strings.Contains(err.Error(), coreda.ErrBlobNotFound.Error()) { - logger.Debug().Uint64("height", dataLayerHeight).Msg("Retrieve helper: Blobs not found at height") - return coreda.ResultRetrieve{ - BaseResult: coreda.BaseResult{ - Code: coreda.StatusNotFound, - Message: coreda.ErrBlobNotFound.Error(), - Height: dataLayerHeight, - Timestamp: time.Now(), - }, - } - } - if strings.Contains(err.Error(), coreda.ErrHeightFromFuture.Error()) { - logger.Debug().Uint64("height", dataLayerHeight).Msg("Retrieve helper: Blobs not found at height") - return coreda.ResultRetrieve{ - BaseResult: coreda.BaseResult{ - Code: coreda.StatusHeightFromFuture, - Message: coreda.ErrHeightFromFuture.Error(), - Height: dataLayerHeight, - Timestamp: time.Now(), - }, - } - } - // Handle other errors during GetIDs - logger.Error().Uint64("height", dataLayerHeight).Err(err).Msg("Retrieve helper: Failed to get IDs") - return coreda.ResultRetrieve{ - BaseResult: coreda.BaseResult{ - Code: coreda.StatusError, - Message: fmt.Sprintf("failed to get IDs: %s", err.Error()), - Height: dataLayerHeight, - Timestamp: time.Now(), - }, - } - } - - // This check should technically be redundant if GetIDs correctly returns ErrBlobNotFound - if idsResult == nil || len(idsResult.IDs) == 0 { - logger.Debug().Uint64("height", dataLayerHeight).Msg("Retrieve helper: No IDs found at height") - return coreda.ResultRetrieve{ - BaseResult: coreda.BaseResult{ - Code: coreda.StatusNotFound, - Message: coreda.ErrBlobNotFound.Error(), - Height: dataLayerHeight, - Timestamp: time.Now(), - }, - } - } - // 2. Get Blobs using the retrieved IDs in batches - batchSize := 100 - blobs := make([][]byte, 0, len(idsResult.IDs)) - for i := 0; i < len(idsResult.IDs); i += batchSize { - end := min(i+batchSize, len(idsResult.IDs)) - - getBlobsCtx, cancel := context.WithTimeout(ctx, requestTimeout) - batchBlobs, err := da.Get(getBlobsCtx, idsResult.IDs[i:end], namespace) - cancel() - if err != nil { - // Handle errors during Get - logger.Error().Uint64("height", dataLayerHeight).Int("num_ids", len(idsResult.IDs)).Err(err).Msg("Retrieve helper: Failed to get blobs") - return coreda.ResultRetrieve{ - BaseResult: coreda.BaseResult{ - Code: coreda.StatusError, - Message: fmt.Sprintf("failed to get blobs for batch %d-%d: %s", i, end-1, err.Error()), - Height: dataLayerHeight, - Timestamp: time.Now(), - }, - } - } - blobs = append(blobs, batchBlobs...) - } - // Success - logger.Debug().Uint64("height", dataLayerHeight).Int("num_blobs", len(blobs)).Msg("Retrieve helper: Successfully retrieved blobs") - return coreda.ResultRetrieve{ - BaseResult: coreda.BaseResult{ - Code: coreda.StatusSuccess, - Height: dataLayerHeight, - IDs: idsResult.IDs, - Timestamp: idsResult.Timestamp, - }, - Data: blobs, - } -} diff --git a/types/da_test.go b/types/da_test.go deleted file mode 100644 index 4a111499dc..0000000000 --- a/types/da_test.go +++ /dev/null @@ -1,298 +0,0 @@ -package types_test - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/rs/zerolog" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - - coreda "github.com/evstack/ev-node/core/da" - "github.com/evstack/ev-node/test/mocks" - "github.com/evstack/ev-node/types" -) - -func TestSubmitWithHelpers(t *testing.T) { - logger := zerolog.Nop() - - testCases := []struct { - name string - data [][]byte - gasPrice float64 - options []byte - submitErr error - submitIDs [][]byte - expectedCode coreda.StatusCode - expectedErrMsg string - expectedIDs [][]byte - expectedCount uint64 - }{ - { - name: "successful submission", - data: [][]byte{[]byte("blob1"), []byte("blob2")}, - gasPrice: 1.0, - options: []byte("opts"), - submitIDs: [][]byte{[]byte("id1"), []byte("id2")}, - expectedCode: coreda.StatusSuccess, - expectedIDs: [][]byte{[]byte("id1"), []byte("id2")}, - expectedCount: 2, - }, - { - name: "context canceled error", - data: [][]byte{[]byte("blob1")}, - gasPrice: 1.0, - options: []byte("opts"), - submitErr: context.Canceled, - expectedCode: coreda.StatusContextCanceled, - expectedErrMsg: "submission canceled", - }, - { - name: "tx timed out error", - data: [][]byte{[]byte("blob1")}, - gasPrice: 1.0, - options: []byte("opts"), - submitErr: coreda.ErrTxTimedOut, - expectedCode: coreda.StatusNotIncludedInBlock, - expectedErrMsg: "failed to submit blobs: " + coreda.ErrTxTimedOut.Error(), - }, - { - name: "tx already in mempool error", - data: [][]byte{[]byte("blob1")}, - gasPrice: 1.0, - options: []byte("opts"), - submitErr: coreda.ErrTxAlreadyInMempool, - expectedCode: coreda.StatusAlreadyInMempool, - expectedErrMsg: "failed to submit blobs: " + coreda.ErrTxAlreadyInMempool.Error(), - }, - { - name: "incorrect account sequence error", - data: [][]byte{[]byte("blob1")}, - gasPrice: 1.0, - options: []byte("opts"), - submitErr: coreda.ErrTxIncorrectAccountSequence, - expectedCode: coreda.StatusIncorrectAccountSequence, - expectedErrMsg: "failed to submit blobs: " + coreda.ErrTxIncorrectAccountSequence.Error(), - }, - { - name: "blob size over limit error", - data: [][]byte{[]byte("blob1")}, - gasPrice: 1.0, - options: []byte("opts"), - submitErr: coreda.ErrBlobSizeOverLimit, - expectedCode: coreda.StatusTooBig, - expectedErrMsg: "failed to submit blobs: " + coreda.ErrBlobSizeOverLimit.Error(), - }, - { - name: "context deadline error", - data: [][]byte{[]byte("blob1")}, - gasPrice: 1.0, - options: []byte("opts"), - submitErr: coreda.ErrContextDeadline, - expectedCode: coreda.StatusContextDeadline, - expectedErrMsg: "failed to submit blobs: " + coreda.ErrContextDeadline.Error(), - }, - { - name: "generic submission error", - data: [][]byte{[]byte("blob1")}, - gasPrice: 1.0, - options: []byte("opts"), - submitErr: errors.New("some generic error"), - expectedCode: coreda.StatusError, - expectedErrMsg: "failed to submit blobs: some generic error", - }, - { - name: "no IDs returned for non-empty data", - data: [][]byte{[]byte("blob1")}, - gasPrice: 1.0, - options: []byte("opts"), - submitIDs: [][]byte{}, - expectedCode: coreda.StatusError, - expectedErrMsg: "failed to submit blobs: no IDs returned despite non-empty input", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - mockDA := mocks.NewMockDA(t) - encodedNamespace := coreda.NamespaceFromString("test-namespace") - - mockDA.On("SubmitWithOptions", mock.Anything, tc.data, tc.gasPrice, encodedNamespace.Bytes(), tc.options).Return(tc.submitIDs, tc.submitErr) - - result := types.SubmitWithHelpers(context.Background(), mockDA, logger, tc.data, tc.gasPrice, encodedNamespace.Bytes(), tc.options) - - assert.Equal(t, tc.expectedCode, result.Code) - if tc.expectedErrMsg != "" { - assert.Contains(t, result.Message, tc.expectedErrMsg) - } - if tc.expectedIDs != nil { - assert.Equal(t, tc.expectedIDs, result.IDs) - } - if tc.expectedCount != 0 { - assert.Equal(t, tc.expectedCount, result.SubmittedCount) - } - mockDA.AssertExpectations(t) - }) - } -} - -func TestRetrieveWithHelpers(t *testing.T) { - logger := zerolog.Nop() - dataLayerHeight := uint64(100) - mockIDs := [][]byte{[]byte("id1"), []byte("id2")} - mockBlobs := [][]byte{[]byte("blobA"), []byte("blobB")} - mockTimestamp := time.Now() - - testCases := []struct { - name string - getIDsResult *coreda.GetIDsResult - getIDsErr error - getBlobsErr error - expectedCode coreda.StatusCode - expectedErrMsg string - expectedIDs [][]byte - expectedData [][]byte - expectedHeight uint64 - }{ - { - name: "successful retrieval", - getIDsResult: &coreda.GetIDsResult{ - IDs: mockIDs, - Timestamp: mockTimestamp, - }, - expectedCode: coreda.StatusSuccess, - expectedIDs: mockIDs, - expectedData: mockBlobs, - expectedHeight: dataLayerHeight, - }, - { - name: "blob not found error during GetIDs", - getIDsErr: coreda.ErrBlobNotFound, - expectedCode: coreda.StatusNotFound, - expectedErrMsg: coreda.ErrBlobNotFound.Error(), - expectedHeight: dataLayerHeight, - }, - { - name: "height from future error during GetIDs", - getIDsErr: coreda.ErrHeightFromFuture, - expectedCode: coreda.StatusHeightFromFuture, - expectedErrMsg: coreda.ErrHeightFromFuture.Error(), - expectedHeight: dataLayerHeight, - }, - { - name: "generic error during GetIDs", - getIDsErr: errors.New("failed to connect to DA"), - expectedCode: coreda.StatusError, - expectedErrMsg: "failed to get IDs: failed to connect to DA", - expectedHeight: dataLayerHeight, - }, - { - name: "GetIDs returns nil result", - getIDsResult: nil, - expectedCode: coreda.StatusNotFound, - expectedErrMsg: coreda.ErrBlobNotFound.Error(), - expectedHeight: dataLayerHeight, - }, - { - name: "GetIDs returns empty IDs", - getIDsResult: &coreda.GetIDsResult{ - IDs: [][]byte{}, - Timestamp: mockTimestamp, - }, - expectedCode: coreda.StatusNotFound, - expectedErrMsg: coreda.ErrBlobNotFound.Error(), - expectedHeight: dataLayerHeight, - }, - { - name: "error during Get (blobs retrieval)", - getIDsResult: &coreda.GetIDsResult{ - IDs: mockIDs, - Timestamp: mockTimestamp, - }, - getBlobsErr: errors.New("network error during blob retrieval"), - expectedCode: coreda.StatusError, - expectedErrMsg: "failed to get blobs for batch 0-1: network error during blob retrieval", - expectedHeight: dataLayerHeight, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - mockDA := mocks.NewMockDA(t) - encodedNamespace := coreda.NamespaceFromString("test-namespace") - - mockDA.On("GetIDs", mock.Anything, dataLayerHeight, mock.Anything).Return(tc.getIDsResult, tc.getIDsErr) - - if tc.getIDsErr == nil && tc.getIDsResult != nil && len(tc.getIDsResult.IDs) > 0 { - mockDA.On("Get", mock.Anything, tc.getIDsResult.IDs, mock.Anything).Return(mockBlobs, tc.getBlobsErr) - } - - result := types.RetrieveWithHelpers(context.Background(), mockDA, logger, dataLayerHeight, encodedNamespace.Bytes(), 5*time.Second) - - assert.Equal(t, tc.expectedCode, result.Code) - assert.Equal(t, tc.expectedHeight, result.Height) - if tc.expectedErrMsg != "" { - assert.Contains(t, result.Message, tc.expectedErrMsg) - } - if tc.expectedIDs != nil { - assert.Equal(t, tc.expectedIDs, result.IDs) - } - if tc.expectedData != nil { - assert.Equal(t, tc.expectedData, result.Data) - } - mockDA.AssertExpectations(t) - }) - } -} - -func TestRetrieveWithHelpers_Timeout(t *testing.T) { - logger := zerolog.Nop() - dataLayerHeight := uint64(100) - encodedNamespace := coreda.NamespaceFromString("test-namespace") - - t.Run("timeout during GetIDs", func(t *testing.T) { - mockDA := mocks.NewMockDA(t) - - // Mock GetIDs to block until context is cancelled - mockDA.On("GetIDs", mock.Anything, dataLayerHeight, mock.Anything).Run(func(args mock.Arguments) { - ctx := args.Get(0).(context.Context) - <-ctx.Done() // Wait for context cancellation - }).Return(nil, context.DeadlineExceeded) - - // Use a very short timeout to ensure it triggers - result := types.RetrieveWithHelpers(context.Background(), mockDA, logger, dataLayerHeight, encodedNamespace.Bytes(), 1*time.Millisecond) - - assert.Equal(t, coreda.StatusError, result.Code) - assert.Contains(t, result.Message, "failed to get IDs") - assert.Contains(t, result.Message, "context deadline exceeded") - mockDA.AssertExpectations(t) - }) - - t.Run("timeout during Get", func(t *testing.T) { - mockDA := mocks.NewMockDA(t) - mockIDs := [][]byte{[]byte("id1")} - mockTimestamp := time.Now() - - // Mock GetIDs to succeed - mockDA.On("GetIDs", mock.Anything, dataLayerHeight, mock.Anything).Return(&coreda.GetIDsResult{ - IDs: mockIDs, - Timestamp: mockTimestamp, - }, nil) - - // Mock Get to block until context is cancelled - mockDA.On("Get", mock.Anything, mockIDs, mock.Anything).Run(func(args mock.Arguments) { - ctx := args.Get(0).(context.Context) - <-ctx.Done() // Wait for context cancellation - }).Return(nil, context.DeadlineExceeded) - - // Use a very short timeout to ensure it triggers - result := types.RetrieveWithHelpers(context.Background(), mockDA, logger, dataLayerHeight, encodedNamespace.Bytes(), 1*time.Millisecond) - - assert.Equal(t, coreda.StatusError, result.Code) - assert.Contains(t, result.Message, "failed to get blobs for batch") - assert.Contains(t, result.Message, "context deadline exceeded") - mockDA.AssertExpectations(t) - }) -} From 729e98f5848a452b9f0d64232c9e6898c2575d41 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Mon, 17 Nov 2025 14:21:18 +0100 Subject: [PATCH 2/4] re-add size checks --- apps/evm/single/cmd/run.go | 2 +- apps/grpc/single/cmd/run.go | 2 +- apps/testapp/cmd/run.go | 2 +- block/public.go | 3 + pkg/cmd/run_node.go | 2 - sequencers/based/based.go | 135 +++++++++++++++++++++++++--- sequencers/single/sequencer.go | 160 ++++++++++++++++++++++++++++----- 7 files changed, 265 insertions(+), 41 deletions(-) diff --git a/apps/evm/single/cmd/run.go b/apps/evm/single/cmd/run.go index 890e30d106..4d20435cdc 100644 --- a/apps/evm/single/cmd/run.go +++ b/apps/evm/single/cmd/run.go @@ -57,7 +57,7 @@ var RunCmd = &cobra.Command{ logger.Info().Str("headerNamespace", headerNamespace.HexString()).Str("dataNamespace", dataNamespace.HexString()).Msg("namespaces") - daJrpc, err := jsonrpc.NewClient(context.Background(), logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, rollcmd.DefaultMaxBlobSize) + daJrpc, err := jsonrpc.NewClient(context.Background(), logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, block.DefaultMaxBlobSize) if err != nil { return err } diff --git a/apps/grpc/single/cmd/run.go b/apps/grpc/single/cmd/run.go index 0ee5d09c1b..885e33d3be 100644 --- a/apps/grpc/single/cmd/run.go +++ b/apps/grpc/single/cmd/run.go @@ -59,7 +59,7 @@ The execution client must implement the Evolve execution gRPC interface.`, logger.Info().Str("headerNamespace", headerNamespace.HexString()).Str("dataNamespace", dataNamespace.HexString()).Msg("namespaces") // Create DA client - daJrpc, err := jsonrpc.NewClient(cmd.Context(), logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, rollcmd.DefaultMaxBlobSize) + daJrpc, err := jsonrpc.NewClient(cmd.Context(), logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, block.DefaultMaxBlobSize) if err != nil { return err } diff --git a/apps/testapp/cmd/run.go b/apps/testapp/cmd/run.go index c185f2ad4b..aef1ddb006 100644 --- a/apps/testapp/cmd/run.go +++ b/apps/testapp/cmd/run.go @@ -57,7 +57,7 @@ var RunCmd = &cobra.Command{ logger.Info().Str("headerNamespace", headerNamespace.HexString()).Str("dataNamespace", dataNamespace.HexString()).Msg("namespaces") - daJrpc, err := jsonrpc.NewClient(ctx, logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, cmd.DefaultMaxBlobSize) + daJrpc, err := jsonrpc.NewClient(ctx, logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, block.DefaultMaxBlobSize) if err != nil { return err } diff --git a/block/public.go b/block/public.go index c06ad6ea55..86b2d30fa5 100644 --- a/block/public.go +++ b/block/public.go @@ -20,6 +20,9 @@ func DefaultBlockOptions() BlockOptions { return common.DefaultBlockOptions() } +// DefaultMaxBlobSize is the default maximum blob size for DA submissions +const DefaultMaxBlobSize = common.DefaultMaxBlobSize + // Expose Metrics for constructor type Metrics = common.Metrics diff --git a/pkg/cmd/run_node.go b/pkg/cmd/run_node.go index d22627baa6..4dbc876879 100644 --- a/pkg/cmd/run_node.go +++ b/pkg/cmd/run_node.go @@ -26,8 +26,6 @@ import ( "github.com/evstack/ev-node/pkg/signer/file" ) -const DefaultMaxBlobSize = 1.5 * 1024 * 1024 // 1.5MB - // ParseConfig is an helpers that loads the node configuration and validates it. func ParseConfig(cmd *cobra.Command) (rollconf.Config, error) { nodeConfig, err := rollconf.Load(cmd) diff --git a/sequencers/based/based.go b/sequencers/based/based.go index 03fa61a7a1..8566bda8ac 100644 --- a/sequencers/based/based.go +++ b/sequencers/based/based.go @@ -20,6 +20,12 @@ type ForcedInclusionRetriever interface { RetrieveForcedIncludedTxs(ctx context.Context, daHeight uint64) (*block.ForcedInclusionEvent, error) } +// pendingForcedInclusionTx represents a forced inclusion transaction that couldn't fit in the current epoch +type pendingForcedInclusionTx struct { + Data []byte + OriginalHeight uint64 +} + var _ coresequencer.Sequencer = (*BasedSequencer)(nil) // BasedSequencer is a sequencer that only retrieves transactions from the DA layer @@ -31,9 +37,10 @@ type BasedSequencer struct { genesis genesis.Genesis logger zerolog.Logger - mu sync.RWMutex - daHeight uint64 - txQueue [][]byte + mu sync.RWMutex + daHeight uint64 + txQueue [][]byte + pendingForcedInclusionTxs []pendingForcedInclusionTx } // NewBasedSequencer creates a new based sequencer instance @@ -45,13 +52,14 @@ func NewBasedSequencer( logger zerolog.Logger, ) *BasedSequencer { return &BasedSequencer{ - fiRetriever: fiRetriever, - da: da, - config: config, - genesis: genesis, - logger: logger.With().Str("component", "based_sequencer").Logger(), - daHeight: genesis.DAStartHeight, - txQueue: make([][]byte, 0), + fiRetriever: fiRetriever, + da: da, + config: config, + genesis: genesis, + logger: logger.With().Str("component", "based_sequencer").Logger(), + daHeight: genesis.DAStartHeight, + txQueue: make([][]byte, 0), + pendingForcedInclusionTxs: make([]pendingForcedInclusionTx, 0), } } @@ -123,8 +131,11 @@ func (s *BasedSequencer) GetNextBatch(ctx context.Context, req coresequencer.Get s.daHeight = forcedTxsEvent.StartDaHeight } - // Add transactions to queue - s.txQueue = append(s.txQueue, forcedTxsEvent.Txs...) + // Process forced inclusion transactions with size validation and pending queue management + if err := s.processForcedInclusionTxs(forcedTxsEvent); err != nil { + s.logger.Error().Err(err).Msg("failed to process forced inclusion transactions") + return nil, err + } s.logger.Info(). Int("tx_count", len(forcedTxsEvent.Txs)). @@ -193,3 +204,103 @@ func (s *BasedSequencer) GetDAHeight() uint64 { defer s.mu.RUnlock() return s.daHeight } + +// processForcedInclusionTxs processes forced inclusion transactions with size validation and pending queue management +func (s *BasedSequencer) processForcedInclusionTxs(event *block.ForcedInclusionEvent) error { + currentSize := 0 + var newPendingTxs []pendingForcedInclusionTx + var txsToQueue [][]byte + + // First, process any pending transactions from previous epochs + for _, pendingTx := range s.pendingForcedInclusionTxs { + txSize := len(pendingTx.Data) + + // Validate individual blob size + if txSize > int(block.DefaultMaxBlobSize) { + s.logger.Warn(). + Uint64("original_height", pendingTx.OriginalHeight). + Int("blob_size", txSize). + Float64("max_size", block.DefaultMaxBlobSize). + Msg("pending forced inclusion blob exceeds maximum size - skipping") + continue + } + + // Check if adding this blob would exceed the current epoch's max size + if currentSize+txSize > int(block.DefaultMaxBlobSize) { + s.logger.Debug(). + Uint64("original_height", pendingTx.OriginalHeight). + Int("current_size", currentSize). + Int("blob_size", txSize). + Msg("pending blob would exceed max size for this epoch - deferring again") + newPendingTxs = append(newPendingTxs, pendingTx) + continue + } + + txsToQueue = append(txsToQueue, pendingTx.Data) + currentSize += txSize + + s.logger.Debug(). + Uint64("original_height", pendingTx.OriginalHeight). + Int("blob_size", txSize). + Int("current_size", currentSize). + Msg("processed pending forced inclusion transaction") + } + + // Now process new transactions from this epoch + for _, tx := range event.Txs { + txSize := len(tx) + + // Validate individual blob size + if txSize > int(block.DefaultMaxBlobSize) { + s.logger.Warn(). + Uint64("da_height", event.StartDaHeight). + Int("blob_size", txSize). + Float64("max_size", block.DefaultMaxBlobSize). + Msg("forced inclusion blob exceeds maximum size - skipping") + continue + } + + // Check if adding this blob would exceed the current epoch's max size + if currentSize+txSize > int(block.DefaultMaxBlobSize) { + s.logger.Debug(). + Uint64("da_height", event.StartDaHeight). + Int("current_size", currentSize). + Int("blob_size", txSize). + Msg("blob would exceed max size for this epoch - deferring to pending queue") + + // Store for next epoch + newPendingTxs = append(newPendingTxs, pendingForcedInclusionTx{ + Data: tx, + OriginalHeight: event.StartDaHeight, + }) + continue + } + + txsToQueue = append(txsToQueue, tx) + currentSize += txSize + + s.logger.Debug(). + Int("blob_size", txSize). + Int("current_size", currentSize). + Msg("processed forced inclusion transaction") + } + + // Update pending queue + s.pendingForcedInclusionTxs = newPendingTxs + if len(newPendingTxs) > 0 { + s.logger.Info(). + Int("new_pending_count", len(newPendingTxs)). + Msg("stored pending forced inclusion transactions for next epoch") + } + + // Add validated transactions to the queue + s.txQueue = append(s.txQueue, txsToQueue...) + + s.logger.Info(). + Int("processed_tx_count", len(txsToQueue)). + Int("pending_tx_count", len(newPendingTxs)). + Int("current_size", currentSize). + Msg("completed processing forced inclusion transactions") + + return nil +} diff --git a/sequencers/single/sequencer.go b/sequencers/single/sequencer.go index 866d655284..5769865bd8 100644 --- a/sequencers/single/sequencer.go +++ b/sequencers/single/sequencer.go @@ -27,6 +27,12 @@ type ForcedInclusionRetriever interface { RetrieveForcedIncludedTxs(ctx context.Context, daHeight uint64) (*block.ForcedInclusionEvent, error) } +// pendingForcedInclusionTx represents a forced inclusion transaction that couldn't fit in the current epoch +type pendingForcedInclusionTx struct { + Data []byte + OriginalHeight uint64 +} + var _ coresequencer.Sequencer = (*Sequencer)(nil) // Sequencer implements core sequencing interface @@ -45,9 +51,10 @@ type Sequencer struct { metrics *Metrics // Forced inclusion support - fiRetriever ForcedInclusionRetriever - genesis genesis.Genesis - daHeight atomic.Uint64 + fiRetriever ForcedInclusionRetriever + genesis genesis.Genesis + daHeight atomic.Uint64 + pendingForcedInclusionTxs []pendingForcedInclusionTx } // NewSequencer creates a new Single Sequencer @@ -65,15 +72,16 @@ func NewSequencer( gen genesis.Genesis, ) (*Sequencer, error) { s := &Sequencer{ - logger: logger, - da: da, - batchTime: batchTime, - Id: id, - queue: NewBatchQueue(db, "batches", maxQueueSize), - metrics: metrics, - proposer: proposer, - fiRetriever: fiRetriever, - genesis: gen, + logger: logger, + da: da, + batchTime: batchTime, + Id: id, + queue: NewBatchQueue(db, "batches", maxQueueSize), + metrics: metrics, + proposer: proposer, + fiRetriever: fiRetriever, + genesis: gen, + pendingForcedInclusionTxs: make([]pendingForcedInclusionTx, 0), } s.SetDAHeight(gen.DAStartHeight) // will be overridden by the executor @@ -152,20 +160,27 @@ func (c *Sequencer) GetNextBatch(ctx context.Context, req coresequencer.GetNextB // Continue without forced txs on other errors } } else { - forcedTxs = forcedEvent.Txs + // Process forced inclusion transactions with size validation + processedTxs, err := c.processForcedInclusionTxs(forcedEvent) + if err != nil { + c.logger.Error().Err(err).Msg("failed to process forced inclusion transactions") + // Continue without forced txs on processing errors + } else { + forcedTxs = processedTxs + + // Update DA height based on the retrieved event + if forcedEvent.EndDaHeight > currentDAHeight { + c.SetDAHeight(forcedEvent.EndDaHeight) + } else if forcedEvent.StartDaHeight > currentDAHeight { + c.SetDAHeight(forcedEvent.StartDaHeight) + } - // Update DA height based on the retrieved event - if forcedEvent.EndDaHeight > currentDAHeight { - c.SetDAHeight(forcedEvent.EndDaHeight) - } else if forcedEvent.StartDaHeight > currentDAHeight { - c.SetDAHeight(forcedEvent.StartDaHeight) + c.logger.Info(). + Int("tx_count", len(processedTxs)). + Uint64("da_height_start", forcedEvent.StartDaHeight). + Uint64("da_height_end", forcedEvent.EndDaHeight). + Msg("retrieved forced inclusion transactions from DA") } - - c.logger.Info(). - Int("tx_count", len(forcedEvent.Txs)). - Uint64("da_height_start", forcedEvent.StartDaHeight). - Uint64("da_height_end", forcedEvent.EndDaHeight). - Msg("retrieved forced inclusion transactions from DA") } batch, err := c.queue.Next(ctx) @@ -244,3 +259,100 @@ func (c *Sequencer) SetDAHeight(height uint64) { func (c *Sequencer) GetDAHeight() uint64 { return c.daHeight.Load() } + +// processForcedInclusionTxs processes forced inclusion transactions with size validation and pending queue management +func (c *Sequencer) processForcedInclusionTxs(event *block.ForcedInclusionEvent) ([][]byte, error) { + currentSize := 0 + var newPendingTxs []pendingForcedInclusionTx + var validatedTxs [][]byte + + // First, process any pending transactions from previous epochs + for _, pendingTx := range c.pendingForcedInclusionTxs { + txSize := len(pendingTx.Data) + + // Validate individual blob size + if txSize > int(block.DefaultMaxBlobSize) { + c.logger.Warn(). + Uint64("original_height", pendingTx.OriginalHeight). + Int("blob_size", txSize). + Float64("max_size", block.DefaultMaxBlobSize). + Msg("pending forced inclusion blob exceeds maximum size - skipping") + continue + } + + // Check if adding this blob would exceed the current epoch's max size + if currentSize+txSize > int(block.DefaultMaxBlobSize) { + c.logger.Debug(). + Uint64("original_height", pendingTx.OriginalHeight). + Int("current_size", currentSize). + Int("blob_size", txSize). + Msg("pending blob would exceed max size for this epoch - deferring again") + newPendingTxs = append(newPendingTxs, pendingTx) + continue + } + + validatedTxs = append(validatedTxs, pendingTx.Data) + currentSize += txSize + + c.logger.Debug(). + Uint64("original_height", pendingTx.OriginalHeight). + Int("blob_size", txSize). + Int("current_size", currentSize). + Msg("processed pending forced inclusion transaction") + } + + // Now process new transactions from this epoch + for _, tx := range event.Txs { + txSize := len(tx) + + // Validate individual blob size + if txSize > int(block.DefaultMaxBlobSize) { + c.logger.Warn(). + Uint64("da_height", event.StartDaHeight). + Int("blob_size", txSize). + Float64("max_size", block.DefaultMaxBlobSize). + Msg("forced inclusion blob exceeds maximum size - skipping") + continue + } + + // Check if adding this blob would exceed the current epoch's max size + if currentSize+txSize > int(block.DefaultMaxBlobSize) { + c.logger.Debug(). + Uint64("da_height", event.StartDaHeight). + Int("current_size", currentSize). + Int("blob_size", txSize). + Msg("blob would exceed max size for this epoch - deferring to pending queue") + + // Store for next epoch + newPendingTxs = append(newPendingTxs, pendingForcedInclusionTx{ + Data: tx, + OriginalHeight: event.StartDaHeight, + }) + continue + } + + validatedTxs = append(validatedTxs, tx) + currentSize += txSize + + c.logger.Debug(). + Int("blob_size", txSize). + Int("current_size", currentSize). + Msg("processed forced inclusion transaction") + } + + // Update pending queue + c.pendingForcedInclusionTxs = newPendingTxs + if len(newPendingTxs) > 0 { + c.logger.Info(). + Int("new_pending_count", len(newPendingTxs)). + Msg("stored pending forced inclusion transactions for next epoch") + } + + c.logger.Info(). + Int("processed_tx_count", len(validatedTxs)). + Int("pending_tx_count", len(newPendingTxs)). + Int("current_size", currentSize). + Msg("completed processing forced inclusion transactions") + + return validatedTxs, nil +} From 897493c78f409f177e8bbf532ea97907aa95b9e4 Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Mon, 17 Nov 2025 14:55:38 +0100 Subject: [PATCH 3/4] correct max bytes --- apps/evm/single/cmd/run.go | 2 +- apps/grpc/single/cmd/run.go | 2 +- apps/testapp/cmd/run.go | 2 +- .../internal/da/forced_inclusion_retriever.go | 6 -- block/public.go | 3 - pkg/cmd/run_node.go | 2 + sequencers/based/{based.go => sequencer.go} | 28 ++++----- .../{based_test.go => sequencer_test.go} | 0 sequencers/common/size_validation.go | 20 ++++++ sequencers/single/sequencer.go | 61 +++++++------------ 10 files changed, 61 insertions(+), 65 deletions(-) rename sequencers/based/{based.go => sequencer.go} (92%) rename sequencers/based/{based_test.go => sequencer_test.go} (100%) create mode 100644 sequencers/common/size_validation.go diff --git a/apps/evm/single/cmd/run.go b/apps/evm/single/cmd/run.go index 4d20435cdc..890e30d106 100644 --- a/apps/evm/single/cmd/run.go +++ b/apps/evm/single/cmd/run.go @@ -57,7 +57,7 @@ var RunCmd = &cobra.Command{ logger.Info().Str("headerNamespace", headerNamespace.HexString()).Str("dataNamespace", dataNamespace.HexString()).Msg("namespaces") - daJrpc, err := jsonrpc.NewClient(context.Background(), logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, block.DefaultMaxBlobSize) + daJrpc, err := jsonrpc.NewClient(context.Background(), logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, rollcmd.DefaultMaxBlobSize) if err != nil { return err } diff --git a/apps/grpc/single/cmd/run.go b/apps/grpc/single/cmd/run.go index 885e33d3be..0ee5d09c1b 100644 --- a/apps/grpc/single/cmd/run.go +++ b/apps/grpc/single/cmd/run.go @@ -59,7 +59,7 @@ The execution client must implement the Evolve execution gRPC interface.`, logger.Info().Str("headerNamespace", headerNamespace.HexString()).Str("dataNamespace", dataNamespace.HexString()).Msg("namespaces") // Create DA client - daJrpc, err := jsonrpc.NewClient(cmd.Context(), logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, block.DefaultMaxBlobSize) + daJrpc, err := jsonrpc.NewClient(cmd.Context(), logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, rollcmd.DefaultMaxBlobSize) if err != nil { return err } diff --git a/apps/testapp/cmd/run.go b/apps/testapp/cmd/run.go index aef1ddb006..c185f2ad4b 100644 --- a/apps/testapp/cmd/run.go +++ b/apps/testapp/cmd/run.go @@ -57,7 +57,7 @@ var RunCmd = &cobra.Command{ logger.Info().Str("headerNamespace", headerNamespace.HexString()).Str("dataNamespace", dataNamespace.HexString()).Msg("namespaces") - daJrpc, err := jsonrpc.NewClient(ctx, logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, block.DefaultMaxBlobSize) + daJrpc, err := jsonrpc.NewClient(ctx, logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, cmd.DefaultMaxBlobSize) if err != nil { return err } diff --git a/block/internal/da/forced_inclusion_retriever.go b/block/internal/da/forced_inclusion_retriever.go index cc5df61a9d..5f50473386 100644 --- a/block/internal/da/forced_inclusion_retriever.go +++ b/block/internal/da/forced_inclusion_retriever.go @@ -51,10 +51,8 @@ func (r *ForcedInclusionRetriever) RetrieveForcedIncludedTxs(ctx context.Context return nil, ErrForceInclusionNotConfigured } - // Calculate deterministic epoch boundaries epochStart, epochEnd := types.CalculateEpochBoundaries(daHeight, r.genesis.DAStartHeight, r.daEpochSize) - // If we're not at epoch start, return empty result if daHeight != epochStart { r.logger.Debug(). Uint64("da_height", daHeight). @@ -83,7 +81,6 @@ func (r *ForcedInclusionRetriever) RetrieveForcedIncludedTxs(ctx context.Context Uint64("epoch_num", currentEpochNumber). Msg("retrieving forced included transactions from DA") - // Check if epoch start is available epochStartResult := r.client.RetrieveForcedInclusion(ctx, epochStart) if epochStartResult.Code == coreda.StatusHeightFromFuture { r.logger.Debug(). @@ -92,7 +89,6 @@ func (r *ForcedInclusionRetriever) RetrieveForcedIncludedTxs(ctx context.Context return nil, fmt.Errorf("%w: epoch start height %d not yet available", coreda.ErrHeightFromFuture, epochStart) } - // Check if epoch end is available epochEndResult := epochStartResult if epochStart != epochEnd { epochEndResult = r.client.RetrieveForcedInclusion(ctx, epochEnd) @@ -106,7 +102,6 @@ func (r *ForcedInclusionRetriever) RetrieveForcedIncludedTxs(ctx context.Context lastProcessedHeight := epochStart - // Process epoch start if err := r.processForcedInclusionBlobs(event, &lastProcessedHeight, epochStartResult, epochStart); err != nil { return nil, err } @@ -136,7 +131,6 @@ func (r *ForcedInclusionRetriever) RetrieveForcedIncludedTxs(ctx context.Context } } - // Set the DA height range based on what we actually processed event.EndDaHeight = lastProcessedHeight r.logger.Info(). diff --git a/block/public.go b/block/public.go index 86b2d30fa5..c06ad6ea55 100644 --- a/block/public.go +++ b/block/public.go @@ -20,9 +20,6 @@ func DefaultBlockOptions() BlockOptions { return common.DefaultBlockOptions() } -// DefaultMaxBlobSize is the default maximum blob size for DA submissions -const DefaultMaxBlobSize = common.DefaultMaxBlobSize - // Expose Metrics for constructor type Metrics = common.Metrics diff --git a/pkg/cmd/run_node.go b/pkg/cmd/run_node.go index 4dbc876879..d22627baa6 100644 --- a/pkg/cmd/run_node.go +++ b/pkg/cmd/run_node.go @@ -26,6 +26,8 @@ import ( "github.com/evstack/ev-node/pkg/signer/file" ) +const DefaultMaxBlobSize = 1.5 * 1024 * 1024 // 1.5MB + // ParseConfig is an helpers that loads the node configuration and validates it. func ParseConfig(cmd *cobra.Command) (rollconf.Config, error) { nodeConfig, err := rollconf.Load(cmd) diff --git a/sequencers/based/based.go b/sequencers/based/sequencer.go similarity index 92% rename from sequencers/based/based.go rename to sequencers/based/sequencer.go index 8566bda8ac..8cb0b07fec 100644 --- a/sequencers/based/based.go +++ b/sequencers/based/sequencer.go @@ -13,6 +13,7 @@ import ( coresequencer "github.com/evstack/ev-node/core/sequencer" "github.com/evstack/ev-node/pkg/config" "github.com/evstack/ev-node/pkg/genesis" + seqcommon "github.com/evstack/ev-node/sequencers/common" ) // ForcedInclusionRetriever defines the interface for retrieving forced inclusion transactions from DA @@ -132,7 +133,7 @@ func (s *BasedSequencer) GetNextBatch(ctx context.Context, req coresequencer.Get } // Process forced inclusion transactions with size validation and pending queue management - if err := s.processForcedInclusionTxs(forcedTxsEvent); err != nil { + if err := s.processForcedInclusionTxs(forcedTxsEvent, req.MaxBytes); err != nil { s.logger.Error().Err(err).Msg("failed to process forced inclusion transactions") return nil, err } @@ -206,31 +207,30 @@ func (s *BasedSequencer) GetDAHeight() uint64 { } // processForcedInclusionTxs processes forced inclusion transactions with size validation and pending queue management -func (s *BasedSequencer) processForcedInclusionTxs(event *block.ForcedInclusionEvent) error { +func (s *BasedSequencer) processForcedInclusionTxs(event *block.ForcedInclusionEvent, maxBytes uint64) error { currentSize := 0 var newPendingTxs []pendingForcedInclusionTx var txsToQueue [][]byte // First, process any pending transactions from previous epochs for _, pendingTx := range s.pendingForcedInclusionTxs { - txSize := len(pendingTx.Data) + txSize := seqcommon.GetBlobSize(pendingTx.Data) - // Validate individual blob size - if txSize > int(block.DefaultMaxBlobSize) { + if !seqcommon.ValidateBlobSize(pendingTx.Data, maxBytes) { s.logger.Warn(). Uint64("original_height", pendingTx.OriginalHeight). Int("blob_size", txSize). - Float64("max_size", block.DefaultMaxBlobSize). + Uint64("max_size", maxBytes). Msg("pending forced inclusion blob exceeds maximum size - skipping") continue } - // Check if adding this blob would exceed the current epoch's max size - if currentSize+txSize > int(block.DefaultMaxBlobSize) { + if seqcommon.WouldExceedCumulativeSize(currentSize, txSize, maxBytes) { s.logger.Debug(). Uint64("original_height", pendingTx.OriginalHeight). Int("current_size", currentSize). Int("blob_size", txSize). + Uint64("max_size", maxBytes). Msg("pending blob would exceed max size for this epoch - deferring again") newPendingTxs = append(newPendingTxs, pendingTx) continue @@ -248,24 +248,23 @@ func (s *BasedSequencer) processForcedInclusionTxs(event *block.ForcedInclusionE // Now process new transactions from this epoch for _, tx := range event.Txs { - txSize := len(tx) + txSize := seqcommon.GetBlobSize(tx) - // Validate individual blob size - if txSize > int(block.DefaultMaxBlobSize) { + if !seqcommon.ValidateBlobSize(tx, maxBytes) { s.logger.Warn(). Uint64("da_height", event.StartDaHeight). Int("blob_size", txSize). - Float64("max_size", block.DefaultMaxBlobSize). + Uint64("max_size", maxBytes). Msg("forced inclusion blob exceeds maximum size - skipping") continue } - // Check if adding this blob would exceed the current epoch's max size - if currentSize+txSize > int(block.DefaultMaxBlobSize) { + if seqcommon.WouldExceedCumulativeSize(currentSize, txSize, maxBytes) { s.logger.Debug(). Uint64("da_height", event.StartDaHeight). Int("current_size", currentSize). Int("blob_size", txSize). + Uint64("max_size", maxBytes). Msg("blob would exceed max size for this epoch - deferring to pending queue") // Store for next epoch @@ -293,7 +292,6 @@ func (s *BasedSequencer) processForcedInclusionTxs(event *block.ForcedInclusionE Msg("stored pending forced inclusion transactions for next epoch") } - // Add validated transactions to the queue s.txQueue = append(s.txQueue, txsToQueue...) s.logger.Info(). diff --git a/sequencers/based/based_test.go b/sequencers/based/sequencer_test.go similarity index 100% rename from sequencers/based/based_test.go rename to sequencers/based/sequencer_test.go diff --git a/sequencers/common/size_validation.go b/sequencers/common/size_validation.go new file mode 100644 index 0000000000..375c952fed --- /dev/null +++ b/sequencers/common/size_validation.go @@ -0,0 +1,20 @@ +package common + +// TODO: technically we need to check for block gas as well + +// ValidateBlobSize checks if a single blob exceeds the maximum allowed size. +// Returns true if the blob is within the size limit, false otherwise. +func ValidateBlobSize(blob []byte, maxBytes uint64) bool { + return uint64(len(blob)) <= maxBytes +} + +// WouldExceedCumulativeSize checks if adding a blob would exceed the cumulative size limit. +// Returns true if adding the blob would exceed the limit, false otherwise. +func WouldExceedCumulativeSize(currentSize int, blobSize int, maxBytes uint64) bool { + return uint64(currentSize)+uint64(blobSize) > maxBytes +} + +// GetBlobSize returns the size of a blob in bytes. +func GetBlobSize(blob []byte) int { + return len(blob) +} diff --git a/sequencers/single/sequencer.go b/sequencers/single/sequencer.go index 5769865bd8..254fde8af8 100644 --- a/sequencers/single/sequencer.go +++ b/sequencers/single/sequencer.go @@ -15,6 +15,7 @@ import ( coreda "github.com/evstack/ev-node/core/da" coresequencer "github.com/evstack/ev-node/core/sequencer" "github.com/evstack/ev-node/pkg/genesis" + seqcommon "github.com/evstack/ev-node/sequencers/common" ) var ( @@ -129,8 +130,6 @@ func (c *Sequencer) GetNextBatch(ctx context.Context, req coresequencer.GetNextB return nil, ErrInvalidId } - // Retrieve forced inclusion transactions if DARetriever is configured - var forcedTxs [][]byte currentDAHeight := c.daHeight.Load() forcedEvent, err := c.fiRetriever.RetrieveForcedIncludedTxs(ctx, currentDAHeight) @@ -159,30 +158,22 @@ func (c *Sequencer) GetNextBatch(ctx context.Context, req coresequencer.GetNextB c.logger.Error().Err(err).Uint64("da_height", currentDAHeight).Msg("failed to retrieve forced inclusion transactions") // Continue without forced txs on other errors } - } else { - // Process forced inclusion transactions with size validation - processedTxs, err := c.processForcedInclusionTxs(forcedEvent) - if err != nil { - c.logger.Error().Err(err).Msg("failed to process forced inclusion transactions") - // Continue without forced txs on processing errors - } else { - forcedTxs = processedTxs - - // Update DA height based on the retrieved event - if forcedEvent.EndDaHeight > currentDAHeight { - c.SetDAHeight(forcedEvent.EndDaHeight) - } else if forcedEvent.StartDaHeight > currentDAHeight { - c.SetDAHeight(forcedEvent.StartDaHeight) - } + } - c.logger.Info(). - Int("tx_count", len(processedTxs)). - Uint64("da_height_start", forcedEvent.StartDaHeight). - Uint64("da_height_end", forcedEvent.EndDaHeight). - Msg("retrieved forced inclusion transactions from DA") - } + // Always try to process forced inclusion transactions (can be in queue) + forcedTxs := c.processForcedInclusionTxs(forcedEvent, req.MaxBytes) + if forcedEvent.EndDaHeight > currentDAHeight { + c.SetDAHeight(forcedEvent.EndDaHeight) + } else if forcedEvent.StartDaHeight > currentDAHeight { + c.SetDAHeight(forcedEvent.StartDaHeight) } + c.logger.Debug(). + Int("tx_count", len(forcedTxs)). + Uint64("da_height_start", forcedEvent.StartDaHeight). + Uint64("da_height_end", forcedEvent.EndDaHeight). + Msg("retrieved forced inclusion transactions from DA") + batch, err := c.queue.Next(ctx) if err != nil { return nil, err @@ -261,27 +252,24 @@ func (c *Sequencer) GetDAHeight() uint64 { } // processForcedInclusionTxs processes forced inclusion transactions with size validation and pending queue management -func (c *Sequencer) processForcedInclusionTxs(event *block.ForcedInclusionEvent) ([][]byte, error) { +func (c *Sequencer) processForcedInclusionTxs(event *block.ForcedInclusionEvent, maxBytes uint64) [][]byte { currentSize := 0 var newPendingTxs []pendingForcedInclusionTx var validatedTxs [][]byte // First, process any pending transactions from previous epochs for _, pendingTx := range c.pendingForcedInclusionTxs { - txSize := len(pendingTx.Data) + txSize := seqcommon.GetBlobSize(pendingTx.Data) - // Validate individual blob size - if txSize > int(block.DefaultMaxBlobSize) { + if !seqcommon.ValidateBlobSize(pendingTx.Data, maxBytes) { c.logger.Warn(). Uint64("original_height", pendingTx.OriginalHeight). Int("blob_size", txSize). - Float64("max_size", block.DefaultMaxBlobSize). Msg("pending forced inclusion blob exceeds maximum size - skipping") continue } - // Check if adding this blob would exceed the current epoch's max size - if currentSize+txSize > int(block.DefaultMaxBlobSize) { + if seqcommon.WouldExceedCumulativeSize(currentSize, txSize, maxBytes) { c.logger.Debug(). Uint64("original_height", pendingTx.OriginalHeight). Int("current_size", currentSize). @@ -303,27 +291,24 @@ func (c *Sequencer) processForcedInclusionTxs(event *block.ForcedInclusionEvent) // Now process new transactions from this epoch for _, tx := range event.Txs { - txSize := len(tx) + txSize := seqcommon.GetBlobSize(tx) - // Validate individual blob size - if txSize > int(block.DefaultMaxBlobSize) { + if !seqcommon.ValidateBlobSize(tx, maxBytes) { c.logger.Warn(). Uint64("da_height", event.StartDaHeight). Int("blob_size", txSize). - Float64("max_size", block.DefaultMaxBlobSize). Msg("forced inclusion blob exceeds maximum size - skipping") continue } - // Check if adding this blob would exceed the current epoch's max size - if currentSize+txSize > int(block.DefaultMaxBlobSize) { + if seqcommon.WouldExceedCumulativeSize(currentSize, txSize, maxBytes) { c.logger.Debug(). Uint64("da_height", event.StartDaHeight). Int("current_size", currentSize). Int("blob_size", txSize). Msg("blob would exceed max size for this epoch - deferring to pending queue") - // Store for next epoch + // Store for next call newPendingTxs = append(newPendingTxs, pendingForcedInclusionTx{ Data: tx, OriginalHeight: event.StartDaHeight, @@ -354,5 +339,5 @@ func (c *Sequencer) processForcedInclusionTxs(event *block.ForcedInclusionEvent) Int("current_size", currentSize). Msg("completed processing forced inclusion transactions") - return validatedTxs, nil + return validatedTxs } From 3b4316d080e004ce17c41e1d43f6a9575114b96e Mon Sep 17 00:00:00 2001 From: Julien Robert Date: Mon, 17 Nov 2025 15:22:22 +0100 Subject: [PATCH 4/4] fixes --- apps/evm/single/cmd/run.go | 3 +- apps/grpc/single/cmd/run.go | 3 +- apps/testapp/cmd/run.go | 3 +- pkg/cmd/run_node.go | 2 - sequencers/based/sequencer.go | 225 +++++--------------- sequencers/based/sequencer_test.go | 145 ++++++++++++- sequencers/common/size_validation.go | 19 +- sequencers/common/size_validation_test.go | 141 ++++++++++++ sequencers/single/queue.go | 20 ++ sequencers/single/queue_test.go | 154 ++++++++++++++ sequencers/single/sequencer.go | 77 ++++--- sequencers/single/sequencer_test.go | 248 ++++++++++++++++++++++ 12 files changed, 829 insertions(+), 211 deletions(-) create mode 100644 sequencers/common/size_validation_test.go diff --git a/apps/evm/single/cmd/run.go b/apps/evm/single/cmd/run.go index 890e30d106..092cea1f15 100644 --- a/apps/evm/single/cmd/run.go +++ b/apps/evm/single/cmd/run.go @@ -27,6 +27,7 @@ import ( "github.com/evstack/ev-node/pkg/p2p/key" "github.com/evstack/ev-node/pkg/store" "github.com/evstack/ev-node/sequencers/based" + seqcommon "github.com/evstack/ev-node/sequencers/common" "github.com/evstack/ev-node/sequencers/single" ) @@ -57,7 +58,7 @@ var RunCmd = &cobra.Command{ logger.Info().Str("headerNamespace", headerNamespace.HexString()).Str("dataNamespace", dataNamespace.HexString()).Msg("namespaces") - daJrpc, err := jsonrpc.NewClient(context.Background(), logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, rollcmd.DefaultMaxBlobSize) + daJrpc, err := jsonrpc.NewClient(context.Background(), logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, seqcommon.AbsoluteMaxBlobSize) if err != nil { return err } diff --git a/apps/grpc/single/cmd/run.go b/apps/grpc/single/cmd/run.go index 0ee5d09c1b..c1c5ba7e85 100644 --- a/apps/grpc/single/cmd/run.go +++ b/apps/grpc/single/cmd/run.go @@ -24,6 +24,7 @@ import ( "github.com/evstack/ev-node/pkg/p2p/key" "github.com/evstack/ev-node/pkg/store" "github.com/evstack/ev-node/sequencers/based" + seqcommon "github.com/evstack/ev-node/sequencers/common" "github.com/evstack/ev-node/sequencers/single" ) @@ -59,7 +60,7 @@ The execution client must implement the Evolve execution gRPC interface.`, logger.Info().Str("headerNamespace", headerNamespace.HexString()).Str("dataNamespace", dataNamespace.HexString()).Msg("namespaces") // Create DA client - daJrpc, err := jsonrpc.NewClient(cmd.Context(), logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, rollcmd.DefaultMaxBlobSize) + daJrpc, err := jsonrpc.NewClient(cmd.Context(), logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, seqcommon.AbsoluteMaxBlobSize) if err != nil { return err } diff --git a/apps/testapp/cmd/run.go b/apps/testapp/cmd/run.go index c185f2ad4b..dd3440b864 100644 --- a/apps/testapp/cmd/run.go +++ b/apps/testapp/cmd/run.go @@ -22,6 +22,7 @@ import ( "github.com/evstack/ev-node/pkg/p2p/key" "github.com/evstack/ev-node/pkg/store" "github.com/evstack/ev-node/sequencers/based" + seqcommon "github.com/evstack/ev-node/sequencers/common" "github.com/evstack/ev-node/sequencers/single" ) @@ -57,7 +58,7 @@ var RunCmd = &cobra.Command{ logger.Info().Str("headerNamespace", headerNamespace.HexString()).Str("dataNamespace", dataNamespace.HexString()).Msg("namespaces") - daJrpc, err := jsonrpc.NewClient(ctx, logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, cmd.DefaultMaxBlobSize) + daJrpc, err := jsonrpc.NewClient(ctx, logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, seqcommon.AbsoluteMaxBlobSize) if err != nil { return err } diff --git a/pkg/cmd/run_node.go b/pkg/cmd/run_node.go index d22627baa6..4dbc876879 100644 --- a/pkg/cmd/run_node.go +++ b/pkg/cmd/run_node.go @@ -26,8 +26,6 @@ import ( "github.com/evstack/ev-node/pkg/signer/file" ) -const DefaultMaxBlobSize = 1.5 * 1024 * 1024 // 1.5MB - // ParseConfig is an helpers that loads the node configuration and validates it. func ParseConfig(cmd *cobra.Command) (rollconf.Config, error) { nodeConfig, err := rollconf.Load(cmd) diff --git a/sequencers/based/sequencer.go b/sequencers/based/sequencer.go index 8cb0b07fec..7636292007 100644 --- a/sequencers/based/sequencer.go +++ b/sequencers/based/sequencer.go @@ -3,7 +3,7 @@ package based import ( "context" "errors" - "sync" + "sync/atomic" "time" "github.com/rs/zerolog" @@ -21,12 +21,6 @@ type ForcedInclusionRetriever interface { RetrieveForcedIncludedTxs(ctx context.Context, daHeight uint64) (*block.ForcedInclusionEvent, error) } -// pendingForcedInclusionTx represents a forced inclusion transaction that couldn't fit in the current epoch -type pendingForcedInclusionTx struct { - Data []byte - OriginalHeight uint64 -} - var _ coresequencer.Sequencer = (*BasedSequencer)(nil) // BasedSequencer is a sequencer that only retrieves transactions from the DA layer @@ -38,10 +32,8 @@ type BasedSequencer struct { genesis genesis.Genesis logger zerolog.Logger - mu sync.RWMutex - daHeight uint64 - txQueue [][]byte - pendingForcedInclusionTxs []pendingForcedInclusionTx + daHeight atomic.Uint64 + txQueue [][]byte } // NewBasedSequencer creates a new based sequencer instance @@ -52,16 +44,17 @@ func NewBasedSequencer( genesis genesis.Genesis, logger zerolog.Logger, ) *BasedSequencer { - return &BasedSequencer{ - fiRetriever: fiRetriever, - da: da, - config: config, - genesis: genesis, - logger: logger.With().Str("component", "based_sequencer").Logger(), - daHeight: genesis.DAStartHeight, - txQueue: make([][]byte, 0), - pendingForcedInclusionTxs: make([]pendingForcedInclusionTx, 0), + bs := &BasedSequencer{ + fiRetriever: fiRetriever, + da: da, + config: config, + genesis: genesis, + logger: logger.With().Str("component", "based_sequencer").Logger(), + txQueue: make([][]byte, 0), } + bs.SetDAHeight(genesis.DAStartHeight) // will be overridden by the executor + + return bs } // SubmitBatchTxs does nothing for a based sequencer as it only pulls from DA @@ -74,29 +67,11 @@ func (s *BasedSequencer) SubmitBatchTxs(ctx context.Context, req coresequencer.S // GetNextBatch retrieves the next batch of transactions from the DA layer // It fetches forced inclusion transactions and returns them as the next batch func (s *BasedSequencer) GetNextBatch(ctx context.Context, req coresequencer.GetNextBatchRequest) (*coresequencer.GetNextBatchResponse, error) { - s.mu.Lock() - defer s.mu.Unlock() - - // If we have transactions in the queue, return them first - if len(s.txQueue) > 0 { - batch := s.createBatchFromQueue(req.MaxBytes) - if len(batch.Transactions) > 0 { - s.logger.Debug(). - Int("tx_count", len(batch.Transactions)). - Int("remaining", len(s.txQueue)). - Msg("returning batch from queue") - return &coresequencer.GetNextBatchResponse{ - Batch: batch, - Timestamp: time.Now(), - BatchData: req.LastBatchData, - }, nil - } - } + currentDAHeight := s.daHeight.Load() - // Fetch forced inclusion transactions from DA - s.logger.Debug().Uint64("da_height", s.daHeight).Msg("fetching forced inclusion transactions from DA") + s.logger.Debug().Uint64("da_height", currentDAHeight).Msg("fetching forced inclusion transactions from DA") - forcedTxsEvent, err := s.fiRetriever.RetrieveForcedIncludedTxs(ctx, s.daHeight) + forcedTxsEvent, err := s.fiRetriever.RetrieveForcedIncludedTxs(ctx, currentDAHeight) if err != nil { // Check if forced inclusion is not configured if errors.Is(err, block.ErrForceInclusionNotConfigured) { @@ -106,43 +81,49 @@ func (s *BasedSequencer) GetNextBatch(ctx context.Context, req coresequencer.Get Timestamp: time.Now(), BatchData: req.LastBatchData, }, nil - } - - // If we get a height from future error, keep the current DA height and return batch - // We'll retry the same height on the next call until DA produces that block - if errors.Is(err, coreda.ErrHeightFromFuture) { + } else if errors.Is(err, coreda.ErrHeightFromFuture) { + // If we get a height from future error, keep the current DA height and return batch + // We'll retry the same height on the next call until DA produces that block s.logger.Debug(). - Uint64("da_height", s.daHeight). + Uint64("da_height", currentDAHeight). Msg("DA height from future, waiting for DA to produce block") - return &coresequencer.GetNextBatchResponse{ - Batch: &coresequencer.Batch{Transactions: nil}, - Timestamp: time.Now(), - BatchData: req.LastBatchData, - }, nil + } else { + s.logger.Error().Err(err).Uint64("da_height", currentDAHeight).Msg("failed to retrieve forced inclusion transactions") + return nil, err } - - s.logger.Error().Err(err).Uint64("da_height", s.daHeight).Msg("failed to retrieve forced inclusion transactions") - return nil, err } // Update DA height based on the retrieved event - if forcedTxsEvent.EndDaHeight > s.daHeight { - s.daHeight = forcedTxsEvent.EndDaHeight - } else if forcedTxsEvent.StartDaHeight > s.daHeight { - s.daHeight = forcedTxsEvent.StartDaHeight + if forcedTxsEvent.EndDaHeight > currentDAHeight { + s.SetDAHeight(forcedTxsEvent.EndDaHeight) + } else if forcedTxsEvent.StartDaHeight > currentDAHeight { + s.SetDAHeight(forcedTxsEvent.StartDaHeight) } - // Process forced inclusion transactions with size validation and pending queue management - if err := s.processForcedInclusionTxs(forcedTxsEvent, req.MaxBytes); err != nil { - s.logger.Error().Err(err).Msg("failed to process forced inclusion transactions") - return nil, err + // Add forced inclusion transactions to the queue with validation + validTxs := 0 + skippedTxs := 0 + for _, tx := range forcedTxsEvent.Txs { + // Validate blob size against absolute maximum + if !seqcommon.ValidateBlobSize(tx) { + s.logger.Warn(). + Uint64("da_height", forcedTxsEvent.StartDaHeight). + Int("blob_size", len(tx)). + Msg("forced inclusion blob exceeds absolute maximum size - skipping") + skippedTxs++ + continue + } + s.txQueue = append(s.txQueue, tx) + validTxs++ } s.logger.Info(). - Int("tx_count", len(forcedTxsEvent.Txs)). + Int("valid_tx_count", validTxs). + Int("skipped_tx_count", skippedTxs). + Int("queue_size", len(s.txQueue)). Uint64("da_height_start", forcedTxsEvent.StartDaHeight). Uint64("da_height_end", forcedTxsEvent.EndDaHeight). - Msg("retrieved forced inclusion transactions from DA") + Msg("processed forced inclusion transactions from DA") batch := s.createBatchFromQueue(req.MaxBytes) @@ -164,7 +145,8 @@ func (s *BasedSequencer) createBatchFromQueue(maxBytes uint64) *coresequencer.Ba for i, tx := range s.txQueue { txSize := uint64(len(tx)) - if totalBytes+txSize > maxBytes && len(batch) > 0 { + // Always respect maxBytes, even for the first transaction + if totalBytes+txSize > maxBytes { // Would exceed max bytes, stop here s.txQueue = s.txQueue[i:] break @@ -192,113 +174,12 @@ func (s *BasedSequencer) VerifyBatch(ctx context.Context, req coresequencer.Veri // SetDAHeight sets the current DA height for the sequencer // This should be called when the sequencer needs to sync to a specific DA height -func (s *BasedSequencer) SetDAHeight(height uint64) { - s.mu.Lock() - defer s.mu.Unlock() - s.daHeight = height - s.logger.Debug().Uint64("da_height", height).Msg("DA height updated") +func (c *BasedSequencer) SetDAHeight(height uint64) { + c.daHeight.Store(height) + c.logger.Debug().Uint64("da_height", height).Msg("DA height updated") } // GetDAHeight returns the current DA height -func (s *BasedSequencer) GetDAHeight() uint64 { - s.mu.RLock() - defer s.mu.RUnlock() - return s.daHeight -} - -// processForcedInclusionTxs processes forced inclusion transactions with size validation and pending queue management -func (s *BasedSequencer) processForcedInclusionTxs(event *block.ForcedInclusionEvent, maxBytes uint64) error { - currentSize := 0 - var newPendingTxs []pendingForcedInclusionTx - var txsToQueue [][]byte - - // First, process any pending transactions from previous epochs - for _, pendingTx := range s.pendingForcedInclusionTxs { - txSize := seqcommon.GetBlobSize(pendingTx.Data) - - if !seqcommon.ValidateBlobSize(pendingTx.Data, maxBytes) { - s.logger.Warn(). - Uint64("original_height", pendingTx.OriginalHeight). - Int("blob_size", txSize). - Uint64("max_size", maxBytes). - Msg("pending forced inclusion blob exceeds maximum size - skipping") - continue - } - - if seqcommon.WouldExceedCumulativeSize(currentSize, txSize, maxBytes) { - s.logger.Debug(). - Uint64("original_height", pendingTx.OriginalHeight). - Int("current_size", currentSize). - Int("blob_size", txSize). - Uint64("max_size", maxBytes). - Msg("pending blob would exceed max size for this epoch - deferring again") - newPendingTxs = append(newPendingTxs, pendingTx) - continue - } - - txsToQueue = append(txsToQueue, pendingTx.Data) - currentSize += txSize - - s.logger.Debug(). - Uint64("original_height", pendingTx.OriginalHeight). - Int("blob_size", txSize). - Int("current_size", currentSize). - Msg("processed pending forced inclusion transaction") - } - - // Now process new transactions from this epoch - for _, tx := range event.Txs { - txSize := seqcommon.GetBlobSize(tx) - - if !seqcommon.ValidateBlobSize(tx, maxBytes) { - s.logger.Warn(). - Uint64("da_height", event.StartDaHeight). - Int("blob_size", txSize). - Uint64("max_size", maxBytes). - Msg("forced inclusion blob exceeds maximum size - skipping") - continue - } - - if seqcommon.WouldExceedCumulativeSize(currentSize, txSize, maxBytes) { - s.logger.Debug(). - Uint64("da_height", event.StartDaHeight). - Int("current_size", currentSize). - Int("blob_size", txSize). - Uint64("max_size", maxBytes). - Msg("blob would exceed max size for this epoch - deferring to pending queue") - - // Store for next epoch - newPendingTxs = append(newPendingTxs, pendingForcedInclusionTx{ - Data: tx, - OriginalHeight: event.StartDaHeight, - }) - continue - } - - txsToQueue = append(txsToQueue, tx) - currentSize += txSize - - s.logger.Debug(). - Int("blob_size", txSize). - Int("current_size", currentSize). - Msg("processed forced inclusion transaction") - } - - // Update pending queue - s.pendingForcedInclusionTxs = newPendingTxs - if len(newPendingTxs) > 0 { - s.logger.Info(). - Int("new_pending_count", len(newPendingTxs)). - Msg("stored pending forced inclusion transactions for next epoch") - } - - s.txQueue = append(s.txQueue, txsToQueue...) - - s.logger.Info(). - Int("processed_tx_count", len(txsToQueue)). - Int("pending_tx_count", len(newPendingTxs)). - Int("current_size", currentSize). - Msg("completed processing forced inclusion transactions") - - return nil +func (c *BasedSequencer) GetDAHeight() uint64 { + return c.daHeight.Load() } diff --git a/sequencers/based/sequencer_test.go b/sequencers/based/sequencer_test.go index de56f94a5e..57866bcaf6 100644 --- a/sequencers/based/sequencer_test.go +++ b/sequencers/based/sequencer_test.go @@ -98,7 +98,7 @@ func TestNewBasedSequencer(t *testing.T) { seq := NewBasedSequencer(fiRetriever, mockDA, cfg, gen, zerolog.Nop()) require.NotNil(t, seq) - assert.Equal(t, uint64(100), seq.daHeight) + assert.Equal(t, uint64(100), seq.daHeight.Load()) assert.Equal(t, 0, len(seq.txQueue)) } @@ -287,11 +287,15 @@ func TestBasedSequencer_GetNextBatch_WithMaxBytes(t *testing.T) { } mockDA := new(MockDA) + // First call returns forced txs mockDA.On("GetIDs", mock.Anything, uint64(100), mock.Anything).Return(&coreda.GetIDsResult{ IDs: []coreda.ID{[]byte("id1"), []byte("id2"), []byte("id3")}, Timestamp: time.Now(), - }, nil) - mockDA.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(testBlobs, nil) + }, nil).Once() + mockDA.On("Get", mock.Anything, mock.Anything, mock.Anything).Return(testBlobs, nil).Once() + + // Subsequent calls should return no new forced txs + mockDA.On("GetIDs", mock.Anything, uint64(100), mock.Anything).Return(nil, coreda.ErrBlobNotFound) gen := genesis.Genesis{ ChainID: "test-chain", @@ -319,7 +323,7 @@ func TestBasedSequencer_GetNextBatch_WithMaxBytes(t *testing.T) { require.NoError(t, err) require.NotNil(t, resp) require.NotNil(t, resp.Batch) - // Should get first tx (50 bytes), then break before second tx (would make 110 total) + // Should get first tx (50 bytes), second tx would exceed limit (50+60=110 > 100) assert.Equal(t, 1, len(resp.Batch.Transactions)) assert.Equal(t, 2, len(seq.txQueue)) // 2 remaining in queue @@ -386,6 +390,139 @@ func TestBasedSequencer_GetNextBatch_FromQueue(t *testing.T) { assert.Equal(t, 0, len(seq.txQueue)) } +func TestBasedSequencer_GetNextBatch_AlwaysCheckPendingForcedInclusion(t *testing.T) { + mockDA := new(MockDA) + + // First call: return a forced tx that will be added to queue + forcedTx := make([]byte, 150) + mockDA.On("GetIDs", mock.Anything, uint64(100), mock.Anything).Return(&coreda.GetIDsResult{ + IDs: []coreda.ID{[]byte("id1")}, + Timestamp: time.Now(), + }, nil).Once() + mockDA.On("Get", mock.Anything, mock.Anything, mock.Anything).Return([][]byte{forcedTx}, nil).Once() + + // Second call: no new forced txs + mockDA.On("GetIDs", mock.Anything, uint64(100), mock.Anything).Return(nil, coreda.ErrBlobNotFound).Once() + + gen := genesis.Genesis{ + ChainID: "test-chain", + DAStartHeight: 100, + DAEpochForcedInclusion: 1, + } + + cfg := config.DefaultConfig() + cfg.DA.Namespace = "test-ns" + cfg.DA.DataNamespace = "test-data-ns" + cfg.DA.ForcedInclusionNamespace = "test-fi-ns" + + daClient := block.NewDAClient(mockDA, cfg, zerolog.Nop()) + fiRetriever := block.NewForcedInclusionRetriever(daClient, gen, zerolog.Nop()) + + seq := NewBasedSequencer(fiRetriever, mockDA, cfg, gen, zerolog.Nop()) + + // First call with maxBytes = 100 + // Forced tx (150 bytes) is added to queue, but batch will be empty since it exceeds maxBytes + req1 := coresequencer.GetNextBatchRequest{ + MaxBytes: 100, + LastBatchData: nil, + } + + resp1, err := seq.GetNextBatch(context.Background(), req1) + require.NoError(t, err) + require.NotNil(t, resp1) + require.NotNil(t, resp1.Batch) + assert.Equal(t, 0, len(resp1.Batch.Transactions), "Should have no txs as forced tx exceeds maxBytes") + + // Verify forced tx is in queue + assert.Equal(t, 1, len(seq.txQueue), "Forced tx should be in queue") + + // Second call with larger maxBytes = 200 + // Should process tx from queue + req2 := coresequencer.GetNextBatchRequest{ + MaxBytes: 200, + LastBatchData: nil, + } + + resp2, err := seq.GetNextBatch(context.Background(), req2) + require.NoError(t, err) + require.NotNil(t, resp2) + require.NotNil(t, resp2.Batch) + assert.Equal(t, 1, len(resp2.Batch.Transactions), "Should include tx from queue") + assert.Equal(t, 150, len(resp2.Batch.Transactions[0])) + + // Queue should now be empty + assert.Equal(t, 0, len(seq.txQueue), "Queue should be empty") + + mockDA.AssertExpectations(t) +} + +func TestBasedSequencer_GetNextBatch_ForcedInclusionExceedsMaxBytes(t *testing.T) { + mockDA := new(MockDA) + + // Return forced txs where combined they exceed maxBytes + forcedTx1 := make([]byte, 100) + forcedTx2 := make([]byte, 80) + mockDA.On("GetIDs", mock.Anything, uint64(100), mock.Anything).Return(&coreda.GetIDsResult{ + IDs: []coreda.ID{[]byte("id1"), []byte("id2")}, + Timestamp: time.Now(), + }, nil).Once() + mockDA.On("Get", mock.Anything, mock.Anything, mock.Anything).Return([][]byte{forcedTx1, forcedTx2}, nil).Once() + + // Second call + mockDA.On("GetIDs", mock.Anything, uint64(100), mock.Anything).Return(nil, coreda.ErrBlobNotFound).Once() + + gen := genesis.Genesis{ + ChainID: "test-chain", + DAStartHeight: 100, + DAEpochForcedInclusion: 1, + } + + cfg := config.DefaultConfig() + cfg.DA.Namespace = "test-ns" + cfg.DA.DataNamespace = "test-data-ns" + cfg.DA.ForcedInclusionNamespace = "test-fi-ns" + + daClient := block.NewDAClient(mockDA, cfg, zerolog.Nop()) + fiRetriever := block.NewForcedInclusionRetriever(daClient, gen, zerolog.Nop()) + + seq := NewBasedSequencer(fiRetriever, mockDA, cfg, gen, zerolog.Nop()) + + // First call with maxBytes = 120 + // Should get only first forced tx (100 bytes), second stays in queue + req1 := coresequencer.GetNextBatchRequest{ + MaxBytes: 120, + LastBatchData: nil, + } + + resp1, err := seq.GetNextBatch(context.Background(), req1) + require.NoError(t, err) + require.NotNil(t, resp1) + require.NotNil(t, resp1.Batch) + assert.Equal(t, 1, len(resp1.Batch.Transactions), "Should only include first forced tx") + assert.Equal(t, 100, len(resp1.Batch.Transactions[0])) + + // Verify second tx is still in queue + assert.Equal(t, 1, len(seq.txQueue), "Second tx should be in queue") + + // Second call - should get the second tx from queue + req2 := coresequencer.GetNextBatchRequest{ + MaxBytes: 120, + LastBatchData: nil, + } + + resp2, err := seq.GetNextBatch(context.Background(), req2) + require.NoError(t, err) + require.NotNil(t, resp2) + require.NotNil(t, resp2.Batch) + assert.Equal(t, 1, len(resp2.Batch.Transactions), "Should include second tx from queue") + assert.Equal(t, 80, len(resp2.Batch.Transactions[0])) + + // Queue should now be empty + assert.Equal(t, 0, len(seq.txQueue), "Queue should be empty") + + mockDA.AssertExpectations(t) +} + func TestBasedSequencer_VerifyBatch(t *testing.T) { mockDA := new(MockDA) gen := genesis.Genesis{ diff --git a/sequencers/common/size_validation.go b/sequencers/common/size_validation.go index 375c952fed..1032f5299f 100644 --- a/sequencers/common/size_validation.go +++ b/sequencers/common/size_validation.go @@ -1,14 +1,21 @@ package common -// TODO: technically we need to check for block gas as well +// TODO(@julienrbrt): technically we may need to check for block gas as well -// ValidateBlobSize checks if a single blob exceeds the maximum allowed size. -// Returns true if the blob is within the size limit, false otherwise. -func ValidateBlobSize(blob []byte, maxBytes uint64) bool { - return uint64(len(blob)) <= maxBytes +const ( + // AbsoluteMaxBlobSize is the absolute maximum size for a single blob (DA layer limit). + // Blobs exceeding this size are invalid and should be rejected permanently. + AbsoluteMaxBlobSize = 1.5 * 1024 * 1024 // 1.5MB +) + +// ValidateBlobSize checks if a single blob exceeds the absolute maximum allowed size. +// This checks against the DA layer limit, not the per-batch limit. +// Returns true if the blob is within the absolute size limit, false otherwise. +func ValidateBlobSize(blob []byte) bool { + return uint64(len(blob)) <= AbsoluteMaxBlobSize } -// WouldExceedCumulativeSize checks if adding a blob would exceed the cumulative size limit. +// WouldExceedCumulativeSize checks if adding a blob would exceed the cumulative size limit for a batch. // Returns true if adding the blob would exceed the limit, false otherwise. func WouldExceedCumulativeSize(currentSize int, blobSize int, maxBytes uint64) bool { return uint64(currentSize)+uint64(blobSize) > maxBytes diff --git a/sequencers/common/size_validation_test.go b/sequencers/common/size_validation_test.go new file mode 100644 index 0000000000..103c66d8be --- /dev/null +++ b/sequencers/common/size_validation_test.go @@ -0,0 +1,141 @@ +package common + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateBlobSize(t *testing.T) { + tests := []struct { + name string + blobSize int + want bool + }{ + { + name: "empty blob", + blobSize: 0, + want: true, + }, + { + name: "small blob", + blobSize: 100, + want: true, + }, + { + name: "exactly at limit", + blobSize: int(AbsoluteMaxBlobSize), + want: true, + }, + { + name: "one byte over limit", + blobSize: int(AbsoluteMaxBlobSize) + 1, + want: false, + }, + { + name: "far exceeds limit", + blobSize: int(AbsoluteMaxBlobSize) * 2, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + blob := make([]byte, tt.blobSize) + got := ValidateBlobSize(blob) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestWouldExceedCumulativeSize(t *testing.T) { + tests := []struct { + name string + currentSize int + blobSize int + maxBytes uint64 + want bool + }{ + { + name: "empty batch, small blob", + currentSize: 0, + blobSize: 50, + maxBytes: 100, + want: false, + }, + { + name: "would fit exactly", + currentSize: 50, + blobSize: 50, + maxBytes: 100, + want: false, + }, + { + name: "would exceed by one byte", + currentSize: 50, + blobSize: 51, + maxBytes: 100, + want: true, + }, + { + name: "far exceeds", + currentSize: 80, + blobSize: 100, + maxBytes: 100, + want: true, + }, + { + name: "zero max bytes", + currentSize: 0, + blobSize: 1, + maxBytes: 0, + want: true, + }, + { + name: "current already at limit", + currentSize: 100, + blobSize: 1, + maxBytes: 100, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := WouldExceedCumulativeSize(tt.currentSize, tt.blobSize, tt.maxBytes) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestGetBlobSize(t *testing.T) { + tests := []struct { + name string + blobSize int + want int + }{ + { + name: "empty blob", + blobSize: 0, + want: 0, + }, + { + name: "small blob", + blobSize: 42, + want: 42, + }, + { + name: "large blob", + blobSize: 1024 * 1024, + want: 1024 * 1024, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + blob := make([]byte, tt.blobSize) + got := GetBlobSize(blob) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/sequencers/single/queue.go b/sequencers/single/queue.go index dd69c26a2c..d992535ead 100644 --- a/sequencers/single/queue.go +++ b/sequencers/single/queue.go @@ -83,6 +83,26 @@ func (bq *BatchQueue) AddBatch(ctx context.Context, batch coresequencer.Batch) e return nil } +// Prepend adds a batch to the front of the queue (before head position). +// This is used to return transactions that couldn't fit in the current batch. +// The batch is NOT persisted to the DB since these are transactions that were +// already in the queue or were just processed. +func (bq *BatchQueue) Prepend(ctx context.Context, batch coresequencer.Batch) error { + bq.mu.Lock() + defer bq.mu.Unlock() + + // If we have room before head, use it + if bq.head > 0 { + bq.head-- + bq.queue[bq.head] = batch + } else { + // Need to expand the queue at the front + bq.queue = append([]coresequencer.Batch{batch}, bq.queue...) + } + + return nil +} + // Next extracts a batch of transactions from the queue and marks it as processed in the WAL func (bq *BatchQueue) Next(ctx context.Context) (*coresequencer.Batch, error) { bq.mu.Lock() diff --git a/sequencers/single/queue_test.go b/sequencers/single/queue_test.go index 0ede59a90e..b7665ee67f 100644 --- a/sequencers/single/queue_test.go +++ b/sequencers/single/queue_test.go @@ -12,6 +12,7 @@ import ( ds "github.com/ipfs/go-datastore" "github.com/ipfs/go-datastore/query" dssync "github.com/ipfs/go-datastore/sync" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" @@ -567,3 +568,156 @@ func TestBatchQueue_QueueLimit_Concurrency(t *testing.T) { t.Logf("Successfully added %d batches, rejected %d due to queue being full", addedCount, errorCount) } + +func TestBatchQueue_Prepend(t *testing.T) { + ctx := context.Background() + db := ds.NewMapDatastore() + + t.Run("prepend to empty queue", func(t *testing.T) { + queue := NewBatchQueue(db, "test-prepend-empty", 0) + err := queue.Load(ctx) + require.NoError(t, err) + + batch := coresequencer.Batch{ + Transactions: [][]byte{[]byte("tx1"), []byte("tx2")}, + } + + err = queue.Prepend(ctx, batch) + require.NoError(t, err) + + assert.Equal(t, 1, queue.Size()) + + // Next should return the prepended batch + nextBatch, err := queue.Next(ctx) + require.NoError(t, err) + assert.Equal(t, 2, len(nextBatch.Transactions)) + assert.Equal(t, []byte("tx1"), nextBatch.Transactions[0]) + }) + + t.Run("prepend to queue with items", func(t *testing.T) { + queue := NewBatchQueue(db, "test-prepend-with-items", 0) + err := queue.Load(ctx) + require.NoError(t, err) + + // Add some batches first + batch1 := coresequencer.Batch{Transactions: [][]byte{[]byte("tx1")}} + batch2 := coresequencer.Batch{Transactions: [][]byte{[]byte("tx2")}} + err = queue.AddBatch(ctx, batch1) + require.NoError(t, err) + err = queue.AddBatch(ctx, batch2) + require.NoError(t, err) + + assert.Equal(t, 2, queue.Size()) + + // Prepend a batch + prependedBatch := coresequencer.Batch{Transactions: [][]byte{[]byte("prepended")}} + err = queue.Prepend(ctx, prependedBatch) + require.NoError(t, err) + + assert.Equal(t, 3, queue.Size()) + + // Next should return the prepended batch first + nextBatch, err := queue.Next(ctx) + require.NoError(t, err) + assert.Equal(t, 1, len(nextBatch.Transactions)) + assert.Equal(t, []byte("prepended"), nextBatch.Transactions[0]) + + // Then the original batches + nextBatch, err = queue.Next(ctx) + require.NoError(t, err) + assert.Equal(t, []byte("tx1"), nextBatch.Transactions[0]) + + nextBatch, err = queue.Next(ctx) + require.NoError(t, err) + assert.Equal(t, []byte("tx2"), nextBatch.Transactions[0]) + }) + + t.Run("prepend after consuming some items", func(t *testing.T) { + queue := NewBatchQueue(db, "test-prepend-after-consume", 0) + err := queue.Load(ctx) + require.NoError(t, err) + + // Add batches + batch1 := coresequencer.Batch{Transactions: [][]byte{[]byte("tx1")}} + batch2 := coresequencer.Batch{Transactions: [][]byte{[]byte("tx2")}} + batch3 := coresequencer.Batch{Transactions: [][]byte{[]byte("tx3")}} + err = queue.AddBatch(ctx, batch1) + require.NoError(t, err) + err = queue.AddBatch(ctx, batch2) + require.NoError(t, err) + err = queue.AddBatch(ctx, batch3) + require.NoError(t, err) + + assert.Equal(t, 3, queue.Size()) + + // Consume first batch + nextBatch, err := queue.Next(ctx) + require.NoError(t, err) + assert.Equal(t, []byte("tx1"), nextBatch.Transactions[0]) + assert.Equal(t, 2, queue.Size()) + + // Prepend - should reuse the head position + prependedBatch := coresequencer.Batch{Transactions: [][]byte{[]byte("prepended")}} + err = queue.Prepend(ctx, prependedBatch) + require.NoError(t, err) + + assert.Equal(t, 3, queue.Size()) + + // Should get prepended, then tx2, then tx3 + nextBatch, err = queue.Next(ctx) + require.NoError(t, err) + assert.Equal(t, []byte("prepended"), nextBatch.Transactions[0]) + + nextBatch, err = queue.Next(ctx) + require.NoError(t, err) + assert.Equal(t, []byte("tx2"), nextBatch.Transactions[0]) + + nextBatch, err = queue.Next(ctx) + require.NoError(t, err) + assert.Equal(t, []byte("tx3"), nextBatch.Transactions[0]) + + assert.Equal(t, 0, queue.Size()) + }) + + t.Run("multiple prepends", func(t *testing.T) { + queue := NewBatchQueue(db, "test-multiple-prepends", 0) + err := queue.Load(ctx) + require.NoError(t, err) + + // Add a batch + batch1 := coresequencer.Batch{Transactions: [][]byte{[]byte("tx1")}} + err = queue.AddBatch(ctx, batch1) + require.NoError(t, err) + + // Prepend multiple batches + prepend1 := coresequencer.Batch{Transactions: [][]byte{[]byte("prepend1")}} + prepend2 := coresequencer.Batch{Transactions: [][]byte{[]byte("prepend2")}} + prepend3 := coresequencer.Batch{Transactions: [][]byte{[]byte("prepend3")}} + + err = queue.Prepend(ctx, prepend1) + require.NoError(t, err) + err = queue.Prepend(ctx, prepend2) + require.NoError(t, err) + err = queue.Prepend(ctx, prepend3) + require.NoError(t, err) + + assert.Equal(t, 4, queue.Size()) + + // Should get in reverse order of prepending (LIFO for prepended items) + nextBatch, err := queue.Next(ctx) + require.NoError(t, err) + assert.Equal(t, []byte("prepend3"), nextBatch.Transactions[0]) + + nextBatch, err = queue.Next(ctx) + require.NoError(t, err) + assert.Equal(t, []byte("prepend2"), nextBatch.Transactions[0]) + + nextBatch, err = queue.Next(ctx) + require.NoError(t, err) + assert.Equal(t, []byte("prepend1"), nextBatch.Transactions[0]) + + nextBatch, err = queue.Next(ctx) + require.NoError(t, err) + assert.Equal(t, []byte("tx1"), nextBatch.Transactions[0]) + }) +} diff --git a/sequencers/single/sequencer.go b/sequencers/single/sequencer.go index 254fde8af8..e97d7a157e 100644 --- a/sequencers/single/sequencer.go +++ b/sequencers/single/sequencer.go @@ -134,33 +134,25 @@ func (c *Sequencer) GetNextBatch(ctx context.Context, req coresequencer.GetNextB forcedEvent, err := c.fiRetriever.RetrieveForcedIncludedTxs(ctx, currentDAHeight) if err != nil { - // If we get a height from future error, keep the current DA height and return batch - // We'll retry the same height on the next call until DA produces that block + // Continue without forced txs. Add logging for clarity. + if errors.Is(err, coreda.ErrHeightFromFuture) { c.logger.Debug(). Uint64("da_height", currentDAHeight). Msg("DA height from future, waiting for DA to produce block") - - batch, err := c.queue.Next(ctx) - if err != nil { - return nil, err - } - - return &coresequencer.GetNextBatchResponse{ - Batch: batch, - Timestamp: time.Now(), - BatchData: req.LastBatchData, - }, nil + } else if !errors.Is(err, block.ErrForceInclusionNotConfigured) { + c.logger.Error().Err(err).Uint64("da_height", currentDAHeight).Msg("failed to retrieve forced inclusion transactions") } - // If forced inclusion is not configured, continue without forced txs - if !errors.Is(err, block.ErrForceInclusionNotConfigured) { - c.logger.Error().Err(err).Uint64("da_height", currentDAHeight).Msg("failed to retrieve forced inclusion transactions") - // Continue without forced txs on other errors + // Still create an empty forced inclusion event + forcedEvent = &block.ForcedInclusionEvent{ + Txs: [][]byte{}, + StartDaHeight: currentDAHeight, + EndDaHeight: currentDAHeight, } } - // Always try to process forced inclusion transactions (can be in queue) + // Always try to process forced inclusion transactions (including pending from previous epochs) forcedTxs := c.processForcedInclusionTxs(forcedEvent, req.MaxBytes) if forcedEvent.EndDaHeight > currentDAHeight { c.SetDAHeight(forcedEvent.EndDaHeight) @@ -174,18 +166,55 @@ func (c *Sequencer) GetNextBatch(ctx context.Context, req coresequencer.GetNextB Uint64("da_height_end", forcedEvent.EndDaHeight). Msg("retrieved forced inclusion transactions from DA") + // Calculate size used by forced inclusion transactions + forcedTxsSize := 0 + for _, tx := range forcedTxs { + forcedTxsSize += len(tx) + } + batch, err := c.queue.Next(ctx) if err != nil { return nil, err } // Prepend forced inclusion transactions to the batch + // and ensure total size doesn't exceed maxBytes if len(forcedTxs) > 0 { - batch.Transactions = append(forcedTxs, batch.Transactions...) + // Trim batch transactions to fit within maxBytes + remainingBytes := int(req.MaxBytes) - forcedTxsSize + trimmedBatchTxs := make([][]byte, 0, len(batch.Transactions)) + currentBatchSize := 0 + + for i, tx := range batch.Transactions { + txSize := len(tx) + if currentBatchSize+txSize > remainingBytes { + // Would exceed limit, return remaining txs to the front of the queue + excludedBatch := coresequencer.Batch{Transactions: batch.Transactions[i:]} + if err := c.queue.Prepend(ctx, excludedBatch); err != nil { + c.logger.Error().Err(err). + Int("excluded_count", len(batch.Transactions)-i). + Msg("failed to prepend excluded transactions back to queue") + } else { + c.logger.Debug(). + Int("excluded_count", len(batch.Transactions)-i). + Msg("returned excluded batch transactions to front of queue") + } + break + } + trimmedBatchTxs = append(trimmedBatchTxs, tx) + currentBatchSize += txSize + } + + batch.Transactions = append(forcedTxs, trimmedBatchTxs...) + c.logger.Debug(). Int("forced_tx_count", len(forcedTxs)). + Int("forced_txs_size", forcedTxsSize). + Int("batch_tx_count", len(trimmedBatchTxs)). + Int("batch_size", currentBatchSize). Int("total_tx_count", len(batch.Transactions)). - Msg("prepended forced inclusion transactions to batch") + Int("total_size", forcedTxsSize+currentBatchSize). + Msg("combined forced inclusion and batch transactions") } return &coresequencer.GetNextBatchResponse{ @@ -261,11 +290,11 @@ func (c *Sequencer) processForcedInclusionTxs(event *block.ForcedInclusionEvent, for _, pendingTx := range c.pendingForcedInclusionTxs { txSize := seqcommon.GetBlobSize(pendingTx.Data) - if !seqcommon.ValidateBlobSize(pendingTx.Data, maxBytes) { + if !seqcommon.ValidateBlobSize(pendingTx.Data) { c.logger.Warn(). Uint64("original_height", pendingTx.OriginalHeight). Int("blob_size", txSize). - Msg("pending forced inclusion blob exceeds maximum size - skipping") + Msg("pending forced inclusion blob exceeds absolute maximum size - skipping") continue } @@ -293,11 +322,11 @@ func (c *Sequencer) processForcedInclusionTxs(event *block.ForcedInclusionEvent, for _, tx := range event.Txs { txSize := seqcommon.GetBlobSize(tx) - if !seqcommon.ValidateBlobSize(tx, maxBytes) { + if !seqcommon.ValidateBlobSize(tx) { c.logger.Warn(). Uint64("da_height", event.StartDaHeight). Int("blob_size", txSize). - Msg("forced inclusion blob exceeds maximum size - skipping") + Msg("forced inclusion blob exceeds absolute maximum size - skipping") continue } diff --git a/sequencers/single/sequencer_test.go b/sequencers/single/sequencer_test.go index c0f4b556bc..e80347115d 100644 --- a/sequencers/single/sequencer_test.go +++ b/sequencers/single/sequencer_test.go @@ -490,6 +490,254 @@ func TestSequencer_GetNextBatch_BeforeDASubmission(t *testing.T) { mockDA.AssertExpectations(t) } +func TestSequencer_GetNextBatch_ForcedInclusionAndBatch_MaxBytes(t *testing.T) { + ctx := context.Background() + logger := zerolog.New(zerolog.NewConsoleWriter()) + + // Create in-memory datastore + db := ds.NewMapDatastore() + + // Create mock forced inclusion retriever with txs that are 50 bytes each + mockFI := &MockForcedInclusionRetriever{} + forcedTx1 := make([]byte, 50) + forcedTx2 := make([]byte, 60) + mockFI.On("RetrieveForcedIncludedTxs", mock.Anything, uint64(100)).Return(&block.ForcedInclusionEvent{ + Txs: [][]byte{forcedTx1, forcedTx2}, // Total 110 bytes + StartDaHeight: 100, + EndDaHeight: 100, + }, nil) + + gen := genesis.Genesis{ + ChainID: "test-chain", + DAStartHeight: 100, + } + + seq, err := NewSequencer( + ctx, + logger, + db, + nil, + []byte("test-chain"), + 1*time.Second, + nil, + true, + 100, + mockFI, + gen, + ) + require.NoError(t, err) + + // Submit batch txs that are 40 bytes each + batchTx1 := make([]byte, 40) + batchTx2 := make([]byte, 40) + batchTx3 := make([]byte, 40) + + submitReq := coresequencer.SubmitBatchTxsRequest{ + Id: []byte("test-chain"), + Batch: &coresequencer.Batch{ + Transactions: [][]byte{batchTx1, batchTx2, batchTx3}, // Total 120 bytes + }, + } + + _, err = seq.SubmitBatchTxs(ctx, submitReq) + require.NoError(t, err) + + // Request batch with maxBytes = 150 + // Forced inclusion: 110 bytes (50 + 60) + // Batch txs: 120 bytes (40 + 40 + 40) + // Combined would be 230 bytes, exceeds 150 + // Should return forced txs + only 1 batch tx (110 + 40 = 150) + getReq := coresequencer.GetNextBatchRequest{ + Id: []byte("test-chain"), + MaxBytes: 150, + LastBatchData: nil, + } + + resp, err := seq.GetNextBatch(ctx, getReq) + require.NoError(t, err) + require.NotNil(t, resp.Batch) + + // Should have forced txs (2) + partial batch txs + // Total size should not exceed 150 bytes + totalSize := 0 + for _, tx := range resp.Batch.Transactions { + totalSize += len(tx) + } + assert.LessOrEqual(t, totalSize, 150, "Total batch size should not exceed maxBytes") + + // First 2 txs should be forced inclusion txs + assert.GreaterOrEqual(t, len(resp.Batch.Transactions), 2, "Should have at least forced inclusion txs") + assert.Equal(t, forcedTx1, resp.Batch.Transactions[0]) + assert.Equal(t, forcedTx2, resp.Batch.Transactions[1]) + + mockFI.AssertExpectations(t) +} + +func TestSequencer_GetNextBatch_ForcedInclusion_ExceedsMaxBytes(t *testing.T) { + ctx := context.Background() + logger := zerolog.New(zerolog.NewConsoleWriter()) + + db := ds.NewMapDatastore() + + // Create forced inclusion txs where combined they exceed maxBytes + mockFI := &MockForcedInclusionRetriever{} + forcedTx1 := make([]byte, 100) + forcedTx2 := make([]byte, 80) // This would be deferred + mockFI.On("RetrieveForcedIncludedTxs", mock.Anything, uint64(100)).Return(&block.ForcedInclusionEvent{ + Txs: [][]byte{forcedTx1, forcedTx2}, + StartDaHeight: 100, + EndDaHeight: 100, + }, nil).Once() + + // Second call should process pending tx + mockFI.On("RetrieveForcedIncludedTxs", mock.Anything, uint64(100)).Return(&block.ForcedInclusionEvent{ + Txs: [][]byte{}, + StartDaHeight: 100, + EndDaHeight: 100, + }, nil).Once() + + gen := genesis.Genesis{ + ChainID: "test-chain", + DAStartHeight: 100, + } + + seq, err := NewSequencer( + ctx, + logger, + db, + nil, + []byte("test-chain"), + 1*time.Second, + nil, + true, + 100, + mockFI, + gen, + ) + require.NoError(t, err) + + // Request batch with maxBytes = 120 + getReq := coresequencer.GetNextBatchRequest{ + Id: []byte("test-chain"), + MaxBytes: 120, + LastBatchData: nil, + } + + // First call - should get only first forced tx (100 bytes) + resp, err := seq.GetNextBatch(ctx, getReq) + require.NoError(t, err) + require.NotNil(t, resp.Batch) + assert.Equal(t, 1, len(resp.Batch.Transactions), "Should only include first forced tx") + assert.Equal(t, 100, len(resp.Batch.Transactions[0])) + + // Verify pending tx is stored + assert.Equal(t, 1, len(seq.pendingForcedInclusionTxs), "Second tx should be pending") + + // Second call - should get the pending forced tx + resp2, err := seq.GetNextBatch(ctx, getReq) + require.NoError(t, err) + require.NotNil(t, resp2.Batch) + assert.Equal(t, 1, len(resp2.Batch.Transactions), "Should include pending forced tx") + assert.Equal(t, 80, len(resp2.Batch.Transactions[0])) + + // Pending queue should now be empty + assert.Equal(t, 0, len(seq.pendingForcedInclusionTxs), "Pending queue should be empty") + + mockFI.AssertExpectations(t) +} + +func TestSequencer_GetNextBatch_AlwaysCheckPendingForcedInclusion(t *testing.T) { + ctx := context.Background() + logger := zerolog.New(zerolog.NewConsoleWriter()) + + db := ds.NewMapDatastore() + + mockFI := &MockForcedInclusionRetriever{} + + // First call returns a large forced tx that gets deferred + largeForcedTx := make([]byte, 150) + mockFI.On("RetrieveForcedIncludedTxs", mock.Anything, uint64(100)).Return(&block.ForcedInclusionEvent{ + Txs: [][]byte{largeForcedTx}, + StartDaHeight: 100, + EndDaHeight: 100, + }, nil).Once() + + // Second call returns no new forced txs, but pending should still be processed + mockFI.On("RetrieveForcedIncludedTxs", mock.Anything, uint64(100)).Return(&block.ForcedInclusionEvent{ + Txs: [][]byte{}, + StartDaHeight: 100, + EndDaHeight: 100, + }, nil).Once() + + gen := genesis.Genesis{ + ChainID: "test-chain", + DAStartHeight: 100, + } + + seq, err := NewSequencer( + ctx, + logger, + db, + nil, + []byte("test-chain"), + 1*time.Second, + nil, + true, + 100, + mockFI, + gen, + ) + require.NoError(t, err) + + // Submit a batch tx + batchTx := make([]byte, 50) + submitReq := coresequencer.SubmitBatchTxsRequest{ + Id: []byte("test-chain"), + Batch: &coresequencer.Batch{ + Transactions: [][]byte{batchTx}, + }, + } + _, err = seq.SubmitBatchTxs(ctx, submitReq) + require.NoError(t, err) + + // First call with maxBytes = 100 + // Large forced tx (150 bytes) won't fit, gets deferred + // Batch tx (50 bytes) should be returned + getReq := coresequencer.GetNextBatchRequest{ + Id: []byte("test-chain"), + MaxBytes: 100, + LastBatchData: nil, + } + + resp, err := seq.GetNextBatch(ctx, getReq) + require.NoError(t, err) + require.NotNil(t, resp.Batch) + assert.Equal(t, 1, len(resp.Batch.Transactions), "Should have batch tx only") + assert.Equal(t, 50, len(resp.Batch.Transactions[0])) + + // Verify pending forced tx is stored + assert.Equal(t, 1, len(seq.pendingForcedInclusionTxs), "Large forced tx should be pending") + + // Second call with larger maxBytes = 200 + // Should process pending forced tx first + getReq2 := coresequencer.GetNextBatchRequest{ + Id: []byte("test-chain"), + MaxBytes: 200, + LastBatchData: nil, + } + + resp2, err := seq.GetNextBatch(ctx, getReq2) + require.NoError(t, err) + require.NotNil(t, resp2.Batch) + assert.Equal(t, 1, len(resp2.Batch.Transactions), "Should include pending forced tx") + assert.Equal(t, 150, len(resp2.Batch.Transactions[0])) + + // Pending queue should now be empty + assert.Equal(t, 0, len(seq.pendingForcedInclusionTxs), "Pending queue should be empty") + + mockFI.AssertExpectations(t) +} + // TestSequencer_RecordMetrics tests the RecordMetrics method to ensure it properly updates metrics. func TestSequencer_RecordMetrics(t *testing.T) { t.Run("With Metrics", func(t *testing.T) {