From f8a3be287af0388485434f06d610fe46184d0689 Mon Sep 17 00:00:00 2001 From: parithosh Date: Tue, 14 Oct 2025 16:53:37 -0400 Subject: [PATCH 1/3] update eth config checker --- .gitignore | 1 + .../clients/execution/rpc/ethconfig.go | 50 ++++ .../tasks/check_eth_config/config.go | 17 ++ .../tasks/check_eth_config/task.go | 218 ++++++++++++++++++ pkg/coordinator/tasks/tasks.go | 2 + .../ethconfig-test-with-rpc-call.yaml | 22 ++ 6 files changed, 310 insertions(+) create mode 100644 pkg/coordinator/clients/execution/rpc/ethconfig.go create mode 100644 pkg/coordinator/tasks/check_eth_config/config.go create mode 100644 pkg/coordinator/tasks/check_eth_config/task.go create mode 100644 playbooks/fusaka-dev/kurtosis/ethconfig-test-with-rpc-call.yaml diff --git a/.gitignore b/.gitignore index 8bbfc038..71bf3650 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ test-*.yaml .hack/devnet/generated-** .hack/devnet/custom-** +CLAUDE.md diff --git a/pkg/coordinator/clients/execution/rpc/ethconfig.go b/pkg/coordinator/clients/execution/rpc/ethconfig.go new file mode 100644 index 00000000..628fdabc --- /dev/null +++ b/pkg/coordinator/clients/execution/rpc/ethconfig.go @@ -0,0 +1,50 @@ +package rpc + +import ( + "context" + "encoding/json" +) + +// EthConfigResponse represents the response from eth_config RPC call (EIP-7910) +type EthConfigResponse struct { + Current *ForkConfig `json:"current"` + Next *ForkConfig `json:"next"` + Last *ForkConfig `json:"last"` +} + +// ForkConfig represents a fork configuration +type ForkConfig struct { + ActivationTime int64 `json:"activationTime"` + BlobSchedule map[string]interface{} `json:"blobSchedule,omitempty"` + ChainID string `json:"chainId"` + ForkID string `json:"forkId"` + Precompiles map[string]interface{} `json:"precompiles,omitempty"` + SystemContracts map[string]interface{} `json:"systemContracts,omitempty"` +} + +// GetEthConfig queries the eth_config RPC method (EIP-7910) +func (ec *ExecutionClient) GetEthConfig(ctx context.Context) (*EthConfigResponse, error) { + closeFn := ec.enforceConcurrencyLimit(ctx) + if closeFn == nil { + return nil, nil + } + + defer closeFn() + + reqCtx, reqCtxCancel := context.WithTimeout(ctx, ec.requestTimeout) + defer reqCtxCancel() + + var result json.RawMessage + err := ec.rpcClient.CallContext(reqCtx, &result, "eth_config") + + if err != nil { + return nil, err + } + + var config EthConfigResponse + if err := json.Unmarshal(result, &config); err != nil { + return nil, err + } + + return &config, nil +} diff --git a/pkg/coordinator/tasks/check_eth_config/config.go b/pkg/coordinator/tasks/check_eth_config/config.go new file mode 100644 index 00000000..a85e1137 --- /dev/null +++ b/pkg/coordinator/tasks/check_eth_config/config.go @@ -0,0 +1,17 @@ +package checkethconfig + +type Config struct { + ClientPattern string `yaml:"clientPattern" json:"clientPattern"` + ExcludeClientPattern string `yaml:"excludeClientPattern" json:"excludeClientPattern"` + FailOnMismatch bool `yaml:"failOnMismatch" json:"failOnMismatch"` +} + +func DefaultConfig() Config { + return Config{ + FailOnMismatch: true, + } +} + +func (c *Config) Validate() error { + return nil +} diff --git a/pkg/coordinator/tasks/check_eth_config/task.go b/pkg/coordinator/tasks/check_eth_config/task.go new file mode 100644 index 00000000..d0050e9c --- /dev/null +++ b/pkg/coordinator/tasks/check_eth_config/task.go @@ -0,0 +1,218 @@ +package checkethconfig + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/ethpandaops/assertoor/pkg/coordinator/clients/execution" + "github.com/ethpandaops/assertoor/pkg/coordinator/types" + "github.com/sirupsen/logrus" +) + +var ( + TaskName = "check_eth_config" + TaskDescriptor = &types.TaskDescriptor{ + Name: TaskName, + Description: "Checks that all execution clients return the same eth_config (EIP-7910)", + Config: DefaultConfig(), + NewTask: NewTask, + } +) + +type Task struct { + ctx *types.TaskContext + options *types.TaskOptions + config Config + logger logrus.FieldLogger +} + +func NewTask(ctx *types.TaskContext, options *types.TaskOptions) (types.Task, error) { + return &Task{ + ctx: ctx, + options: options, + logger: ctx.Logger.GetLogger(), + }, nil +} + +func (t *Task) Config() interface{} { + return t.config +} + +func (t *Task) Timeout() time.Duration { + return t.options.Timeout.Duration +} + +func (t *Task) LoadConfig() error { + config := DefaultConfig() + + // parse static config + if t.options.Config != nil { + if err := t.options.Config.Unmarshal(&config); err != nil { + return fmt.Errorf("error parsing task config for %v: %w", TaskName, err) + } + } + + // load dynamic vars + err := t.ctx.Vars.ConsumeVars(&config, t.options.ConfigVars) + if err != nil { + return err + } + + // validate config + if err := config.Validate(); err != nil { + return err + } + + t.config = config + + return nil +} + +func (t *Task) Execute(ctx context.Context) error { + // Get the client pool from the scheduler + clientPool := t.ctx.Scheduler.GetServices().ClientPool() + + // Get matching clients from the pool + var clients []*execution.Client + + if t.config.ClientPattern == "" && t.config.ExcludeClientPattern == "" { + clients = clientPool.GetExecutionPool().GetReadyEndpoints(true) + if len(clients) == 0 { + t.logger.Error("check failed: no matching clients found") + t.ctx.SetResult(types.TaskResultFailure) + + return nil + } + } else { + poolClients := clientPool.GetClientsByNamePatterns(t.config.ClientPattern, t.config.ExcludeClientPattern) + if len(poolClients) == 0 { + t.logger.Errorf("check failed: no matching clients found with pattern %v", t.config.ClientPattern) + t.ctx.SetResult(types.TaskResultFailure) + + return nil + } + + clients = make([]*execution.Client, len(poolClients)) + for i, c := range poolClients { + clients[i] = c.ExecutionClient + } + } + + // Query eth_config from all clients + type clientResult struct { + client *execution.Client + configJSON string + err error + } + + results := make([]clientResult, len(clients)) + + for i, client := range clients { + t.logger.Infof("querying eth_config from client %v", client.GetName()) + + ethConfig, err := client.GetRPCClient().GetEthConfig(ctx) + if err != nil { + results[i] = clientResult{ + client: client, + err: err, + } + + t.logger.WithField("client", client.GetName()).Errorf("RPC error when querying eth_config: %v", err) + + if t.config.FailOnMismatch { + t.ctx.SetResult(types.TaskResultFailure) + return nil + } + + continue + } + + // Convert to JSON for comparison + configBytes, err := json.MarshalIndent(ethConfig, "", " ") + if err != nil { + results[i] = clientResult{ + client: client, + err: fmt.Errorf("failed to marshal config: %w", err), + } + + t.logger.WithField("client", client.GetName()).Errorf("error marshaling eth_config: %v", err) + + if t.config.FailOnMismatch { + t.ctx.SetResult(types.TaskResultFailure) + return nil + } + + continue + } + + results[i] = clientResult{ + client: client, + configJSON: string(configBytes), + } + + t.logger.WithField("client", client.GetName()).Debugf("eth_config response:\n%s", string(configBytes)) + t.logger.Infof("client %v returned eth_config successfully", client.GetName()) + } + + // Check for consistency + var referenceConfig string + + mismatchFound := false + configMap := make(map[string][]string) // config JSON -> list of client names + + for _, result := range results { + if result.err != nil { + continue + } + + if referenceConfig == "" { + referenceConfig = result.configJSON + t.ctx.Outputs.SetVar("ethConfig", result.configJSON) + } + + // Track which clients returned which config + configMap[result.configJSON] = append(configMap[result.configJSON], result.client.GetName()) + + if result.configJSON != referenceConfig { + mismatchFound = true + } + } + + if mismatchFound { + // Build diff output + var diffBuilder strings.Builder + + diffBuilder.WriteString("eth_config mismatch detected across clients:\n\n") + + configIndex := 1 + for config, clientNames := range configMap { + diffBuilder.WriteString(fmt.Sprintf("Config variant #%d (clients: %v):\n", configIndex, clientNames)) + diffBuilder.WriteString(config) + diffBuilder.WriteString("\n\n") + + configIndex++ + } + + t.logger.Error(diffBuilder.String()) + + if t.config.FailOnMismatch { + t.ctx.SetResult(types.TaskResultFailure) + } else { + t.ctx.SetResult(types.TaskResultNone) + } + + return nil + } + + // All checks passed + if len(results) > 0 { + t.logger.Infof("all %d clients returned consistent eth_config", len(results)) + t.logger.Debugf("consistent eth_config:\n%s", referenceConfig) + t.ctx.SetResult(types.TaskResultSuccess) + } + + return nil +} diff --git a/pkg/coordinator/tasks/tasks.go b/pkg/coordinator/tasks/tasks.go index 52c7085b..cee96960 100644 --- a/pkg/coordinator/tasks/tasks.go +++ b/pkg/coordinator/tasks/tasks.go @@ -15,6 +15,7 @@ import ( checkconsensussyncstatus "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/check_consensus_sync_status" checkconsensusvalidatorstatus "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/check_consensus_validator_status" checkethcall "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/check_eth_call" + checkethconfig "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/check_eth_config" checkexecutionsyncstatus "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/check_execution_sync_status" generateblobtransactions "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/generate_blob_transactions" generateblschanges "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/generate_bls_changes" @@ -57,6 +58,7 @@ var AvailableTaskDescriptors = []*types.TaskDescriptor{ checkconsensusvalidatorstatus.TaskDescriptor, checkexecutionblock.TaskDescriptor, checkethcall.TaskDescriptor, + checkethconfig.TaskDescriptor, checkexecutionsyncstatus.TaskDescriptor, generateblobtransactions.TaskDescriptor, generateblschanges.TaskDescriptor, diff --git a/playbooks/fusaka-dev/kurtosis/ethconfig-test-with-rpc-call.yaml b/playbooks/fusaka-dev/kurtosis/ethconfig-test-with-rpc-call.yaml new file mode 100644 index 00000000..001c31ee --- /dev/null +++ b/playbooks/fusaka-dev/kurtosis/ethconfig-test-with-rpc-call.yaml @@ -0,0 +1,22 @@ + +id: ethconfig-test +name: "eth_config RPC consistency test (EIP-7910)" +timeout: 1h +config: +#walletPrivkey: "" +tasks: + - name: check_clients_are_healthy + title: "Check if at least one client is ready" + timeout: 5m + config: + minClientCount: 1 + - name: check_consensus_slot_range + title: "Wait for slot >= 10" + timeout: 10m + config: + minSlotNumber: 10 + - name: check_eth_config + title: "Check eth_config consistency across all execution clients" + timeout: 5m + config: + failOnMismatch: true From 339dbbf3848a2178c53ce8d43f94b5dc7e39e2cd Mon Sep 17 00:00:00 2001 From: parithosh Date: Tue, 14 Oct 2025 16:55:50 -0400 Subject: [PATCH 2/3] update README --- .../tasks/check_eth_config/README.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 pkg/coordinator/tasks/check_eth_config/README.md diff --git a/pkg/coordinator/tasks/check_eth_config/README.md b/pkg/coordinator/tasks/check_eth_config/README.md new file mode 100644 index 00000000..db1eade8 --- /dev/null +++ b/pkg/coordinator/tasks/check_eth_config/README.md @@ -0,0 +1,72 @@ +## `check_eth_config` Task + +### Description +The `check_eth_config` task verifies that all execution clients in the network return consistent chain configuration via the `eth_config` JSON-RPC method as defined in EIP-7910. This task is essential for ensuring that all execution layer clients have the same fork configuration, including chain ID, fork IDs, activation times, precompiles, and system contracts. When mismatches are detected, the task provides a detailed diff showing which clients returned which configuration variants. + +### Configuration Parameters + +- **`clientPattern`**: + A regex pattern to select specific execution client endpoints for querying `eth_config`. This allows targeting specific clients within the network. An empty pattern (default) targets all ready execution clients. + +- **`excludeClientPattern`**: + A regex pattern to exclude certain execution clients from the `eth_config` check. This is useful for excluding known misconfigured or test clients from the consistency check. + +- **`failOnMismatch`**: + Determines whether the task should fail if any execution client returns a different `eth_config` response. If set to `true` (default), the task fails on configuration mismatches. If set to `false`, mismatches are logged but the task continues without failure. + +### Outputs + +- **`ethConfig`**: + The reference `eth_config` response from the first successful client query, returned as a JSON string. This output contains the complete fork configuration including current, next, and last fork details with activation times, chain ID, fork ID, blob schedule, precompiles, and system contracts. + +### Defaults + +Default settings for the `check_eth_config` task: + +```yaml +- name: check_eth_config + config: + clientPattern: "" + excludeClientPattern: "" + failOnMismatch: true +``` + +### Example Usage + +Basic usage checking all execution clients: + +```yaml +- name: check_eth_config + title: "Verify eth_config consistency across all EL clients" + config: + failOnMismatch: true +``` + +Checking specific clients only: + +```yaml +- name: check_eth_config + title: "Verify eth_config for Geth clients only" + config: + clientPattern: ".*geth.*" + failOnMismatch: true +``` + +Non-blocking check that logs mismatches but doesn't fail: + +```yaml +- name: check_eth_config + title: "Monitor eth_config consistency" + config: + failOnMismatch: false +``` + +### Implementation Details + +The task queries the `eth_config` RPC method (EIP-7910) from all matching execution clients and performs a JSON-level comparison of the responses. When configurations match, the task succeeds and outputs the reference configuration. When mismatches are detected, the task provides a detailed error log showing: + +- All unique configuration variants encountered +- Which clients returned each variant +- The full JSON structure of each variant for easy comparison + +This makes it easy to diagnose configuration drift or misconfiguration across the execution layer. From 1db5d4fa7e87cea9488869a414d96eff5bc5d13f Mon Sep 17 00:00:00 2001 From: parithosh Date: Tue, 14 Oct 2025 17:28:20 -0400 Subject: [PATCH 3/3] update to add syncing networks --- .../tasks/check_eth_config/README.md | 14 +++++++++ .../tasks/check_eth_config/config.go | 10 ++++--- .../tasks/check_eth_config/task.go | 30 ++++++++++++++++++- .../ethconfig-test-with-rpc-call.yaml | 8 ++--- 4 files changed, 51 insertions(+), 11 deletions(-) diff --git a/pkg/coordinator/tasks/check_eth_config/README.md b/pkg/coordinator/tasks/check_eth_config/README.md index db1eade8..cd1c6154 100644 --- a/pkg/coordinator/tasks/check_eth_config/README.md +++ b/pkg/coordinator/tasks/check_eth_config/README.md @@ -14,6 +14,9 @@ The `check_eth_config` task verifies that all execution clients in the network r - **`failOnMismatch`**: Determines whether the task should fail if any execution client returns a different `eth_config` response. If set to `true` (default), the task fails on configuration mismatches. If set to `false`, mismatches are logged but the task continues without failure. +- **`excludeSyncingClients`**: + When set to `true`, the task excludes execution clients that are currently syncing. If set to `false` (default), syncing clients are included in the check. This is useful for testing configuration consistency even before clients are fully synced, as `eth_config` returns configuration data that doesn't depend on sync status. + ### Outputs - **`ethConfig`**: @@ -29,6 +32,7 @@ Default settings for the `check_eth_config` task: clientPattern: "" excludeClientPattern: "" failOnMismatch: true + excludeSyncingClients: false ``` ### Example Usage @@ -61,6 +65,16 @@ Non-blocking check that logs mismatches but doesn't fail: failOnMismatch: false ``` +Only check fully synced clients: + +```yaml +- name: check_eth_config + title: "Verify eth_config for synced clients only" + config: + excludeSyncingClients: true + failOnMismatch: true +``` + ### Implementation Details The task queries the `eth_config` RPC method (EIP-7910) from all matching execution clients and performs a JSON-level comparison of the responses. When configurations match, the task succeeds and outputs the reference configuration. When mismatches are detected, the task provides a detailed error log showing: diff --git a/pkg/coordinator/tasks/check_eth_config/config.go b/pkg/coordinator/tasks/check_eth_config/config.go index a85e1137..190ee390 100644 --- a/pkg/coordinator/tasks/check_eth_config/config.go +++ b/pkg/coordinator/tasks/check_eth_config/config.go @@ -1,14 +1,16 @@ package checkethconfig type Config struct { - ClientPattern string `yaml:"clientPattern" json:"clientPattern"` - ExcludeClientPattern string `yaml:"excludeClientPattern" json:"excludeClientPattern"` - FailOnMismatch bool `yaml:"failOnMismatch" json:"failOnMismatch"` + ClientPattern string `yaml:"clientPattern" json:"clientPattern"` + ExcludeClientPattern string `yaml:"excludeClientPattern" json:"excludeClientPattern"` + FailOnMismatch bool `yaml:"failOnMismatch" json:"failOnMismatch"` + ExcludeSyncingClients bool `yaml:"excludeSyncingClients" json:"excludeSyncingClients"` } func DefaultConfig() Config { return Config{ - FailOnMismatch: true, + FailOnMismatch: true, + ExcludeSyncingClients: false, } } diff --git a/pkg/coordinator/tasks/check_eth_config/task.go b/pkg/coordinator/tasks/check_eth_config/task.go index d0050e9c..2029d363 100644 --- a/pkg/coordinator/tasks/check_eth_config/task.go +++ b/pkg/coordinator/tasks/check_eth_config/task.go @@ -79,7 +79,35 @@ func (t *Task) Execute(ctx context.Context) error { var clients []*execution.Client if t.config.ClientPattern == "" && t.config.ExcludeClientPattern == "" { - clients = clientPool.GetExecutionPool().GetReadyEndpoints(true) + executionPool := clientPool.GetExecutionPool() + allClients := executionPool.GetAllEndpoints() + + if t.config.ExcludeSyncingClients { + clients = executionPool.GetReadyEndpoints(true) + } else { + // Include all clients regardless of sync status + clients = allClients + } + + t.logger.Infof("execution pool has %d total clients, %d clients selected (excludeSyncing=%v)", len(allClients), len(clients), t.config.ExcludeSyncingClients) + + // Log details about each client + for i, client := range allClients { + statusStr := "unknown" + + status := client.GetStatus() + switch status { + case execution.ClientStatusOnline: + statusStr = "online" + case execution.ClientStatusOffline: + statusStr = "offline" + case execution.ClientStatusSynchronizing: + statusStr = "synchronizing" + } + + t.logger.Debugf("client %d: name=%s, status=%s", i, client.GetName(), statusStr) + } + if len(clients) == 0 { t.logger.Error("check failed: no matching clients found") t.ctx.SetResult(types.TaskResultFailure) diff --git a/playbooks/fusaka-dev/kurtosis/ethconfig-test-with-rpc-call.yaml b/playbooks/fusaka-dev/kurtosis/ethconfig-test-with-rpc-call.yaml index 001c31ee..d76e02f6 100644 --- a/playbooks/fusaka-dev/kurtosis/ethconfig-test-with-rpc-call.yaml +++ b/playbooks/fusaka-dev/kurtosis/ethconfig-test-with-rpc-call.yaml @@ -6,17 +6,13 @@ config: #walletPrivkey: "" tasks: - name: check_clients_are_healthy - title: "Check if at least one client is ready" + title: "Wait for at least one client to be responding" timeout: 5m config: minClientCount: 1 - - name: check_consensus_slot_range - title: "Wait for slot >= 10" - timeout: 10m - config: - minSlotNumber: 10 - name: check_eth_config title: "Check eth_config consistency across all execution clients" timeout: 5m config: failOnMismatch: true + excludeSyncingClients: false