Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/monitoringapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,13 +284,15 @@ func beaconNodeVersionMetric(ctx context.Context, eth2Cl eth2wrap.Client, beacon
if err != nil {
log.Warn(ctx, "Failed to fetch beacon node version", err,
z.Str("beacon_node_address", addr))

continue
}

response, err := scopedClient.NodeIdentity(ctx, &eth2api.NodeIdentityOpts{})
if err != nil {
log.Warn(ctx, "Failed to fetch beacon node identity", err,
z.Str("beacon_node_address", addr))

continue
}

Expand Down
5 changes: 3 additions & 2 deletions cluster/cluster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
//go:generate go test . -v -update -clean

const (
v1_11 = "v1.11.0"
v1_10 = "v1.10.0"
v1_9 = "v1.9.0"
v1_8 = "v1.8.0"
Expand Down Expand Up @@ -64,12 +65,12 @@ func TestEncode(t *testing.T) {
}

var partialAmounts []int
if isAnyVersion(version, v1_8, v1_9, v1_10) {
if isAnyVersion(version, v1_8, v1_9, v1_10, v1_11) {
partialAmounts = []int{16, 16}
}

targetGasLimit := uint(0)
if isAnyVersion(version, v1_10) {
if isAnyVersion(version, v1_10, v1_11) {
targetGasLimit = 30000000
}

Expand Down
153 changes: 152 additions & 1 deletion cluster/definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,11 @@ func (d Definition) VerifySignatures(eth1 eth1wrap.EthClientRunner) error {
return errors.New("empty operator config signature", z.Any("operator_address", o.Address))
}

// Validate signature lengths for v1.11.0+ (Safe multisig support)
if err := d.validateSignatureLengths(); err != nil {
return err
}

Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VerifySignatures calls d.validateSignatureLengths() inside the per-operator loop, but validateSignatureLengths itself iterates over all operators and the creator. This makes verification O(n²) and also means signature-length validation is skipped entirely when operators are unsigned (e.g., creator-only signatures). Consider validating once up-front (before the loop / before any early-continues) and only over the relevant signatures.

Copilot uses AI. Check for mistakes.
// Check that we have a valid config signature for each operator.
if ok, err := verifySig(o.Address, operatorConfigHashDigest, o.ConfigSignature); err != nil {
return err
Expand Down Expand Up @@ -320,6 +325,56 @@ func (d Definition) VerifySignatures(eth1 eth1wrap.EthClientRunner) error {
return nil
}

// validateSignatureLengths validates that all signatures in v1.11.0+ are multiples of 65 bytes.
// This ensures compatibility with Safe multisig wallets where signature length = threshold × 65 bytes.
func (d Definition) validateSignatureLengths() error {
if !isAnyVersion(d.Version, v1_11) {
return nil // Skip validation for older versions
}

for i, op := range d.Operators {
if err := validateSignatureLength(op.ConfigSignature, "operator config signature"); err != nil {
return errors.Wrap(err, "invalid signature length", z.Int("operator_index", i), z.Str("address", op.Address))
}
if err := validateSignatureLength(op.ENRSignature, "operator enr signature"); err != nil {
return errors.Wrap(err, "invalid signature length", z.Int("operator_index", i), z.Str("address", op.Address))
}
}

if err := validateSignatureLength(d.Creator.ConfigSignature, "creator config signature"); err != nil {
return errors.Wrap(err, "invalid signature length", z.Str("address", d.Creator.Address))
}

return nil
}

// validateSignatureLength validates that a signature is either empty or a multiple of 65 bytes.
// Safe/Gnosis Safe multisig signatures are concatenated ECDSA signatures: threshold × 65 bytes.
func validateSignatureLength(sig []byte, fieldName string) error {
if len(sig) == 0 {
return nil // Empty signatures are valid
}

if len(sig)%65 != 0 {
return errors.New("signature must be multiple of 65 bytes",
z.Str("field", fieldName),
z.Int("length", len(sig)),
z.Int("remainder", len(sig)%65),
)
}

if len(sig) > sszMaxSignature {
return errors.New("signature exceeds maximum length",
z.Str("field", fieldName),
z.Int("length", len(sig)),
z.Int("max", sszMaxSignature),
z.Int("max_threshold", sszMaxSignature/65),
)
}

return nil
}

// Peers returns the operators as a slice of p2p peers.
func (d Definition) Peers() ([]p2p.Peer, error) {
var resp []p2p.Peer
Expand Down Expand Up @@ -441,6 +496,8 @@ func (d Definition) MarshalJSON() ([]byte, error) {
return marshalDefinitionV1x9(d2)
case isAnyVersion(d2.Version, v1_10):
return marshalDefinitionV1x10(d2)
case isAnyVersion(d2.Version, v1_11):
return marshalDefinitionV1x11(d2)
default:
return nil, errors.New("unsupported version")
}
Expand Down Expand Up @@ -501,6 +558,11 @@ func (d *Definition) UnmarshalJSON(data []byte) error {
if err != nil {
return err
}
case isAnyVersion(version.Version, v1_11):
def, err = unmarshalDefinitionV1x11(data)
if err != nil {
return err
}
default:
return errors.New("unsupported version")
}
Expand Down Expand Up @@ -733,6 +795,36 @@ func marshalDefinitionV1x10(def Definition) ([]byte, error) {
return resp, nil
}

func marshalDefinitionV1x11(def Definition) ([]byte, error) {
resp, err := json.Marshal(definitionJSONv1x11{
Name: def.Name,
UUID: def.UUID,
Version: def.Version,
Timestamp: def.Timestamp,
NumValidators: def.NumValidators,
Threshold: def.Threshold,
DKGAlgorithm: def.DKGAlgorithm,
ValidatorAddresses: validatorAddressesToJSON(def.ValidatorAddresses),
ForkVersion: def.ForkVersion,
ConfigHash: def.ConfigHash,
DefinitionHash: def.DefinitionHash,
Operators: operatorsToV1x2orLater(def.Operators),
Creator: creatorJSON{
Address: def.Creator.Address,
ConfigSignature: def.Creator.ConfigSignature,
},
DepositAmounts: def.DepositAmounts,
ConsensusProtocol: def.ConsensusProtocol,
TargetGasLimit: def.TargetGasLimit,
Compounding: def.Compounding,
})
if err != nil {
return nil, errors.Wrap(err, "marshal definition", z.Str("version", def.Version))
}

return resp, nil
}

func unmarshalDefinitionV1x0or1(data []byte) (def Definition, err error) {
var defJSON definitionJSONv1x0or1
if err := json.Unmarshal(data, &defJSON); err != nil {
Expand Down Expand Up @@ -970,6 +1062,44 @@ func unmarshalDefinitionV1x10(data []byte) (def Definition, err error) {
}, nil
}

func unmarshalDefinitionV1x11(data []byte) (def Definition, err error) {
Comment thread
KaloyanTanev marked this conversation as resolved.
Outdated
var defJSON definitionJSONv1x11
if err := json.Unmarshal(data, &defJSON); err != nil {
return Definition{}, errors.Wrap(err, "unmarshal definition v1_11")
}

if len(defJSON.ValidatorAddresses) != defJSON.NumValidators {
return Definition{}, errors.New("num_validators not matching validators length")
}

if err := deposit.VerifyDepositAmounts(def.DepositAmounts, def.Compounding); err != nil {
return Definition{}, errors.Wrap(err, "invalid deposit amounts")
}
Comment on lines +1009 to +1011
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unmarshalDefinitionV1x11 calls deposit.VerifyDepositAmounts(def.DepositAmounts, def.Compounding) before def is populated, so it always validates zero values and can miss invalid deposit amounts/compounding combinations. This should validate defJSON.DepositAmounts and defJSON.Compounding (or validate after constructing the Definition).

Copilot uses AI. Check for mistakes.

return Definition{
Name: defJSON.Name,
UUID: defJSON.UUID,
Version: defJSON.Version,
Timestamp: defJSON.Timestamp,
NumValidators: defJSON.NumValidators,
Threshold: defJSON.Threshold,
DKGAlgorithm: defJSON.DKGAlgorithm,
ForkVersion: defJSON.ForkVersion,
ConfigHash: defJSON.ConfigHash,
DefinitionHash: defJSON.DefinitionHash,
Operators: operatorsFromV1x2orLater(defJSON.Operators),
ValidatorAddresses: validatorAddressesFromJSON(defJSON.ValidatorAddresses),
Creator: Creator{
Address: defJSON.Creator.Address,
ConfigSignature: defJSON.Creator.ConfigSignature,
},
DepositAmounts: defJSON.DepositAmounts,
ConsensusProtocol: defJSON.ConsensusProtocol,
TargetGasLimit: defJSON.TargetGasLimit,
Compounding: defJSON.Compounding,
}, nil
}

// supportEIP712Sigs returns true if the provided definition version supports EIP712 signatures.
// Note that Definition versions prior to v1.3.0 don't support EIP712 signatures.
func supportEIP712Sigs(version string) bool {
Expand Down Expand Up @@ -1107,7 +1237,7 @@ type definitionJSONv1x9 struct {
DefinitionHash ethHex `json:"definition_hash"`
}

// definitionJSONv1x10 is the json formatter of Definition for versions v1.10 or later.
// definitionJSONv1x10 is the json formatter of Definition for versions v1.10.
type definitionJSONv1x10 struct {
Name string `json:"name,omitempty"`
Creator creatorJSON `json:"creator"`
Expand All @@ -1128,6 +1258,27 @@ type definitionJSONv1x10 struct {
DefinitionHash ethHex `json:"definition_hash"`
}

// definitionJSONv1x11 is the json formatter of Definition for version v1.11 or later.
type definitionJSONv1x11 struct {
Comment thread
KaloyanTanev marked this conversation as resolved.
Outdated
Name string `json:"name,omitempty"`
Creator creatorJSON `json:"creator"`
Operators []operatorJSONv1x2orLater `json:"operators"`
UUID string `json:"uuid"`
Version string `json:"version"`
Timestamp string `json:"timestamp,omitempty"`
NumValidators int `json:"num_validators"`
Threshold int `json:"threshold"`
ValidatorAddresses []validatorAddressesJSON `json:"validators"`
DKGAlgorithm string `json:"dkg_algorithm"`
ForkVersion ethHex `json:"fork_version"`
DepositAmounts []eth2p0.Gwei `json:"deposit_amounts"`
ConsensusProtocol string `json:"consensus_protocol"`
TargetGasLimit uint `json:"target_gas_limit"`
Compounding bool `json:"compounding"`
ConfigHash ethHex `json:"config_hash"`
DefinitionHash ethHex `json:"definition_hash"`
}

// Creator identifies the creator of a cluster definition.
// Note the following struct tag meanings:
// - json: json field name. Suffix 0xhex indicates bytes are formatted as 0x prefixed hex strings.
Expand Down
4 changes: 2 additions & 2 deletions cluster/lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func (l Lock) MarshalJSON() ([]byte, error) {
return marshalLockV1x6(l, lockHash)
case isAnyVersion(l.Version, v1_7):
return marshalLockV1x7(l, lockHash)
case isAnyVersion(l.Version, v1_8, v1_9, v1_10):
case isAnyVersion(l.Version, v1_8, v1_9, v1_10, v1_11):
return marshalLockV1x8OrLater(l, lockHash)
default:
return nil, errors.New("unsupported version")
Expand Down Expand Up @@ -104,7 +104,7 @@ func (l *Lock) UnmarshalJSON(data []byte) error {
if err != nil {
return err
}
case isAnyVersion(version.Definition.Version, v1_8, v1_9, v1_10):
case isAnyVersion(version.Definition.Version, v1_8, v1_9, v1_10, v1_11):
lock, err = unmarshalLockV1x8OrLater(data)
if err != nil {
return err
Expand Down
4 changes: 2 additions & 2 deletions cluster/operator.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ type Operator struct {
ENR string `config_hash:"-" definition_hash:"1" json:"enr" ssz:"ByteList[1024]"`

// ConfigSignature is an EIP712 signature of the config_hash using privkey corresponding to operator Ethereum Address.
ConfigSignature []byte `json:"config_signature,0xhex" ssz:"Bytes65" config_hash:"-" definition_hash:"2"`
ConfigSignature []byte `json:"config_signature,0xhex" ssz:"Bytes65|ByteList[384]" config_hash:"-" definition_hash:"2"`
Comment thread
KaloyanTanev marked this conversation as resolved.
Outdated

// ENRSignature is a EIP712 signature of the ENR by the Address, authorising the charon node to act on behalf of the operator in the cluster.
ENRSignature []byte `json:"enr_signature,0xhex" ssz:"Bytes65" config_hash:"-" definition_hash:"3"`
ENRSignature []byte `json:"enr_signature,0xhex" ssz:"Bytes65|ByteList[384]" config_hash:"-" definition_hash:"3"`
Comment thread
KaloyanTanev marked this conversation as resolved.
Outdated
}

// operatorJSONv1x1 is the json formatter of Operator for versions v1.0.0 and v1.1.0.
Expand Down
Loading
Loading