From f495a7cb10da1e10ead7063b532c2323fb0c9c59 Mon Sep 17 00:00:00 2001 From: pk910 Date: Mon, 11 Aug 2025 19:53:33 +0200 Subject: [PATCH 1/5] add cgc test --- Makefile | 2 +- .../clients/consensus/rpc/beaconapi.go | 27 + .../tasks/check_consensus_identity/README.md | 117 +++++ .../tasks/check_consensus_identity/config.go | 71 +++ .../tasks/check_consensus_identity/task.go | 464 ++++++++++++++++++ .../tasks/get_consensus_validators/README.md | 112 +++++ .../tasks/get_consensus_validators/config.go | 63 +++ .../tasks/get_consensus_validators/task.go | 242 +++++++++ pkg/coordinator/tasks/tasks.go | 4 + .../web/templates/test_run/test_run.html | 54 +- .../kurtosis/cgc-validation-test.yaml | 335 +++++++++++++ 11 files changed, 1461 insertions(+), 30 deletions(-) create mode 100644 pkg/coordinator/tasks/check_consensus_identity/README.md create mode 100644 pkg/coordinator/tasks/check_consensus_identity/config.go create mode 100644 pkg/coordinator/tasks/check_consensus_identity/task.go create mode 100644 pkg/coordinator/tasks/get_consensus_validators/README.md create mode 100644 pkg/coordinator/tasks/get_consensus_validators/config.go create mode 100644 pkg/coordinator/tasks/get_consensus_validators/task.go create mode 100644 playbooks/fusaka-dev/kurtosis/cgc-validation-test.yaml diff --git a/Makefile b/Makefile index 1db24f76..bc8d1cf0 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,7 @@ devnet: .hack/devnet/run.sh devnet-run: devnet - go run main.go --config .hack/devnet/generated-assertoor-config.yaml + go run main.go --config .hack/devnet/generated-assertoor-config.yaml --verbose devnet-run-docker: devnet docker build --file ./Dockerfile-local -t assertoor:devnet-run --build-arg userid=$(CURRENT_UID) --build-arg groupid=$(CURRENT_GID) . diff --git a/pkg/coordinator/clients/consensus/rpc/beaconapi.go b/pkg/coordinator/clients/consensus/rpc/beaconapi.go index b152a602..2b1e0431 100644 --- a/pkg/coordinator/clients/consensus/rpc/beaconapi.go +++ b/pkg/coordinator/clients/consensus/rpc/beaconapi.go @@ -496,3 +496,30 @@ func (bc *BeaconClient) SubmitProposerSlashing(ctx context.Context, slashing *ph return nil } + +type NodeIdentity struct { + PeerID string `json:"peer_id"` + ENR string `json:"enr"` + P2PAddresses []string `json:"p2p_addresses"` + DiscoveryAddresses []string `json:"discovery_addresses"` + Metadata struct { + SeqNumber uint64 `json:"seq_number,string"` + Attnets string `json:"attnets"` + Syncnets string `json:"syncnets"` + } `json:"metadata"` +} + +type apiNodeIdentity struct { + Data *NodeIdentity `json:"data"` +} + +func (bc *BeaconClient) GetNodeIdentity(ctx context.Context) (*NodeIdentity, error) { + var nodeIdentity apiNodeIdentity + + err := bc.getJSON(ctx, fmt.Sprintf("%s/eth/v1/node/identity", bc.endpoint), &nodeIdentity) + if err != nil { + return nil, fmt.Errorf("error retrieving node identity: %v", err) + } + + return nodeIdentity.Data, nil +} diff --git a/pkg/coordinator/tasks/check_consensus_identity/README.md b/pkg/coordinator/tasks/check_consensus_identity/README.md new file mode 100644 index 00000000..158d8e88 --- /dev/null +++ b/pkg/coordinator/tasks/check_consensus_identity/README.md @@ -0,0 +1,117 @@ +# `check_consensus_identity` Task + +This task checks consensus client node identity information by querying the `/eth/v1/node/identity` API endpoint. It can verify various aspects of the node identity including CGC (Custody Group Count) extracted from ENR (Ethereum Node Record). + +## Configuration + +### Required Parameters +- **`clientPattern`** *(string)*: Pattern to match client names (e.g., `"lodestar-*"`, `"*"` for all) + +### Optional Parameters +- **`pollInterval`** *(duration)*: Interval between checks (default: `10s`) +- **`minClientCount`** *(int)*: Minimum number of clients that must pass checks (default: `1`) +- **`maxFailCount`** *(int)*: Maximum number of clients that can fail (-1 for no limit, default: `-1`) +- **`failOnCheckMiss`** *(bool)*: Whether to fail the task when checks don't pass (default: `false`) + +### CGC (Custody Group Count) Checks +- **`expectCgc`** *(int)*: Expect exact CGC value +- **`minCgc`** *(int)*: Minimum CGC value required +- **`maxCgc`** *(int)*: Maximum CGC value allowed + +### ENR Checks +- **`expectEnrField`** *(map[string]interface{})*: Expected ENR field values + +### PeerID Checks +- **`expectPeerIdPattern`** *(string)*: Regex pattern that PeerID must match + +### P2P Address Checks +- **`expectP2pAddressCount`** *(int)*: Expected number of P2P addresses +- **`expectP2pAddressMatch`** *(string)*: Regex pattern that at least one P2P address must match + +### Metadata Checks +- **`expectSeqNumber`** *(uint64)*: Expected sequence number +- **`minSeqNumber`** *(uint64)*: Minimum sequence number required + +## Outputs + +The task exports the following data via `ctx.Outputs`: + +- **`matchingClients`**: Array of clients that passed all checks +- **`failedClients`**: Array of clients that failed checks +- **`totalCount`**: Total number of clients checked +- **`matchingCount`**: Number of clients that passed checks +- **`failedCount`**: Number of clients that failed checks + +Each client result includes: +- `clientName`: Name of the consensus client +- `peerId`: Peer ID from node identity +- `enr`: ENR string +- `p2pAddresses`: Array of P2P addresses +- `discoveryAddresses`: Array of discovery addresses +- `seqNumber`: Metadata sequence number +- `attnets`: Attestation subnets +- `syncnets`: Sync subnets +- `cgc`: Extracted Custody Group Count +- `enrFields`: Parsed ENR fields +- `checksPassed`: Whether all configured checks passed +- `failureReasons`: Array of reasons why checks failed (if any) + +## Example Configurations + +### Basic Identity Check +```yaml +- name: check_node_identity + task: check_consensus_identity + config: + clientPattern: "lodestar-*" + minClientCount: 1 +``` + +### CGC Validation +```yaml +- name: validate_cgc + task: check_consensus_identity + config: + clientPattern: "*" + expectCgc: 8 + failOnCheckMiss: true +``` + +### Comprehensive Identity Check +```yaml +- name: full_identity_check + task: check_consensus_identity + config: + clientPattern: "prysm-*" + minCgc: 4 + maxCgc: 16 + expectP2pAddressCount: 2 + expectPeerIdPattern: "^16Uiu2HA.*" + minSeqNumber: 1 + pollInterval: 30s + failOnCheckMiss: true +``` + +### Using Outputs in Subsequent Tasks +```yaml +- name: check_identity + task: check_consensus_identity + config: + clientPattern: "*" + expectCgc: 8 + +- name: verify_results + task: run_shell + config: + command: | + echo "Found ${check_identity.matchingCount} matching clients" + echo "Total CGC sum: $(echo '${check_identity.matchingClients}' | jq '[.[] | .cgc] | add')" +``` + +## Use Cases + +1. **PeerDAS Validation**: Verify nodes have correct custody assignments +2. **Network Health**: Check node identity consistency across clients +3. **Configuration Validation**: Ensure nodes are properly configured for specific network requirements +4. **Testing**: Validate node behavior changes after deposits or configuration updates +5. **Monitoring**: Track node identity changes over time \ No newline at end of file diff --git a/pkg/coordinator/tasks/check_consensus_identity/config.go b/pkg/coordinator/tasks/check_consensus_identity/config.go new file mode 100644 index 00000000..6c5cc0fd --- /dev/null +++ b/pkg/coordinator/tasks/check_consensus_identity/config.go @@ -0,0 +1,71 @@ +package checkconsensusidentity + +import ( + "fmt" + "time" + + "github.com/ethpandaops/assertoor/pkg/coordinator/helper" +) + +type Config struct { + ClientPattern string `yaml:"clientPattern" json:"clientPattern"` + PollInterval helper.Duration `yaml:"pollInterval" json:"pollInterval"` + MinClientCount int `yaml:"minClientCount" json:"minClientCount"` + MaxFailCount int `yaml:"maxFailCount" json:"maxFailCount"` + FailOnCheckMiss bool `yaml:"failOnCheckMiss" json:"failOnCheckMiss"` + + // CGC (Custody Group Count) checks + ExpectCGC *int `yaml:"expectCgc" json:"expectCgc"` + MinCGC *int `yaml:"minCgc" json:"minCgc"` + MaxCGC *int `yaml:"maxCgc" json:"maxCgc"` + + // ENR checks + ExpectENRField map[string]interface{} `yaml:"expectEnrField" json:"expectEnrField"` + + // PeerID checks + ExpectPeerIDPattern string `yaml:"expectPeerIdPattern" json:"expectPeerIdPattern"` + + // P2P address checks + ExpectP2PAddressCount *int `yaml:"expectP2pAddressCount" json:"expectP2pAddressCount"` + ExpectP2PAddressMatch string `yaml:"expectP2pAddressMatch" json:"expectP2pAddressMatch"` + + // Metadata checks + ExpectSeqNumber *uint64 `yaml:"expectSeqNumber" json:"expectSeqNumber"` + MinSeqNumber *uint64 `yaml:"minSeqNumber" json:"minSeqNumber"` +} + +func DefaultConfig() Config { + return Config{ + PollInterval: helper.Duration{Duration: 10 * time.Second}, + MaxFailCount: -1, + MinClientCount: 1, + } +} + +func (c *Config) Validate() error { + if c.ClientPattern == "" { + return fmt.Errorf("clientPattern is required") + } + + if c.ExpectCGC != nil && *c.ExpectCGC < 0 { + return fmt.Errorf("expectCgc must be >= 0") + } + + if c.MinCGC != nil && *c.MinCGC < 0 { + return fmt.Errorf("minCgc must be >= 0") + } + + if c.MaxCGC != nil && *c.MaxCGC < 0 { + return fmt.Errorf("maxCgc must be >= 0") + } + + if c.MinCGC != nil && c.MaxCGC != nil && *c.MinCGC > *c.MaxCGC { + return fmt.Errorf("minCgc must be <= maxCgc") + } + + if c.ExpectP2PAddressCount != nil && *c.ExpectP2PAddressCount < 0 { + return fmt.Errorf("expectP2pAddressCount must be >= 0") + } + + return nil +} \ No newline at end of file diff --git a/pkg/coordinator/tasks/check_consensus_identity/task.go b/pkg/coordinator/tasks/check_consensus_identity/task.go new file mode 100644 index 00000000..7885f826 --- /dev/null +++ b/pkg/coordinator/tasks/check_consensus_identity/task.go @@ -0,0 +1,464 @@ +package checkconsensusidentity + +import ( + "context" + "encoding/base64" + "encoding/hex" + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "github.com/ethereum/go-ethereum/p2p/enr" + "github.com/ethereum/go-ethereum/rlp" + "github.com/ethpandaops/assertoor/pkg/coordinator/clients" + "github.com/ethpandaops/assertoor/pkg/coordinator/types" + "github.com/ethpandaops/assertoor/pkg/coordinator/vars" + "github.com/sirupsen/logrus" +) + +var ( + TaskName = "check_consensus_identity" + TaskDescriptor = &types.TaskDescriptor{ + Name: TaskName, + Description: "Checks consensus client node identity information including CGC extraction from ENR.", + Config: DefaultConfig(), + NewTask: NewTask, + } +) + +type Task struct { + ctx *types.TaskContext + options *types.TaskOptions + config Config + logger logrus.FieldLogger +} + +type IdentityCheckResult struct { + ClientName string `json:"clientName"` + PeerID string `json:"peerId"` + ENR string `json:"enr"` + P2PAddresses []string `json:"p2pAddresses"` + DiscoveryAddresses []string `json:"discoveryAddresses"` + SeqNumber uint64 `json:"seqNumber"` + Attnets string `json:"attnets"` + Syncnets string `json:"syncnets"` + CGC int `json:"cgc"` + ENRFields map[string]interface{} `json:"enrFields"` + ChecksPassed bool `json:"checksPassed"` + FailureReasons []string `json:"failureReasons"` +} + +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() + + 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) + } + } + + err := t.ctx.Vars.ConsumeVars(&config, t.options.ConfigVars) + if err != nil { + return err + } + + if err := config.Validate(); err != nil { + return err + } + + t.config = config + + return nil +} + +func (t *Task) Execute(ctx context.Context) error { + t.processCheck() + + for { + select { + case <-time.After(t.config.PollInterval.Duration): + t.processCheck() + case <-ctx.Done(): + return nil + } + } +} + +func (t *Task) processCheck() { + passResultCount := 0 + totalClientCount := 0 + matchingClients := []*IdentityCheckResult{} + failedClients := []*IdentityCheckResult{} + failedClientNames := []string{} + + t.logger.Infof("Starting identity check for pattern: %s", t.config.ClientPattern) + + for _, client := range t.ctx.Scheduler.GetServices().ClientPool().GetClientsByNamePatterns(t.config.ClientPattern, "") { + if client.ConsensusClient == nil { + t.logger.Warnf("Client %s has no consensus client, skipping", client.Config.Name) + continue + } + + totalClientCount++ + t.logger.Infof("Checking identity for client: %s", client.Config.Name) + + result := t.checkClientIdentity(client) + + // Debug output for each client + t.logger.Infof("Client %s identity check result:", result.ClientName) + t.logger.Infof(" PeerID: %s", result.PeerID) + t.logger.Infof(" ENR: %s", result.ENR) + t.logger.Infof(" CGC: %d", result.CGC) + t.logger.Infof(" P2P Addresses: %v", result.P2PAddresses) + t.logger.Infof(" Discovery Addresses: %v", result.DiscoveryAddresses) + t.logger.Infof(" Sequence Number: %d", result.SeqNumber) + t.logger.Infof(" Checks Passed: %v", result.ChecksPassed) + if len(result.FailureReasons) > 0 { + t.logger.Infof(" Failure Reasons: %v", result.FailureReasons) + } + + if result.ChecksPassed { + passResultCount++ + matchingClients = append(matchingClients, result) + t.logger.Infof("✅ Client %s passed all checks", result.ClientName) + } else { + failedClients = append(failedClients, result) + failedClientNames = append(failedClientNames, result.ClientName) + t.logger.Warnf("❌ Client %s failed checks: %v", result.ClientName, result.FailureReasons) + } + } + + requiredPassCount := t.config.MinClientCount + if requiredPassCount == 0 { + requiredPassCount = totalClientCount + } + + resultPass := passResultCount >= requiredPassCount + + // Set output variables using context.Outputs + if matchingClientsData, err := vars.GeneralizeData(matchingClients); err == nil { + t.ctx.Outputs.SetVar("matchingClients", matchingClientsData) + } else { + t.logger.Warnf("Failed setting `matchingClients` output: %v", err) + } + + if failedClientsData, err := vars.GeneralizeData(failedClients); err == nil { + t.ctx.Outputs.SetVar("failedClients", failedClientsData) + } else { + t.logger.Warnf("Failed setting `failedClients` output: %v", err) + } + + t.ctx.Outputs.SetVar("totalCount", totalClientCount) + t.ctx.Outputs.SetVar("matchingCount", passResultCount) + t.ctx.Outputs.SetVar("failedCount", totalClientCount-passResultCount) + + // Enhanced summary logging + t.logger.Infof("📊 Identity check summary:") + t.logger.Infof(" Total clients: %d", totalClientCount) + t.logger.Infof(" Passed: %d", passResultCount) + t.logger.Infof(" Failed: %d", totalClientCount-passResultCount) + t.logger.Infof(" Required pass count: %d", requiredPassCount) + t.logger.Infof(" Overall result: %v", resultPass) + + if len(failedClientNames) > 0 { + t.logger.Infof(" Failed clients: %v", failedClientNames) + } + + // Set task result - default to pending instead of failure unless explicitly configured + switch { + case t.config.MaxFailCount > -1 && len(failedClients) > t.config.MaxFailCount: + if t.config.FailOnCheckMiss { + t.logger.Infof("Setting result to FAILURE (too many failures: %d > %d)", len(failedClients), t.config.MaxFailCount) + t.ctx.SetResult(types.TaskResultFailure) + } else { + t.logger.Infof("Setting result to PENDING (too many failures but failOnCheckMiss=false)") + t.ctx.SetResult(types.TaskResultNone) + } + case resultPass: + t.logger.Infof("Setting result to SUCCESS (requirements met)") + t.ctx.SetResult(types.TaskResultSuccess) + default: + if t.config.FailOnCheckMiss { + t.logger.Infof("Setting result to FAILURE (requirements not met and failOnCheckMiss=true)") + t.ctx.SetResult(types.TaskResultFailure) + } else { + t.logger.Infof("Setting result to PENDING (requirements not met but failOnCheckMiss=false)") + t.ctx.SetResult(types.TaskResultNone) + } + } +} + +func (t *Task) checkClientIdentity(client *clients.PoolClient) *IdentityCheckResult { + result := &IdentityCheckResult{ + ClientName: client.Config.Name, + ChecksPassed: true, + FailureReasons: []string{}, + } + + t.logger.Debugf("Getting node identity for client %s", client.Config.Name) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + identity, err := client.ConsensusClient.GetRPCClient().GetNodeIdentity(ctx) + if err != nil { + t.logger.Errorf("Failed to get node identity for client %s: %v", client.Config.Name, err) + result.ChecksPassed = false + result.FailureReasons = append(result.FailureReasons, fmt.Sprintf("Failed to get node identity: %v", err)) + return result + } + + t.logger.Debugf("Retrieved node identity for client %s: PeerID=%s, ENR=%s", + client.Config.Name, identity.PeerID, identity.ENR) + + result.PeerID = identity.PeerID + result.ENR = identity.ENR + result.P2PAddresses = identity.P2PAddresses + result.DiscoveryAddresses = identity.DiscoveryAddresses + result.SeqNumber = identity.Metadata.SeqNumber + result.Attnets = identity.Metadata.Attnets + result.Syncnets = identity.Metadata.Syncnets + + // Extract CGC from ENR + t.logger.Debugf("Extracting CGC from ENR for client %s", client.Config.Name) + cgc, enrFields, err := t.extractCGCFromENR(identity.ENR) + if err != nil { + t.logger.Errorf("Failed to parse ENR for client %s: %v", client.Config.Name, err) + result.ChecksPassed = false + result.FailureReasons = append(result.FailureReasons, fmt.Sprintf("Failed to parse ENR: %v", err)) + return result + } + + t.logger.Debugf("Extracted CGC=%d for client %s", cgc, client.Config.Name) + + result.CGC = cgc + result.ENRFields = enrFields + + // Perform configured checks + t.logger.Debugf("Performing checks for client %s", client.Config.Name) + t.performChecks(result) + + return result +} + +func (t *Task) performChecks(result *IdentityCheckResult) { + // Check CGC expectations + if t.config.ExpectCGC != nil && result.CGC != *t.config.ExpectCGC { + result.ChecksPassed = false + result.FailureReasons = append(result.FailureReasons, + fmt.Sprintf("Expected CGC %d, got %d", *t.config.ExpectCGC, result.CGC)) + } + + if t.config.MinCGC != nil && result.CGC < *t.config.MinCGC { + result.ChecksPassed = false + result.FailureReasons = append(result.FailureReasons, + fmt.Sprintf("CGC %d is below minimum %d", result.CGC, *t.config.MinCGC)) + } + + if t.config.MaxCGC != nil && result.CGC > *t.config.MaxCGC { + result.ChecksPassed = false + result.FailureReasons = append(result.FailureReasons, + fmt.Sprintf("CGC %d is above maximum %d", result.CGC, *t.config.MaxCGC)) + } + + // Check PeerID pattern + if t.config.ExpectPeerIDPattern != "" { + matched, err := regexp.MatchString(t.config.ExpectPeerIDPattern, result.PeerID) + if err != nil { + result.ChecksPassed = false + result.FailureReasons = append(result.FailureReasons, + fmt.Sprintf("Invalid PeerID pattern: %v", err)) + } else if !matched { + result.ChecksPassed = false + result.FailureReasons = append(result.FailureReasons, + fmt.Sprintf("PeerID %s does not match pattern %s", result.PeerID, t.config.ExpectPeerIDPattern)) + } + } + + // Check P2P address count + if t.config.ExpectP2PAddressCount != nil && len(result.P2PAddresses) != *t.config.ExpectP2PAddressCount { + result.ChecksPassed = false + result.FailureReasons = append(result.FailureReasons, + fmt.Sprintf("Expected %d P2P addresses, got %d", *t.config.ExpectP2PAddressCount, len(result.P2PAddresses))) + } + + // Check P2P address match + if t.config.ExpectP2PAddressMatch != "" { + found := false + for _, addr := range result.P2PAddresses { + if matched, _ := regexp.MatchString(t.config.ExpectP2PAddressMatch, addr); matched { + found = true + break + } + } + if !found { + result.ChecksPassed = false + result.FailureReasons = append(result.FailureReasons, + fmt.Sprintf("No P2P address matches pattern %s", t.config.ExpectP2PAddressMatch)) + } + } + + // Check sequence number + if t.config.ExpectSeqNumber != nil && result.SeqNumber != *t.config.ExpectSeqNumber { + result.ChecksPassed = false + result.FailureReasons = append(result.FailureReasons, + fmt.Sprintf("Expected sequence number %d, got %d", *t.config.ExpectSeqNumber, result.SeqNumber)) + } + + if t.config.MinSeqNumber != nil && result.SeqNumber < *t.config.MinSeqNumber { + result.ChecksPassed = false + result.FailureReasons = append(result.FailureReasons, + fmt.Sprintf("Sequence number %d is below minimum %d", result.SeqNumber, *t.config.MinSeqNumber)) + } + + // Check ENR fields + for expectedField, expectedValue := range t.config.ExpectENRField { + if actualValue, exists := result.ENRFields[expectedField]; !exists { + result.ChecksPassed = false + result.FailureReasons = append(result.FailureReasons, + fmt.Sprintf("Expected ENR field %s not found", expectedField)) + } else if actualValue != expectedValue { + result.ChecksPassed = false + result.FailureReasons = append(result.FailureReasons, + fmt.Sprintf("ENR field %s expected %v, got %v", expectedField, expectedValue, actualValue)) + } + } +} + +// extractCGCFromENR extracts the Custody Group Count from ENR using proper ENR parsing +func (t *Task) extractCGCFromENR(enrStr string) (int, map[string]interface{}, error) { + if enrStr == "" { + t.logger.Debugf("Empty ENR provided") + return 0, nil, fmt.Errorf("empty ENR") + } + + t.logger.Debugf("Parsing ENR: %s", enrStr) + + // Decode ENR using go-ethereum's ENR package + record, err := t.decodeENR(enrStr) + if err != nil { + t.logger.Errorf("Failed to decode ENR: %v", err) + return 0, nil, err + } + + // Get all key-value pairs from ENR + enrFields := t.getKeyValuesFromENR(record) + + // Extract CGC from the fields + cgc := 0 + if cgcHex, ok := enrFields["cgc"]; ok { + // CGC is stored as hex string, parse it + cgcStr, ok := cgcHex.(string) + if !ok { + t.logger.Warnf("CGC field is not a string: %v", cgcHex) + } else { + // Remove "0x" prefix if present + cgcStr = strings.TrimPrefix(cgcStr, "0x") + val, err := strconv.ParseUint(cgcStr, 16, 64) + if err != nil { + t.logger.Errorf("Failed to parse CGC value %s: %v", cgcStr, err) + } else { + cgc = int(val) + t.logger.Debugf("Found CGC in ENR: %d", cgc) + } + } + } else { + t.logger.Debugf("No CGC field found in ENR") + } + + enrFields["enr_original"] = enrStr + + return cgc, enrFields, nil +} + +// decodeENR decodes an ENR string into a Record (from Dora's implementation) +func (t *Task) decodeENR(raw string) (*enr.Record, error) { + b := []byte(raw) + if strings.HasPrefix(raw, "enr:") { + b = b[4:] + } + + dec := make([]byte, base64.RawURLEncoding.DecodedLen(len(b))) + n, err := base64.RawURLEncoding.Decode(dec, b) + if err != nil { + return nil, err + } + + var r enr.Record + err = rlp.DecodeBytes(dec[:n], &r) + return &r, err +} + +// getKeyValuesFromENR extracts all key-value pairs from an ENR record (from Dora's implementation) +func (t *Task) getKeyValuesFromENR(r *enr.Record) map[string]interface{} { + fields := make(map[string]interface{}) + + fields["seq"] = r.Seq() + fields["signature"] = "0x" + hex.EncodeToString(r.Signature()) + + // Get all key-value pairs from the record + kv := r.AppendElements(nil)[1:] // Skip the sequence number + for i := 0; i < len(kv); i += 2 { + key := kv[i].(string) + val := kv[i+1].(rlp.RawValue) + + // Format the value based on the key + fmtval := t.formatENRValue(key, val) + fields[key] = fmtval + } + + return fields +} + +// formatENRValue formats an ENR value based on its key type +func (t *Task) formatENRValue(key string, val rlp.RawValue) string { + switch key { + case "id": + content, _, err := rlp.SplitString(val) + if err == nil { + return string(content) + } + case "ip", "ip6": + content, _, err := rlp.SplitString(val) + if err == nil && (len(content) == 4 || len(content) == 16) { + return fmt.Sprintf("%v", content) // Return IP as string + } + case "tcp", "tcp6", "udp", "udp6": + var x uint64 + if err := rlp.DecodeBytes(val, &x); err == nil { + return strconv.FormatUint(x, 10) + } + case "cgc": + // CGC is stored as a single byte + content, _, err := rlp.SplitString(val) + if err == nil && len(content) > 0 { + return "0x" + hex.EncodeToString(content) + } + } + + // Default: return as hex + content, _, err := rlp.SplitString(val) + if err == nil { + return "0x" + hex.EncodeToString(content) + } + + return "0x" + hex.EncodeToString(val) +} \ No newline at end of file diff --git a/pkg/coordinator/tasks/get_consensus_validators/README.md b/pkg/coordinator/tasks/get_consensus_validators/README.md new file mode 100644 index 00000000..a5bf8af7 --- /dev/null +++ b/pkg/coordinator/tasks/get_consensus_validators/README.md @@ -0,0 +1,112 @@ +# `get_consensus_validators` Task + +This task retrieves validators from the consensus layer that match specified filtering criteria. It's useful for finding validators associated with specific clients, status conditions, or other attributes. + +## Configuration + +### Client/Name Filtering +- **`clientPattern`** *(string)*: Regex pattern to match client names in validator names +- **`validatorNamePattern`** *(string)*: Regex pattern to match validator names directly + +### Status Filtering +- **`validatorStatus`** *([]string)*: Array of allowed validator statuses (default: all statuses) + - Possible values: `pending_initialized`, `pending_queued`, `active_ongoing`, `active_exiting`, `active_slashed`, `exited_unslashed`, `exited_slashed`, `withdrawal_possible`, `withdrawal_done` + +### Balance Filtering +- **`minValidatorBalance`** *(uint64)*: Minimum validator balance in Gwei +- **`maxValidatorBalance`** *(uint64)*: Maximum validator balance in Gwei + +### Index Filtering +- **`minValidatorIndex`** *(uint64)*: Minimum validator index +- **`maxValidatorIndex`** *(uint64)*: Maximum validator index + +### Other Filtering +- **`withdrawalCredsPrefix`** *(string)*: Required prefix for withdrawal credentials (e.g., "0x01", "0x02") + +### Output Options +- **`maxResults`** *(int)*: Maximum number of validators to return (default: 100) +- **`outputFormat`** *(string)*: Output format - "full", "pubkeys", or "indices" (default: "full") + +## Outputs + +Depending on `outputFormat`, the task exports: + +### Format: "full" (default) +- **`validators`**: Array of full validator information objects +- **`count`**: Number of matching validators + +Each validator object includes: +- `index`: Validator index +- `pubkey`: Validator public key (0x prefixed hex) +- `balance`: Current balance in Gwei +- `status`: Validator status string +- `effectiveBalance`: Effective balance in Gwei +- `withdrawalCredentials`: Withdrawal credentials (0x prefixed hex) +- `activationEpoch`: Activation epoch +- `exitEpoch`: Exit epoch +- `withdrawableEpoch`: Withdrawable epoch +- `slashed`: Boolean indicating if validator is slashed + +### Format: "pubkeys" +- **`pubkeys`**: Array of validator public keys as hex strings +- **`count`**: Number of matching validators + +### Format: "indices" +- **`indices`**: Array of validator indices as integers +- **`count`**: Number of matching validators + +## Example Configurations + +### Find Validators for Specific Client +```yaml +- name: find_lighthouse_validators + task: get_consensus_validators + config: + clientPattern: "lighthouse.*" + validatorStatus: ["active_ongoing"] + maxResults: 10 + outputFormat: "full" +``` + +### Get Validator Pubkeys for BLS Changes +```yaml +- name: get_validator_pubkeys + task: get_consensus_validators + config: + validatorNamePattern: "validator_[0-9]+" + validatorStatus: ["active_ongoing"] + withdrawalCredsPrefix: "0x01" + outputFormat: "pubkeys" + maxResults: 5 +``` + +### Find Validators by Balance Range +```yaml +- name: find_rich_validators + task: get_consensus_validators + config: + clientPattern: "prysm.*" + minValidatorBalance: 40000000000 # > 40 ETH + validatorStatus: ["active_ongoing"] + maxResults: 20 +``` + +### Get Validator Indices for Operations +```yaml +- name: get_validator_indices + task: get_consensus_validators + config: + validatorNamePattern: "test_validator_.*" + validatorStatus: ["pending_initialized", "pending_queued"] + outputFormat: "indices" + maxResults: 2 +``` + +## Use Cases + +1. **Client-Specific Operations**: Find validators belonging to specific consensus clients +2. **BLS Changes**: Identify validators that need withdrawal credential updates +3. **Deposit Operations**: Find validators for top-up deposits +4. **Status Monitoring**: Track validators in specific states +5. **Balance Analysis**: Identify validators by balance criteria +6. **Bulk Operations**: Get validator sets for matrix operations \ No newline at end of file diff --git a/pkg/coordinator/tasks/get_consensus_validators/config.go b/pkg/coordinator/tasks/get_consensus_validators/config.go new file mode 100644 index 00000000..3c844c03 --- /dev/null +++ b/pkg/coordinator/tasks/get_consensus_validators/config.go @@ -0,0 +1,63 @@ +package getconsensusvalidators + +import ( + "fmt" +) + +type Config struct { + ClientPattern string `yaml:"clientPattern" json:"clientPattern"` + ValidatorNamePattern string `yaml:"validatorNamePattern" json:"validatorNamePattern"` + ValidatorStatus []string `yaml:"validatorStatus" json:"validatorStatus"` + MinValidatorBalance *uint64 `yaml:"minValidatorBalance" json:"minValidatorBalance"` + MaxValidatorBalance *uint64 `yaml:"maxValidatorBalance" json:"maxValidatorBalance"` + WithdrawalCredsPrefix string `yaml:"withdrawalCredsPrefix" json:"withdrawalCredsPrefix"` + MinValidatorIndex *uint64 `yaml:"minValidatorIndex" json:"minValidatorIndex"` + MaxValidatorIndex *uint64 `yaml:"maxValidatorIndex" json:"maxValidatorIndex"` + MaxResults int `yaml:"maxResults" json:"maxResults"` + + // Output format options + OutputFormat string `yaml:"outputFormat" json:"outputFormat"` // "full", "pubkeys", "indices" +} + +func DefaultConfig() Config { + return Config{ + MaxResults: 100, + OutputFormat: "full", + ValidatorStatus: []string{ + "pending_initialized", + "pending_queued", + "active_ongoing", + "active_exiting", + "active_slashed", + "exited_unslashed", + "exited_slashed", + "withdrawal_possible", + "withdrawal_done", + }, + } +} + +func (c *Config) Validate() error { + if c.ClientPattern == "" && c.ValidatorNamePattern == "" { + return fmt.Errorf("either clientPattern or validatorNamePattern is required") + } + + if c.MaxResults <= 0 { + return fmt.Errorf("maxResults must be > 0") + } + + if c.MinValidatorIndex != nil && c.MaxValidatorIndex != nil && *c.MinValidatorIndex > *c.MaxValidatorIndex { + return fmt.Errorf("minValidatorIndex must be <= maxValidatorIndex") + } + + if c.MinValidatorBalance != nil && c.MaxValidatorBalance != nil && *c.MinValidatorBalance > *c.MaxValidatorBalance { + return fmt.Errorf("minValidatorBalance must be <= maxValidatorBalance") + } + + validFormats := map[string]bool{"full": true, "pubkeys": true, "indices": true} + if !validFormats[c.OutputFormat] { + return fmt.Errorf("outputFormat must be one of: full, pubkeys, indices") + } + + return nil +} \ No newline at end of file diff --git a/pkg/coordinator/tasks/get_consensus_validators/task.go b/pkg/coordinator/tasks/get_consensus_validators/task.go new file mode 100644 index 00000000..f57b1192 --- /dev/null +++ b/pkg/coordinator/tasks/get_consensus_validators/task.go @@ -0,0 +1,242 @@ +package getconsensusvalidators + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" + + "github.com/ethpandaops/assertoor/pkg/coordinator/types" + "github.com/ethpandaops/assertoor/pkg/coordinator/vars" + "github.com/sirupsen/logrus" +) + +var ( + TaskName = "get_consensus_validators" + TaskDescriptor = &types.TaskDescriptor{ + Name: TaskName, + Description: "Retrieves validators from the consensus layer matching specified criteria.", + Config: DefaultConfig(), + NewTask: NewTask, + } +) + +type Task struct { + ctx *types.TaskContext + options *types.TaskOptions + config Config + logger logrus.FieldLogger +} + +type ValidatorInfo struct { + Index int `json:"index"` + Pubkey string `json:"pubkey"` + Balance uint64 `json:"balance"` + Status string `json:"status"` + EffectiveBalance uint64 `json:"effectiveBalance"` + WithdrawalCredentials string `json:"withdrawalCredentials"` + ActivationEpoch uint64 `json:"activationEpoch"` + ExitEpoch uint64 `json:"exitEpoch"` + WithdrawableEpoch uint64 `json:"withdrawableEpoch"` + Slashed bool `json:"slashed"` +} + +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() + + 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) + } + } + + err := t.ctx.Vars.ConsumeVars(&config, t.options.ConfigVars) + if err != nil { + return err + } + + if err := config.Validate(); err != nil { + return err + } + + t.config = config + + return nil +} + +func (t *Task) Execute(ctx context.Context) error { + // Get client pool and validator names + clientPool := t.ctx.Scheduler.GetServices().ClientPool() + consensusPool := clientPool.GetConsensusPool() + validatorNames := t.ctx.Scheduler.GetServices().ValidatorNames() + + // Get validator set from consensus client + validatorSet := consensusPool.GetValidatorSet() + if validatorSet == nil { + return fmt.Errorf("validator set not available") + } + + matchingValidators := []ValidatorInfo{} + + // Compile validator name pattern regex if provided + var validatorNameRegex *regexp.Regexp + if t.config.ValidatorNamePattern != "" { + var err error + validatorNameRegex, err = regexp.Compile(t.config.ValidatorNamePattern) + if err != nil { + return fmt.Errorf("invalid validator name pattern: %v", err) + } + } + + // Compile client pattern regex if provided + var clientNameRegex *regexp.Regexp + if t.config.ClientPattern != "" { + var err error + clientNameRegex, err = regexp.Compile(t.config.ClientPattern) + if err != nil { + return fmt.Errorf("invalid client pattern: %v", err) + } + } + + // Iterate through validators and apply filters + for validatorIndex, validator := range validatorSet { + if len(matchingValidators) >= t.config.MaxResults { + break + } + + // Check index range + if t.config.MinValidatorIndex != nil && uint64(validatorIndex) < *t.config.MinValidatorIndex { + continue + } + if t.config.MaxValidatorIndex != nil && uint64(validatorIndex) > *t.config.MaxValidatorIndex { + continue + } + + // Check balance range + if t.config.MinValidatorBalance != nil && uint64(validator.Balance) < *t.config.MinValidatorBalance { + continue + } + if t.config.MaxValidatorBalance != nil && uint64(validator.Balance) > *t.config.MaxValidatorBalance { + continue + } + + // Check withdrawal credentials prefix + if t.config.WithdrawalCredsPrefix != "" { + credsHex := fmt.Sprintf("0x%x", validator.Validator.WithdrawalCredentials) + if !strings.HasPrefix(credsHex, t.config.WithdrawalCredsPrefix) { + continue + } + } + + // Check validator status + if len(t.config.ValidatorStatus) > 0 { + statusMatch := false + validatorStatus := validator.Status.String() + for _, allowedStatus := range t.config.ValidatorStatus { + if validatorStatus == allowedStatus { + statusMatch = true + break + } + } + if !statusMatch { + continue + } + } + + // Get validator name for pattern matching + validatorName := "" + if validatorNames != nil { + validatorName = validatorNames.GetValidatorName(uint64(validatorIndex)) + } + + // Check validator name pattern + if validatorNameRegex != nil && validatorName != "" { + if !validatorNameRegex.MatchString(validatorName) { + continue + } + } + + // Check client pattern (match validator name against client pattern) + if clientNameRegex != nil && validatorName != "" { + if !clientNameRegex.MatchString(validatorName) { + continue + } + } + + // Create validator info + validatorInfo := ValidatorInfo{ + Index: int(validatorIndex), + Pubkey: fmt.Sprintf("0x%x", validator.Validator.PublicKey), + Balance: uint64(validator.Balance), + Status: validator.Status.String(), + EffectiveBalance: uint64(validator.Validator.EffectiveBalance), + WithdrawalCredentials: fmt.Sprintf("0x%x", validator.Validator.WithdrawalCredentials), + ActivationEpoch: uint64(validator.Validator.ActivationEpoch), + ExitEpoch: uint64(validator.Validator.ExitEpoch), + WithdrawableEpoch: uint64(validator.Validator.WithdrawableEpoch), + Slashed: validator.Validator.Slashed, + } + + matchingValidators = append(matchingValidators, validatorInfo) + } + + // Set outputs based on format + switch t.config.OutputFormat { + case "full": + if validatorsData, err := vars.GeneralizeData(matchingValidators); err == nil { + t.ctx.Outputs.SetVar("validators", validatorsData) + } else { + return fmt.Errorf("failed to generalize validators data: %v", err) + } + case "pubkeys": + pubkeys := make([]string, len(matchingValidators)) + for i, validator := range matchingValidators { + pubkeys[i] = validator.Pubkey + } + if pubkeysData, err := vars.GeneralizeData(pubkeys); err == nil { + t.ctx.Outputs.SetVar("pubkeys", pubkeysData) + } else { + return fmt.Errorf("failed to generalize pubkeys data: %v", err) + } + case "indices": + indices := make([]int, len(matchingValidators)) + for i, validator := range matchingValidators { + indices[i] = validator.Index + } + if indicesData, err := vars.GeneralizeData(indices); err == nil { + t.ctx.Outputs.SetVar("indices", indicesData) + } else { + return fmt.Errorf("failed to generalize indices data: %v", err) + } + } + + // Always set count + t.ctx.Outputs.SetVar("count", len(matchingValidators)) + + t.logger.Infof("Found %d validators matching criteria", len(matchingValidators)) + + if len(matchingValidators) > 0 { + t.ctx.SetResult(types.TaskResultSuccess) + } else { + t.ctx.SetResult(types.TaskResultNone) + } + + return nil +} \ No newline at end of file diff --git a/pkg/coordinator/tasks/tasks.go b/pkg/coordinator/tasks/tasks.go index bd482f49..52c7085b 100644 --- a/pkg/coordinator/tasks/tasks.go +++ b/pkg/coordinator/tasks/tasks.go @@ -8,6 +8,7 @@ import ( checkconsensusblockproposals "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/check_consensus_block_proposals" checkconsensusfinality "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/check_consensus_finality" checkconsensusforks "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/check_consensus_forks" + checkconsensusidentity "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/check_consensus_identity" checkconsensusproposerduty "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/check_consensus_proposer_duty" checkconsensusreorgs "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/check_consensus_reorgs" checkconsensusslotrange "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/check_consensus_slot_range" @@ -26,6 +27,7 @@ import ( generatetransaction "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/generate_transaction" generatewithdrawalrequests "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/generate_withdrawal_requests" getconsensusspecs "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/get_consensus_specs" + getconsensusvalidators "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/get_consensus_validators" checkexecutionblock "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/get_execution_block" getpubkeysfrommnemonic "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/get_pubkeys_from_mnemonic" getrandommnemonic "github.com/ethpandaops/assertoor/pkg/coordinator/tasks/get_random_mnemonic" @@ -47,6 +49,7 @@ var AvailableTaskDescriptors = []*types.TaskDescriptor{ checkconsensusblockproposals.TaskDescriptor, checkconsensusfinality.TaskDescriptor, checkconsensusforks.TaskDescriptor, + checkconsensusidentity.TaskDescriptor, checkconsensusproposerduty.TaskDescriptor, checkconsensusreorgs.TaskDescriptor, checkconsensusslotrange.TaskDescriptor, @@ -67,6 +70,7 @@ var AvailableTaskDescriptors = []*types.TaskDescriptor{ generatewithdrawalrequests.TaskDescriptor, getpubkeysfrommnemonic.TaskDescriptor, getconsensusspecs.TaskDescriptor, + getconsensusvalidators.TaskDescriptor, getrandommnemonic.TaskDescriptor, getwalletdetails.TaskDescriptor, runcommand.TaskDescriptor, diff --git a/pkg/coordinator/web/templates/test_run/test_run.html b/pkg/coordinator/web/templates/test_run/test_run.html index 95269429..c7593de9 100644 --- a/pkg/coordinator/web/templates/test_run/test_run.html +++ b/pkg/coordinator/web/templates/test_run/test_run.html @@ -441,27 +441,22 @@
Tasks
self.setChildVisibility = function(visible, recursive) { ko.utils.arrayForEach(self.children(), function(child) { - // Only hide if parent is collapsing. - // If parent is expanding, child visibility depends on its own expand state. if (!visible) { + // When parent is collapsing, hide all descendants regardless of their state child.isVisible(false); + if (recursive) { + child.setChildVisibility(false, true); + } } else { - // When expanding parent, make immediate children visible. - // Their own children visibility depends on their expanded state. + // When expanding parent, make immediate children visible child.isVisible(true); - // If the child itself is not expanded, its children should remain hidden. + // Their own children visibility depends on their expanded state if (recursive && child.isExpanded()) { child.setChildVisibility(true, true); } else if (recursive && !child.isExpanded()) { child.setChildVisibility(false, true); // Ensure grandchildren hidden } } - - // // Old simpler logic: - // child.isVisible(visible); - // if (recursive) { - // child.setChildVisibility(visible, true); - // } }); }; @@ -726,26 +721,27 @@
Tasks
ko.utils.arrayForEach(taskViewModels, function(task) { const parentIndex = ko.unwrap(task.parent_index); if (parentIndex && parentIndex !== 0) { - const parent = self.allTasksMap[parentIndex]; - // If parent exists and is NOT expanded, hide this task and its children recursively - if (parent && !parent.isExpanded()) { - task.isVisible(false); - // Need to ensure setChildVisibility is robust - if (typeof task.setChildVisibility === 'function') { - task.setChildVisibility(false, true); // Hide children recursively + // Check if any ancestor is collapsed + let shouldBeVisible = true; + let currentParentIndex = parentIndex; + + while (currentParentIndex && currentParentIndex !== 0) { + const ancestor = self.allTasksMap[currentParentIndex]; + if (ancestor && !ancestor.isExpanded()) { + shouldBeVisible = false; + break; } - } else if (parent && parent.isExpanded()) { - // If parent IS expanded, this task should be visible - task.isVisible(true); - // Visibility of its own children depends on its own expanded state - if (typeof task.setChildVisibility === 'function') { + currentParentIndex = ancestor ? ko.unwrap(ancestor.parent_index) : 0; + } + + task.isVisible(shouldBeVisible); + + // Set visibility of children based on this task's state + if (typeof task.setChildVisibility === 'function') { + if (shouldBeVisible) { task.setChildVisibility(task.isExpanded(), true); - } - } else if (!parent) { - // If parent doesn't exist (e.g., orphan), treat as root, make visible - task.isVisible(true); - if (typeof task.setChildVisibility === 'function') { - task.setChildVisibility(task.isExpanded(), true); // Apply to children + } else { + task.setChildVisibility(false, true); // Hide all children if this task is hidden } } } else { diff --git a/playbooks/fusaka-dev/kurtosis/cgc-validation-test.yaml b/playbooks/fusaka-dev/kurtosis/cgc-validation-test.yaml new file mode 100644 index 00000000..a437739d --- /dev/null +++ b/playbooks/fusaka-dev/kurtosis/cgc-validation-test.yaml @@ -0,0 +1,335 @@ +id: cgc-validation-test +name: "CGC Validation Test with Deposits" +timeout: 12h + +config: + walletPrivkey: "" + validatorMnemonic: "giant issue aisle success illegal bike spike question tent bar rely arctic volcano long crawl hungry vocal artwork sniff fantasy very lucky have athlete" + validatorPairNames: [] + depositContract: "0x00000000219ab540356cBB839Cbe05303d7705Fa" + depositAmount: 1024 + validatorCount: 2 + +tasks: +- name: check_clients_are_healthy + title: "Check if at least one client is ready" + timeout: 5m + config: + minClientCount: 1 + +- name: get_wallet_details + title: "Get target wallet address" + id: target_wallet + configVars: + privateKey: "walletPrivkey" + +- name: run_task_matrix + title: "Run CGC validation test for all validator pairs" + id: cgc_test + timeout: 90m + configVars: + matrixValues: "validatorPairNames" + config: + runConcurrent: true + + matrixVar: "validatorPairName" + task: + name: run_tasks + title: "CGC test for ${validatorPairName}" + config: + tasks: + # Step 1: Find validators for this client + - name: get_consensus_validators + title: "Find 2 active validators with 0x00 withdrawal credentials for ${validatorPairName}" + id: validators + timeout: 30s + config: + validatorStatus: ["active_ongoing"] + withdrawalCredsPrefix: "0x00" + maxResults: 2 + outputFormat: "full" + configVars: + clientPattern: "validatorPairName" + + # Step 2: Initial CGC check + - name: check_consensus_identity + title: "Check initial CGC for ${validatorPairName}" + id: initial_cgc + timeout: 30s + config: + minClientCount: 1 + minCgc: 1 + failOnCheckMiss: true + pollInterval: 5s + configVars: + clientPattern: "validatorPairName" + + # Step 2b: Check if CGC is already at maximum + - name: run_shell + title: "Check if CGC is already at maximum (128)" + id: cgc_check + timeout: 30s + config: + envVars: + initial_clients: "tasks.initial_cgc.outputs.matchingClients" + client_name: "validatorPairName" + command: | + initial_cgc=$(echo "$initial_clients" | jq -r '.[0].cgc') + echo "Initial CGC for $client_name: $initial_cgc" + + if [ "$initial_cgc" -eq 128 ]; then + echo "✅ CGC is already at maximum (128). No further action needed." + echo "::set-out-json isMaxCGC true" + else + echo "CGC is $initial_cgc, proceeding with credential changes and deposits..." + echo "::set-out-json isMaxCGC false" + fi + + # Step 3: Generate BLS changes to change withdrawal credentials from 0x00 to 0x01 + - name: run_task_matrix + title: "Generate BLS changes for found validators (0x00 → 0x01)" + id: bls_changes + timeout: 10m + if: "tasks.cgc_check.outputs.isMaxCGC != true" + configVars: + matrixValues: "tasks.validators.outputs.validators" + config: + runConcurrent: true + matrixVar: "validator" + task: + name: run_task_background + title: "BLS change for validator ${{validator.index}} & track inclusion" + config: + onBackgroundComplete: failOrIgnore + backgroundTask: + name: generate_bls_changes + title: "Generate BLS change for validator ${{validator.index}}" + config: + limitTotal: 1 + limitPerSlot: 1 + indexCount: 1 + awaitReceipt: true + failOnReject: true + configVars: + mnemonic: "validatorMnemonic" + startIndex: "validator.index" + targetAddress: "tasks.target_wallet.outputs.address" + foregroundTask: + name: check_consensus_block_proposals + title: "Wait for inclusion of BLS change for validator ${{validator.index}}" + config: + minBlsChangeCount: 1 + configVars: + expectBlsChanges: "| [{publicKey: .validator.pubkey, address: .tasks.target_wallet.outputs.address}]" + + # Step 4: Generate self-consolidations to change withdrawal credentials from 0x01 to 0x02 + - name: run_task_matrix + title: "Generate self-consolidations for found validators" + id: self_consolidations + timeout: 10m + if: "tasks.cgc_check.outputs.isMaxCGC != true" + configVars: + matrixValues: "tasks.validators.outputs.validators" + config: + runConcurrent: false + matrixVar: "validator" + task: + name: run_task_background + title: "Self-consolidation for validator ${{validator.index}} & track inclusion" + config: + onBackgroundComplete: failOrIgnore + backgroundTask: + name: generate_consolidations + title: "Generate self-consolidation (validator ${{validator.index}})" + config: + limitTotal: 1 + sourceIndexCount: 1 + awaitReceipt: true + failOnReject: true + configVars: + sourceMnemonic: "validatorMnemonic" + sourceStartIndex: "validator.index" + targetPublicKey: "validator.pubkey" + walletPrivkey: "walletPrivkey" + foregroundTask: + name: check_consensus_block_proposals + title: "Wait for inclusion of self-consolidation request for validator ${{validator.index}}" + config: + minConsolidationRequestCount: 1 + configVars: + expectConsolidationRequests: "| [{sourceAddress: .tasks.target_wallet.outputs.address, sourcePubkey: .validator.pubkey}]" + + # Step 5: Generate top-up deposits for found validators (now with 0x02 credentials) + - name: run_task_matrix + title: "Generate top-up deposits for found validators" + id: deposits + timeout: 15m + if: "tasks.cgc_check.outputs.isMaxCGC != true" + configVars: + matrixValues: "tasks.validators.outputs.validators" + config: + runConcurrent: false + matrixVar: "validator" + task: + name: run_tasks + title: "Generate top-up deposits for validator ${{validator.index}}" + config: + stopChildOnResult: false + tasks: + - name: generate_deposits + title: "Top-up deposit ${depositAmount} ETH to validator ${{validator.index}}" + config: + limitTotal: 1 + awaitReceipt: true + failOnReject: true + topUpDeposit: true + configVars: + walletPrivkey: "walletPrivkey" + depositContract: "depositContract" + depositAmount: "depositAmount" + publicKey: "validator.pubkey" + + # Step 6: Wait for validator balances to increase (can take hours) + - name: run_task_matrix + title: "Wait for validator balance increases" + id: balance_check + timeout: 8h + if: "tasks.cgc_check.outputs.isMaxCGC != true" + configVars: + matrixValues: "tasks.validators.outputs.validators" + config: + runConcurrent: true + matrixVar: "validator" + task: + name: check_consensus_validator_status + title: "Wait for validator ${{validator.index}} balance to increase" + config: + validatorStatus: + - active_ongoing + withdrawalCredsPrefix: "0x02" + configVars: + validatorIndex: "validator.index" + minValidatorBalance: "| (32 + .depositAmount) * 1000000000" + + # Step 7: Final CGC check (should be higher than initial) + - name: run_task_options + title: "Wait for CGC increase for ${validatorPairName} (ignore failure)" + config: + ignoreFailure: true + exitOnResult: true + task: + name: check_consensus_identity + title: "Wait for CGC increase for ${validatorPairName}" + id: final_cgc + timeout: 1h + if: "tasks.cgc_check.outputs.isMaxCGC != true" + config: + minClientCount: 1 + pollInterval: 30s + configVars: + clientPattern: "validatorPairName" + minCgc: "| (.tasks.initial_cgc.outputs.matchingClients[0].cgc // 0) + 1" + + # Step 8: Verify CGC results + - name: run_shell + title: "Verify CGC results for ${validatorPairName}" + id: cgc_results + timeout: 1m + config: + envVars: + initial_clients: "tasks.initial_cgc.outputs.matchingClients" + final_clients: "tasks.final_cgc.outputs.matchingClients" + is_max_cgc: "tasks.cgc_check.outputs.isMaxCGC" + client_name: "validatorPairName" + command: | + initial_cgc=$(echo "$initial_clients" | jq -r '.[0].cgc') + + if [ "$is_max_cgc" = "true" ]; then + echo "✅ CGC was already at maximum (128) for $client_name" + echo "::set-out-json cgc_initial $initial_cgc" + echo "::set-out-json cgc_final $initial_cgc" + echo "::set-out-json cgc_increase 0" + echo "::set-out-json was_at_max true" + else + final_cgc=$(echo "$final_clients" | jq -r '.[0].cgc') + + echo "Initial CGC: $initial_cgc" + echo "Final CGC: $final_cgc" + + if [ "$final_cgc" -le "$initial_cgc" ]; then + echo "❌ CGC did not increase! Initial: $initial_cgc, Final: $final_cgc" + exit 1 + fi + + cgc_increase=$((final_cgc - initial_cgc)) + echo "✅ CGC increased by $cgc_increase (from $initial_cgc to $final_cgc)" + + echo "::set-out-json cgc_initial $initial_cgc" + echo "::set-out-json cgc_final $final_cgc" + echo "::set-out-json cgc_increase $cgc_increase" + echo "::set-out-json was_at_max false" + fi + +# Final summary task +- name: run_shell + title: "Display test summary" + timeout: 1m + config: + envVars: + validatorPairNames: "validatorPairNames" + results: "| [.tasks.cgc_test.outputs.childScopes[]?.tasks.cgc_results.outputs]" + command: | + echo "=== CGC Validation Test Summary ===" + + # Count validator pairs + pair_count=$(echo "$validatorPairNames" | jq -r 'length') + echo "Test completed for $pair_count validator pairs" + echo "" + + # Extract pair names array + pairs=$(echo "$validatorPairNames" | jq -r '.[]') + + # Process results with jq + total_increase=0 + at_max_count=0 + increased_count=0 + + # Loop through results with index + for i in $(seq 0 $((pair_count - 1))); do + # Get pair name and result for this index + pair=$(echo "$validatorPairNames" | jq -r ".[$i]") + result=$(echo "$results" | jq -r ".[$i]") + + if [ "$result" != "null" ]; then + # Extract values from result + initial=$(echo "$result" | jq -r '.cgc_initial') + final=$(echo "$result" | jq -r '.cgc_final') + increase=$(echo "$result" | jq -r '.cgc_increase') + was_at_max=$(echo "$result" | jq -r '.was_at_max') + + echo "Results for $pair:" + echo " Initial CGC: $initial" + echo " Final CGC: $final" + + if [ "$was_at_max" = "true" ]; then + echo " Status: Already at maximum (128)" + at_max_count=$((at_max_count + 1)) + else + echo " CGC Increase: $increase" + increased_count=$((increased_count + 1)) + total_increase=$((total_increase + increase)) + fi + echo "" + else + echo "Results for $pair: No data available" + echo "" + fi + done + + echo "=== Summary Statistics ===" + echo "Total validator pairs tested: $pair_count" + echo "Pairs already at max CGC: $at_max_count" + echo "Pairs with CGC increase: $increased_count" + echo "Total CGC increase across all pairs: $total_increase" + echo "" + echo "✅ CGC validation test completed successfully!" From 177019f03592b0db9a08e04f3dd1110c3f155902 Mon Sep 17 00:00:00 2001 From: pk910 Date: Mon, 11 Aug 2025 19:56:29 +0200 Subject: [PATCH 2/5] `go fmt` --- .../clients/consensus/rpc/beaconapi.go | 4 +- .../tasks/check_consensus_identity/config.go | 12 ++-- .../tasks/check_consensus_identity/task.go | 64 +++++++++---------- .../tasks/get_consensus_validators/config.go | 34 +++++----- .../tasks/get_consensus_validators/task.go | 40 ++++++------ 5 files changed, 77 insertions(+), 77 deletions(-) diff --git a/pkg/coordinator/clients/consensus/rpc/beaconapi.go b/pkg/coordinator/clients/consensus/rpc/beaconapi.go index 2b1e0431..dd249127 100644 --- a/pkg/coordinator/clients/consensus/rpc/beaconapi.go +++ b/pkg/coordinator/clients/consensus/rpc/beaconapi.go @@ -498,8 +498,8 @@ func (bc *BeaconClient) SubmitProposerSlashing(ctx context.Context, slashing *ph } type NodeIdentity struct { - PeerID string `json:"peer_id"` - ENR string `json:"enr"` + PeerID string `json:"peer_id"` + ENR string `json:"enr"` P2PAddresses []string `json:"p2p_addresses"` DiscoveryAddresses []string `json:"discovery_addresses"` Metadata struct { diff --git a/pkg/coordinator/tasks/check_consensus_identity/config.go b/pkg/coordinator/tasks/check_consensus_identity/config.go index 6c5cc0fd..9f7fc764 100644 --- a/pkg/coordinator/tasks/check_consensus_identity/config.go +++ b/pkg/coordinator/tasks/check_consensus_identity/config.go @@ -15,9 +15,9 @@ type Config struct { FailOnCheckMiss bool `yaml:"failOnCheckMiss" json:"failOnCheckMiss"` // CGC (Custody Group Count) checks - ExpectCGC *int `yaml:"expectCgc" json:"expectCgc"` - MinCGC *int `yaml:"minCgc" json:"minCgc"` - MaxCGC *int `yaml:"maxCgc" json:"maxCgc"` + ExpectCGC *int `yaml:"expectCgc" json:"expectCgc"` + MinCGC *int `yaml:"minCgc" json:"minCgc"` + MaxCGC *int `yaml:"maxCgc" json:"maxCgc"` // ENR checks ExpectENRField map[string]interface{} `yaml:"expectEnrField" json:"expectEnrField"` @@ -26,8 +26,8 @@ type Config struct { ExpectPeerIDPattern string `yaml:"expectPeerIdPattern" json:"expectPeerIdPattern"` // P2P address checks - ExpectP2PAddressCount *int `yaml:"expectP2pAddressCount" json:"expectP2pAddressCount"` - ExpectP2PAddressMatch string `yaml:"expectP2pAddressMatch" json:"expectP2pAddressMatch"` + ExpectP2PAddressCount *int `yaml:"expectP2pAddressCount" json:"expectP2pAddressCount"` + ExpectP2PAddressMatch string `yaml:"expectP2pAddressMatch" json:"expectP2pAddressMatch"` // Metadata checks ExpectSeqNumber *uint64 `yaml:"expectSeqNumber" json:"expectSeqNumber"` @@ -68,4 +68,4 @@ func (c *Config) Validate() error { } return nil -} \ No newline at end of file +} diff --git a/pkg/coordinator/tasks/check_consensus_identity/task.go b/pkg/coordinator/tasks/check_consensus_identity/task.go index 7885f826..66e268fc 100644 --- a/pkg/coordinator/tasks/check_consensus_identity/task.go +++ b/pkg/coordinator/tasks/check_consensus_identity/task.go @@ -36,18 +36,18 @@ type Task struct { } type IdentityCheckResult struct { - ClientName string `json:"clientName"` - PeerID string `json:"peerId"` - ENR string `json:"enr"` - P2PAddresses []string `json:"p2pAddresses"` - DiscoveryAddresses []string `json:"discoveryAddresses"` - SeqNumber uint64 `json:"seqNumber"` - Attnets string `json:"attnets"` - Syncnets string `json:"syncnets"` - CGC int `json:"cgc"` - ENRFields map[string]interface{} `json:"enrFields"` - ChecksPassed bool `json:"checksPassed"` - FailureReasons []string `json:"failureReasons"` + ClientName string `json:"clientName"` + PeerID string `json:"peerId"` + ENR string `json:"enr"` + P2PAddresses []string `json:"p2pAddresses"` + DiscoveryAddresses []string `json:"discoveryAddresses"` + SeqNumber uint64 `json:"seqNumber"` + Attnets string `json:"attnets"` + Syncnets string `json:"syncnets"` + CGC int `json:"cgc"` + ENRFields map[string]interface{} `json:"enrFields"` + ChecksPassed bool `json:"checksPassed"` + FailureReasons []string `json:"failureReasons"` } func NewTask(ctx *types.TaskContext, options *types.TaskOptions) (types.Task, error) { @@ -121,7 +121,7 @@ func (t *Task) processCheck() { t.logger.Infof("Checking identity for client: %s", client.Config.Name) result := t.checkClientIdentity(client) - + // Debug output for each client t.logger.Infof("Client %s identity check result:", result.ClientName) t.logger.Infof(" PeerID: %s", result.PeerID) @@ -177,7 +177,7 @@ func (t *Task) processCheck() { t.logger.Infof(" Failed: %d", totalClientCount-passResultCount) t.logger.Infof(" Required pass count: %d", requiredPassCount) t.logger.Infof(" Overall result: %v", resultPass) - + if len(failedClientNames) > 0 { t.logger.Infof(" Failed clients: %v", failedClientNames) } @@ -226,7 +226,7 @@ func (t *Task) checkClientIdentity(client *clients.PoolClient) *IdentityCheckRes return result } - t.logger.Debugf("Retrieved node identity for client %s: PeerID=%s, ENR=%s", + t.logger.Debugf("Retrieved node identity for client %s: PeerID=%s, ENR=%s", client.Config.Name, identity.PeerID, identity.ENR) result.PeerID = identity.PeerID @@ -263,19 +263,19 @@ func (t *Task) performChecks(result *IdentityCheckResult) { // Check CGC expectations if t.config.ExpectCGC != nil && result.CGC != *t.config.ExpectCGC { result.ChecksPassed = false - result.FailureReasons = append(result.FailureReasons, + result.FailureReasons = append(result.FailureReasons, fmt.Sprintf("Expected CGC %d, got %d", *t.config.ExpectCGC, result.CGC)) } if t.config.MinCGC != nil && result.CGC < *t.config.MinCGC { result.ChecksPassed = false - result.FailureReasons = append(result.FailureReasons, + result.FailureReasons = append(result.FailureReasons, fmt.Sprintf("CGC %d is below minimum %d", result.CGC, *t.config.MinCGC)) } if t.config.MaxCGC != nil && result.CGC > *t.config.MaxCGC { result.ChecksPassed = false - result.FailureReasons = append(result.FailureReasons, + result.FailureReasons = append(result.FailureReasons, fmt.Sprintf("CGC %d is above maximum %d", result.CGC, *t.config.MaxCGC)) } @@ -284,11 +284,11 @@ func (t *Task) performChecks(result *IdentityCheckResult) { matched, err := regexp.MatchString(t.config.ExpectPeerIDPattern, result.PeerID) if err != nil { result.ChecksPassed = false - result.FailureReasons = append(result.FailureReasons, + result.FailureReasons = append(result.FailureReasons, fmt.Sprintf("Invalid PeerID pattern: %v", err)) } else if !matched { result.ChecksPassed = false - result.FailureReasons = append(result.FailureReasons, + result.FailureReasons = append(result.FailureReasons, fmt.Sprintf("PeerID %s does not match pattern %s", result.PeerID, t.config.ExpectPeerIDPattern)) } } @@ -296,7 +296,7 @@ func (t *Task) performChecks(result *IdentityCheckResult) { // Check P2P address count if t.config.ExpectP2PAddressCount != nil && len(result.P2PAddresses) != *t.config.ExpectP2PAddressCount { result.ChecksPassed = false - result.FailureReasons = append(result.FailureReasons, + result.FailureReasons = append(result.FailureReasons, fmt.Sprintf("Expected %d P2P addresses, got %d", *t.config.ExpectP2PAddressCount, len(result.P2PAddresses))) } @@ -311,7 +311,7 @@ func (t *Task) performChecks(result *IdentityCheckResult) { } if !found { result.ChecksPassed = false - result.FailureReasons = append(result.FailureReasons, + result.FailureReasons = append(result.FailureReasons, fmt.Sprintf("No P2P address matches pattern %s", t.config.ExpectP2PAddressMatch)) } } @@ -319,13 +319,13 @@ func (t *Task) performChecks(result *IdentityCheckResult) { // Check sequence number if t.config.ExpectSeqNumber != nil && result.SeqNumber != *t.config.ExpectSeqNumber { result.ChecksPassed = false - result.FailureReasons = append(result.FailureReasons, + result.FailureReasons = append(result.FailureReasons, fmt.Sprintf("Expected sequence number %d, got %d", *t.config.ExpectSeqNumber, result.SeqNumber)) } if t.config.MinSeqNumber != nil && result.SeqNumber < *t.config.MinSeqNumber { result.ChecksPassed = false - result.FailureReasons = append(result.FailureReasons, + result.FailureReasons = append(result.FailureReasons, fmt.Sprintf("Sequence number %d is below minimum %d", result.SeqNumber, *t.config.MinSeqNumber)) } @@ -333,11 +333,11 @@ func (t *Task) performChecks(result *IdentityCheckResult) { for expectedField, expectedValue := range t.config.ExpectENRField { if actualValue, exists := result.ENRFields[expectedField]; !exists { result.ChecksPassed = false - result.FailureReasons = append(result.FailureReasons, + result.FailureReasons = append(result.FailureReasons, fmt.Sprintf("Expected ENR field %s not found", expectedField)) } else if actualValue != expectedValue { result.ChecksPassed = false - result.FailureReasons = append(result.FailureReasons, + result.FailureReasons = append(result.FailureReasons, fmt.Sprintf("ENR field %s expected %v, got %v", expectedField, expectedValue, actualValue)) } } @@ -385,7 +385,7 @@ func (t *Task) extractCGCFromENR(enrStr string) (int, map[string]interface{}, er } enrFields["enr_original"] = enrStr - + return cgc, enrFields, nil } @@ -395,13 +395,13 @@ func (t *Task) decodeENR(raw string) (*enr.Record, error) { if strings.HasPrefix(raw, "enr:") { b = b[4:] } - + dec := make([]byte, base64.RawURLEncoding.DecodedLen(len(b))) n, err := base64.RawURLEncoding.Decode(dec, b) if err != nil { return nil, err } - + var r enr.Record err = rlp.DecodeBytes(dec[:n], &r) return &r, err @@ -424,7 +424,7 @@ func (t *Task) getKeyValuesFromENR(r *enr.Record) map[string]interface{} { fmtval := t.formatENRValue(key, val) fields[key] = fmtval } - + return fields } @@ -459,6 +459,6 @@ func (t *Task) formatENRValue(key string, val rlp.RawValue) string { if err == nil { return "0x" + hex.EncodeToString(content) } - + return "0x" + hex.EncodeToString(val) -} \ No newline at end of file +} diff --git a/pkg/coordinator/tasks/get_consensus_validators/config.go b/pkg/coordinator/tasks/get_consensus_validators/config.go index 3c844c03..3527a856 100644 --- a/pkg/coordinator/tasks/get_consensus_validators/config.go +++ b/pkg/coordinator/tasks/get_consensus_validators/config.go @@ -5,16 +5,16 @@ import ( ) type Config struct { - ClientPattern string `yaml:"clientPattern" json:"clientPattern"` - ValidatorNamePattern string `yaml:"validatorNamePattern" json:"validatorNamePattern"` - ValidatorStatus []string `yaml:"validatorStatus" json:"validatorStatus"` - MinValidatorBalance *uint64 `yaml:"minValidatorBalance" json:"minValidatorBalance"` - MaxValidatorBalance *uint64 `yaml:"maxValidatorBalance" json:"maxValidatorBalance"` - WithdrawalCredsPrefix string `yaml:"withdrawalCredsPrefix" json:"withdrawalCredsPrefix"` - MinValidatorIndex *uint64 `yaml:"minValidatorIndex" json:"minValidatorIndex"` - MaxValidatorIndex *uint64 `yaml:"maxValidatorIndex" json:"maxValidatorIndex"` - MaxResults int `yaml:"maxResults" json:"maxResults"` - + ClientPattern string `yaml:"clientPattern" json:"clientPattern"` + ValidatorNamePattern string `yaml:"validatorNamePattern" json:"validatorNamePattern"` + ValidatorStatus []string `yaml:"validatorStatus" json:"validatorStatus"` + MinValidatorBalance *uint64 `yaml:"minValidatorBalance" json:"minValidatorBalance"` + MaxValidatorBalance *uint64 `yaml:"maxValidatorBalance" json:"maxValidatorBalance"` + WithdrawalCredsPrefix string `yaml:"withdrawalCredsPrefix" json:"withdrawalCredsPrefix"` + MinValidatorIndex *uint64 `yaml:"minValidatorIndex" json:"minValidatorIndex"` + MaxValidatorIndex *uint64 `yaml:"maxValidatorIndex" json:"maxValidatorIndex"` + MaxResults int `yaml:"maxResults" json:"maxResults"` + // Output format options OutputFormat string `yaml:"outputFormat" json:"outputFormat"` // "full", "pubkeys", "indices" } @@ -25,7 +25,7 @@ func DefaultConfig() Config { OutputFormat: "full", ValidatorStatus: []string{ "pending_initialized", - "pending_queued", + "pending_queued", "active_ongoing", "active_exiting", "active_slashed", @@ -41,23 +41,23 @@ func (c *Config) Validate() error { if c.ClientPattern == "" && c.ValidatorNamePattern == "" { return fmt.Errorf("either clientPattern or validatorNamePattern is required") } - + if c.MaxResults <= 0 { return fmt.Errorf("maxResults must be > 0") } - + if c.MinValidatorIndex != nil && c.MaxValidatorIndex != nil && *c.MinValidatorIndex > *c.MaxValidatorIndex { return fmt.Errorf("minValidatorIndex must be <= maxValidatorIndex") } - + if c.MinValidatorBalance != nil && c.MaxValidatorBalance != nil && *c.MinValidatorBalance > *c.MaxValidatorBalance { return fmt.Errorf("minValidatorBalance must be <= maxValidatorBalance") } - + validFormats := map[string]bool{"full": true, "pubkeys": true, "indices": true} if !validFormats[c.OutputFormat] { return fmt.Errorf("outputFormat must be one of: full, pubkeys, indices") } - + return nil -} \ No newline at end of file +} diff --git a/pkg/coordinator/tasks/get_consensus_validators/task.go b/pkg/coordinator/tasks/get_consensus_validators/task.go index f57b1192..ab1a9afa 100644 --- a/pkg/coordinator/tasks/get_consensus_validators/task.go +++ b/pkg/coordinator/tasks/get_consensus_validators/task.go @@ -30,16 +30,16 @@ type Task struct { } type ValidatorInfo struct { - Index int `json:"index"` - Pubkey string `json:"pubkey"` - Balance uint64 `json:"balance"` - Status string `json:"status"` - EffectiveBalance uint64 `json:"effectiveBalance"` + Index int `json:"index"` + Pubkey string `json:"pubkey"` + Balance uint64 `json:"balance"` + Status string `json:"status"` + EffectiveBalance uint64 `json:"effectiveBalance"` WithdrawalCredentials string `json:"withdrawalCredentials"` - ActivationEpoch uint64 `json:"activationEpoch"` - ExitEpoch uint64 `json:"exitEpoch"` - WithdrawableEpoch uint64 `json:"withdrawableEpoch"` - Slashed bool `json:"slashed"` + ActivationEpoch uint64 `json:"activationEpoch"` + ExitEpoch uint64 `json:"exitEpoch"` + WithdrawableEpoch uint64 `json:"withdrawableEpoch"` + Slashed bool `json:"slashed"` } func NewTask(ctx *types.TaskContext, options *types.TaskOptions) (types.Task, error) { @@ -94,7 +94,7 @@ func (t *Task) Execute(ctx context.Context) error { } matchingValidators := []ValidatorInfo{} - + // Compile validator name pattern regex if provided var validatorNameRegex *regexp.Regexp if t.config.ValidatorNamePattern != "" { @@ -105,7 +105,7 @@ func (t *Task) Execute(ctx context.Context) error { } } - // Compile client pattern regex if provided + // Compile client pattern regex if provided var clientNameRegex *regexp.Regexp if t.config.ClientPattern != "" { var err error @@ -183,15 +183,15 @@ func (t *Task) Execute(ctx context.Context) error { // Create validator info validatorInfo := ValidatorInfo{ Index: int(validatorIndex), - Pubkey: fmt.Sprintf("0x%x", validator.Validator.PublicKey), - Balance: uint64(validator.Balance), - Status: validator.Status.String(), - EffectiveBalance: uint64(validator.Validator.EffectiveBalance), + Pubkey: fmt.Sprintf("0x%x", validator.Validator.PublicKey), + Balance: uint64(validator.Balance), + Status: validator.Status.String(), + EffectiveBalance: uint64(validator.Validator.EffectiveBalance), WithdrawalCredentials: fmt.Sprintf("0x%x", validator.Validator.WithdrawalCredentials), - ActivationEpoch: uint64(validator.Validator.ActivationEpoch), - ExitEpoch: uint64(validator.Validator.ExitEpoch), - WithdrawableEpoch: uint64(validator.Validator.WithdrawableEpoch), - Slashed: validator.Validator.Slashed, + ActivationEpoch: uint64(validator.Validator.ActivationEpoch), + ExitEpoch: uint64(validator.Validator.ExitEpoch), + WithdrawableEpoch: uint64(validator.Validator.WithdrawableEpoch), + Slashed: validator.Validator.Slashed, } matchingValidators = append(matchingValidators, validatorInfo) @@ -239,4 +239,4 @@ func (t *Task) Execute(ctx context.Context) error { } return nil -} \ No newline at end of file +} From 1455cc02ba311317601a0bf3bf7677b069b65b87 Mon Sep 17 00:00:00 2001 From: pk910 Date: Mon, 11 Aug 2025 20:06:01 +0200 Subject: [PATCH 3/5] fix linter issues --- .../tasks/check_consensus_identity/config.go | 18 ++-------- .../tasks/check_consensus_identity/task.go | 36 ++++++++++++++----- .../tasks/get_consensus_validators/task.go | 19 +++++++--- 3 files changed, 46 insertions(+), 27 deletions(-) diff --git a/pkg/coordinator/tasks/check_consensus_identity/config.go b/pkg/coordinator/tasks/check_consensus_identity/config.go index 9f7fc764..769b1dac 100644 --- a/pkg/coordinator/tasks/check_consensus_identity/config.go +++ b/pkg/coordinator/tasks/check_consensus_identity/config.go @@ -15,9 +15,9 @@ type Config struct { FailOnCheckMiss bool `yaml:"failOnCheckMiss" json:"failOnCheckMiss"` // CGC (Custody Group Count) checks - ExpectCGC *int `yaml:"expectCgc" json:"expectCgc"` - MinCGC *int `yaml:"minCgc" json:"minCgc"` - MaxCGC *int `yaml:"maxCgc" json:"maxCgc"` + ExpectCGC *uint64 `yaml:"expectCgc" json:"expectCgc"` + MinCGC *uint64 `yaml:"minCgc" json:"minCgc"` + MaxCGC *uint64 `yaml:"maxCgc" json:"maxCgc"` // ENR checks ExpectENRField map[string]interface{} `yaml:"expectEnrField" json:"expectEnrField"` @@ -47,18 +47,6 @@ func (c *Config) Validate() error { return fmt.Errorf("clientPattern is required") } - if c.ExpectCGC != nil && *c.ExpectCGC < 0 { - return fmt.Errorf("expectCgc must be >= 0") - } - - if c.MinCGC != nil && *c.MinCGC < 0 { - return fmt.Errorf("minCgc must be >= 0") - } - - if c.MaxCGC != nil && *c.MaxCGC < 0 { - return fmt.Errorf("maxCgc must be >= 0") - } - if c.MinCGC != nil && c.MaxCGC != nil && *c.MinCGC > *c.MaxCGC { return fmt.Errorf("minCgc must be <= maxCgc") } diff --git a/pkg/coordinator/tasks/check_consensus_identity/task.go b/pkg/coordinator/tasks/check_consensus_identity/task.go index 66e268fc..c25f13b9 100644 --- a/pkg/coordinator/tasks/check_consensus_identity/task.go +++ b/pkg/coordinator/tasks/check_consensus_identity/task.go @@ -44,7 +44,7 @@ type IdentityCheckResult struct { SeqNumber uint64 `json:"seqNumber"` Attnets string `json:"attnets"` Syncnets string `json:"syncnets"` - CGC int `json:"cgc"` + CGC uint64 `json:"cgc"` ENRFields map[string]interface{} `json:"enrFields"` ChecksPassed bool `json:"checksPassed"` FailureReasons []string `json:"failureReasons"` @@ -118,6 +118,7 @@ func (t *Task) processCheck() { } totalClientCount++ + t.logger.Infof("Checking identity for client: %s", client.Config.Name) result := t.checkClientIdentity(client) @@ -131,12 +132,14 @@ func (t *Task) processCheck() { t.logger.Infof(" Discovery Addresses: %v", result.DiscoveryAddresses) t.logger.Infof(" Sequence Number: %d", result.SeqNumber) t.logger.Infof(" Checks Passed: %v", result.ChecksPassed) + if len(result.FailureReasons) > 0 { t.logger.Infof(" Failure Reasons: %v", result.FailureReasons) } if result.ChecksPassed { passResultCount++ + matchingClients = append(matchingClients, result) t.logger.Infof("✅ Client %s passed all checks", result.ClientName) } else { @@ -221,8 +224,10 @@ func (t *Task) checkClientIdentity(client *clients.PoolClient) *IdentityCheckRes identity, err := client.ConsensusClient.GetRPCClient().GetNodeIdentity(ctx) if err != nil { t.logger.Errorf("Failed to get node identity for client %s: %v", client.Config.Name, err) + result.ChecksPassed = false result.FailureReasons = append(result.FailureReasons, fmt.Sprintf("Failed to get node identity: %v", err)) + return result } @@ -239,11 +244,14 @@ func (t *Task) checkClientIdentity(client *clients.PoolClient) *IdentityCheckRes // Extract CGC from ENR t.logger.Debugf("Extracting CGC from ENR for client %s", client.Config.Name) + cgc, enrFields, err := t.extractCGCFromENR(identity.ENR) if err != nil { t.logger.Errorf("Failed to parse ENR for client %s: %v", client.Config.Name, err) + result.ChecksPassed = false result.FailureReasons = append(result.FailureReasons, fmt.Sprintf("Failed to parse ENR: %v", err)) + return result } @@ -303,12 +311,14 @@ func (t *Task) performChecks(result *IdentityCheckResult) { // Check P2P address match if t.config.ExpectP2PAddressMatch != "" { found := false + for _, addr := range result.P2PAddresses { if matched, _ := regexp.MatchString(t.config.ExpectP2PAddressMatch, addr); matched { found = true break } } + if !found { result.ChecksPassed = false result.FailureReasons = append(result.FailureReasons, @@ -344,7 +354,7 @@ func (t *Task) performChecks(result *IdentityCheckResult) { } // extractCGCFromENR extracts the Custody Group Count from ENR using proper ENR parsing -func (t *Task) extractCGCFromENR(enrStr string) (int, map[string]interface{}, error) { +func (t *Task) extractCGCFromENR(enrStr string) (cgc uint64, enrFields map[string]interface{}, err error) { if enrStr == "" { t.logger.Debugf("Empty ENR provided") return 0, nil, fmt.Errorf("empty ENR") @@ -360,10 +370,8 @@ func (t *Task) extractCGCFromENR(enrStr string) (int, map[string]interface{}, er } // Get all key-value pairs from ENR - enrFields := t.getKeyValuesFromENR(record) + enrFields = t.getKeyValuesFromENR(record) - // Extract CGC from the fields - cgc := 0 if cgcHex, ok := enrFields["cgc"]; ok { // CGC is stored as hex string, parse it cgcStr, ok := cgcHex.(string) @@ -372,11 +380,12 @@ func (t *Task) extractCGCFromENR(enrStr string) (int, map[string]interface{}, er } else { // Remove "0x" prefix if present cgcStr = strings.TrimPrefix(cgcStr, "0x") + val, err := strconv.ParseUint(cgcStr, 16, 64) if err != nil { t.logger.Errorf("Failed to parse CGC value %s: %v", cgcStr, err) } else { - cgc = int(val) + cgc = val t.logger.Debugf("Found CGC in ENR: %d", cgc) } } @@ -397,6 +406,7 @@ func (t *Task) decodeENR(raw string) (*enr.Record, error) { } dec := make([]byte, base64.RawURLEncoding.DecodedLen(len(b))) + n, err := base64.RawURLEncoding.Decode(dec, b) if err != nil { return nil, err @@ -404,6 +414,7 @@ func (t *Task) decodeENR(raw string) (*enr.Record, error) { var r enr.Record err = rlp.DecodeBytes(dec[:n], &r) + return &r, err } @@ -417,8 +428,17 @@ func (t *Task) getKeyValuesFromENR(r *enr.Record) map[string]interface{} { // Get all key-value pairs from the record kv := r.AppendElements(nil)[1:] // Skip the sequence number for i := 0; i < len(kv); i += 2 { - key := kv[i].(string) - val := kv[i+1].(rlp.RawValue) + key, ok := kv[i].(string) + if !ok { + t.logger.Warnf("Invalid ENR key type: %T", kv[i]) + continue + } + + val, ok := kv[i+1].(rlp.RawValue) + if !ok { + t.logger.Warnf("Invalid ENR value type for key %s: %T", key, kv[i+1]) + continue + } // Format the value based on the key fmtval := t.formatENRValue(key, val) diff --git a/pkg/coordinator/tasks/get_consensus_validators/task.go b/pkg/coordinator/tasks/get_consensus_validators/task.go index ab1a9afa..2a294ff2 100644 --- a/pkg/coordinator/tasks/get_consensus_validators/task.go +++ b/pkg/coordinator/tasks/get_consensus_validators/task.go @@ -30,7 +30,7 @@ type Task struct { } type ValidatorInfo struct { - Index int `json:"index"` + Index uint64 `json:"index"` Pubkey string `json:"pubkey"` Balance uint64 `json:"balance"` Status string `json:"status"` @@ -81,7 +81,8 @@ func (t *Task) LoadConfig() error { return nil } -func (t *Task) Execute(ctx context.Context) error { +//nolint:gocyclo // ignore +func (t *Task) Execute(_ context.Context) error { // Get client pool and validator names clientPool := t.ctx.Scheduler.GetServices().ClientPool() consensusPool := clientPool.GetConsensusPool() @@ -97,9 +98,11 @@ func (t *Task) Execute(ctx context.Context) error { // Compile validator name pattern regex if provided var validatorNameRegex *regexp.Regexp + if t.config.ValidatorNamePattern != "" { var err error validatorNameRegex, err = regexp.Compile(t.config.ValidatorNamePattern) + if err != nil { return fmt.Errorf("invalid validator name pattern: %v", err) } @@ -107,8 +110,10 @@ func (t *Task) Execute(ctx context.Context) error { // Compile client pattern regex if provided var clientNameRegex *regexp.Regexp + if t.config.ClientPattern != "" { var err error + clientNameRegex, err = regexp.Compile(t.config.ClientPattern) if err != nil { return fmt.Errorf("invalid client pattern: %v", err) @@ -125,6 +130,7 @@ func (t *Task) Execute(ctx context.Context) error { if t.config.MinValidatorIndex != nil && uint64(validatorIndex) < *t.config.MinValidatorIndex { continue } + if t.config.MaxValidatorIndex != nil && uint64(validatorIndex) > *t.config.MaxValidatorIndex { continue } @@ -133,6 +139,7 @@ func (t *Task) Execute(ctx context.Context) error { if t.config.MinValidatorBalance != nil && uint64(validator.Balance) < *t.config.MinValidatorBalance { continue } + if t.config.MaxValidatorBalance != nil && uint64(validator.Balance) > *t.config.MaxValidatorBalance { continue } @@ -149,12 +156,14 @@ func (t *Task) Execute(ctx context.Context) error { if len(t.config.ValidatorStatus) > 0 { statusMatch := false validatorStatus := validator.Status.String() + for _, allowedStatus := range t.config.ValidatorStatus { if validatorStatus == allowedStatus { statusMatch = true break } } + if !statusMatch { continue } @@ -182,7 +191,7 @@ func (t *Task) Execute(ctx context.Context) error { // Create validator info validatorInfo := ValidatorInfo{ - Index: int(validatorIndex), + Index: uint64(validatorIndex), Pubkey: fmt.Sprintf("0x%x", validator.Validator.PublicKey), Balance: uint64(validator.Balance), Status: validator.Status.String(), @@ -210,16 +219,18 @@ func (t *Task) Execute(ctx context.Context) error { for i, validator := range matchingValidators { pubkeys[i] = validator.Pubkey } + if pubkeysData, err := vars.GeneralizeData(pubkeys); err == nil { t.ctx.Outputs.SetVar("pubkeys", pubkeysData) } else { return fmt.Errorf("failed to generalize pubkeys data: %v", err) } case "indices": - indices := make([]int, len(matchingValidators)) + indices := make([]uint64, len(matchingValidators)) for i, validator := range matchingValidators { indices[i] = validator.Index } + if indicesData, err := vars.GeneralizeData(indices); err == nil { t.ctx.Outputs.SetVar("indices", indicesData) } else { From 16f6579a98999e411ffb9a4969760771f9488e4e Mon Sep 17 00:00:00 2001 From: pk910 Date: Mon, 11 Aug 2025 20:21:23 +0200 Subject: [PATCH 4/5] add analyze-cgc test --- .../fusaka-dev/kurtosis/analyze-cgc.yaml | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 playbooks/fusaka-dev/kurtosis/analyze-cgc.yaml diff --git a/playbooks/fusaka-dev/kurtosis/analyze-cgc.yaml b/playbooks/fusaka-dev/kurtosis/analyze-cgc.yaml new file mode 100644 index 00000000..9adb65b8 --- /dev/null +++ b/playbooks/fusaka-dev/kurtosis/analyze-cgc.yaml @@ -0,0 +1,250 @@ +id: analyze-cgc +name: "CGC Analysis - Validate CGC vs Validator Balances" +timeout: 30m + +config: + validatorPairNames: [] + +tasks: +- name: check_clients_are_healthy + title: "Check if at least one client is ready" + timeout: 5m + config: + minClientCount: 1 + +# Matrix task to analyze each client pattern +- name: run_task_matrix + title: "Analyze CGC for all client patterns" + id: cgc_analysis + timeout: 25m + configVars: + matrixValues: "validatorPairNames" + config: + runConcurrent: true + matrixVar: "validatorPairName" + task: + name: run_tasks + title: "Analyze CGC for ${validatorPairName}" + config: + tasks: + # Step 1: Get consensus client identity (CGC) + - name: check_consensus_identity + title: "Get CGC for clients matching ${validatorPairName}" + id: client_identity + timeout: 30s + config: + minClientCount: 1 + failOnCheckMiss: false + pollInterval: 5s + configVars: + clientPattern: "validatorPairName" + + # Step 2: Get validators for these clients + - name: get_consensus_validators + title: "Get validators for clients matching ${validatorPairName}" + id: client_validators + timeout: 60s + config: + validatorStatus: ["active_ongoing", "active_exiting", "active_slashed"] + maxResults: 10000 # Get all validators + outputFormat: "full" + configVars: + clientPattern: "validatorPairName" + + # Step 3: Calculate and analyze results + - name: run_shell + title: "Calculate CGC analysis for ${validatorPairName}" + id: cgc_calculation + timeout: 30s + config: + envVars: + client_identity: "tasks.client_identity.outputs.matchingClients" + validators: "tasks.client_validators.outputs.validators" + client_pattern: "validatorPairName" + command: | + echo "=== CGC Analysis for ${client_pattern} ===" + + # Extract client info + client_count=$(echo "$client_identity" | jq -r 'length') + if [ "$client_count" -eq 0 ]; then + echo "⚠️ No clients found matching pattern: ${client_pattern}" + echo "::set-out-json status \"no_clients\"" + echo "::set-out-json client_name \"${client_pattern}\"" + exit 0 + fi + + # Get first client's data + client_name=$(echo "$client_identity" | jq -r '.[0].clientName') + current_cgc=$(echo "$client_identity" | jq -r '.[0].cgc') + + echo "Client: $client_name" + echo "Current CGC: $current_cgc" + + # Calculate validator stats + validator_count=$(echo "$validators" | jq -r 'length') + echo "Validators found: $validator_count" + + if [ "$validator_count" -eq 0 ]; then + echo "⚠️ No validators found for client: $client_name" + echo "::set-out-json status \"no_validators\"" + echo "::set-out-json client_name \"$client_name\"" + echo "::set-out-json current_cgc $current_cgc" + echo "::set-out-json validator_count 0" + echo "::set-out-json total_effective_balance_eth 0" + echo "::set-out-json required_cgc 0" + echo "::set-out-json cgc_sufficient true" + exit 0 + fi + + # Sum effective balances (in gwei) + total_balance_gwei=$(echo "$validators" | jq -r '[.[].effectiveBalance] | add') + + # Convert to ETH (1 ETH = 1e9 gwei) + total_balance_eth=$(echo "scale=6; $total_balance_gwei / 1000000000" | bc -l) + + echo "Total effective balance: ${total_balance_eth} ETH" + + # Calculate required CGC: ceil(total_balance_eth / 32) + # Using bc for floating point math + required_cgc=$(echo "scale=0; ($total_balance_gwei + 31999999999) / 32000000000" | bc -l) + + echo "Required CGC (minimum): $required_cgc" + echo "Current CGC: $current_cgc" + + # Check if CGC is sufficient + if [ "$current_cgc" -ge "$required_cgc" ]; then + cgc_sufficient=true + status="✅ CGC sufficient" + echo "$status" + else + cgc_sufficient=false + status="❌ CGC insufficient" + echo "$status" + deficit=$((required_cgc - current_cgc)) + echo "CGC deficit: $deficit" + fi + + # Set outputs + echo "::set-out-json status \"analyzed\"" + echo "::set-out-json client_name \"$client_name\"" + echo "::set-out-json current_cgc $current_cgc" + echo "::set-out-json validator_count $validator_count" + echo "::set-out-json total_effective_balance_eth $total_balance_eth" + echo "::set-out-json total_effective_balance_gwei $total_balance_gwei" + echo "::set-out-json required_cgc $required_cgc" + echo "::set-out-json cgc_sufficient $cgc_sufficient" + + if [ "$cgc_sufficient" = "false" ]; then + echo "::set-out-json cgc_deficit $((required_cgc - current_cgc))" + else + echo "::set-out-json cgc_deficit 0" + fi + +# Final summary +- name: run_shell + title: "CGC Analysis Summary" + timeout: 1m + config: + envVars: + analysis_results: "| [.tasks.cgc_analysis.outputs.childScopes[]?.tasks.cgc_calculation.outputs]" + command: | + echo "=======================================" + echo " CGC ANALYSIS SUMMARY" + echo "=======================================" + echo "" + + # Count results + total_clients=$(echo "$analysis_results" | jq -r 'length') + analyzed_count=0 + no_clients_count=0 + no_validators_count=0 + sufficient_count=0 + insufficient_count=0 + total_balance_sum=0 + total_validator_count=0 + + echo "Analyzed $total_clients client patterns:" + echo "" + + for i in $(seq 0 $((total_clients - 1))); do + result=$(echo "$analysis_results" | jq -r ".[$i]") + + if [ "$result" != "null" ]; then + status=$(echo "$result" | jq -r '.status // "unknown"') + client_name=$(echo "$result" | jq -r '.client_name // "unknown"') + + case "$status" in + "analyzed") + analyzed_count=$((analyzed_count + 1)) + current_cgc=$(echo "$result" | jq -r '.current_cgc') + validator_count=$(echo "$result" | jq -r '.validator_count') + balance_eth=$(echo "$result" | jq -r '.total_effective_balance_eth') + balance_gwei=$(echo "$result" | jq -r '.total_effective_balance_gwei') + required_cgc=$(echo "$result" | jq -r '.required_cgc') + cgc_sufficient=$(echo "$result" | jq -r '.cgc_sufficient') + + echo "📊 Client: $client_name" + echo " Validators: $validator_count" + echo " Total Balance: ${balance_eth} ETH" + echo " Current CGC: $current_cgc" + echo " Required CGC: $required_cgc" + + if [ "$cgc_sufficient" = "true" ]; then + echo " Status: ✅ CGC Sufficient" + sufficient_count=$((sufficient_count + 1)) + else + deficit=$(echo "$result" | jq -r '.cgc_deficit') + echo " Status: ❌ CGC Insufficient (deficit: $deficit)" + insufficient_count=$((insufficient_count + 1)) + fi + + # Add to totals (using integer math for gwei) + total_balance_sum=$((total_balance_sum + balance_gwei)) + total_validator_count=$((total_validator_count + validator_count)) + ;; + "no_clients") + no_clients_count=$((no_clients_count + 1)) + echo "⚠️ Pattern: $client_name - No clients found" + ;; + "no_validators") + no_validators_count=$((no_validators_count + 1)) + current_cgc=$(echo "$result" | jq -r '.current_cgc') + echo "⚠️ Client: $client_name - No validators (CGC: $current_cgc)" + ;; + esac + echo "" + fi + done + + echo "=======================================" + echo " SUMMARY STATISTICS" + echo "=======================================" + echo "Total patterns analyzed: $total_clients" + echo "Clients analyzed: $analyzed_count" + echo "Patterns with no clients: $no_clients_count" + echo "Clients with no validators: $no_validators_count" + echo "" + echo "CGC Status:" + echo " ✅ Sufficient: $sufficient_count" + echo " ❌ Insufficient: $insufficient_count" + echo "" + + if [ "$total_validator_count" -gt 0 ]; then + # Convert total balance back to ETH for display + total_balance_eth=$(echo "scale=6; $total_balance_sum / 1000000000" | bc -l) + echo "Network totals:" + echo " Total validators: $total_validator_count" + echo " Total effective balance: ${total_balance_eth} ETH" + + # Calculate network-wide required CGC + network_required_cgc=$(echo "scale=0; ($total_balance_sum + 31999999999) / 32000000000" | bc -l) + echo " Network required CGC: $network_required_cgc" + fi + + echo "" + if [ "$insufficient_count" -eq 0 ]; then + echo "🎉 All clients have sufficient CGC!" + else + echo "⚠️ $insufficient_count clients have insufficient CGC" + fi + echo "=======================================" \ No newline at end of file From e8a7d58d81d46f9585931b781f1e5bc6d61a9462 Mon Sep 17 00:00:00 2001 From: pk910 Date: Mon, 11 Aug 2025 21:07:11 +0200 Subject: [PATCH 5/5] port mac fix from #92 --- Dockerfile-local | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile-local b/Dockerfile-local index e2093cc9..e3dbbbf6 100644 --- a/Dockerfile-local +++ b/Dockerfile-local @@ -23,11 +23,12 @@ RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-reco && update-ca-certificates ARG userid=10001 ARG groupid=10001 -RUN groupadd -g ${groupid} assertoor && useradd -m -u ${userid} -g assertoor assertoor +RUN (getent group ${groupid} || groupadd -g ${groupid} assertoor) && \ + useradd -m -u ${userid} -g ${groupid} assertoor RUN echo "assertoor ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/assertoor WORKDIR /app COPY --from=builder /src/bin/* /app/ -RUN chown -R assertoor:assertoor /app +RUN chown -R assertoor:${groupid} /app RUN mkdir /workspace USER assertoor WORKDIR /workspace