From b72b7bd5d954120cf16c3dfb94d565bade96bb4b Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Wed, 15 Jul 2026 18:03:03 +0200 Subject: [PATCH 1/5] support p2p-only followers without DA --- apps/evm/cmd/init.go | 3 ++ apps/evm/cmd/run.go | 20 ++++++++---- apps/testapp/cmd/init.go | 3 ++ apps/testapp/cmd/run.go | 6 +++- block/components.go | 37 +++++++++++---------- block/components_test.go | 33 +++++++++++++++++++ block/internal/syncing/syncer.go | 50 ++++++++++++++++------------- docs/learn/config.md | 4 +-- docs/learn/specs/da.md | 2 +- docs/reference/specs/da.md | 2 +- pkg/cmd/run_node.go | 15 ++++++--- pkg/config/config.go | 19 +++++++++++ pkg/config/config_test.go | 55 +++++++++++++++++++++++++++++++- pkg/config/defaults.go | 5 ++- 14 files changed, 196 insertions(+), 58 deletions(-) diff --git a/apps/evm/cmd/init.go b/apps/evm/cmd/init.go index 5c8adbd75c..336b364cf4 100644 --- a/apps/evm/cmd/init.go +++ b/apps/evm/cmd/init.go @@ -34,6 +34,9 @@ func InitCmd() *cobra.Command { // we use load in order to parse all the flags cfg, _ := rollconf.Load(cmd) cfg.Node.Aggregator = aggregator + if cfg.Node.Aggregator && cfg.DA.Address == "" { + cfg.DA.Address = cfg.EffectiveDAAddress() + } if err := cfg.Validate(); err != nil { return fmt.Errorf("error validating config: %w", err) } diff --git a/apps/evm/cmd/run.go b/apps/evm/cmd/run.go index a88e548d21..25231f8d1f 100644 --- a/apps/evm/cmd/run.go +++ b/apps/evm/cmd/run.go @@ -59,13 +59,17 @@ var RunCmd = &cobra.Command{ return err } - blobClient, err := blobrpc.NewWSClient(cmd.Context(), logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, "") - if err != nil { - return fmt.Errorf("failed to create blob client: %w", err) + var daClient block.FullDAClient + if nodeConfig.DAEnabled() { + blobClient, err := blobrpc.NewWSClient(cmd.Context(), logger, nodeConfig.EffectiveDAAddress(), nodeConfig.DA.AuthToken, "") + if err != nil { + return fmt.Errorf("failed to create blob client: %w", err) + } + defer blobClient.Close() + daClient = block.NewDAClient(blobClient, nodeConfig, logger) + } else { + logger.Info().Msg("DA address is not configured; syncing through P2P only") } - defer blobClient.Close() - - daClient := block.NewDAClient(blobClient, nodeConfig, logger) headerNamespace := da.NamespaceFromString(nodeConfig.DA.GetNamespace()) dataNamespace := da.NamespaceFromString(nodeConfig.DA.GetDataNamespace()) @@ -100,6 +104,10 @@ var RunCmd = &cobra.Command{ } if forceInclusionAddr != "" { + if daClient == nil { + return fmt.Errorf("force inclusion server requires a DA address") + } + ethURL, err := cmd.Flags().GetString(evm.FlagEvmEthURL) if err != nil { return fmt.Errorf("failed to get '%s' flag: %w", evm.FlagEvmEthURL, err) diff --git a/apps/testapp/cmd/init.go b/apps/testapp/cmd/init.go index 625948b7a1..3cd8a3e51d 100644 --- a/apps/testapp/cmd/init.go +++ b/apps/testapp/cmd/init.go @@ -35,6 +35,9 @@ func InitCmd() *cobra.Command { // we use load in order to parse all the flags cfg, _ := rollconf.Load(cmd) cfg.Node.Aggregator = aggregator + if cfg.Node.Aggregator && cfg.DA.Address == "" { + cfg.DA.Address = cfg.EffectiveDAAddress() + } cfg.Node.BlockTime = rollconf.DurationWrapper{Duration: 100 * time.Millisecond} if err := cfg.Validate(); err != nil { return fmt.Errorf("error validating config: %w", err) diff --git a/apps/testapp/cmd/run.go b/apps/testapp/cmd/run.go index 75e5a49019..b484c3ddc2 100644 --- a/apps/testapp/cmd/run.go +++ b/apps/testapp/cmd/run.go @@ -113,6 +113,10 @@ func createSequencer( genesis genesis.Genesis, executor execution.Executor, ) (coresequencer.Sequencer, error) { + if !nodeConfig.Node.Aggregator { + return nil, nil + } + if enabled, _ := cmd.Flags().GetBool(flagSoloSequencer); enabled { if nodeConfig.Node.BasedSequencer { return nil, fmt.Errorf("solo sequencer cannot be used with based") @@ -121,7 +125,7 @@ func createSequencer( return solo.NewSoloSequencer(logger, []byte(genesis.ChainID), executor), nil } - blobClient, err := blobrpc.NewWSClient(ctx, logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, "") + blobClient, err := blobrpc.NewWSClient(ctx, logger, nodeConfig.EffectiveDAAddress(), nodeConfig.DA.AuthToken, "") if err != nil { return nil, fmt.Errorf("failed to create blob client: %w", err) } diff --git a/block/components.go b/block/components.go index 94085031c5..5a7f4a7493 100644 --- a/block/components.go +++ b/block/components.go @@ -184,24 +184,27 @@ func NewSyncComponents( } pruner := pruner.New(logger, store, execPruner, config.Pruning, config.Node.BlockTime.Duration, config.DA.Address) - // Create submitter for sync nodes (no signer, only DA inclusion processing) - var daSubmitter submitting.DASubmitterAPI = submitting.NewDASubmitter(daClient, config, genesis, blockOpts, metrics, logger, headerDAHintAppender, dataDAHintAppender) - if config.Instrumentation.IsTracingEnabled() { - daSubmitter = submitting.WithTracingDASubmitter(daSubmitter) + var submitter *submitting.Submitter + if daClient != nil { + // Create submitter for DA-enabled sync nodes (no signer, only DA inclusion processing). + var daSubmitter submitting.DASubmitterAPI = submitting.NewDASubmitter(daClient, config, genesis, blockOpts, metrics, logger, headerDAHintAppender, dataDAHintAppender) + if config.Instrumentation.IsTracingEnabled() { + daSubmitter = submitting.WithTracingDASubmitter(daSubmitter) + } + submitter = submitting.NewSubmitter( + store, + exec, + cacheManager, + metrics, + config, + genesis, + daSubmitter, + nil, // No sequencer for sync nodes + nil, // No signer for sync nodes + logger, + errorCh, + ) } - submitter := submitting.NewSubmitter( - store, - exec, - cacheManager, - metrics, - config, - genesis, - daSubmitter, - nil, // No sequencer for sync nodes - nil, // No signer for sync nodes - logger, - errorCh, - ) return &Components{ Syncer: syncer, diff --git a/block/components_test.go b/block/components_test.go index 527b7061cf..310f383f56 100644 --- a/block/components_test.go +++ b/block/components_test.go @@ -132,6 +132,39 @@ func TestNewSyncComponents_Creation(t *testing.T) { assert.Nil(t, components.Executor) // Sync nodes don't have executors } +func TestNewSyncComponents_WithoutDA(t *testing.T) { + ds := sync.MutexWrap(datastore.NewMapDatastore()) + memStore := store.New(ds) + + cfg := config.DefaultConfig() + gen := genesis.Genesis{ + ChainID: "test-chain", + InitialHeight: 1, + StartTime: time.Now(), + ProposerAddress: []byte("test-proposer"), + } + + components, err := NewSyncComponents( + cfg, + gen, + memStore, + testmocks.NewMockExecutor(t), + nil, + extmocks.NewMockStore[*types.P2PSignedHeader](t), + extmocks.NewMockStore[*types.P2PData](t), + noopDAHintAppender{}, + noopDAHintAppender{}, + zerolog.Nop(), + NopMetrics(), + DefaultBlockOptions(), + nil, + ) + + require.NoError(t, err) + assert.NotNil(t, components.Syncer) + assert.Nil(t, components.Submitter) +} + func TestNewAggregatorComponents_Creation(t *testing.T) { ds := sync.MutexWrap(datastore.NewMapDatastore()) memStore := store.New(ds) diff --git a/block/internal/syncing/syncer.go b/block/internal/syncing/syncer.go index aec5f2fdaa..0ab241a1b4 100644 --- a/block/internal/syncing/syncer.go +++ b/block/internal/syncing/syncer.go @@ -181,16 +181,18 @@ func (s *Syncer) Start(ctx context.Context) (err error) { return fmt.Errorf("failed to initialize syncer state: %w", err) } - // Initialize handlers - daRetriever := NewDARetriever(s.daClient, s.cache, s.genesis, s.logger) - daRetriever.setExpectedProposerProvider(s.expectedProposerForHeight) - s.daRetriever = daRetriever - if s.config.Instrumentation.IsTracingEnabled() { - s.daRetriever = WithTracingDARetriever(s.daRetriever) - } + if s.daClient != nil { + // Initialize DA-specific handlers only when DA is configured. + daRetriever := NewDARetriever(s.daClient, s.cache, s.genesis, s.logger) + daRetriever.setExpectedProposerProvider(s.expectedProposerForHeight) + s.daRetriever = daRetriever + if s.config.Instrumentation.IsTracingEnabled() { + s.daRetriever = WithTracingDARetriever(s.daRetriever) + } - s.fiRetriever = da.NewForcedInclusionRetriever(s.daClient, s.logger, s.config.DA.BlockTime.Duration, s.config.Instrumentation.IsTracingEnabled(), s.genesis.DAStartHeight, s.genesis.DAEpochForcedInclusion) - s.fiRetriever.Start(ctx) + s.fiRetriever = da.NewForcedInclusionRetriever(s.daClient, s.logger, s.config.DA.BlockTime.Duration, s.config.Instrumentation.IsTracingEnabled(), s.genesis.DAStartHeight, s.genesis.DAEpochForcedInclusion) + s.fiRetriever.Start(ctx) + } s.p2pHandler = NewP2PHandler(s.headerStore, s.dataStore, s.cache, s.genesis, s.logger) currentHeight, initErr := s.store.Height(ctx) @@ -213,19 +215,21 @@ func (s *Syncer) Start(ctx context.Context) (err error) { // Start main processing loop s.wg.Go(func() { s.processLoop(ctx) }) - // Start the DA follower (subscribe + catchup) and other workers - s.daFollower = NewDAFollower(DAFollowerConfig{ - Client: s.daClient, - Retriever: s.daRetriever, - Logger: s.logger, - EventSink: s, - Namespace: s.daClient.GetHeaderNamespace(), - DataNamespace: s.daClient.GetDataNamespace(), - StartDAHeight: s.daRetrieverHeight.Load(), - DABlockTime: s.config.DA.BlockTime.Duration, - }) - if err = s.daFollower.Start(ctx); err != nil { - return fmt.Errorf("failed to start DA follower: %w", err) + if s.daClient != nil { + // Start the DA follower (subscribe + catchup) when DA is configured. + s.daFollower = NewDAFollower(DAFollowerConfig{ + Client: s.daClient, + Retriever: s.daRetriever, + Logger: s.logger, + EventSink: s, + Namespace: s.daClient.GetHeaderNamespace(), + DataNamespace: s.daClient.GetDataNamespace(), + StartDAHeight: s.daRetrieverHeight.Load(), + DABlockTime: s.config.DA.BlockTime.Duration, + }) + if err = s.daFollower.Start(ctx); err != nil { + return fmt.Errorf("failed to start DA follower: %w", err) + } } s.startSyncWorkers(ctx) @@ -602,7 +606,7 @@ func (s *Syncer) processHeightEvent(ctx context.Context, event *common.DAHeightE // If this is a P2P event with a DA height hint, trigger targeted DA retrieval // This allows us to fetch the block directly from the specified DA height instead of sequential scanning - if event.Source == common.SourceP2P { + if event.Source == common.SourceP2P && s.daFollower != nil { var daHeightHints []uint64 switch { case event.DaHeightHints == [2]uint64{0, 0}: diff --git a/docs/learn/config.md b/docs/learn/config.md index f700c44482..3e1e592f87 100644 --- a/docs/learn/config.md +++ b/docs/learn/config.md @@ -472,7 +472,7 @@ da: ### DA Service Address **Description:** -The network address (host:port) of the Data Availability layer service. Evolve connects to this endpoint to submit and retrieve block data. +The network address (host:port) of the Data Availability layer service. A full follower with this value unset syncs exclusively through P2P and does not create a DA client. Aggregators retain the legacy `http://localhost:7980` fallback when this value is unset. **YAML:** @@ -484,7 +484,7 @@ da: **Command-line Flag:** `--evnode.da.address ` _Example:_ `--evnode.da.address 192.168.1.100:26659` -_Default:_ `"http://localhost:7980"` +_Default:_ `""` (empty; P2P-only follower) _Constant:_ `FlagDAAddress` ### DA Authentication Token diff --git a/docs/learn/specs/da.md b/docs/learn/specs/da.md index 481a433852..0d7aff34e2 100644 --- a/docs/learn/specs/da.md +++ b/docs/learn/specs/da.md @@ -6,7 +6,7 @@ Evolve provides a generic [data availability interface][da-interface] for modula `Client` can connect via JSON-RPC transports using Evolve's [jsonrpc][jsonrpc] implementations. The connection can be configured using the following cli flags: -* `--rollkit.da.address`: url address of the DA service (default: "grpc://localhost:26650") +* `--rollkit.da.address`: URL address of the DA service (default: empty for P2P-only followers; aggregators fall back to `http://localhost:7980`) * `--rollkit.da.auth_token`: authentication token of the DA service * `--rollkit.da.namespace`: namespace to use when submitting blobs to the DA service (deprecated) * `--rollkit.da.header_namespace`: namespace to use when submitting headers to the DA service diff --git a/docs/reference/specs/da.md b/docs/reference/specs/da.md index 481a433852..0d7aff34e2 100644 --- a/docs/reference/specs/da.md +++ b/docs/reference/specs/da.md @@ -6,7 +6,7 @@ Evolve provides a generic [data availability interface][da-interface] for modula `Client` can connect via JSON-RPC transports using Evolve's [jsonrpc][jsonrpc] implementations. The connection can be configured using the following cli flags: -* `--rollkit.da.address`: url address of the DA service (default: "grpc://localhost:26650") +* `--rollkit.da.address`: URL address of the DA service (default: empty for P2P-only followers; aggregators fall back to `http://localhost:7980`) * `--rollkit.da.auth_token`: authentication token of the DA service * `--rollkit.da.namespace`: namespace to use when submitting blobs to the DA service (deprecated) * `--rollkit.da.header_namespace`: namespace to use when submitting headers to the DA service diff --git a/pkg/cmd/run_node.go b/pkg/cmd/run_node.go index 89bd43fa9c..490b3dc530 100644 --- a/pkg/cmd/run_node.go +++ b/pkg/cmd/run_node.go @@ -150,12 +150,17 @@ func StartNode( } } - blobClient, err := blobrpc.NewWSClient(ctx, logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, "") - if err != nil { - return fmt.Errorf("failed to create blob client: %w", err) + var daClient block.FullDAClient + if nodeConfig.DAEnabled() { + blobClient, err := blobrpc.NewWSClient(ctx, logger, nodeConfig.EffectiveDAAddress(), nodeConfig.DA.AuthToken, "") + if err != nil { + return fmt.Errorf("failed to create blob client: %w", err) + } + defer blobClient.Close() + daClient = block.NewDAClient(blobClient, nodeConfig, logger) + } else { + logger.Info().Msg("DA address is not configured; syncing through P2P only") } - defer blobClient.Close() - daClient := block.NewDAClient(blobClient, nodeConfig, logger) // sanity check for based sequencer if nodeConfig.Node.BasedSequencer && genesis.DAStartHeight == 0 { diff --git a/pkg/config/config.go b/pkg/config/config.go index 423543466c..609a2035dd 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -270,6 +270,25 @@ type DAConfig struct { BatchMinItems uint64 `mapstructure:"batch_min_items" yaml:"batch_min_items" comment:"Minimum number of items (headers or data) to accumulate before considering submission. Helps avoid submitting single items when more are expected soon. Default: 1."` } +// DAEnabled reports whether this node should initialize a DA client. Full +// followers without a DA address sync exclusively through P2P. Aggregators +// retain the legacy local DA fallback for backwards compatibility. +func (c Config) DAEnabled() bool { + return !c.Node.Light && (c.Node.Aggregator || c.DA.Address != "") +} + +// EffectiveDAAddress returns the configured DA address, or the legacy local +// endpoint for aggregators that do not explicitly configure one. +func (c Config) EffectiveDAAddress() string { + if c.DA.Address != "" { + return c.DA.Address + } + if c.Node.Aggregator { + return DefaultAggregatorDAAddress + } + return "" +} + // GetNamespace returns the namespace for header submissions. func (d *DAConfig) GetNamespace() string { return d.Namespace diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index eb473f4294..da52f9043c 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -24,7 +24,8 @@ func TestDefaultConfig(t *testing.T) { assert.Equal(t, false, def.Node.Aggregator) assert.Equal(t, false, def.Node.Promotable) assert.Equal(t, false, def.Node.Light) - assert.Equal(t, DefaultConfig().DA.Address, def.DA.Address) + assert.Empty(t, def.DA.Address) + assert.False(t, def.DAEnabled()) assert.Equal(t, "", def.DA.AuthToken) assert.Equal(t, "", def.DA.SubmitOptions) assert.NotEmpty(t, def.DA.Namespace) @@ -42,6 +43,58 @@ func TestDefaultConfig(t *testing.T) { assert.NoError(t, def.Validate()) } +func TestDAEnabled(t *testing.T) { + tests := []struct { + name string + configure func(*Config) + expectedEnabled bool + expectedAddress string + }{ + { + name: "follower without DA address", + expectedEnabled: false, + expectedAddress: "", + }, + { + name: "follower with DA address", + configure: func(cfg *Config) { + cfg.DA.Address = "http://da.example:7980" + }, + expectedEnabled: true, + expectedAddress: "http://da.example:7980", + }, + { + name: "aggregator without DA address uses legacy default", + configure: func(cfg *Config) { + cfg.Node.Aggregator = true + }, + expectedEnabled: true, + expectedAddress: DefaultAggregatorDAAddress, + }, + { + name: "light node does not initialize DA", + configure: func(cfg *Config) { + cfg.Node.Light = true + cfg.DA.Address = "http://da.example:7980" + }, + expectedEnabled: false, + expectedAddress: "http://da.example:7980", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := DefaultConfig() + if tt.configure != nil { + tt.configure(&cfg) + } + + assert.Equal(t, tt.expectedEnabled, cfg.DAEnabled()) + assert.Equal(t, tt.expectedAddress, cfg.EffectiveDAAddress()) + }) + } +} + func TestAddFlags(t *testing.T) { // AddFlags and the assertions below each call DefaultConfig(), whose DA.Namespace // is randString-seeded by time.Now().Unix(); run in a synctest bubble so both calls observe the same fake clock. diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 150335c0bb..b25a2122be 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -16,6 +16,9 @@ const ( ConfigName = ConfigFileName + "." + ConfigExtension // AppConfigDir is the directory name for the app configuration. AppConfigDir = "config" + // DefaultAggregatorDAAddress preserves the legacy local DA endpoint for aggregators + // that do not explicitly configure one. + DefaultAggregatorDAAddress = "http://localhost:7980" ) // DefaultRootDir returns the default root directory for evolve @@ -74,7 +77,7 @@ func DefaultConfig() Config { CatchupTimeout: DurationWrapper{0}, }, DA: DAConfig{ - Address: "http://localhost:7980", + Address: "", BlockTime: DurationWrapper{6 * time.Second}, MaxSubmitAttempts: 30, RequestTimeout: DurationWrapper{60 * time.Second}, From b624b73ed156a4840a027041650008e6f8b874ce Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Thu, 16 Jul 2026 09:20:56 +0200 Subject: [PATCH 2/5] require DA endpoint for aggregators --- apps/evm/cmd/init.go | 3 --- apps/evm/cmd/run.go | 2 +- apps/testapp/cmd/init.go | 3 --- apps/testapp/cmd/init_test.go | 2 +- apps/testapp/cmd/run.go | 2 +- docs/learn/config.md | 2 +- docs/learn/specs/da.md | 2 +- docs/reference/specs/da.md | 2 +- pkg/cmd/run_node.go | 2 +- pkg/cmd/run_node_test.go | 3 ++- pkg/config/config.go | 21 ++++++--------------- pkg/config/config_test.go | 23 +++++++++++++++++++---- pkg/config/defaults.go | 3 --- 13 files changed, 34 insertions(+), 36 deletions(-) diff --git a/apps/evm/cmd/init.go b/apps/evm/cmd/init.go index 336b364cf4..5c8adbd75c 100644 --- a/apps/evm/cmd/init.go +++ b/apps/evm/cmd/init.go @@ -34,9 +34,6 @@ func InitCmd() *cobra.Command { // we use load in order to parse all the flags cfg, _ := rollconf.Load(cmd) cfg.Node.Aggregator = aggregator - if cfg.Node.Aggregator && cfg.DA.Address == "" { - cfg.DA.Address = cfg.EffectiveDAAddress() - } if err := cfg.Validate(); err != nil { return fmt.Errorf("error validating config: %w", err) } diff --git a/apps/evm/cmd/run.go b/apps/evm/cmd/run.go index 25231f8d1f..211f684602 100644 --- a/apps/evm/cmd/run.go +++ b/apps/evm/cmd/run.go @@ -61,7 +61,7 @@ var RunCmd = &cobra.Command{ var daClient block.FullDAClient if nodeConfig.DAEnabled() { - blobClient, err := blobrpc.NewWSClient(cmd.Context(), logger, nodeConfig.EffectiveDAAddress(), nodeConfig.DA.AuthToken, "") + blobClient, err := blobrpc.NewWSClient(cmd.Context(), logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, "") if err != nil { return fmt.Errorf("failed to create blob client: %w", err) } diff --git a/apps/testapp/cmd/init.go b/apps/testapp/cmd/init.go index 3cd8a3e51d..625948b7a1 100644 --- a/apps/testapp/cmd/init.go +++ b/apps/testapp/cmd/init.go @@ -35,9 +35,6 @@ func InitCmd() *cobra.Command { // we use load in order to parse all the flags cfg, _ := rollconf.Load(cmd) cfg.Node.Aggregator = aggregator - if cfg.Node.Aggregator && cfg.DA.Address == "" { - cfg.DA.Address = cfg.EffectiveDAAddress() - } cfg.Node.BlockTime = rollconf.DurationWrapper{Duration: 100 * time.Millisecond} if err := cfg.Validate(); err != nil { return fmt.Errorf("error validating config: %w", err) diff --git a/apps/testapp/cmd/init_test.go b/apps/testapp/cmd/init_test.go index b5be23d60d..97b95baf06 100644 --- a/apps/testapp/cmd/init_test.go +++ b/apps/testapp/cmd/init_test.go @@ -47,7 +47,7 @@ func TestInitCommand(t *testing.T) { require.NoError(t, os.WriteFile(passphraseFile, []byte("test"), 0600)) // Set home flag to the test directory - cmd.SetArgs([]string{"init", "--home", dir, "--evnode.node.aggregator", "--evnode.signer.passphrase_file", passphraseFile}) + cmd.SetArgs([]string{"init", "--home", dir, "--evnode.node.aggregator", "--evnode.da.address", "http://localhost:7980", "--evnode.signer.passphrase_file", passphraseFile}) // Execute the command err = cmd.Execute() diff --git a/apps/testapp/cmd/run.go b/apps/testapp/cmd/run.go index b484c3ddc2..c6a65eb169 100644 --- a/apps/testapp/cmd/run.go +++ b/apps/testapp/cmd/run.go @@ -125,7 +125,7 @@ func createSequencer( return solo.NewSoloSequencer(logger, []byte(genesis.ChainID), executor), nil } - blobClient, err := blobrpc.NewWSClient(ctx, logger, nodeConfig.EffectiveDAAddress(), nodeConfig.DA.AuthToken, "") + blobClient, err := blobrpc.NewWSClient(ctx, logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, "") if err != nil { return nil, fmt.Errorf("failed to create blob client: %w", err) } diff --git a/docs/learn/config.md b/docs/learn/config.md index 3e1e592f87..3d043a4381 100644 --- a/docs/learn/config.md +++ b/docs/learn/config.md @@ -472,7 +472,7 @@ da: ### DA Service Address **Description:** -The network address (host:port) of the Data Availability layer service. A full follower with this value unset syncs exclusively through P2P and does not create a DA client. Aggregators retain the legacy `http://localhost:7980` fallback when this value is unset. +The network address (host:port) of the Data Availability layer service. A full follower with this value unset syncs exclusively through P2P and does not create a DA client. Aggregators must configure this value. **YAML:** diff --git a/docs/learn/specs/da.md b/docs/learn/specs/da.md index 0d7aff34e2..6564847676 100644 --- a/docs/learn/specs/da.md +++ b/docs/learn/specs/da.md @@ -6,7 +6,7 @@ Evolve provides a generic [data availability interface][da-interface] for modula `Client` can connect via JSON-RPC transports using Evolve's [jsonrpc][jsonrpc] implementations. The connection can be configured using the following cli flags: -* `--rollkit.da.address`: URL address of the DA service (default: empty for P2P-only followers; aggregators fall back to `http://localhost:7980`) +* `--rollkit.da.address`: URL address of the DA service (default: empty for P2P-only followers; required for aggregators) * `--rollkit.da.auth_token`: authentication token of the DA service * `--rollkit.da.namespace`: namespace to use when submitting blobs to the DA service (deprecated) * `--rollkit.da.header_namespace`: namespace to use when submitting headers to the DA service diff --git a/docs/reference/specs/da.md b/docs/reference/specs/da.md index 0d7aff34e2..6564847676 100644 --- a/docs/reference/specs/da.md +++ b/docs/reference/specs/da.md @@ -6,7 +6,7 @@ Evolve provides a generic [data availability interface][da-interface] for modula `Client` can connect via JSON-RPC transports using Evolve's [jsonrpc][jsonrpc] implementations. The connection can be configured using the following cli flags: -* `--rollkit.da.address`: URL address of the DA service (default: empty for P2P-only followers; aggregators fall back to `http://localhost:7980`) +* `--rollkit.da.address`: URL address of the DA service (default: empty for P2P-only followers; required for aggregators) * `--rollkit.da.auth_token`: authentication token of the DA service * `--rollkit.da.namespace`: namespace to use when submitting blobs to the DA service (deprecated) * `--rollkit.da.header_namespace`: namespace to use when submitting headers to the DA service diff --git a/pkg/cmd/run_node.go b/pkg/cmd/run_node.go index 490b3dc530..59955a5729 100644 --- a/pkg/cmd/run_node.go +++ b/pkg/cmd/run_node.go @@ -152,7 +152,7 @@ func StartNode( var daClient block.FullDAClient if nodeConfig.DAEnabled() { - blobClient, err := blobrpc.NewWSClient(ctx, logger, nodeConfig.EffectiveDAAddress(), nodeConfig.DA.AuthToken, "") + blobClient, err := blobrpc.NewWSClient(ctx, logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, "") if err != nil { return fmt.Errorf("failed to create blob client: %w", err) } diff --git a/pkg/cmd/run_node_test.go b/pkg/cmd/run_node_test.go index 49e06447d5..cc6b87b622 100644 --- a/pkg/cmd/run_node_test.go +++ b/pkg/cmd/run_node_test.go @@ -147,6 +147,7 @@ func TestAggregatorFlagInvariants(t *testing.T) { validValues := []bool{false, true, true} for i, flags := range flagVariants { + flags = append(flags, "--rollkit.da.address", "http://da.example:7980") args := append([]string{"start"}, flags...) executor, sequencer, keyProvider, nodeKey, ds, stopDAHeightTicker := createTestComponents(context.Background(), t) @@ -235,7 +236,7 @@ func TestDefaultAggregatorValue(t *testing.T) { // Create a new command without specifying any flags var args []string if tc.expected { - args = []string{"start", "--rollkit.node.aggregator"} + args = []string{"start", "--rollkit.node.aggregator", "--rollkit.da.address", "http://da.example:7980"} } else { args = []string{"start", "--rollkit.node.aggregator=false"} } diff --git a/pkg/config/config.go b/pkg/config/config.go index 609a2035dd..a61f498b19 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -271,22 +271,9 @@ type DAConfig struct { } // DAEnabled reports whether this node should initialize a DA client. Full -// followers without a DA address sync exclusively through P2P. Aggregators -// retain the legacy local DA fallback for backwards compatibility. +// followers without a DA address sync exclusively through P2P. func (c Config) DAEnabled() bool { - return !c.Node.Light && (c.Node.Aggregator || c.DA.Address != "") -} - -// EffectiveDAAddress returns the configured DA address, or the legacy local -// endpoint for aggregators that do not explicitly configure one. -func (c Config) EffectiveDAAddress() string { - if c.DA.Address != "" { - return c.DA.Address - } - if c.Node.Aggregator { - return DefaultAggregatorDAAddress - } - return "" + return !c.Node.Light && strings.TrimSpace(c.DA.Address) != "" } // GetNamespace returns the namespace for header submissions. @@ -524,6 +511,10 @@ func (c *Config) Validate() error { return fmt.Errorf("promotable mode cannot be combined with light node mode") } + if c.Node.Aggregator && strings.TrimSpace(c.DA.Address) == "" { + return fmt.Errorf("DA address is required when aggregator mode is enabled") + } + // Promotable mode and Raft both control leader/follower role switching. // Combining them needs a composed elector that gates Raft leadership on the execution-selected proposer. if c.Node.Promotable && c.Raft.Enable { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index da52f9043c..5f063b3624 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -64,12 +64,12 @@ func TestDAEnabled(t *testing.T) { expectedAddress: "http://da.example:7980", }, { - name: "aggregator without DA address uses legacy default", + name: "aggregator without DA address does not initialize DA", configure: func(cfg *Config) { cfg.Node.Aggregator = true }, - expectedEnabled: true, - expectedAddress: DefaultAggregatorDAAddress, + expectedEnabled: false, + expectedAddress: "", }, { name: "light node does not initialize DA", @@ -90,11 +90,20 @@ func TestDAEnabled(t *testing.T) { } assert.Equal(t, tt.expectedEnabled, cfg.DAEnabled()) - assert.Equal(t, tt.expectedAddress, cfg.EffectiveDAAddress()) + assert.Equal(t, tt.expectedAddress, cfg.DA.Address) }) } } +func TestConfigValidate_AggregatorRequiresDAAddress(t *testing.T) { + cfg := DefaultConfig() + cfg.RootDir = t.TempDir() + cfg.Node.Aggregator = true + + err := cfg.Validate() + require.EqualError(t, err, "DA address is required when aggregator mode is enabled") +} + func TestAddFlags(t *testing.T) { // AddFlags and the assertions below each call DefaultConfig(), whose DA.Namespace // is randString-seeded by time.Now().Unix(); run in a synctest bubble so both calls observe the same fake clock. @@ -605,6 +614,9 @@ func TestBasedSequencerValidation(t *testing.T) { cfg.RootDir = t.TempDir() cfg.Node.Aggregator = tt.aggregator cfg.Node.BasedSequencer = tt.basedSeq + if tt.aggregator { + cfg.DA.Address = "http://da.example:7980" + } err := cfg.Validate() @@ -666,6 +678,9 @@ func TestPromotableValidation(t *testing.T) { cfg.Node.BasedSequencer = tt.basedSeq cfg.Node.Light = tt.light cfg.Raft.Enable = tt.raft + if tt.aggregator { + cfg.DA.Address = "http://da.example:7980" + } err := cfg.Validate() diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index b25a2122be..664bea6869 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -16,9 +16,6 @@ const ( ConfigName = ConfigFileName + "." + ConfigExtension // AppConfigDir is the directory name for the app configuration. AppConfigDir = "config" - // DefaultAggregatorDAAddress preserves the legacy local DA endpoint for aggregators - // that do not explicitly configure one. - DefaultAggregatorDAAddress = "http://localhost:7980" ) // DefaultRootDir returns the default root directory for evolve From febec86ca25b645917e8af54d25c699861314da1 Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Thu, 16 Jul 2026 09:24:49 +0200 Subject: [PATCH 3/5] add DA configuration changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eaabe6416..8cad40d709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Allow full followers without a DA endpoint to sync through P2P only, and reject aggregator startup without an explicit DA endpoint. - Reject unsupported `net-info --output` formats instead of falling back to text output [#3360](https://github.com/evstack/ev-node/pull/3360). ## v1.1.4 From 4de8f43129fccab84e17e082d95f008d760259ca Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Thu, 16 Jul 2026 09:48:29 +0200 Subject: [PATCH 4/5] normalize DA endpoint at startup --- apps/evm/cmd/run.go | 2 +- apps/testapp/cmd/run.go | 2 +- docs/learn/config.md | 2 +- pkg/cmd/run_node.go | 5 ++++- pkg/cmd/run_node_test.go | 14 ++++++++++++++ pkg/config/config.go | 11 ++++++----- pkg/config/config_test.go | 15 +++++++++++---- test/e2e/base_test.go | 5 +++++ 8 files changed, 43 insertions(+), 13 deletions(-) diff --git a/apps/evm/cmd/run.go b/apps/evm/cmd/run.go index 211f684602..8572b31986 100644 --- a/apps/evm/cmd/run.go +++ b/apps/evm/cmd/run.go @@ -61,7 +61,7 @@ var RunCmd = &cobra.Command{ var daClient block.FullDAClient if nodeConfig.DAEnabled() { - blobClient, err := blobrpc.NewWSClient(cmd.Context(), logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, "") + blobClient, err := blobrpc.NewWSClient(cmd.Context(), logger, nodeConfig.GetDAAddress(), nodeConfig.DA.AuthToken, "") if err != nil { return fmt.Errorf("failed to create blob client: %w", err) } diff --git a/apps/testapp/cmd/run.go b/apps/testapp/cmd/run.go index c6a65eb169..10f919ae7e 100644 --- a/apps/testapp/cmd/run.go +++ b/apps/testapp/cmd/run.go @@ -125,7 +125,7 @@ func createSequencer( return solo.NewSoloSequencer(logger, []byte(genesis.ChainID), executor), nil } - blobClient, err := blobrpc.NewWSClient(ctx, logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, "") + blobClient, err := blobrpc.NewWSClient(ctx, logger, nodeConfig.GetDAAddress(), nodeConfig.DA.AuthToken, "") if err != nil { return nil, fmt.Errorf("failed to create blob client: %w", err) } diff --git a/docs/learn/config.md b/docs/learn/config.md index 3d043a4381..b66fbcfcc1 100644 --- a/docs/learn/config.md +++ b/docs/learn/config.md @@ -472,7 +472,7 @@ da: ### DA Service Address **Description:** -The network address (host:port) of the Data Availability layer service. A full follower with this value unset syncs exclusively through P2P and does not create a DA client. Aggregators must configure this value. +The network address (host:port) of the Data Availability layer service. A full follower with this value unset syncs exclusively through P2P and does not create a DA client. Aggregators must configure this value before startup. **YAML:** diff --git a/pkg/cmd/run_node.go b/pkg/cmd/run_node.go index 59955a5729..ed07cba520 100644 --- a/pkg/cmd/run_node.go +++ b/pkg/cmd/run_node.go @@ -38,6 +38,9 @@ func ParseConfig(cmd *cobra.Command) (rollconf.Config, error) { if err := nodeConfig.Validate(); err != nil { return rollconf.Config{}, fmt.Errorf("failed to validate node config: %w", err) } + if nodeConfig.Node.Aggregator && nodeConfig.GetDAAddress() == "" { + return rollconf.Config{}, fmt.Errorf("DA address is required when aggregator mode is enabled") + } return nodeConfig, nil } @@ -152,7 +155,7 @@ func StartNode( var daClient block.FullDAClient if nodeConfig.DAEnabled() { - blobClient, err := blobrpc.NewWSClient(ctx, logger, nodeConfig.DA.Address, nodeConfig.DA.AuthToken, "") + blobClient, err := blobrpc.NewWSClient(ctx, logger, nodeConfig.GetDAAddress(), nodeConfig.DA.AuthToken, "") if err != nil { return fmt.Errorf("failed to create blob client: %w", err) } diff --git a/pkg/cmd/run_node_test.go b/pkg/cmd/run_node_test.go index cc6b87b622..a93e15295e 100644 --- a/pkg/cmd/run_node_test.go +++ b/pkg/cmd/run_node_test.go @@ -174,6 +174,20 @@ func TestAggregatorFlagInvariants(t *testing.T) { } } +func TestParseConfig_AggregatorRequiresDAAddress(t *testing.T) { + executor, sequencer, keyProvider, nodeKey, ds, stopDAHeightTicker := createTestComponents(context.Background(), t) + defer stopDAHeightTicker() + + nodeConfig := rollconf.DefaultConfig() + nodeConfig.RootDir = t.TempDir() + + cmd := newRunNodeCmd(t.Context(), executor, sequencer, keyProvider, nodeKey, ds, nodeConfig) + require.NoError(t, cmd.ParseFlags([]string{"start", "--rollkit.node.aggregator"})) + + _, err := ParseConfig(cmd) + require.EqualError(t, err, "DA address is required when aggregator mode is enabled") +} + func TestPromotableFlagInvariants(t *testing.T) { flagVariants := [][]string{{ "--rollkit.node.promotable=false", diff --git a/pkg/config/config.go b/pkg/config/config.go index a61f498b19..74aab704c3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -273,7 +273,12 @@ type DAConfig struct { // DAEnabled reports whether this node should initialize a DA client. Full // followers without a DA address sync exclusively through P2P. func (c Config) DAEnabled() bool { - return !c.Node.Light && strings.TrimSpace(c.DA.Address) != "" + return !c.Node.Light && c.GetDAAddress() != "" +} + +// GetDAAddress returns the configured DA address without surrounding whitespace. +func (c Config) GetDAAddress() string { + return strings.TrimSpace(c.DA.Address) } // GetNamespace returns the namespace for header submissions. @@ -511,10 +516,6 @@ func (c *Config) Validate() error { return fmt.Errorf("promotable mode cannot be combined with light node mode") } - if c.Node.Aggregator && strings.TrimSpace(c.DA.Address) == "" { - return fmt.Errorf("DA address is required when aggregator mode is enabled") - } - // Promotable mode and Raft both control leader/follower role switching. // Combining them needs a composed elector that gates Raft leadership on the execution-selected proposer. if c.Node.Promotable && c.Raft.Enable { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 5f063b3624..6743fe1603 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -63,6 +63,14 @@ func TestDAEnabled(t *testing.T) { expectedEnabled: true, expectedAddress: "http://da.example:7980", }, + { + name: "follower with whitespace around DA address", + configure: func(cfg *Config) { + cfg.DA.Address = " http://da.example:7980\n" + }, + expectedEnabled: true, + expectedAddress: "http://da.example:7980", + }, { name: "aggregator without DA address does not initialize DA", configure: func(cfg *Config) { @@ -90,18 +98,17 @@ func TestDAEnabled(t *testing.T) { } assert.Equal(t, tt.expectedEnabled, cfg.DAEnabled()) - assert.Equal(t, tt.expectedAddress, cfg.DA.Address) + assert.Equal(t, tt.expectedAddress, cfg.GetDAAddress()) }) } } -func TestConfigValidate_AggregatorRequiresDAAddress(t *testing.T) { +func TestConfigValidate_AggregatorCanBeInitializedWithoutDAAddress(t *testing.T) { cfg := DefaultConfig() cfg.RootDir = t.TempDir() cfg.Node.Aggregator = true - err := cfg.Validate() - require.EqualError(t, err, "DA address is required when aggregator mode is enabled") + require.NoError(t, cfg.Validate()) } func TestAddFlags(t *testing.T) { diff --git a/test/e2e/base_test.go b/test/e2e/base_test.go index 6c73bb02fc..8a35480e34 100644 --- a/test/e2e/base_test.go +++ b/test/e2e/base_test.go @@ -21,6 +21,8 @@ import ( var binaryPath string +const localDAAddress = "http://localhost:7980" + func init() { flag.StringVar(&binaryPath, "binary", "testapp", "testapp binary") } @@ -72,6 +74,7 @@ func TestBasic(t *testing.T) { "--evnode.signer.passphrase_file="+passphraseFile, "--evnode.node.block_time=5ms", "--evnode.da.block_time=15ms", + "--evnode.da.address="+localDAAddress, fmt.Sprintf("--kv-endpoint=127.0.0.1:%d", kvPort), ) @@ -174,6 +177,7 @@ func TestNodeRestartPersistence(t *testing.T) { "--evnode.signer.passphrase_file="+passphraseFile, "--evnode.node.block_time=5ms", "--evnode.da.block_time=15ms", + "--evnode.da.address="+localDAAddress, fmt.Sprintf("--kv-endpoint=127.0.0.1:%d", kvPort), ) sut.AwaitNodeUp(t, "http://127.0.0.1:7331", 2*time.Second) @@ -214,6 +218,7 @@ func TestNodeRestartPersistence(t *testing.T) { "--evnode.signer.passphrase_file="+passphraseFile, "--evnode.node.block_time=5ms", "--evnode.da.block_time=15ms", + "--evnode.da.address="+localDAAddress, fmt.Sprintf("--kv-endpoint=127.0.0.1:%d", kvPort), ) sut.AwaitNodeUp(t, "http://127.0.0.1:7331", 2*time.Second) From 3cf718de75220e1156d5058089c7f2f3780695a5 Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Thu, 16 Jul 2026 11:56:35 +0200 Subject: [PATCH 5/5] scope DA endpoint validation to node startup --- apps/evm/cmd/run.go | 2 +- apps/testapp/cmd/run.go | 2 +- pkg/cmd/run_node.go | 10 ++++++++++ pkg/cmd/run_node_test.go | 5 ++++- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/evm/cmd/run.go b/apps/evm/cmd/run.go index 8572b31986..ccb45b5a02 100644 --- a/apps/evm/cmd/run.go +++ b/apps/evm/cmd/run.go @@ -40,7 +40,7 @@ var RunCmd = &cobra.Command{ Aliases: []string{"node", "run"}, Short: "Run the evolve node with EVM execution client", RunE: func(cmd *cobra.Command, args []string) error { - nodeConfig, err := rollcmd.ParseConfig(cmd) + nodeConfig, err := rollcmd.ParseStartConfig(cmd) if err != nil { return err } diff --git a/apps/testapp/cmd/run.go b/apps/testapp/cmd/run.go index 10f919ae7e..6eaaa33683 100644 --- a/apps/testapp/cmd/run.go +++ b/apps/testapp/cmd/run.go @@ -33,7 +33,7 @@ var RunCmd = &cobra.Command{ Aliases: []string{"node", "run"}, Short: "Run the testapp node", RunE: func(command *cobra.Command, args []string) error { - nodeConfig, err := cmd.ParseConfig(command) + nodeConfig, err := cmd.ParseStartConfig(command) if err != nil { return err } diff --git a/pkg/cmd/run_node.go b/pkg/cmd/run_node.go index ed07cba520..fd98b0ee25 100644 --- a/pkg/cmd/run_node.go +++ b/pkg/cmd/run_node.go @@ -38,6 +38,16 @@ func ParseConfig(cmd *cobra.Command) (rollconf.Config, error) { if err := nodeConfig.Validate(); err != nil { return rollconf.Config{}, fmt.Errorf("failed to validate node config: %w", err) } + + return nodeConfig, nil +} + +// ParseStartConfig loads and validates configuration required to start a node. +func ParseStartConfig(cmd *cobra.Command) (rollconf.Config, error) { + nodeConfig, err := ParseConfig(cmd) + if err != nil { + return rollconf.Config{}, err + } if nodeConfig.Node.Aggregator && nodeConfig.GetDAAddress() == "" { return rollconf.Config{}, fmt.Errorf("DA address is required when aggregator mode is enabled") } diff --git a/pkg/cmd/run_node_test.go b/pkg/cmd/run_node_test.go index a93e15295e..6de41048fe 100644 --- a/pkg/cmd/run_node_test.go +++ b/pkg/cmd/run_node_test.go @@ -174,7 +174,7 @@ func TestAggregatorFlagInvariants(t *testing.T) { } } -func TestParseConfig_AggregatorRequiresDAAddress(t *testing.T) { +func TestParseStartConfig_AggregatorRequiresDAAddress(t *testing.T) { executor, sequencer, keyProvider, nodeKey, ds, stopDAHeightTicker := createTestComponents(context.Background(), t) defer stopDAHeightTicker() @@ -185,6 +185,9 @@ func TestParseConfig_AggregatorRequiresDAAddress(t *testing.T) { require.NoError(t, cmd.ParseFlags([]string{"start", "--rollkit.node.aggregator"})) _, err := ParseConfig(cmd) + require.NoError(t, err) + + _, err = ParseStartConfig(cmd) require.EqualError(t, err, "DA address is required when aggregator mode is enabled") }