Skip to content

Commit 9ce6f3d

Browse files
committed
feat: add enr cgc checker
1 parent 6539304 commit 9ce6f3d

File tree

5 files changed

+394
-0
lines changed

5 files changed

+394
-0
lines changed

pkg/coordinator/clients/consensus/rpc/beaconapi.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,3 +496,25 @@ func (bc *BeaconClient) SubmitProposerSlashing(ctx context.Context, slashing *ph
496496

497497
return nil
498498
}
499+
500+
type NodeIdentity struct {
501+
PeerID string `json:"peer_id"`
502+
ENR string `json:"enr"`
503+
P2PAddresses []string `json:"p2p_addresses"`
504+
DiscoveryAddresses []string `json:"discovery_addresses"`
505+
Metadata map[string]string `json:"metadata"`
506+
}
507+
508+
type NodeIdentityResponse struct {
509+
Data *NodeIdentity `json:"data"`
510+
}
511+
512+
func (bc *BeaconClient) GetNodeIdentity(ctx context.Context) (*NodeIdentity, error) {
513+
var response NodeIdentityResponse
514+
err := bc.getJSON(ctx, fmt.Sprintf("%s/eth/v1/node/identity", bc.endpoint), &response)
515+
if err != nil {
516+
return nil, err
517+
}
518+
519+
return response.Data, nil
520+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# `check_consensus_cgc` Task
2+
3+
This task checks the CGC (Custody Group Count) value in consensus layer client ENR records.
4+
5+
## Description
6+
7+
The CGC field in ENR records indicates the custody responsibilities of a consensus layer node:
8+
- **0x04**: Default value for non-validating consensus layer nodes
9+
- **0x08**: Default value for validating consensus layer nodes
10+
- **0x08 + N**: For validating nodes, where N represents the number of additional 32 ETH chunks being custodied
11+
12+
## Configuration Parameters
13+
14+
- **`clientPattern`** *(string)*: Pattern to match client names (default: empty, matches all)
15+
- **`pollInterval`** *(duration)*: How often to check CGC values (default: 30s)
16+
- **`expectedCgcValue`** *(int)*: Specific CGC value to expect (overrides other checks if set)
17+
- **`expectedNonValidating`** *(int)*: Expected CGC value for non-validating nodes (default: 0x04)
18+
- **`expectedValidating`** *(int)*: Expected CGC value for validating nodes (default: 0x08)
19+
- **`minClientCount`** *(int)*: Minimum number of clients that must pass (default: all)
20+
- **`failOnCheckMiss`** *(bool)*: Whether to fail the task if checks don't pass (default: false)
21+
- **`resultVar`** *(string)*: Variable name to store the first valid CGC value found
22+
23+
## Outputs
24+
25+
The task provides these output variables:
26+
27+
- **`validClients`**: Array of clients with valid CGC values
28+
- **`invalidClients`**: Array of clients with invalid CGC values
29+
- **`totalCount`**: Total number of clients checked
30+
- **`validCount`**: Number of clients with valid CGC values
31+
- **`invalidCount`**: Number of clients with invalid CGC values
32+
33+
## Example Usage
34+
35+
### Basic CGC Check
36+
```yaml
37+
- name: check_consensus_cgc
38+
title: "Check CGC values in ENR records"
39+
config:
40+
clientPattern: "beacon-*"
41+
pollInterval: 30s
42+
failOnCheckMiss: true
43+
```
44+
45+
### Check for Specific CGC Value
46+
```yaml
47+
- name: check_consensus_cgc
48+
title: "Verify specific CGC value"
49+
config:
50+
expectedCgcValue: 12 # Expecting 0x08 + 4 (validating with 4 additional 32 ETH chunks)
51+
minClientCount: 1
52+
resultVar: "detected_cgc_value"
53+
```
54+
55+
### Check Non-Validating Nodes
56+
```yaml
57+
- name: check_consensus_cgc
58+
title: "Verify non-validating nodes"
59+
config:
60+
expectedCgcValue: 4 # 0x04 for non-validating
61+
clientPattern: "non-validator-*"
62+
```
63+
64+
## Notes
65+
66+
- The task extracts ENR records from the `/eth/v1/node/identity` beacon API endpoint
67+
- CGC parsing is currently simplified and may need enhancement for production use
68+
- If no CGC field is found in the ENR, the task assumes the default non-validating value (0x04)
69+
- The task validates that CGC values follow the expected pattern for validating vs non-validating nodes
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package checkconsensuscgc
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"github.com/ethpandaops/assertoor/pkg/coordinator/helper"
8+
)
9+
10+
type Config struct {
11+
ClientPattern string `yaml:"clientPattern" json:"clientPattern"`
12+
PollInterval helper.Duration `yaml:"pollInterval" json:"pollInterval"`
13+
ExpectedCGCValue int `yaml:"expectedCgcValue" json:"expectedCgcValue"`
14+
ExpectedNonValidating int `yaml:"expectedNonValidating" json:"expectedNonValidating"`
15+
ExpectedValidating int `yaml:"expectedValidating" json:"expectedValidating"`
16+
MinClientCount int `yaml:"minClientCount" json:"minClientCount"`
17+
FailOnCheckMiss bool `yaml:"failOnCheckMiss" json:"failOnCheckMiss"`
18+
ResultVar string `yaml:"resultVar" json:"resultVar"`
19+
}
20+
21+
func DefaultConfig() Config {
22+
return Config{
23+
PollInterval: helper.Duration{Duration: 30 * time.Second},
24+
ExpectedNonValidating: 0x04, // Default for non-validating consensus layer node
25+
ExpectedValidating: 0x08, // Default for validating consensus layer node
26+
}
27+
}
28+
29+
func (c *Config) Validate() error {
30+
if c.ExpectedCGCValue < 0 {
31+
return fmt.Errorf("expectedCgcValue must be non-negative")
32+
}
33+
if c.ExpectedNonValidating < 0 {
34+
return fmt.Errorf("expectedNonValidating must be non-negative")
35+
}
36+
if c.ExpectedValidating < 0 {
37+
return fmt.Errorf("expectedValidating must be non-negative")
38+
}
39+
return nil
40+
}
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
package checkconsensuscgc
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"fmt"
7+
"strings"
8+
"time"
9+
10+
"github.com/ethpandaops/assertoor/pkg/coordinator/clients"
11+
"github.com/ethpandaops/assertoor/pkg/coordinator/types"
12+
"github.com/ethpandaops/assertoor/pkg/coordinator/vars"
13+
"github.com/sirupsen/logrus"
14+
)
15+
16+
var (
17+
TaskName = "check_consensus_cgc"
18+
TaskDescriptor = &types.TaskDescriptor{
19+
Name: TaskName,
20+
Description: "Checks the CGC (Custody Group Count) value in consensus layer client ENR records.",
21+
Config: DefaultConfig(),
22+
NewTask: NewTask,
23+
}
24+
)
25+
26+
type Task struct {
27+
ctx *types.TaskContext
28+
options *types.TaskOptions
29+
config Config
30+
logger logrus.FieldLogger
31+
}
32+
33+
type ClientCGCInfo struct {
34+
Name string `json:"name"`
35+
ClRPCURL string `json:"clRpcUrl"`
36+
ENR string `json:"enr"`
37+
CGCValue int `json:"cgcValue"`
38+
IsValid bool `json:"isValid"`
39+
}
40+
41+
func NewTask(ctx *types.TaskContext, options *types.TaskOptions) (types.Task, error) {
42+
return &Task{
43+
ctx: ctx,
44+
options: options,
45+
logger: ctx.Logger.GetLogger(),
46+
}, nil
47+
}
48+
49+
func (t *Task) Config() interface{} {
50+
return t.config
51+
}
52+
53+
func (t *Task) Timeout() time.Duration {
54+
return t.options.Timeout.Duration
55+
}
56+
57+
func (t *Task) LoadConfig() error {
58+
config := DefaultConfig()
59+
60+
// parse static config
61+
if t.options.Config != nil {
62+
if err := t.options.Config.Unmarshal(&config); err != nil {
63+
return fmt.Errorf("error parsing task config for %v: %w", TaskName, err)
64+
}
65+
}
66+
67+
// load dynamic vars
68+
err := t.ctx.Vars.ConsumeVars(&config, t.options.ConfigVars)
69+
if err != nil {
70+
return err
71+
}
72+
73+
// validate config
74+
if err := config.Validate(); err != nil {
75+
return err
76+
}
77+
78+
t.config = config
79+
80+
return nil
81+
}
82+
83+
func (t *Task) Execute(ctx context.Context) error {
84+
t.processCheck()
85+
86+
for {
87+
select {
88+
case <-time.After(t.config.PollInterval.Duration):
89+
t.processCheck()
90+
case <-ctx.Done():
91+
return nil
92+
}
93+
}
94+
}
95+
96+
func (t *Task) processCheck() {
97+
passResultCount := 0
98+
totalClientCount := 0
99+
validClients := []*ClientCGCInfo{}
100+
invalidClients := []*ClientCGCInfo{}
101+
invalidClientNames := []string{}
102+
103+
for _, client := range t.ctx.Scheduler.GetServices().ClientPool().GetClientsByNamePatterns(t.config.ClientPattern, "") {
104+
if client.ConsensusClient == nil {
105+
continue
106+
}
107+
108+
totalClientCount++
109+
110+
cgcInfo := t.processClientCGCCheck(client)
111+
if cgcInfo.IsValid {
112+
passResultCount++
113+
validClients = append(validClients, cgcInfo)
114+
115+
if t.config.ResultVar != "" && passResultCount == 1 {
116+
t.ctx.Vars.SetVar(t.config.ResultVar, cgcInfo.CGCValue)
117+
}
118+
} else {
119+
invalidClients = append(invalidClients, cgcInfo)
120+
invalidClientNames = append(invalidClientNames, client.Config.Name)
121+
}
122+
}
123+
124+
requiredPassCount := t.config.MinClientCount
125+
if requiredPassCount == 0 {
126+
requiredPassCount = totalClientCount
127+
}
128+
129+
resultPass := passResultCount >= requiredPassCount
130+
131+
if validClientsData, err := vars.GeneralizeData(validClients); err == nil {
132+
t.ctx.Outputs.SetVar("validClients", validClientsData)
133+
} else {
134+
t.logger.Warnf("Failed setting `validClients` output: %v", err)
135+
}
136+
137+
if invalidClientsData, err := vars.GeneralizeData(invalidClients); err == nil {
138+
t.ctx.Outputs.SetVar("invalidClients", invalidClientsData)
139+
} else {
140+
t.logger.Warnf("Failed setting `invalidClients` output: %v", err)
141+
}
142+
143+
t.ctx.Outputs.SetVar("totalCount", totalClientCount)
144+
t.ctx.Outputs.SetVar("invalidCount", totalClientCount-passResultCount)
145+
t.ctx.Outputs.SetVar("validCount", passResultCount)
146+
147+
t.logger.Infof("CGC Check result: %v, Invalid Clients: %v", resultPass, invalidClientNames)
148+
149+
switch {
150+
case resultPass:
151+
t.ctx.SetResult(types.TaskResultSuccess)
152+
default:
153+
if t.config.FailOnCheckMiss {
154+
t.ctx.SetResult(types.TaskResultFailure)
155+
} else {
156+
t.ctx.SetResult(types.TaskResultNone)
157+
}
158+
}
159+
}
160+
161+
func (t *Task) processClientCGCCheck(client *clients.PoolClient) *ClientCGCInfo {
162+
cgcInfo := &ClientCGCInfo{
163+
Name: client.Config.Name,
164+
IsValid: false,
165+
}
166+
167+
if client.ConsensusClient != nil {
168+
cgcInfo.ClRPCURL = client.ConsensusClient.GetEndpointConfig().URL
169+
}
170+
171+
// Get node identity to extract ENR
172+
nodeIdentity, err := client.ConsensusClient.GetRPCClient().GetNodeIdentity(context.Background())
173+
if err != nil {
174+
t.logger.Warnf("Failed to get node identity for client %s: %v", client.Config.Name, err)
175+
return cgcInfo
176+
}
177+
178+
cgcInfo.ENR = nodeIdentity.ENR
179+
180+
// Extract CGC value from ENR
181+
cgcValue, err := t.extractCGCFromENR(nodeIdentity.ENR)
182+
if err != nil {
183+
t.logger.Warnf("Failed to extract CGC from ENR for client %s: %v", client.Config.Name, err)
184+
return cgcInfo
185+
}
186+
187+
cgcInfo.CGCValue = cgcValue
188+
189+
// Validate CGC value
190+
isValid := t.validateCGCValue(cgcValue)
191+
cgcInfo.IsValid = isValid
192+
193+
return cgcInfo
194+
}
195+
196+
func (t *Task) extractCGCFromENR(enr string) (int, error) {
197+
// Remove enr: prefix if present
198+
if strings.HasPrefix(enr, "enr:") {
199+
enr = enr[4:]
200+
}
201+
202+
// Decode base64 ENR
203+
enrBytes, err := base64.RawURLEncoding.DecodeString(enr)
204+
if err != nil {
205+
return 0, fmt.Errorf("failed to decode ENR base64: %w", err)
206+
}
207+
208+
// For now, we'll implement a simple search for a "cgc" field in the ENR
209+
// This is a simplified implementation - in practice, you'd want to properly
210+
// parse the RLP-encoded ENR structure and look for the "cgc" key-value pair
211+
212+
// Convert to string to search for cgc field
213+
enrStr := string(enrBytes)
214+
215+
// Look for "cgc" followed by a value
216+
// This is a simplified approach - proper ENR parsing would use RLP decoding
217+
cgcIndex := strings.Index(enrStr, "cgc")
218+
if cgcIndex == -1 {
219+
// If no CGC field found, assume default non-validating value
220+
return t.config.ExpectedNonValidating, nil
221+
}
222+
223+
// Extract the value after "cgc" - this is a simplified extraction
224+
// In a real implementation, you'd properly parse the RLP structure
225+
valueStart := cgcIndex + 3
226+
if valueStart >= len(enrStr) {
227+
return t.config.ExpectedNonValidating, nil
228+
}
229+
230+
// Try to extract a hex value (assume single byte for now)
231+
if valueStart+1 < len(enrStr) {
232+
valueByte := enrStr[valueStart]
233+
return int(valueByte), nil
234+
}
235+
236+
return t.config.ExpectedNonValidating, nil
237+
}
238+
239+
func (t *Task) validateCGCValue(cgcValue int) bool {
240+
// If a specific CGC value is expected, check against that
241+
if t.config.ExpectedCGCValue > 0 {
242+
return cgcValue == t.config.ExpectedCGCValue
243+
}
244+
245+
// Check if the value matches expected non-validating or validating values
246+
if cgcValue == t.config.ExpectedNonValidating || cgcValue == t.config.ExpectedValidating {
247+
return true
248+
}
249+
250+
// Check if the value represents a validating node with additional 32 ETH increments
251+
// CGC = 0x08 + number_of_32eth_chunks
252+
if cgcValue >= t.config.ExpectedValidating {
253+
// Calculate if the excess is a multiple of 1 (each 32 ETH adds 1 to CGC)
254+
excess := cgcValue - t.config.ExpectedValidating
255+
// For now, we'll accept any positive excess as valid
256+
// In practice, you might want to check against known validator counts
257+
return excess >= 0
258+
}
259+
260+
return false
261+
}

0 commit comments

Comments
 (0)