From b58110981bb8ec3b44d88dd037bb5fc71f577d39 Mon Sep 17 00:00:00 2001 From: Andrei Smirnov Date: Thu, 26 Feb 2026 17:09:42 +0300 Subject: [PATCH 01/16] *: feerecipient cmd, wip --- app/obolapi/feerecipient.go | 105 +++++++++++ app/obolapi/feerecipient_model.go | 32 ++++ cmd/cmd.go | 4 + cmd/feerecipient.go | 42 +++++ cmd/feerecipientfetch.go | 252 +++++++++++++++++++++++++ cmd/feerecipientfetch_internal_test.go | 140 ++++++++++++++ cmd/feerecipientsign.go | 197 +++++++++++++++++++ cmd/feerecipientsign_internal_test.go | 143 ++++++++++++++ testutil/obolapimock/feerecipient.go | 181 ++++++++++++++++++ testutil/obolapimock/obolapi.go | 19 +- 10 files changed, 1109 insertions(+), 6 deletions(-) create mode 100644 app/obolapi/feerecipient.go create mode 100644 app/obolapi/feerecipient_model.go create mode 100644 cmd/feerecipient.go create mode 100644 cmd/feerecipientfetch.go create mode 100644 cmd/feerecipientfetch_internal_test.go create mode 100644 cmd/feerecipientsign.go create mode 100644 cmd/feerecipientsign_internal_test.go create mode 100644 testutil/obolapimock/feerecipient.go diff --git a/app/obolapi/feerecipient.go b/app/obolapi/feerecipient.go new file mode 100644 index 000000000..f1e6c3e55 --- /dev/null +++ b/app/obolapi/feerecipient.go @@ -0,0 +1,105 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package obolapi + +import ( + "context" + "encoding/hex" + "encoding/json" + "net/url" + "strconv" + "strings" + + "github.com/obolnetwork/charon/app/errors" +) + +const ( + submitPartialFeeRecipientTmpl = "/fee_recipient/partial/" + lockHashPath + "/" + shareIndexPath + fetchPartialFeeRecipientTmpl = "/fee_recipient/" + lockHashPath + "/" + valPubkeyPath +) + +// submitPartialFeeRecipientURL returns the partial fee recipient Obol API URL for a given lock hash. +func submitPartialFeeRecipientURL(lockHash string, shareIndex uint64) string { + return strings.NewReplacer( + lockHashPath, + lockHash, + shareIndexPath, + strconv.FormatUint(shareIndex, 10), + ).Replace(submitPartialFeeRecipientTmpl) +} + +// fetchPartialFeeRecipientURL returns the partial fee recipient Obol API URL for a given validator public key. +func fetchPartialFeeRecipientURL(valPubkey, lockHash string) string { + return strings.NewReplacer( + valPubkeyPath, + valPubkey, + lockHashPath, + lockHash, + ).Replace(fetchPartialFeeRecipientTmpl) +} + +// PostPartialFeeRecipients POSTs partial fee recipient registrations to the Obol API. +// It respects the timeout specified in the Client instance. +func (c Client) PostPartialFeeRecipients(ctx context.Context, lockHash []byte, shareIndex uint64, partialRegs []PartialRegistration) error { + lockHashStr := "0x" + hex.EncodeToString(lockHash) + + path := submitPartialFeeRecipientURL(lockHashStr, shareIndex) + + u, err := url.ParseRequestURI(c.baseURL) + if err != nil { + return errors.Wrap(err, "bad Obol API url") + } + + u.Path = path + + req := PartialFeeRecipientRequest{PartialRegistrations: partialRegs} + + data, err := json.Marshal(req) + if err != nil { + return errors.Wrap(err, "json marshal error") + } + + ctx, cancel := context.WithTimeout(ctx, c.reqTimeout) + defer cancel() + + err = httpPost(ctx, u, data, nil) + if err != nil { + return errors.Wrap(err, "http Obol API POST request") + } + + return nil +} + +// GetPartialFeeRecipients fetches partial fee recipient registrations from the Obol API. +// It respects the timeout specified in the Client instance. +func (c Client) GetPartialFeeRecipients(ctx context.Context, valPubkey string, lockHash []byte, _ int) (PartialFeeRecipientResponse, error) { + path := fetchPartialFeeRecipientURL(valPubkey, "0x"+hex.EncodeToString(lockHash)) + + u, err := url.ParseRequestURI(c.baseURL) + if err != nil { + return PartialFeeRecipientResponse{}, errors.Wrap(err, "bad Obol API url") + } + + u.Path = path + + ctx, cancel := context.WithTimeout(ctx, c.reqTimeout) + defer cancel() + + respBody, err := httpGet(ctx, u, map[string]string{}) + if err != nil { + return PartialFeeRecipientResponse{}, errors.Wrap(err, "http Obol API GET request") + } + + defer respBody.Close() + + var resp PartialFeeRecipientResponse + if err := json.NewDecoder(respBody).Decode(&resp); err != nil { + return PartialFeeRecipientResponse{}, errors.Wrap(err, "unmarshal response") + } + + if len(resp.Partials) == 0 { + return PartialFeeRecipientResponse{}, ErrNoValue + } + + return resp, nil +} diff --git a/app/obolapi/feerecipient_model.go b/app/obolapi/feerecipient_model.go new file mode 100644 index 000000000..d81ee6a85 --- /dev/null +++ b/app/obolapi/feerecipient_model.go @@ -0,0 +1,32 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package obolapi + +import ( + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + + "github.com/obolnetwork/charon/tbls" +) + +// PartialRegistration represents a partial builder registration with a partial BLS signature. +type PartialRegistration struct { + Message *eth2v1.ValidatorRegistration `json:"message"` + Signature tbls.Signature `json:"signature"` +} + +// PartialFeeRecipientRequest represents the request body for posting partial fee recipient registrations. +type PartialFeeRecipientRequest struct { + PartialRegistrations []PartialRegistration `json:"partial_registrations"` +} + +// PartialFeeRecipientResponsePartial represents a single partial registration in the response. +type PartialFeeRecipientResponsePartial struct { + ShareIdx int `json:"share_index"` + Message *eth2v1.ValidatorRegistration `json:"message"` + Signature []byte `json:"signature"` +} + +// PartialFeeRecipientResponse represents the response body when fetching partial fee recipient registrations. +type PartialFeeRecipientResponse struct { + Partials []PartialFeeRecipientResponsePartial `json:"partial_registrations"` +} diff --git a/cmd/cmd.go b/cmd/cmd.go index 8394396d2..fb00dab2e 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -74,6 +74,10 @@ func New() *cobra.Command { newDepositSignCmd(runDepositSign), newDepositFetchCmd(runDepositFetch), ), + newFeeRecipientCmd( + newFeeRecipientSignCmd(runFeeRecipientSign), + newFeeRecipientFetchCmd(runFeeRecipientFetch), + ), newUnsafeCmd(newRunCmd(app.Run, true)), ) } diff --git a/cmd/feerecipient.go b/cmd/feerecipient.go new file mode 100644 index 000000000..339aa4f4c --- /dev/null +++ b/cmd/feerecipient.go @@ -0,0 +1,42 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "time" + + "github.com/spf13/cobra" + + "github.com/obolnetwork/charon/app/log" +) + +type feerecipientConfig struct { + ValidatorPublicKeys []string + PrivateKeyPath string + LockFilePath string + ValidatorKeysDir string + PublishAddress string + PublishTimeout time.Duration + Log log.Config +} + +func newFeeRecipientCmd(cmds ...*cobra.Command) *cobra.Command { + root := &cobra.Command{ + Use: "feerecipient", + Short: "Sign and fetch updated fee recipient registrations.", + Long: "Sign and fetch updated builder registration messages with new fee recipients using a remote API, enabling the modification of fee recipient addresses without cluster restart.", + } + + root.AddCommand(cmds...) + + return root +} + +func bindFeeRecipientFlags(cmd *cobra.Command, config *feerecipientConfig) { + cmd.Flags().StringSliceVar(&config.ValidatorPublicKeys, "validator-public-keys", []string{}, "Comma-separated list of validator public keys to update (required for the sign subcommand).") + cmd.Flags().StringVar(&config.PrivateKeyPath, privateKeyPath.String(), ".charon/charon-enr-private-key", "Path to the charon enr private key file.") + cmd.Flags().StringVar(&config.ValidatorKeysDir, validatorKeysDir.String(), ".charon/validator_keys", "Path to the directory containing the validator private key share files and passwords.") + cmd.Flags().StringVar(&config.LockFilePath, lockFilePath.String(), ".charon/cluster-lock.json", "Path to the cluster lock file defining the distributed validator cluster.") + cmd.Flags().StringVar(&config.PublishAddress, publishAddress.String(), "https://api.obol.tech/v1", "The URL of the remote API.") + cmd.Flags().DurationVar(&config.PublishTimeout, publishTimeout.String(), 5*time.Minute, "Timeout for publishing to the publish-address API.") +} diff --git a/cmd/feerecipientfetch.go b/cmd/feerecipientfetch.go new file mode 100644 index 000000000..89a58d37b --- /dev/null +++ b/cmd/feerecipientfetch.go @@ -0,0 +1,252 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + + eth2api "github.com/attestantio/go-eth2-client/api" + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + eth2spec "github.com/attestantio/go-eth2-client/spec" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/spf13/cobra" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/obolapi" + "github.com/obolnetwork/charon/app/z" + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/eth2util/registration" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/tbls/tblsconv" +) + +type feerecipientFetchConfig struct { + feerecipientConfig + + OutputDir string +} + +const defaultBuilderRegistrationsDir = ".charon/builder_registrations" + +func newFeeRecipientFetchCmd(runFunc func(context.Context, feerecipientFetchConfig) error) *cobra.Command { + var config feerecipientFetchConfig + + cmd := &cobra.Command{ + Use: "fetch", + Short: "Fetch aggregated fee recipient registrations.", + Long: "Fetches aggregated builder registration messages with updated fee recipients from a remote API and writes them to the local builder registrations folder.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runFunc(cmd.Context(), config) + }, + } + + bindFeeRecipientFlags(cmd, &config.feerecipientConfig) + bindFeeRecipientFetchFlags(cmd, &config) + + return cmd +} + +func bindFeeRecipientFetchFlags(cmd *cobra.Command, config *feerecipientFetchConfig) { + cmd.Flags().StringVar(&config.OutputDir, "output-dir", defaultBuilderRegistrationsDir, "Path to the directory where fetched builder registrations will be stored.") +} + +func runFeeRecipientFetch(ctx context.Context, config feerecipientFetchConfig) error { + cl, err := cluster.LoadClusterLockAndVerify(ctx, config.LockFilePath) + if err != nil { + return err + } + + oAPI, err := obolapi.New(config.PublishAddress, obolapi.WithTimeout(config.PublishTimeout)) + if err != nil { + return errors.Wrap(err, "create Obol API client", z.Str("publish_address", config.PublishAddress)) + } + + // Determine which validators to fetch. + var pubkeys []string + if len(config.ValidatorPublicKeys) > 0 { + pubkeys = config.ValidatorPublicKeys + } else { + // Fetch all validators from cluster lock. + for _, dv := range cl.Validators { + pubkeys = append(pubkeys, "0x"+hex.EncodeToString(dv.PubKey)) + } + } + + // Create output directory. + err = os.MkdirAll(config.OutputDir, 0o755) + if err != nil { + return errors.Wrap(err, "create output directory") + } + + for _, pubkeyStr := range pubkeys { + log.Info(ctx, "Fetching fee recipient registration", z.Str("validator_pubkey", pubkeyStr)) + + // Get partial registrations from API. + resp, err := oAPI.GetPartialFeeRecipients(ctx, pubkeyStr, cl.LockHash, cl.Threshold) + if err != nil { + if errors.Is(err, obolapi.ErrNoValue) { + log.Warn(ctx, "No fee recipient registration found for validator", nil, z.Str("validator_pubkey", pubkeyStr)) + continue + } + + return errors.Wrap(err, "fetch partial fee recipient registrations from Obol API") + } + + if len(resp.Partials) < cl.Threshold { + log.Warn(ctx, "Insufficient partial signatures for aggregation", + nil, + z.Str("validator_pubkey", pubkeyStr), + z.Int("partial_count", len(resp.Partials)), + z.Int("threshold", cl.Threshold)) + + continue + } + + // Aggregate partial signatures. + signedReg, err := aggregateFeeRecipientRegistration(ctx, *cl, pubkeyStr, resp) + if err != nil { + return errors.Wrap(err, "aggregate fee recipient registration") + } + + // Write to output file. + filename := filepath.Join(config.OutputDir, strings.TrimPrefix(pubkeyStr, "0x")+".json") + + err = writeSignedValidatorRegistration(filename, signedReg) + if err != nil { + return errors.Wrap(err, "write signed validator registration", z.Str("filename", filename)) + } + + log.Info(ctx, "Successfully fetched fee recipient registration", + z.Str("validator_pubkey", pubkeyStr), + z.Str("output_file", filename), + ) + } + + return nil +} + +// aggregateFeeRecipientRegistration aggregates partial BLS signatures into a full registration. +func aggregateFeeRecipientRegistration(ctx context.Context, cl cluster.Lock, pubkeyStr string, resp obolapi.PartialFeeRecipientResponse) (*eth2api.VersionedSignedValidatorRegistration, error) { + if len(resp.Partials) == 0 { + return nil, errors.New("no partial registrations") + } + + // Use the message from the first partial (all should have the same message). + msg := resp.Partials[0].Message + + // Get the validator's group public key for verification. + pubkeyBytes, err := hex.DecodeString(strings.TrimPrefix(pubkeyStr, "0x")) + if err != nil { + return nil, errors.Wrap(err, "decode validator pubkey") + } + + // Find the validator's public shares in the cluster lock. + var pubShares []tbls.PublicKey + + for _, dv := range cl.Validators { + if hex.EncodeToString(dv.PubKey) == strings.TrimPrefix(pubkeyStr, "0x") { + for _, share := range dv.PubShares { + pk, err := tblsconv.PubkeyFromBytes(share) + if err != nil { + return nil, errors.Wrap(err, "parse public share") + } + + pubShares = append(pubShares, pk) + } + + break + } + } + + if len(pubShares) == 0 { + return nil, errors.New("validator not found in cluster lock") + } + + // Compute signing root for verification. + sigRoot, err := registration.GetMessageSigningRoot(msg, eth2p0.Version(cl.ForkVersion)) + if err != nil { + return nil, errors.Wrap(err, "get signing root") + } + + // Collect partial signatures with their share indices. + partialSigs := make(map[int]tbls.Signature) + + for _, partial := range resp.Partials { + sig, err := tblsconv.SignatureFromBytes(partial.Signature) + if err != nil { + return nil, errors.Wrap(err, "parse partial signature") + } + + // Verify partial signature against the corresponding public share. + if partial.ShareIdx < 1 || partial.ShareIdx > len(pubShares) { + return nil, errors.New("invalid share index", z.Int("share_idx", partial.ShareIdx)) + } + + err = tbls.Verify(pubShares[partial.ShareIdx-1], sigRoot[:], sig) + if err != nil { + log.Warn(ctx, "Invalid partial signature, skipping", + err, + z.Int("share_idx", partial.ShareIdx), + ) + + continue + } + + partialSigs[partial.ShareIdx] = sig + } + + if len(partialSigs) < cl.Threshold { + return nil, errors.New("insufficient valid partial signatures", + z.Int("valid_count", len(partialSigs)), + z.Int("threshold", cl.Threshold), + ) + } + + // Aggregate signatures. + fullSig, err := tbls.ThresholdAggregate(partialSigs) + if err != nil { + return nil, errors.Wrap(err, "threshold aggregate signatures") + } + + // Verify aggregated signature against the group public key. + groupPubkey, err := tblsconv.PubkeyFromBytes(pubkeyBytes) + if err != nil { + return nil, errors.Wrap(err, "parse group public key") + } + + err = tbls.Verify(groupPubkey, sigRoot[:], fullSig) + if err != nil { + return nil, errors.Wrap(err, "verify aggregated signature") + } + + // Build the final signed registration. + return ð2api.VersionedSignedValidatorRegistration{ + Version: eth2spec.BuilderVersionV1, + V1: ð2v1.SignedValidatorRegistration{ + Message: msg, + Signature: eth2p0.BLSSignature(fullSig), + }, + }, nil +} + +// writeSignedValidatorRegistration writes the signed registration to a JSON file. +func writeSignedValidatorRegistration(filename string, reg *eth2api.VersionedSignedValidatorRegistration) error { + data, err := json.MarshalIndent(reg, "", " ") + if err != nil { + return errors.Wrap(err, "marshal registration to JSON") + } + + err = os.WriteFile(filename, data, 0o644) //nolint:gosec // G306: world-readable output file is intentional + if err != nil { + return errors.Wrap(err, "write file") + } + + return nil +} diff --git a/cmd/feerecipientfetch_internal_test.go b/cmd/feerecipientfetch_internal_test.go new file mode 100644 index 000000000..78dff10c7 --- /dev/null +++ b/cmd/feerecipientfetch_internal_test.go @@ -0,0 +1,140 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "encoding/json" + "fmt" + "math/rand" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/z" + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/testutil/obolapimock" +) + +func TestFeeRecipientFetchValid(t *testing.T) { + ctx := t.Context() + ctx = log.WithCtx(ctx, z.Str("test_case", t.Name())) + + valAmt := 4 + operatorAmt := 4 + + random := rand.New(rand.NewSource(0)) + + lock, enrs, keyShares := cluster.NewForT( + t, + valAmt, + operatorAmt, + operatorAmt, + 0, + random, + ) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + + for opIdx := range operatorAmt { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + lockJSON, err := json.Marshal(lock) + require.NoError(t, err) + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, lockJSON) + + handler, addLockFiles := obolapimock.MockServer(false, nil) + + srv := httptest.NewServer(handler) + defer srv.Close() + + addLockFiles(lock) + + // First, submit partial signatures from threshold operators. + newFeeRecipient := "0x0000000000000000000000000000000000001234" + validatorPubkey := lock.Validators[0].PublicKeyHex() + + for opIdx := range lock.Threshold { + baseDir := filepath.Join(root, fmt.Sprintf("op%d", opIdx)) + + signConfig := feerecipientSignConfig{ + feerecipientConfig: feerecipientConfig{ + ValidatorPublicKeys: []string{validatorPubkey}, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, + }, + FeeRecipient: newFeeRecipient, + } + + require.NoError(t, runFeeRecipientSign(ctx, signConfig), "operator %d submit feerecipient sign", opIdx) + } + + // Now fetch the aggregated registration. + outputDir := filepath.Join(root, "builder_registrations") + + fetchConfig := feerecipientFetchConfig{ + feerecipientConfig: feerecipientConfig{ + ValidatorPublicKeys: []string{validatorPubkey}, + PrivateKeyPath: filepath.Join(root, "op0", "charon-enr-private-key"), + LockFilePath: filepath.Join(root, "op0", "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, + }, + OutputDir: outputDir, + } + + require.NoError(t, runFeeRecipientFetch(ctx, fetchConfig)) + + // Verify output file exists. + files, err := os.ReadDir(outputDir) + require.NoError(t, err) + require.Len(t, files, 1) +} + +func TestFeeRecipientFetchCLI(t *testing.T) { + tests := []struct { + name string + expectedErr string + flags []string + }{ + { + name: "correct flags", + expectedErr: "read cluster-lock.json: open test: no such file or directory", + flags: []string{ + "--lock-file=test", + "--publish-address=test", + "--publish-timeout=1ms", + "--output-dir=test", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := newFeeRecipientCmd(newFeeRecipientFetchCmd(runFeeRecipientFetch)) + cmd.SetArgs(append([]string{"fetch"}, test.flags...)) + + err := cmd.Execute() + if test.expectedErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, test.expectedErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cmd/feerecipientsign.go b/cmd/feerecipientsign.go new file mode 100644 index 000000000..1ecf68c32 --- /dev/null +++ b/cmd/feerecipientsign.go @@ -0,0 +1,197 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "bytes" + "context" + "encoding/hex" + "strings" + + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/spf13/cobra" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/k1util" + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/obolapi" + "github.com/obolnetwork/charon/app/z" + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/core" + "github.com/obolnetwork/charon/eth2util" + "github.com/obolnetwork/charon/eth2util/keystore" + "github.com/obolnetwork/charon/eth2util/registration" + "github.com/obolnetwork/charon/tbls" +) + +type feerecipientSignConfig struct { + feerecipientConfig + + FeeRecipient string +} + +func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig) error) *cobra.Command { + var config feerecipientSignConfig + + cmd := &cobra.Command{ + Use: "sign", + Short: "Sign partial fee recipient registration messages.", + Long: "Signs new partial builder registration messages with updated fee recipients and publishes them to a remote API.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runFunc(cmd.Context(), config) + }, + } + + bindFeeRecipientFlags(cmd, &config.feerecipientConfig) + bindFeeRecipientSignFlags(cmd, &config) + + wrapPreRunE(cmd, func(cmd *cobra.Command, _ []string) error { + mustMarkFlagRequired(cmd, "validator-public-keys") + mustMarkFlagRequired(cmd, "fee-recipient") + + return nil + }) + + return cmd +} + +func bindFeeRecipientSignFlags(cmd *cobra.Command, config *feerecipientSignConfig) { + cmd.Flags().StringVar(&config.FeeRecipient, "fee-recipient", "", "[REQUIRED] New fee recipient address to be applied to all specified validators.") +} + +func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) error { + // Validate fee recipient address. + if _, err := eth2util.ChecksumAddress(config.FeeRecipient); err != nil { + return errors.Wrap(err, "invalid fee recipient address", z.Str("fee_recipient", config.FeeRecipient)) + } + + identityKey, err := k1util.Load(config.PrivateKeyPath) + if err != nil { + return errors.Wrap(err, "load identity key", z.Str("private_key_path", config.PrivateKeyPath)) + } + + cl, err := cluster.LoadClusterLockAndVerify(ctx, config.LockFilePath) + if err != nil { + return err + } + + oAPI, err := obolapi.New(config.PublishAddress, obolapi.WithTimeout(config.PublishTimeout)) + if err != nil { + return errors.Wrap(err, "create Obol API client", z.Str("publish_address", config.PublishAddress)) + } + + shareIdx, err := keystore.ShareIdxForCluster(*cl, *identityKey.PubKey()) + if err != nil { + return errors.Wrap(err, "determine operator index from cluster lock for supplied identity key") + } + + rawValKeys, err := keystore.LoadFilesUnordered(config.ValidatorKeysDir) + if err != nil { + return errors.Wrap(err, "load keystore, check if path exists", z.Str("validator_keys_dir", config.ValidatorKeysDir)) + } + + valKeys, err := rawValKeys.SequencedKeys() + if err != nil { + return errors.Wrap(err, "load keystore") + } + + shares, err := keystore.KeysharesToValidatorPubkey(*cl, valKeys) + if err != nil { + return errors.Wrap(err, "match local validator key shares with their counterparty in cluster lock") + } + + // Parse requested validator pubkeys. + pubkeys := make([]eth2p0.BLSPubKey, 0, len(config.ValidatorPublicKeys)) + for _, valPubKey := range config.ValidatorPublicKeys { + pubkey, err := hex.DecodeString(strings.TrimPrefix(valPubKey, "0x")) + if err != nil { + return errors.Wrap(err, "decode pubkey", z.Str("validator_public_key", valPubKey)) + } + + pubkeys = append(pubkeys, eth2p0.BLSPubKey(pubkey)) + } + + // Parse fee recipient address. + feeRecipientBytes, err := hex.DecodeString(strings.TrimPrefix(config.FeeRecipient, "0x")) + if err != nil { + return errors.Wrap(err, "decode fee recipient address") + } + + var feeRecipient [20]byte + copy(feeRecipient[:], feeRecipientBytes) + + // Build partial registrations. + partialRegs := make([]obolapi.PartialRegistration, 0, len(pubkeys)) + + for _, pubkey := range pubkeys { + // Find existing builder registration in cluster lock. + var existingReg *cluster.BuilderRegistration + + for _, dv := range cl.Validators { + if bytes.Equal(dv.PubKey, pubkey[:]) { + existingReg = &dv.BuilderRegistration + break + } + } + + if existingReg == nil || existingReg.Message.Timestamp.IsZero() { + return errors.New("no existing builder registration found for validator", z.Str("pubkey", hex.EncodeToString(pubkey[:]))) + } + + // Create new registration with updated fee recipient, keeping gas limit and timestamp. + regMsg := ð2v1.ValidatorRegistration{ + FeeRecipient: feeRecipient, + GasLimit: uint64(existingReg.Message.GasLimit), + Timestamp: existingReg.Message.Timestamp, + Pubkey: pubkey, + } + + // Get signing root. + sigRoot, err := registration.GetMessageSigningRoot(regMsg, eth2p0.Version(cl.ForkVersion)) + if err != nil { + return errors.Wrap(err, "get signing root for registration message") + } + + // Get the secret share for this validator. + corePubkey, err := core.PubKeyFromBytes(pubkey[:]) + if err != nil { + return errors.Wrap(err, "convert pubkey to core pubkey") + } + + secretShare, ok := shares[corePubkey] + if !ok { + return errors.New("no key share found for validator pubkey", z.Str("pubkey", hex.EncodeToString(pubkey[:]))) + } + + // Sign with threshold BLS. + sig, err := tbls.Sign(secretShare.Share, sigRoot[:]) + if err != nil { + return errors.Wrap(err, "sign registration message") + } + + partialRegs = append(partialRegs, obolapi.PartialRegistration{ + Message: regMsg, + Signature: sig, + }) + + log.Info(ctx, "Signed partial fee recipient registration", + z.Str("validator_pubkey", hex.EncodeToString(pubkey[:])), + z.Str("fee_recipient", config.FeeRecipient), + ) + } + + log.Info(ctx, "Submitting partial fee recipient registrations") + + err = oAPI.PostPartialFeeRecipients(ctx, cl.LockHash, shareIdx, partialRegs) + if err != nil { + return errors.Wrap(err, "submit partial fee recipient registrations to Obol API") + } + + log.Info(ctx, "Successfully submitted partial fee recipient registrations", + z.Int("count", len(partialRegs)), + ) + + return nil +} diff --git a/cmd/feerecipientsign_internal_test.go b/cmd/feerecipientsign_internal_test.go new file mode 100644 index 000000000..312ff7cb1 --- /dev/null +++ b/cmd/feerecipientsign_internal_test.go @@ -0,0 +1,143 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "encoding/json" + "fmt" + "math/rand" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/z" + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/testutil/obolapimock" +) + +func TestFeeRecipientSignValid(t *testing.T) { + ctx := t.Context() + ctx = log.WithCtx(ctx, z.Str("test_case", t.Name())) + + valAmt := 4 + operatorAmt := 4 + + random := rand.New(rand.NewSource(0)) + + lock, enrs, keyShares := cluster.NewForT( + t, + valAmt, + operatorAmt, + operatorAmt, + 0, + random, + ) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + + for opIdx := range operatorAmt { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + lockJSON, err := json.Marshal(lock) + require.NoError(t, err) + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, lockJSON) + + handler, addLockFiles := obolapimock.MockServer(false, nil) + + srv := httptest.NewServer(handler) + defer srv.Close() + + addLockFiles(lock) + + idx := 0 + + baseDir := filepath.Join(root, fmt.Sprintf("op%d", idx)) + + config := feerecipientConfig{ + ValidatorPublicKeys: []string{lock.Validators[0].PublicKeyHex()}, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, + } + + signConfig := feerecipientSignConfig{ + feerecipientConfig: config, + FeeRecipient: "0x0000000000000000000000000000000000001234", + } + + require.NoError(t, runFeeRecipientSign(ctx, signConfig), "operator index submit feerecipient sign: %v", idx) +} + +func TestFeeRecipientSignCLI(t *testing.T) { + tests := []struct { + name string + expectedErr string + flags []string + }{ + { + name: "correct flags", + expectedErr: "load identity key: read private key from disk: open test: no such file or directory", + flags: []string{ + "--validator-public-keys=test", + "--fee-recipient=0x0000000000000000000000000000000000001234", + "--private-key-file=test", + "--validator-keys-dir=test", + "--lock-file=test", + "--publish-address=test", + "--publish-timeout=1ms", + }, + }, + { + name: "missing validator public keys", + expectedErr: "required flag(s) \"validator-public-keys\" not set", + flags: []string{ + "--fee-recipient=0x0000000000000000000000000000000000001234", + "--private-key-file=test", + "--validator-keys-dir=test", + "--lock-file=test", + "--publish-address=test", + "--publish-timeout=1ms", + }, + }, + { + name: "missing fee recipient", + expectedErr: "required flag(s) \"fee-recipient\" not set", + flags: []string{ + "--validator-public-keys=test", + "--private-key-file=test", + "--validator-keys-dir=test", + "--lock-file=test", + "--publish-address=test", + "--publish-timeout=1ms", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := newFeeRecipientCmd(newFeeRecipientSignCmd(runFeeRecipientSign)) + cmd.SetArgs(append([]string{"sign"}, test.flags...)) + + err := cmd.Execute() + if test.expectedErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, test.expectedErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/testutil/obolapimock/feerecipient.go b/testutil/obolapimock/feerecipient.go new file mode 100644 index 000000000..cc3824da7 --- /dev/null +++ b/testutil/obolapimock/feerecipient.go @@ -0,0 +1,181 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package obolapimock + +import ( + "encoding/hex" + "encoding/json" + "net/http" + "strconv" + "strings" + + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/gorilla/mux" + + "github.com/obolnetwork/charon/app/obolapi" + "github.com/obolnetwork/charon/eth2util/registration" + "github.com/obolnetwork/charon/tbls" +) + +const ( + submitPartialFeeRecipientTmpl = "/fee_recipient/partial/" + lockHashPath + "/" + shareIndexPath + fetchPartialFeeRecipientTmpl = "/fee_recipient/" + lockHashPath + "/" + valPubkeyPath +) + +// feeRecipientBlob represents partial fee recipient registrations for a validator. +type feeRecipientBlob struct { + partials map[int]obolapi.PartialFeeRecipientResponsePartial // keyed by share index +} + +func (ts *testServer) HandleSubmitPartialFeeRecipient(writer http.ResponseWriter, request *http.Request) { + ts.lock.Lock() + defer ts.lock.Unlock() + + vars := mux.Vars(request) + + var data obolapi.PartialFeeRecipientRequest + + if err := json.NewDecoder(request.Body).Decode(&data); err != nil { + writeErr(writer, http.StatusBadRequest, "invalid body") + return + } + + lockHash := vars[cleanTmpl(lockHashPath)] + if lockHash == "" { + writeErr(writer, http.StatusBadRequest, "invalid lock hash") + return + } + + lock, ok := ts.lockFiles[lockHash] + if !ok { + writeErr(writer, http.StatusNotFound, "lock not found") + return + } + + shareIndexVar := vars[cleanTmpl(shareIndexPath)] + if shareIndexVar == "" { + writeErr(writer, http.StatusBadRequest, "invalid share index") + return + } + + shareIndex, err := strconv.ParseUint(shareIndexVar, 10, 64) + if err != nil { + writeErr(writer, http.StatusBadRequest, "malformed share index") + return + } + + // check that share index is valid + if shareIndex == 0 || shareIndex > uint64(len(lock.Operators)) { + writeErr(writer, http.StatusBadRequest, "invalid share index") + return + } + + for _, partialReg := range data.PartialRegistrations { + // Verify the partial signature using the public share. + sigRoot, err := registration.GetMessageSigningRoot(partialReg.Message, eth2p0.Version(lock.ForkVersion)) + if err != nil { + writeErr(writer, http.StatusInternalServerError, "cannot calculate signing root") + return + } + + var publicKeyShare tbls.PublicKey + + validatorPubkeyHex := hex.EncodeToString(partialReg.Message.Pubkey[:]) + + for _, v := range lock.Validators { + if strings.TrimPrefix(v.PublicKeyHex(), "0x") == validatorPubkeyHex { + publicKeyShare, err = v.PublicShare(int(shareIndex) - 1) + if err != nil { + writeErr(writer, http.StatusBadRequest, "cannot fetch public share: "+err.Error()) + return + } + + break + } + } + + if len(publicKeyShare) == 0 { + writeErr(writer, http.StatusBadRequest, "cannot find public key in lock file") + return + } + + if err := tbls.Verify(publicKeyShare, sigRoot[:], partialReg.Signature); err != nil { + writeErr(writer, http.StatusBadRequest, "cannot verify signature: "+err.Error()) + return + } + + // Store the partial registration. + key := lockHash + "/" + validatorPubkeyHex + + existing, ok := ts.partialFeeRecipients[key] + if !ok { + existing = feeRecipientBlob{ + partials: make(map[int]obolapi.PartialFeeRecipientResponsePartial), + } + } + + existing.partials[int(shareIndex)] = obolapi.PartialFeeRecipientResponsePartial{ + ShareIdx: int(shareIndex), + Message: partialReg.Message, + Signature: partialReg.Signature[:], + } + + ts.partialFeeRecipients[key] = existing + } + + writer.WriteHeader(http.StatusOK) +} + +func (ts *testServer) HandleGetPartialFeeRecipient(writer http.ResponseWriter, request *http.Request) { + ts.lock.Lock() + defer ts.lock.Unlock() + + vars := mux.Vars(request) + + lockHash := vars[cleanTmpl(lockHashPath)] + if lockHash == "" { + writeErr(writer, http.StatusBadRequest, "invalid lock hash") + return + } + + _, ok := ts.lockFiles[lockHash] + if !ok { + writeErr(writer, http.StatusNotFound, "lock not found") + return + } + + valPubkey := vars[cleanTmpl(valPubkeyPath)] + if valPubkey == "" { + writeErr(writer, http.StatusBadRequest, "invalid validator pubkey") + return + } + + // Normalize pubkey (remove 0x prefix for storage key lookup). + valPubkeyNormalized := strings.TrimPrefix(valPubkey, "0x") + + key := lockHash + "/" + valPubkeyNormalized + + existing, ok := ts.partialFeeRecipients[key] + if !ok { + writeErr(writer, http.StatusNotFound, "no partial registrations found") + return + } + + resp := obolapi.PartialFeeRecipientResponse{ + Partials: make([]obolapi.PartialFeeRecipientResponsePartial, 0, len(existing.partials)), + } + + for i, partial := range existing.partials { + // Optionally drop one partial signature for testing threshold behavior. + if ts.dropOnePsig && i == len(existing.partials)-1 { + continue + } + + resp.Partials = append(resp.Partials, partial) + } + + if err := json.NewEncoder(writer).Encode(resp); err != nil { + writeErr(writer, http.StatusInternalServerError, "cannot encode response") + return + } +} diff --git a/testutil/obolapimock/obolapi.go b/testutil/obolapimock/obolapi.go index ad1ce86d2..f0417c767 100644 --- a/testutil/obolapimock/obolapi.go +++ b/testutil/obolapimock/obolapi.go @@ -58,6 +58,9 @@ type testServer struct { // store the partial deposits by the validator pubkey partialDeposits map[string]depositBlob + // store the partial fee recipient registrations by lock_hash/validator_pubkey + partialFeeRecipients map[string]feeRecipientBlob + // store the lock file by its lock hash lockFiles map[string]cluster.Lock @@ -108,12 +111,13 @@ func cleanTmpl(tmpl string) string { // It returns a http.Handler to be served over HTTP, and a function to add cluster lock files to its database. func MockServer(dropOnePsig bool, beacon eth2wrap.Client) (http.Handler, func(lock cluster.Lock)) { ts := testServer{ - lock: sync.Mutex{}, - partialExits: map[string][]exitBlob{}, - partialDeposits: map[string]depositBlob{}, - lockFiles: map[string]cluster.Lock{}, - dropOnePsig: dropOnePsig, - beacon: beacon, + lock: sync.Mutex{}, + partialExits: map[string][]exitBlob{}, + partialDeposits: map[string]depositBlob{}, + partialFeeRecipients: map[string]feeRecipientBlob{}, + lockFiles: map[string]cluster.Lock{}, + dropOnePsig: dropOnePsig, + beacon: beacon, } router := mux.NewRouter() @@ -131,6 +135,9 @@ func MockServer(dropOnePsig bool, beacon eth2wrap.Client) (http.Handler, func(lo router.HandleFunc(submitPartialDepositTmpl, ts.HandleSubmitPartialDeposit).Methods(http.MethodPost) router.HandleFunc(fetchFullDepositTmpl, ts.HandleGetFullDeposit).Methods(http.MethodGet) + router.HandleFunc(submitPartialFeeRecipientTmpl, ts.HandleSubmitPartialFeeRecipient).Methods(http.MethodPost) + router.HandleFunc(fetchPartialFeeRecipientTmpl, ts.HandleGetPartialFeeRecipient).Methods(http.MethodGet) + return router, ts.addLockFiles } From 4e6f15c6808b07d16b03ae389585a8133f199da0 Mon Sep 17 00:00:00 2001 From: Andrei Smirnov Date: Fri, 6 Mar 2026 12:10:49 +0300 Subject: [PATCH 02/16] Addressed PR feedback --- testutil/obolapimock/feerecipient.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/testutil/obolapimock/feerecipient.go b/testutil/obolapimock/feerecipient.go index cc3824da7..0cdc46c2b 100644 --- a/testutil/obolapimock/feerecipient.go +++ b/testutil/obolapimock/feerecipient.go @@ -58,14 +58,14 @@ func (ts *testServer) HandleSubmitPartialFeeRecipient(writer http.ResponseWriter return } - shareIndex, err := strconv.ParseUint(shareIndexVar, 10, 64) + shareIndex, err := strconv.Atoi(shareIndexVar) if err != nil { writeErr(writer, http.StatusBadRequest, "malformed share index") return } // check that share index is valid - if shareIndex == 0 || shareIndex > uint64(len(lock.Operators)) { + if shareIndex <= 0 || shareIndex > len(lock.Operators) { writeErr(writer, http.StatusBadRequest, "invalid share index") return } @@ -84,7 +84,7 @@ func (ts *testServer) HandleSubmitPartialFeeRecipient(writer http.ResponseWriter for _, v := range lock.Validators { if strings.TrimPrefix(v.PublicKeyHex(), "0x") == validatorPubkeyHex { - publicKeyShare, err = v.PublicShare(int(shareIndex) - 1) + publicKeyShare, err = v.PublicShare(shareIndex - 1) if err != nil { writeErr(writer, http.StatusBadRequest, "cannot fetch public share: "+err.Error()) return @@ -114,8 +114,8 @@ func (ts *testServer) HandleSubmitPartialFeeRecipient(writer http.ResponseWriter } } - existing.partials[int(shareIndex)] = obolapi.PartialFeeRecipientResponsePartial{ - ShareIdx: int(shareIndex), + existing.partials[shareIndex] = obolapi.PartialFeeRecipientResponsePartial{ + ShareIdx: shareIndex, Message: partialReg.Message, Signature: partialReg.Signature[:], } From 806a55ec7040266ee07ab64d7976a25c02e372d9 Mon Sep 17 00:00:00 2001 From: Andrei Smirnov Date: Fri, 6 Mar 2026 12:34:16 +0300 Subject: [PATCH 03/16] Changed semantic --- app/obolapi/feerecipient.go | 28 ++-- app/obolapi/feerecipient_model.go | 19 ++- cmd/feerecipientfetch.go | 200 +++++-------------------- cmd/feerecipientfetch_internal_test.go | 23 ++- cmd/feerecipientsign.go | 25 +++- cmd/feerecipientsign_internal_test.go | 17 +++ testutil/obolapimock/feerecipient.go | 138 +++++++++++++---- testutil/obolapimock/obolapi.go | 2 +- 8 files changed, 219 insertions(+), 233 deletions(-) diff --git a/app/obolapi/feerecipient.go b/app/obolapi/feerecipient.go index f1e6c3e55..4f8406b10 100644 --- a/app/obolapi/feerecipient.go +++ b/app/obolapi/feerecipient.go @@ -15,7 +15,7 @@ import ( const ( submitPartialFeeRecipientTmpl = "/fee_recipient/partial/" + lockHashPath + "/" + shareIndexPath - fetchPartialFeeRecipientTmpl = "/fee_recipient/" + lockHashPath + "/" + valPubkeyPath + fetchFeeRecipientTmpl = "/fee_recipient/" + lockHashPath ) // submitPartialFeeRecipientURL returns the partial fee recipient Obol API URL for a given lock hash. @@ -28,14 +28,12 @@ func submitPartialFeeRecipientURL(lockHash string, shareIndex uint64) string { ).Replace(submitPartialFeeRecipientTmpl) } -// fetchPartialFeeRecipientURL returns the partial fee recipient Obol API URL for a given validator public key. -func fetchPartialFeeRecipientURL(valPubkey, lockHash string) string { +// fetchFeeRecipientURL returns the fee recipient Obol API URL for a given lock hash. +func fetchFeeRecipientURL(lockHash string) string { return strings.NewReplacer( - valPubkeyPath, - valPubkey, lockHashPath, lockHash, - ).Replace(fetchPartialFeeRecipientTmpl) + ).Replace(fetchFeeRecipientTmpl) } // PostPartialFeeRecipients POSTs partial fee recipient registrations to the Obol API. @@ -70,14 +68,14 @@ func (c Client) PostPartialFeeRecipients(ctx context.Context, lockHash []byte, s return nil } -// GetPartialFeeRecipients fetches partial fee recipient registrations from the Obol API. +// GetFeeRecipients fetches aggregated fee recipient registrations and per-validator status from the Obol API. // It respects the timeout specified in the Client instance. -func (c Client) GetPartialFeeRecipients(ctx context.Context, valPubkey string, lockHash []byte, _ int) (PartialFeeRecipientResponse, error) { - path := fetchPartialFeeRecipientURL(valPubkey, "0x"+hex.EncodeToString(lockHash)) +func (c Client) GetFeeRecipients(ctx context.Context, lockHash []byte) (FeeRecipientFetchResponse, error) { + path := fetchFeeRecipientURL("0x" + hex.EncodeToString(lockHash)) u, err := url.ParseRequestURI(c.baseURL) if err != nil { - return PartialFeeRecipientResponse{}, errors.Wrap(err, "bad Obol API url") + return FeeRecipientFetchResponse{}, errors.Wrap(err, "bad Obol API url") } u.Path = path @@ -87,18 +85,14 @@ func (c Client) GetPartialFeeRecipients(ctx context.Context, valPubkey string, l respBody, err := httpGet(ctx, u, map[string]string{}) if err != nil { - return PartialFeeRecipientResponse{}, errors.Wrap(err, "http Obol API GET request") + return FeeRecipientFetchResponse{}, errors.Wrap(err, "http Obol API GET request") } defer respBody.Close() - var resp PartialFeeRecipientResponse + var resp FeeRecipientFetchResponse if err := json.NewDecoder(respBody).Decode(&resp); err != nil { - return PartialFeeRecipientResponse{}, errors.Wrap(err, "unmarshal response") - } - - if len(resp.Partials) == 0 { - return PartialFeeRecipientResponse{}, ErrNoValue + return FeeRecipientFetchResponse{}, errors.Wrap(err, "unmarshal response") } return resp, nil diff --git a/app/obolapi/feerecipient_model.go b/app/obolapi/feerecipient_model.go index d81ee6a85..59a5e7ed8 100644 --- a/app/obolapi/feerecipient_model.go +++ b/app/obolapi/feerecipient_model.go @@ -3,6 +3,7 @@ package obolapi import ( + eth2api "github.com/attestantio/go-eth2-client/api" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/obolnetwork/charon/tbls" @@ -19,14 +20,16 @@ type PartialFeeRecipientRequest struct { PartialRegistrations []PartialRegistration `json:"partial_registrations"` } -// PartialFeeRecipientResponsePartial represents a single partial registration in the response. -type PartialFeeRecipientResponsePartial struct { - ShareIdx int `json:"share_index"` - Message *eth2v1.ValidatorRegistration `json:"message"` - Signature []byte `json:"signature"` +// FeeRecipientValidatorStatus represents the aggregation status for a single validator. +type FeeRecipientValidatorStatus struct { + Pubkey string `json:"pubkey"` + Status string `json:"status"` // "pending" or "complete" + PartialCount int `json:"partial_count"` + Threshold int `json:"threshold"` } -// PartialFeeRecipientResponse represents the response body when fetching partial fee recipient registrations. -type PartialFeeRecipientResponse struct { - Partials []PartialFeeRecipientResponsePartial `json:"partial_registrations"` +// FeeRecipientFetchResponse represents the response for fetching fee recipient registrations for a cluster. +type FeeRecipientFetchResponse struct { + Registrations []*eth2api.VersionedSignedValidatorRegistration `json:"registrations"` + Validators []FeeRecipientValidatorStatus `json:"validators"` } diff --git a/cmd/feerecipientfetch.go b/cmd/feerecipientfetch.go index 89a58d37b..e4f9fda4c 100644 --- a/cmd/feerecipientfetch.go +++ b/cmd/feerecipientfetch.go @@ -4,16 +4,11 @@ package cmd import ( "context" - "encoding/hex" "encoding/json" "os" "path/filepath" - "strings" eth2api "github.com/attestantio/go-eth2-client/api" - eth2v1 "github.com/attestantio/go-eth2-client/api/v1" - eth2spec "github.com/attestantio/go-eth2-client/spec" - eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/spf13/cobra" "github.com/obolnetwork/charon/app/errors" @@ -21,18 +16,18 @@ import ( "github.com/obolnetwork/charon/app/obolapi" "github.com/obolnetwork/charon/app/z" "github.com/obolnetwork/charon/cluster" - "github.com/obolnetwork/charon/eth2util/registration" - "github.com/obolnetwork/charon/tbls" - "github.com/obolnetwork/charon/tbls/tblsconv" ) type feerecipientFetchConfig struct { feerecipientConfig - OutputDir string + DataDir string } -const defaultBuilderRegistrationsDir = ".charon/builder_registrations" +const ( + defaultFeeRecipientDataDir = ".charon" + builderRegistrationsOverridesFilename = "builder_registrations_overrides.json" +) func newFeeRecipientFetchCmd(runFunc func(context.Context, feerecipientFetchConfig) error) *cobra.Command { var config feerecipientFetchConfig @@ -40,7 +35,7 @@ func newFeeRecipientFetchCmd(runFunc func(context.Context, feerecipientFetchConf cmd := &cobra.Command{ Use: "fetch", Short: "Fetch aggregated fee recipient registrations.", - Long: "Fetches aggregated builder registration messages with updated fee recipients from a remote API and writes them to the local builder registrations folder.", + Long: "Fetches aggregated builder registration messages with updated fee recipients from a remote API and writes them to a local JSON file.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return runFunc(cmd.Context(), config) @@ -54,7 +49,7 @@ func newFeeRecipientFetchCmd(runFunc func(context.Context, feerecipientFetchConf } func bindFeeRecipientFetchFlags(cmd *cobra.Command, config *feerecipientFetchConfig) { - cmd.Flags().StringVar(&config.OutputDir, "output-dir", defaultBuilderRegistrationsDir, "Path to the directory where fetched builder registrations will be stored.") + cmd.Flags().StringVar(&config.DataDir, "data-dir", defaultFeeRecipientDataDir, "The directory where the builder_registrations_overrides.json file will be written.") } func runFeeRecipientFetch(ctx context.Context, config feerecipientFetchConfig) error { @@ -68,179 +63,52 @@ func runFeeRecipientFetch(ctx context.Context, config feerecipientFetchConfig) e return errors.Wrap(err, "create Obol API client", z.Str("publish_address", config.PublishAddress)) } - // Determine which validators to fetch. - var pubkeys []string - if len(config.ValidatorPublicKeys) > 0 { - pubkeys = config.ValidatorPublicKeys - } else { - // Fetch all validators from cluster lock. - for _, dv := range cl.Validators { - pubkeys = append(pubkeys, "0x"+hex.EncodeToString(dv.PubKey)) - } - } - - // Create output directory. - err = os.MkdirAll(config.OutputDir, 0o755) + resp, err := oAPI.GetFeeRecipients(ctx, cl.LockHash) if err != nil { - return errors.Wrap(err, "create output directory") + return errors.Wrap(err, "fetch fee recipient registrations from Obol API") } - for _, pubkeyStr := range pubkeys { - log.Info(ctx, "Fetching fee recipient registration", z.Str("validator_pubkey", pubkeyStr)) - - // Get partial registrations from API. - resp, err := oAPI.GetPartialFeeRecipients(ctx, pubkeyStr, cl.LockHash, cl.Threshold) - if err != nil { - if errors.Is(err, obolapi.ErrNoValue) { - log.Warn(ctx, "No fee recipient registration found for validator", nil, z.Str("validator_pubkey", pubkeyStr)) - continue - } - - return errors.Wrap(err, "fetch partial fee recipient registrations from Obol API") - } - - if len(resp.Partials) < cl.Threshold { - log.Warn(ctx, "Insufficient partial signatures for aggregation", - nil, - z.Str("validator_pubkey", pubkeyStr), - z.Int("partial_count", len(resp.Partials)), - z.Int("threshold", cl.Threshold)) - - continue - } - - // Aggregate partial signatures. - signedReg, err := aggregateFeeRecipientRegistration(ctx, *cl, pubkeyStr, resp) - if err != nil { - return errors.Wrap(err, "aggregate fee recipient registration") - } - - // Write to output file. - filename := filepath.Join(config.OutputDir, strings.TrimPrefix(pubkeyStr, "0x")+".json") - - err = writeSignedValidatorRegistration(filename, signedReg) - if err != nil { - return errors.Wrap(err, "write signed validator registration", z.Str("filename", filename)) - } - - log.Info(ctx, "Successfully fetched fee recipient registration", - z.Str("validator_pubkey", pubkeyStr), - z.Str("output_file", filename), + // Display per-validator status. + for _, vs := range resp.Validators { + log.Info(ctx, "Validator fee recipient status", + z.Str("pubkey", vs.Pubkey), + z.Str("status", vs.Status), + z.Int("partial_count", vs.PartialCount), + z.Int("threshold", vs.Threshold), ) } - return nil -} - -// aggregateFeeRecipientRegistration aggregates partial BLS signatures into a full registration. -func aggregateFeeRecipientRegistration(ctx context.Context, cl cluster.Lock, pubkeyStr string, resp obolapi.PartialFeeRecipientResponse) (*eth2api.VersionedSignedValidatorRegistration, error) { - if len(resp.Partials) == 0 { - return nil, errors.New("no partial registrations") + if len(resp.Registrations) == 0 { + log.Warn(ctx, "No fully signed fee recipient registrations available yet", nil) + return nil } - // Use the message from the first partial (all should have the same message). - msg := resp.Partials[0].Message - - // Get the validator's group public key for verification. - pubkeyBytes, err := hex.DecodeString(strings.TrimPrefix(pubkeyStr, "0x")) + // Ensure data directory exists. + err = os.MkdirAll(config.DataDir, 0o755) if err != nil { - return nil, errors.Wrap(err, "decode validator pubkey") + return errors.Wrap(err, "create data directory") } - // Find the validator's public shares in the cluster lock. - var pubShares []tbls.PublicKey - - for _, dv := range cl.Validators { - if hex.EncodeToString(dv.PubKey) == strings.TrimPrefix(pubkeyStr, "0x") { - for _, share := range dv.PubShares { - pk, err := tblsconv.PubkeyFromBytes(share) - if err != nil { - return nil, errors.Wrap(err, "parse public share") - } - - pubShares = append(pubShares, pk) - } + outputPath := filepath.Join(config.DataDir, builderRegistrationsOverridesFilename) - break - } - } - - if len(pubShares) == 0 { - return nil, errors.New("validator not found in cluster lock") - } - - // Compute signing root for verification. - sigRoot, err := registration.GetMessageSigningRoot(msg, eth2p0.Version(cl.ForkVersion)) + err = writeSignedValidatorRegistrations(outputPath, resp.Registrations) if err != nil { - return nil, errors.Wrap(err, "get signing root") + return errors.Wrap(err, "write builder registrations overrides", z.Str("path", outputPath)) } - // Collect partial signatures with their share indices. - partialSigs := make(map[int]tbls.Signature) - - for _, partial := range resp.Partials { - sig, err := tblsconv.SignatureFromBytes(partial.Signature) - if err != nil { - return nil, errors.Wrap(err, "parse partial signature") - } - - // Verify partial signature against the corresponding public share. - if partial.ShareIdx < 1 || partial.ShareIdx > len(pubShares) { - return nil, errors.New("invalid share index", z.Int("share_idx", partial.ShareIdx)) - } - - err = tbls.Verify(pubShares[partial.ShareIdx-1], sigRoot[:], sig) - if err != nil { - log.Warn(ctx, "Invalid partial signature, skipping", - err, - z.Int("share_idx", partial.ShareIdx), - ) - - continue - } + log.Info(ctx, "Successfully wrote builder registrations overrides", + z.Int("count", len(resp.Registrations)), + z.Str("path", outputPath), + ) - partialSigs[partial.ShareIdx] = sig - } - - if len(partialSigs) < cl.Threshold { - return nil, errors.New("insufficient valid partial signatures", - z.Int("valid_count", len(partialSigs)), - z.Int("threshold", cl.Threshold), - ) - } - - // Aggregate signatures. - fullSig, err := tbls.ThresholdAggregate(partialSigs) - if err != nil { - return nil, errors.Wrap(err, "threshold aggregate signatures") - } - - // Verify aggregated signature against the group public key. - groupPubkey, err := tblsconv.PubkeyFromBytes(pubkeyBytes) - if err != nil { - return nil, errors.Wrap(err, "parse group public key") - } - - err = tbls.Verify(groupPubkey, sigRoot[:], fullSig) - if err != nil { - return nil, errors.Wrap(err, "verify aggregated signature") - } - - // Build the final signed registration. - return ð2api.VersionedSignedValidatorRegistration{ - Version: eth2spec.BuilderVersionV1, - V1: ð2v1.SignedValidatorRegistration{ - Message: msg, - Signature: eth2p0.BLSSignature(fullSig), - }, - }, nil + return nil } -// writeSignedValidatorRegistration writes the signed registration to a JSON file. -func writeSignedValidatorRegistration(filename string, reg *eth2api.VersionedSignedValidatorRegistration) error { - data, err := json.MarshalIndent(reg, "", " ") +// writeSignedValidatorRegistrations writes all signed registrations to a single JSON file. +func writeSignedValidatorRegistrations(filename string, regs []*eth2api.VersionedSignedValidatorRegistration) error { + data, err := json.MarshalIndent(regs, "", " ") if err != nil { - return errors.Wrap(err, "marshal registration to JSON") + return errors.Wrap(err, "marshal registrations to JSON") } err = os.WriteFile(filename, data, 0o644) //nolint:gosec // G306: world-readable output file is intentional diff --git a/cmd/feerecipientfetch_internal_test.go b/cmd/feerecipientfetch_internal_test.go index 78dff10c7..30a960bde 100644 --- a/cmd/feerecipientfetch_internal_test.go +++ b/cmd/feerecipientfetch_internal_test.go @@ -78,31 +78,30 @@ func TestFeeRecipientFetchValid(t *testing.T) { PublishTimeout: 10 * time.Second, }, FeeRecipient: newFeeRecipient, + Timestamp: "9999999999", } require.NoError(t, runFeeRecipientSign(ctx, signConfig), "operator %d submit feerecipient sign", opIdx) } - // Now fetch the aggregated registration. - outputDir := filepath.Join(root, "builder_registrations") + // Now fetch the aggregated registrations. + dataDir := filepath.Join(root, "output") fetchConfig := feerecipientFetchConfig{ feerecipientConfig: feerecipientConfig{ - ValidatorPublicKeys: []string{validatorPubkey}, - PrivateKeyPath: filepath.Join(root, "op0", "charon-enr-private-key"), - LockFilePath: filepath.Join(root, "op0", "cluster-lock.json"), - PublishAddress: srv.URL, - PublishTimeout: 10 * time.Second, + LockFilePath: filepath.Join(root, "op0", "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, }, - OutputDir: outputDir, + DataDir: dataDir, } require.NoError(t, runFeeRecipientFetch(ctx, fetchConfig)) - // Verify output file exists. - files, err := os.ReadDir(outputDir) + // Verify output file exists and contains registrations. + data, err := os.ReadFile(filepath.Join(dataDir, builderRegistrationsOverridesFilename)) require.NoError(t, err) - require.Len(t, files, 1) + require.NotEmpty(t, data) } func TestFeeRecipientFetchCLI(t *testing.T) { @@ -118,7 +117,7 @@ func TestFeeRecipientFetchCLI(t *testing.T) { "--lock-file=test", "--publish-address=test", "--publish-timeout=1ms", - "--output-dir=test", + "--data-dir=test", }, }, } diff --git a/cmd/feerecipientsign.go b/cmd/feerecipientsign.go index 1ecf68c32..6fa5796eb 100644 --- a/cmd/feerecipientsign.go +++ b/cmd/feerecipientsign.go @@ -6,7 +6,9 @@ import ( "bytes" "context" "encoding/hex" + "strconv" "strings" + "time" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" @@ -29,6 +31,7 @@ type feerecipientSignConfig struct { feerecipientConfig FeeRecipient string + Timestamp string } func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig) error) *cobra.Command { @@ -50,6 +53,7 @@ func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig wrapPreRunE(cmd, func(cmd *cobra.Command, _ []string) error { mustMarkFlagRequired(cmd, "validator-public-keys") mustMarkFlagRequired(cmd, "fee-recipient") + mustMarkFlagRequired(cmd, "timestamp") return nil }) @@ -59,6 +63,7 @@ func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig func bindFeeRecipientSignFlags(cmd *cobra.Command, config *feerecipientSignConfig) { cmd.Flags().StringVar(&config.FeeRecipient, "fee-recipient", "", "[REQUIRED] New fee recipient address to be applied to all specified validators.") + cmd.Flags().StringVar(&config.Timestamp, "timestamp", "", "[REQUIRED] Unix timestamp for the builder registration message (e.g. 1704067200).") } func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) error { @@ -122,6 +127,14 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err var feeRecipient [20]byte copy(feeRecipient[:], feeRecipientBytes) + // Parse timestamp. + unixTimestamp, err := strconv.ParseInt(config.Timestamp, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid timestamp, expected unix timestamp", z.Str("timestamp", config.Timestamp)) + } + + timestamp := time.Unix(unixTimestamp, 0) + // Build partial registrations. partialRegs := make([]obolapi.PartialRegistration, 0, len(pubkeys)) @@ -140,11 +153,19 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err return errors.New("no existing builder registration found for validator", z.Str("pubkey", hex.EncodeToString(pubkey[:]))) } - // Create new registration with updated fee recipient, keeping gas limit and timestamp. + if !timestamp.After(existingReg.Message.Timestamp) { + return errors.New("timestamp must be higher than existing builder registration timestamp", + z.Str("pubkey", hex.EncodeToString(pubkey[:])), + z.I64("existing_timestamp", existingReg.Message.Timestamp.Unix()), + z.I64("provided_timestamp", timestamp.Unix()), + ) + } + + // Create new registration with updated fee recipient and timestamp, keeping gas limit. regMsg := ð2v1.ValidatorRegistration{ FeeRecipient: feeRecipient, GasLimit: uint64(existingReg.Message.GasLimit), - Timestamp: existingReg.Message.Timestamp, + Timestamp: timestamp, Pubkey: pubkey, } diff --git a/cmd/feerecipientsign_internal_test.go b/cmd/feerecipientsign_internal_test.go index 312ff7cb1..0d1d807fa 100644 --- a/cmd/feerecipientsign_internal_test.go +++ b/cmd/feerecipientsign_internal_test.go @@ -76,6 +76,7 @@ func TestFeeRecipientSignValid(t *testing.T) { signConfig := feerecipientSignConfig{ feerecipientConfig: config, FeeRecipient: "0x0000000000000000000000000000000000001234", + Timestamp: "9999999999", } require.NoError(t, runFeeRecipientSign(ctx, signConfig), "operator index submit feerecipient sign: %v", idx) @@ -93,6 +94,7 @@ func TestFeeRecipientSignCLI(t *testing.T) { flags: []string{ "--validator-public-keys=test", "--fee-recipient=0x0000000000000000000000000000000000001234", + "--timestamp=9999999999", "--private-key-file=test", "--validator-keys-dir=test", "--lock-file=test", @@ -105,6 +107,7 @@ func TestFeeRecipientSignCLI(t *testing.T) { expectedErr: "required flag(s) \"validator-public-keys\" not set", flags: []string{ "--fee-recipient=0x0000000000000000000000000000000000001234", + "--timestamp=9999999999", "--private-key-file=test", "--validator-keys-dir=test", "--lock-file=test", @@ -117,6 +120,20 @@ func TestFeeRecipientSignCLI(t *testing.T) { expectedErr: "required flag(s) \"fee-recipient\" not set", flags: []string{ "--validator-public-keys=test", + "--timestamp=9999999999", + "--private-key-file=test", + "--validator-keys-dir=test", + "--lock-file=test", + "--publish-address=test", + "--publish-timeout=1ms", + }, + }, + { + name: "missing timestamp", + expectedErr: "required flag(s) \"timestamp\" not set", + flags: []string{ + "--validator-public-keys=test", + "--fee-recipient=0x0000000000000000000000000000000000001234", "--private-key-file=test", "--validator-keys-dir=test", "--lock-file=test", diff --git a/testutil/obolapimock/feerecipient.go b/testutil/obolapimock/feerecipient.go index 0cdc46c2b..341e1dbf1 100644 --- a/testutil/obolapimock/feerecipient.go +++ b/testutil/obolapimock/feerecipient.go @@ -9,22 +9,34 @@ import ( "strconv" "strings" + eth2api "github.com/attestantio/go-eth2-client/api" + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + eth2spec "github.com/attestantio/go-eth2-client/spec" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/gorilla/mux" "github.com/obolnetwork/charon/app/obolapi" + "github.com/obolnetwork/charon/cluster" "github.com/obolnetwork/charon/eth2util/registration" "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/tbls/tblsconv" ) const ( submitPartialFeeRecipientTmpl = "/fee_recipient/partial/" + lockHashPath + "/" + shareIndexPath - fetchPartialFeeRecipientTmpl = "/fee_recipient/" + lockHashPath + "/" + valPubkeyPath + fetchFeeRecipientTmpl = "/fee_recipient/" + lockHashPath ) +// feeRecipientPartial represents a single partial fee recipient registration. +type feeRecipientPartial struct { + ShareIdx int + Message *eth2v1.ValidatorRegistration + Signature []byte +} + // feeRecipientBlob represents partial fee recipient registrations for a validator. type feeRecipientBlob struct { - partials map[int]obolapi.PartialFeeRecipientResponsePartial // keyed by share index + partials map[int]feeRecipientPartial // keyed by share index } func (ts *testServer) HandleSubmitPartialFeeRecipient(writer http.ResponseWriter, request *http.Request) { @@ -110,11 +122,11 @@ func (ts *testServer) HandleSubmitPartialFeeRecipient(writer http.ResponseWriter existing, ok := ts.partialFeeRecipients[key] if !ok { existing = feeRecipientBlob{ - partials: make(map[int]obolapi.PartialFeeRecipientResponsePartial), + partials: make(map[int]feeRecipientPartial), } } - existing.partials[shareIndex] = obolapi.PartialFeeRecipientResponsePartial{ + existing.partials[shareIndex] = feeRecipientPartial{ ShareIdx: shareIndex, Message: partialReg.Message, Signature: partialReg.Signature[:], @@ -126,7 +138,7 @@ func (ts *testServer) HandleSubmitPartialFeeRecipient(writer http.ResponseWriter writer.WriteHeader(http.StatusOK) } -func (ts *testServer) HandleGetPartialFeeRecipient(writer http.ResponseWriter, request *http.Request) { +func (ts *testServer) HandleGetFeeRecipient(writer http.ResponseWriter, request *http.Request) { ts.lock.Lock() defer ts.lock.Unlock() @@ -138,44 +150,116 @@ func (ts *testServer) HandleGetPartialFeeRecipient(writer http.ResponseWriter, r return } - _, ok := ts.lockFiles[lockHash] + lock, ok := ts.lockFiles[lockHash] if !ok { writeErr(writer, http.StatusNotFound, "lock not found") return } - valPubkey := vars[cleanTmpl(valPubkeyPath)] - if valPubkey == "" { - writeErr(writer, http.StatusBadRequest, "invalid validator pubkey") - return - } + var ( + registrations []*eth2api.VersionedSignedValidatorRegistration + validators []obolapi.FeeRecipientValidatorStatus + ) - // Normalize pubkey (remove 0x prefix for storage key lookup). - valPubkeyNormalized := strings.TrimPrefix(valPubkey, "0x") + for _, v := range lock.Validators { + pubkeyHex := strings.TrimPrefix(v.PublicKeyHex(), "0x") + key := lockHash + "/" + pubkeyHex - key := lockHash + "/" + valPubkeyNormalized + existing, hasPartials := ts.partialFeeRecipients[key] - existing, ok := ts.partialFeeRecipients[key] - if !ok { - writeErr(writer, http.StatusNotFound, "no partial registrations found") - return - } + partialCount := 0 + if hasPartials { + partialCount = len(existing.partials) + } - resp := obolapi.PartialFeeRecipientResponse{ - Partials: make([]obolapi.PartialFeeRecipientResponsePartial, 0, len(existing.partials)), - } + status := "pending" + if partialCount >= lock.Threshold { + status = "complete" + } - for i, partial := range existing.partials { - // Optionally drop one partial signature for testing threshold behavior. - if ts.dropOnePsig && i == len(existing.partials)-1 { + validators = append(validators, obolapi.FeeRecipientValidatorStatus{ + Pubkey: "0x" + pubkeyHex, + Status: status, + PartialCount: partialCount, + Threshold: lock.Threshold, + }) + + if status != "complete" { continue } - resp.Partials = append(resp.Partials, partial) + // Aggregate partial signatures server-side. + signedReg, err := ts.aggregateFeeRecipient(lock, v, existing) + if err != nil { + writeErr(writer, http.StatusInternalServerError, "aggregate error: "+err.Error()) + return + } + + registrations = append(registrations, signedReg) + } + + resp := obolapi.FeeRecipientFetchResponse{ + Registrations: registrations, + Validators: validators, } if err := json.NewEncoder(writer).Encode(resp); err != nil { writeErr(writer, http.StatusInternalServerError, "cannot encode response") - return } } + +// aggregateFeeRecipient aggregates partial BLS signatures into a fully signed registration. +func (ts *testServer) aggregateFeeRecipient(lock cluster.Lock, v cluster.DistValidator, blob feeRecipientBlob) (*eth2api.VersionedSignedValidatorRegistration, error) { + // Use the message from the first partial (all should have the same message). + var msg *eth2v1.ValidatorRegistration + for _, p := range blob.partials { + msg = p.Message + break + } + + // Collect partial signatures. + partialSigs := make(map[int]tbls.Signature) + for _, p := range blob.partials { + if ts.dropOnePsig && len(partialSigs) == len(blob.partials)-1 { + continue + } + + sig, err := tblsconv.SignatureFromBytes(p.Signature) + if err != nil { + return nil, err + } + + partialSigs[p.ShareIdx] = sig + } + + // Aggregate signatures. + fullSig, err := tbls.ThresholdAggregate(partialSigs) + if err != nil { + return nil, err + } + + // Verify aggregated signature against the group public key. + pubkeyBytes := v.PubKey + + groupPubkey, err := tblsconv.PubkeyFromBytes(pubkeyBytes) + if err != nil { + return nil, err + } + + sigRoot, err := registration.GetMessageSigningRoot(msg, eth2p0.Version(lock.ForkVersion)) + if err != nil { + return nil, err + } + + if err := tbls.Verify(groupPubkey, sigRoot[:], fullSig); err != nil { + return nil, err + } + + return ð2api.VersionedSignedValidatorRegistration{ + Version: eth2spec.BuilderVersionV1, + V1: ð2v1.SignedValidatorRegistration{ + Message: msg, + Signature: eth2p0.BLSSignature(fullSig), + }, + }, nil +} diff --git a/testutil/obolapimock/obolapi.go b/testutil/obolapimock/obolapi.go index f0417c767..9131941c8 100644 --- a/testutil/obolapimock/obolapi.go +++ b/testutil/obolapimock/obolapi.go @@ -136,7 +136,7 @@ func MockServer(dropOnePsig bool, beacon eth2wrap.Client) (http.Handler, func(lo router.HandleFunc(fetchFullDepositTmpl, ts.HandleGetFullDeposit).Methods(http.MethodGet) router.HandleFunc(submitPartialFeeRecipientTmpl, ts.HandleSubmitPartialFeeRecipient).Methods(http.MethodPost) - router.HandleFunc(fetchPartialFeeRecipientTmpl, ts.HandleGetPartialFeeRecipient).Methods(http.MethodGet) + router.HandleFunc(fetchFeeRecipientTmpl, ts.HandleGetFeeRecipient).Methods(http.MethodGet) return router, ts.addLockFiles } From 3c4f8f05402591d117968d88b10c781547aa4a82 Mon Sep 17 00:00:00 2001 From: Andrei Smirnov Date: Fri, 6 Mar 2026 12:47:32 +0300 Subject: [PATCH 04/16] Changed semantic --- app/obolapi/feerecipient_model.go | 3 +- cmd/feerecipientfetch.go | 3 +- testutil/obolapimock/feerecipient.go | 54 ++++++++++++++++++++++------ 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/app/obolapi/feerecipient_model.go b/app/obolapi/feerecipient_model.go index 59a5e7ed8..a7278a978 100644 --- a/app/obolapi/feerecipient_model.go +++ b/app/obolapi/feerecipient_model.go @@ -25,11 +25,10 @@ type FeeRecipientValidatorStatus struct { Pubkey string `json:"pubkey"` Status string `json:"status"` // "pending" or "complete" PartialCount int `json:"partial_count"` - Threshold int `json:"threshold"` } // FeeRecipientFetchResponse represents the response for fetching fee recipient registrations for a cluster. type FeeRecipientFetchResponse struct { Registrations []*eth2api.VersionedSignedValidatorRegistration `json:"registrations"` - Validators []FeeRecipientValidatorStatus `json:"validators"` + Validators []FeeRecipientValidatorStatus `json:"status"` } diff --git a/cmd/feerecipientfetch.go b/cmd/feerecipientfetch.go index e4f9fda4c..6a43324f0 100644 --- a/cmd/feerecipientfetch.go +++ b/cmd/feerecipientfetch.go @@ -35,7 +35,7 @@ func newFeeRecipientFetchCmd(runFunc func(context.Context, feerecipientFetchConf cmd := &cobra.Command{ Use: "fetch", Short: "Fetch aggregated fee recipient registrations.", - Long: "Fetches aggregated builder registration messages with updated fee recipients from a remote API and writes them to a local JSON file.", + Long: "Fetches aggregated builder registration messages with updated fee recipients from a remote API for validators that have had partial signatures submitted, and writes them to a local JSON file.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return runFunc(cmd.Context(), config) @@ -74,7 +74,6 @@ func runFeeRecipientFetch(ctx context.Context, config feerecipientFetchConfig) e z.Str("pubkey", vs.Pubkey), z.Str("status", vs.Status), z.Int("partial_count", vs.PartialCount), - z.Int("threshold", vs.Threshold), ) } diff --git a/testutil/obolapimock/feerecipient.go b/testutil/obolapimock/feerecipient.go index 341e1dbf1..8dde9aa30 100644 --- a/testutil/obolapimock/feerecipient.go +++ b/testutil/obolapimock/feerecipient.go @@ -126,6 +126,26 @@ func (ts *testServer) HandleSubmitPartialFeeRecipient(writer http.ResponseWriter } } + // Check message consistency with existing partials. + for _, p := range existing.partials { + if p.Message.FeeRecipient != partialReg.Message.FeeRecipient { + writeErr(writer, http.StatusBadRequest, "fee_recipient mismatch with existing partial") + return + } + + if !p.Message.Timestamp.Equal(partialReg.Message.Timestamp) { + writeErr(writer, http.StatusBadRequest, "timestamp mismatch with existing partial") + return + } + + if p.Message.GasLimit != partialReg.Message.GasLimit { + writeErr(writer, http.StatusBadRequest, "gas_limit mismatch with existing partial") + return + } + + break // Only need to check against one existing partial. + } + existing.partials[shareIndex] = feeRecipientPartial{ ShareIdx: shareIndex, Message: partialReg.Message, @@ -161,16 +181,16 @@ func (ts *testServer) HandleGetFeeRecipient(writer http.ResponseWriter, request validators []obolapi.FeeRecipientValidatorStatus ) - for _, v := range lock.Validators { - pubkeyHex := strings.TrimPrefix(v.PublicKeyHex(), "0x") - key := lockHash + "/" + pubkeyHex + // Only iterate over validators that have partial signatures submitted. + for key, existing := range ts.partialFeeRecipients { + // Key format: "lockHash/pubkeyHex" + if !strings.HasPrefix(key, lockHash+"/") { + continue + } - existing, hasPartials := ts.partialFeeRecipients[key] + pubkeyHex := strings.TrimPrefix(key, lockHash+"/") - partialCount := 0 - if hasPartials { - partialCount = len(existing.partials) - } + partialCount := len(existing.partials) status := "pending" if partialCount >= lock.Threshold { @@ -181,15 +201,29 @@ func (ts *testServer) HandleGetFeeRecipient(writer http.ResponseWriter, request Pubkey: "0x" + pubkeyHex, Status: status, PartialCount: partialCount, - Threshold: lock.Threshold, }) if status != "complete" { continue } + // Find the validator in the lock to get public shares for aggregation. + var v *cluster.DistValidator + + for i := range lock.Validators { + if strings.TrimPrefix(lock.Validators[i].PublicKeyHex(), "0x") == pubkeyHex { + v = &lock.Validators[i] + break + } + } + + if v == nil { + writeErr(writer, http.StatusInternalServerError, "validator not found in lock") + return + } + // Aggregate partial signatures server-side. - signedReg, err := ts.aggregateFeeRecipient(lock, v, existing) + signedReg, err := ts.aggregateFeeRecipient(lock, *v, existing) if err != nil { writeErr(writer, http.StatusInternalServerError, "aggregate error: "+err.Error()) return From 47de42e376e0fbb867622a84066d2f0b15abd9a6 Mon Sep 17 00:00:00 2001 From: Andrei Smirnov Date: Tue, 10 Mar 2026 12:49:47 +0300 Subject: [PATCH 05/16] Reworked fetch behavior --- app/obolapi/api.go | 29 ++++++++++ app/obolapi/feerecipient.go | 17 ++++-- app/obolapi/feerecipient_model.go | 28 ++++++++- cmd/feerecipient.go | 10 +++- cmd/feerecipientfetch.go | 80 +++++++++++++++----------- cmd/feerecipientfetch_internal_test.go | 15 ++--- cmd/feerecipientsign.go | 3 +- testutil/obolapimock/feerecipient.go | 79 +++++++++++++++---------- testutil/obolapimock/obolapi.go | 2 +- 9 files changed, 178 insertions(+), 85 deletions(-) diff --git a/app/obolapi/api.go b/app/obolapi/api.go index 529612ab7..4b071db40 100644 --- a/app/obolapi/api.go +++ b/app/obolapi/api.go @@ -117,6 +117,35 @@ func httpPost(ctx context.Context, url *url.URL, body []byte, headers map[string return nil } +func httpPostWithResponse(ctx context.Context, url *url.URL, body []byte, headers map[string]string) (io.ReadCloser, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url.String(), bytes.NewReader(body)) + if err != nil { + return nil, errors.Wrap(err, "create POST request") + } + + req.Header.Add("Content-Type", "application/json") + + for key, val := range headers { + req.Header.Set(key, val) + } + + res, err := new(http.Client).Do(req) + if err != nil { + return nil, errors.Wrap(err, "call POST endpoint") + } + + if res.StatusCode/100 != 2 { + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, errors.Wrap(err, "read POST response", z.Int("status", res.StatusCode)) + } + + return nil, errors.New("http POST failed", z.Int("status", res.StatusCode), z.Str("body", string(data))) + } + + return res.Body, nil +} + func httpGet(ctx context.Context, url *url.URL, headers map[string]string) (io.ReadCloser, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil) if err != nil { diff --git a/app/obolapi/feerecipient.go b/app/obolapi/feerecipient.go index 4f8406b10..6f534ace4 100644 --- a/app/obolapi/feerecipient.go +++ b/app/obolapi/feerecipient.go @@ -68,9 +68,11 @@ func (c Client) PostPartialFeeRecipients(ctx context.Context, lockHash []byte, s return nil } -// GetFeeRecipients fetches aggregated fee recipient registrations and per-validator status from the Obol API. +// PostFeeRecipientsFetch fetches aggregated fee recipient registrations and per-validator status from the Obol API. +// If pubkeys is non-empty, only the specified validators are included in the response. +// If pubkeys is empty, status for all validators in the cluster is returned. // It respects the timeout specified in the Client instance. -func (c Client) GetFeeRecipients(ctx context.Context, lockHash []byte) (FeeRecipientFetchResponse, error) { +func (c Client) PostFeeRecipientsFetch(ctx context.Context, lockHash []byte, pubkeys []string) (FeeRecipientFetchResponse, error) { path := fetchFeeRecipientURL("0x" + hex.EncodeToString(lockHash)) u, err := url.ParseRequestURI(c.baseURL) @@ -80,12 +82,19 @@ func (c Client) GetFeeRecipients(ctx context.Context, lockHash []byte) (FeeRecip u.Path = path + req := FeeRecipientFetchRequest{Pubkeys: pubkeys} + + data, err := json.Marshal(req) + if err != nil { + return FeeRecipientFetchResponse{}, errors.Wrap(err, "json marshal error") + } + ctx, cancel := context.WithTimeout(ctx, c.reqTimeout) defer cancel() - respBody, err := httpGet(ctx, u, map[string]string{}) + respBody, err := httpPostWithResponse(ctx, u, data, nil) if err != nil { - return FeeRecipientFetchResponse{}, errors.Wrap(err, "http Obol API GET request") + return FeeRecipientFetchResponse{}, errors.Wrap(err, "http Obol API POST request") } defer respBody.Close() diff --git a/app/obolapi/feerecipient_model.go b/app/obolapi/feerecipient_model.go index a7278a978..6ee29fe0b 100644 --- a/app/obolapi/feerecipient_model.go +++ b/app/obolapi/feerecipient_model.go @@ -3,6 +3,8 @@ package obolapi import ( + "time" + eth2api "github.com/attestantio/go-eth2-client/api" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" @@ -20,11 +22,31 @@ type PartialFeeRecipientRequest struct { PartialRegistrations []PartialRegistration `json:"partial_registrations"` } +// FeeRecipientFetchRequest represents the request body for fetching fee recipient registrations. +// Pubkeys is an optional list of validator public keys to filter the response. +// If empty, all validators in the cluster are returned. +type FeeRecipientFetchRequest struct { + Pubkeys []string `json:"pubkeys"` +} + +// FeeRecipientStatus represents the aggregation status for a validator's fee recipient registration. +type FeeRecipientStatus string + +const ( + // FeeRecipientStatusUnknown indicates no partial signatures received. + FeeRecipientStatusUnknown FeeRecipientStatus = "unknown" + // FeeRecipientStatusPartial indicates some but not all partial signatures received. + FeeRecipientStatusPartial FeeRecipientStatus = "partial" + // FeeRecipientStatusComplete indicates enough partial signatures received to produce a complete signature. + FeeRecipientStatusComplete FeeRecipientStatus = "complete" +) + // FeeRecipientValidatorStatus represents the aggregation status for a single validator. type FeeRecipientValidatorStatus struct { - Pubkey string `json:"pubkey"` - Status string `json:"status"` // "pending" or "complete" - PartialCount int `json:"partial_count"` + Pubkey string `json:"pubkey"` + Status FeeRecipientStatus `json:"status"` + Timestamp time.Time `json:"timestamp"` + PartialCount int `json:"partial_count"` } // FeeRecipientFetchResponse represents the response for fetching fee recipient registrations for a cluster. diff --git a/cmd/feerecipient.go b/cmd/feerecipient.go index 339aa4f4c..5ab54f083 100644 --- a/cmd/feerecipient.go +++ b/cmd/feerecipient.go @@ -15,6 +15,7 @@ type feerecipientConfig struct { PrivateKeyPath string LockFilePath string ValidatorKeysDir string + OverridesFilePath string PublishAddress string PublishTimeout time.Duration Log log.Config @@ -32,11 +33,14 @@ func newFeeRecipientCmd(cmds ...*cobra.Command) *cobra.Command { return root } -func bindFeeRecipientFlags(cmd *cobra.Command, config *feerecipientConfig) { - cmd.Flags().StringSliceVar(&config.ValidatorPublicKeys, "validator-public-keys", []string{}, "Comma-separated list of validator public keys to update (required for the sign subcommand).") +func bindFeeRecipientCharonFilesFlags(cmd *cobra.Command, config *feerecipientConfig) { cmd.Flags().StringVar(&config.PrivateKeyPath, privateKeyPath.String(), ".charon/charon-enr-private-key", "Path to the charon enr private key file.") cmd.Flags().StringVar(&config.ValidatorKeysDir, validatorKeysDir.String(), ".charon/validator_keys", "Path to the directory containing the validator private key share files and passwords.") cmd.Flags().StringVar(&config.LockFilePath, lockFilePath.String(), ".charon/cluster-lock.json", "Path to the cluster lock file defining the distributed validator cluster.") + cmd.Flags().StringVar(&config.OverridesFilePath, "overrides-file", ".charon/builder_registrations_overrides.json", "Path to the builder registrations overrides file.") +} + +func bindFeeRecipientRemoteAPIFlags(cmd *cobra.Command, config *feerecipientConfig) { cmd.Flags().StringVar(&config.PublishAddress, publishAddress.String(), "https://api.obol.tech/v1", "The URL of the remote API.") - cmd.Flags().DurationVar(&config.PublishTimeout, publishTimeout.String(), 5*time.Minute, "Timeout for publishing to the publish-address API.") + cmd.Flags().DurationVar(&config.PublishTimeout, publishTimeout.String(), 5*time.Minute, "Timeout for accessing the remote API.") } diff --git a/cmd/feerecipientfetch.go b/cmd/feerecipientfetch.go index 6a43324f0..ce6dfbf54 100644 --- a/cmd/feerecipientfetch.go +++ b/cmd/feerecipientfetch.go @@ -6,7 +6,6 @@ import ( "context" "encoding/json" "os" - "path/filepath" eth2api "github.com/attestantio/go-eth2-client/api" "github.com/spf13/cobra" @@ -20,15 +19,8 @@ import ( type feerecipientFetchConfig struct { feerecipientConfig - - DataDir string } -const ( - defaultFeeRecipientDataDir = ".charon" - builderRegistrationsOverridesFilename = "builder_registrations_overrides.json" -) - func newFeeRecipientFetchCmd(runFunc func(context.Context, feerecipientFetchConfig) error) *cobra.Command { var config feerecipientFetchConfig @@ -42,14 +34,12 @@ func newFeeRecipientFetchCmd(runFunc func(context.Context, feerecipientFetchConf }, } - bindFeeRecipientFlags(cmd, &config.feerecipientConfig) - bindFeeRecipientFetchFlags(cmd, &config) + cmd.Flags().StringSliceVar(&config.ValidatorPublicKeys, "validator-public-keys", []string{}, "Optional comma-separated list of validator public keys to fetch builder registrations for.") - return cmd -} + bindFeeRecipientCharonFilesFlags(cmd, &config.feerecipientConfig) + bindFeeRecipientRemoteAPIFlags(cmd, &config.feerecipientConfig) -func bindFeeRecipientFetchFlags(cmd *cobra.Command, config *feerecipientFetchConfig) { - cmd.Flags().StringVar(&config.DataDir, "data-dir", defaultFeeRecipientDataDir, "The directory where the builder_registrations_overrides.json file will be written.") + return cmd } func runFeeRecipientFetch(ctx context.Context, config feerecipientFetchConfig) error { @@ -63,47 +53,67 @@ func runFeeRecipientFetch(ctx context.Context, config feerecipientFetchConfig) e return errors.Wrap(err, "create Obol API client", z.Str("publish_address", config.PublishAddress)) } - resp, err := oAPI.GetFeeRecipients(ctx, cl.LockHash) + resp, err := oAPI.PostFeeRecipientsFetch(ctx, cl.LockHash, config.ValidatorPublicKeys) if err != nil { - return errors.Wrap(err, "fetch fee recipient registrations from Obol API") + return errors.Wrap(err, "fetch builder registrations from Obol API") } - // Display per-validator status. + // Group validators by status. + grouped := make(map[obolapi.FeeRecipientStatus][]obolapi.FeeRecipientValidatorStatus) for _, vs := range resp.Validators { - log.Info(ctx, "Validator fee recipient status", - z.Str("pubkey", vs.Pubkey), - z.Str("status", vs.Status), - z.Int("partial_count", vs.PartialCount), - ) + grouped[vs.Status] = append(grouped[vs.Status], vs) } - if len(resp.Registrations) == 0 { - log.Warn(ctx, "No fully signed fee recipient registrations available yet", nil) - return nil + if vals := grouped[obolapi.FeeRecipientStatusComplete]; len(vals) > 0 { + log.Info(ctx, "Validators with complete builder registrations", z.Int("count", len(vals))) + + for _, vs := range vals { + log.Info(ctx, " Complete registration", + z.Str("pubkey", vs.Pubkey), + z.I64("timestamp_unix", vs.Timestamp.UTC().Unix()), + z.Str("timestamp", vs.Timestamp.String())) + } } - // Ensure data directory exists. - err = os.MkdirAll(config.DataDir, 0o755) - if err != nil { - return errors.Wrap(err, "create data directory") + if vals := grouped[obolapi.FeeRecipientStatusPartial]; len(vals) > 0 { + log.Info(ctx, "Validators with partial builder registrations", z.Int("count", len(vals))) + + for _, vs := range vals { + log.Info(ctx, " Partial registration", + z.Str("pubkey", vs.Pubkey), + z.I64("timestamp_unix", vs.Timestamp.UTC().Unix()), + z.Str("timestamp", vs.Timestamp.String()), + z.Int("partial_count", vs.PartialCount)) + } + } + + if vals := grouped[obolapi.FeeRecipientStatusUnknown]; len(vals) > 0 { + log.Info(ctx, "Validators unknown to the API", z.Int("count", len(vals))) + + for _, vs := range vals { + log.Info(ctx, " Unknown validator", z.Str("pubkey", vs.Pubkey)) + } } - outputPath := filepath.Join(config.DataDir, builderRegistrationsOverridesFilename) + if len(resp.Registrations) == 0 { + log.Warn(ctx, "No fully signed builder registrations available yet", nil) + + return nil + } - err = writeSignedValidatorRegistrations(outputPath, resp.Registrations) + err = writeSignedValidatorRegistrations(config.OverridesFilePath, resp.Registrations) if err != nil { - return errors.Wrap(err, "write builder registrations overrides", z.Str("path", outputPath)) + return errors.Wrap(err, "write builder registrations overrides", z.Str("path", config.OverridesFilePath)) } log.Info(ctx, "Successfully wrote builder registrations overrides", z.Int("count", len(resp.Registrations)), - z.Str("path", outputPath), + z.Str("path", config.OverridesFilePath), ) return nil } -// writeSignedValidatorRegistrations writes all signed registrations to a single JSON file. func writeSignedValidatorRegistrations(filename string, regs []*eth2api.VersionedSignedValidatorRegistration) error { data, err := json.MarshalIndent(regs, "", " ") if err != nil { @@ -112,7 +122,7 @@ func writeSignedValidatorRegistrations(filename string, regs []*eth2api.Versione err = os.WriteFile(filename, data, 0o644) //nolint:gosec // G306: world-readable output file is intentional if err != nil { - return errors.Wrap(err, "write file") + return errors.Wrap(err, "write registrations overrides file") } return nil diff --git a/cmd/feerecipientfetch_internal_test.go b/cmd/feerecipientfetch_internal_test.go index 30a960bde..f1268c9fa 100644 --- a/cmd/feerecipientfetch_internal_test.go +++ b/cmd/feerecipientfetch_internal_test.go @@ -85,21 +85,22 @@ func TestFeeRecipientFetchValid(t *testing.T) { } // Now fetch the aggregated registrations. - dataDir := filepath.Join(root, "output") + overridesFile := filepath.Join(root, "output", "builder_registrations_overrides.json") + require.NoError(t, os.MkdirAll(filepath.Dir(overridesFile), 0o755)) fetchConfig := feerecipientFetchConfig{ feerecipientConfig: feerecipientConfig{ - LockFilePath: filepath.Join(root, "op0", "cluster-lock.json"), - PublishAddress: srv.URL, - PublishTimeout: 10 * time.Second, + LockFilePath: filepath.Join(root, "op0", "cluster-lock.json"), + OverridesFilePath: overridesFile, + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, }, - DataDir: dataDir, } require.NoError(t, runFeeRecipientFetch(ctx, fetchConfig)) // Verify output file exists and contains registrations. - data, err := os.ReadFile(filepath.Join(dataDir, builderRegistrationsOverridesFilename)) + data, err := os.ReadFile(overridesFile) require.NoError(t, err) require.NotEmpty(t, data) } @@ -117,7 +118,7 @@ func TestFeeRecipientFetchCLI(t *testing.T) { "--lock-file=test", "--publish-address=test", "--publish-timeout=1ms", - "--data-dir=test", + "--overrides-file=test", }, }, } diff --git a/cmd/feerecipientsign.go b/cmd/feerecipientsign.go index 6fa5796eb..d71d78a19 100644 --- a/cmd/feerecipientsign.go +++ b/cmd/feerecipientsign.go @@ -47,7 +47,8 @@ func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig }, } - bindFeeRecipientFlags(cmd, &config.feerecipientConfig) + bindFeeRecipientCharonFilesFlags(cmd, &config.feerecipientConfig) + bindFeeRecipientRemoteAPIFlags(cmd, &config.feerecipientConfig) bindFeeRecipientSignFlags(cmd, &config) wrapPreRunE(cmd, func(cmd *cobra.Command, _ []string) error { diff --git a/testutil/obolapimock/feerecipient.go b/testutil/obolapimock/feerecipient.go index 8dde9aa30..5eaaa4e99 100644 --- a/testutil/obolapimock/feerecipient.go +++ b/testutil/obolapimock/feerecipient.go @@ -158,7 +158,7 @@ func (ts *testServer) HandleSubmitPartialFeeRecipient(writer http.ResponseWriter writer.WriteHeader(http.StatusOK) } -func (ts *testServer) HandleGetFeeRecipient(writer http.ResponseWriter, request *http.Request) { +func (ts *testServer) HandlePostFeeRecipientFetch(writer http.ResponseWriter, request *http.Request) { ts.lock.Lock() defer ts.lock.Unlock() @@ -176,54 +176,71 @@ func (ts *testServer) HandleGetFeeRecipient(writer http.ResponseWriter, request return } + var fetchReq obolapi.FeeRecipientFetchRequest + if err := json.NewDecoder(request.Body).Decode(&fetchReq); err != nil { + writeErr(writer, http.StatusBadRequest, "invalid body") + return + } + + // Build a set of requested pubkeys for filtering. + pubkeyFilter := make(map[string]bool) + for _, pk := range fetchReq.Pubkeys { + pubkeyFilter[strings.ToLower(strings.TrimPrefix(pk, "0x"))] = true + } + + // Determine which validators to report on: filtered list or all in the cluster. + type validatorInfo struct { + pubkeyHex string + validator *cluster.DistValidator + } + + var targets []validatorInfo + + for i := range lock.Validators { + pkHex := strings.TrimPrefix(lock.Validators[i].PublicKeyHex(), "0x") + if len(pubkeyFilter) > 0 && !pubkeyFilter[strings.ToLower(pkHex)] { + continue + } + + targets = append(targets, validatorInfo{ + pubkeyHex: pkHex, + validator: &lock.Validators[i], + }) + } + var ( registrations []*eth2api.VersionedSignedValidatorRegistration validators []obolapi.FeeRecipientValidatorStatus ) - // Only iterate over validators that have partial signatures submitted. - for key, existing := range ts.partialFeeRecipients { - // Key format: "lockHash/pubkeyHex" - if !strings.HasPrefix(key, lockHash+"/") { - continue - } - - pubkeyHex := strings.TrimPrefix(key, lockHash+"/") + for _, t := range targets { + key := lockHash + "/" + t.pubkeyHex + existing, hasPartials := ts.partialFeeRecipients[key] - partialCount := len(existing.partials) + partialCount := 0 + if hasPartials { + partialCount = len(existing.partials) + } - status := "pending" - if partialCount >= lock.Threshold { - status = "complete" + status := obolapi.FeeRecipientStatusUnknown + if partialCount > 0 && partialCount < lock.Threshold { + status = obolapi.FeeRecipientStatusPartial + } else if partialCount >= lock.Threshold { + status = obolapi.FeeRecipientStatusComplete } validators = append(validators, obolapi.FeeRecipientValidatorStatus{ - Pubkey: "0x" + pubkeyHex, + Pubkey: "0x" + t.pubkeyHex, Status: status, PartialCount: partialCount, }) - if status != "complete" { + if status != obolapi.FeeRecipientStatusComplete { continue } - // Find the validator in the lock to get public shares for aggregation. - var v *cluster.DistValidator - - for i := range lock.Validators { - if strings.TrimPrefix(lock.Validators[i].PublicKeyHex(), "0x") == pubkeyHex { - v = &lock.Validators[i] - break - } - } - - if v == nil { - writeErr(writer, http.StatusInternalServerError, "validator not found in lock") - return - } - // Aggregate partial signatures server-side. - signedReg, err := ts.aggregateFeeRecipient(lock, *v, existing) + signedReg, err := ts.aggregateFeeRecipient(lock, *t.validator, existing) if err != nil { writeErr(writer, http.StatusInternalServerError, "aggregate error: "+err.Error()) return diff --git a/testutil/obolapimock/obolapi.go b/testutil/obolapimock/obolapi.go index 9131941c8..6addfbe11 100644 --- a/testutil/obolapimock/obolapi.go +++ b/testutil/obolapimock/obolapi.go @@ -136,7 +136,7 @@ func MockServer(dropOnePsig bool, beacon eth2wrap.Client) (http.Handler, func(lo router.HandleFunc(fetchFullDepositTmpl, ts.HandleGetFullDeposit).Methods(http.MethodGet) router.HandleFunc(submitPartialFeeRecipientTmpl, ts.HandleSubmitPartialFeeRecipient).Methods(http.MethodPost) - router.HandleFunc(fetchFeeRecipientTmpl, ts.HandleGetFeeRecipient).Methods(http.MethodGet) + router.HandleFunc(fetchFeeRecipientTmpl, ts.HandlePostFeeRecipientFetch).Methods(http.MethodPost) return router, ts.addLockFiles } From e03b5fcaf13cccfcfea8f1eb24543972c0f4bedc Mon Sep 17 00:00:00 2001 From: Andrei Smirnov Date: Tue, 10 Mar 2026 13:29:13 +0300 Subject: [PATCH 06/16] Reworked sign behavior --- app/obolapi/feerecipient.go | 4 +- app/obolapi/feerecipient_model.go | 9 +- cmd/feerecipient.go | 2 +- cmd/feerecipientfetch.go | 4 +- cmd/feerecipientfetch_internal_test.go | 1 - cmd/feerecipientsign.go | 181 ++++++++++++++++--------- cmd/feerecipientsign_internal_test.go | 17 --- testutil/obolapimock/feerecipient.go | 13 +- testutil/obolapimock/obolapi.go | 2 +- 9 files changed, 140 insertions(+), 93 deletions(-) diff --git a/app/obolapi/feerecipient.go b/app/obolapi/feerecipient.go index 6f534ace4..b49e1958f 100644 --- a/app/obolapi/feerecipient.go +++ b/app/obolapi/feerecipient.go @@ -36,7 +36,7 @@ func fetchFeeRecipientURL(lockHash string) string { ).Replace(fetchFeeRecipientTmpl) } -// PostPartialFeeRecipients POSTs partial fee recipient registrations to the Obol API. +// PostPartialFeeRecipients POSTs partial builder registrations to the Obol API. // It respects the timeout specified in the Client instance. func (c Client) PostPartialFeeRecipients(ctx context.Context, lockHash []byte, shareIndex uint64, partialRegs []PartialRegistration) error { lockHashStr := "0x" + hex.EncodeToString(lockHash) @@ -68,7 +68,7 @@ func (c Client) PostPartialFeeRecipients(ctx context.Context, lockHash []byte, s return nil } -// PostFeeRecipientsFetch fetches aggregated fee recipient registrations and per-validator status from the Obol API. +// PostFeeRecipientsFetch fetches aggregated builder registrations and per-validator status from the Obol API. // If pubkeys is non-empty, only the specified validators are included in the response. // If pubkeys is empty, status for all validators in the cluster is returned. // It respects the timeout specified in the Client instance. diff --git a/app/obolapi/feerecipient_model.go b/app/obolapi/feerecipient_model.go index 6ee29fe0b..6ea980dbf 100644 --- a/app/obolapi/feerecipient_model.go +++ b/app/obolapi/feerecipient_model.go @@ -17,19 +17,19 @@ type PartialRegistration struct { Signature tbls.Signature `json:"signature"` } -// PartialFeeRecipientRequest represents the request body for posting partial fee recipient registrations. +// PartialFeeRecipientRequest represents the request body for posting partial builder registrations. type PartialFeeRecipientRequest struct { PartialRegistrations []PartialRegistration `json:"partial_registrations"` } -// FeeRecipientFetchRequest represents the request body for fetching fee recipient registrations. +// FeeRecipientFetchRequest represents the request body for fetching builder registrations. // Pubkeys is an optional list of validator public keys to filter the response. // If empty, all validators in the cluster are returned. type FeeRecipientFetchRequest struct { Pubkeys []string `json:"pubkeys"` } -// FeeRecipientStatus represents the aggregation status for a validator's fee recipient registration. +// FeeRecipientStatus represents the aggregation status for a validator's builder registration. type FeeRecipientStatus string const ( @@ -45,11 +45,12 @@ const ( type FeeRecipientValidatorStatus struct { Pubkey string `json:"pubkey"` Status FeeRecipientStatus `json:"status"` + FeeRecipient string `json:"fee_recipient"` Timestamp time.Time `json:"timestamp"` PartialCount int `json:"partial_count"` } -// FeeRecipientFetchResponse represents the response for fetching fee recipient registrations for a cluster. +// FeeRecipientFetchResponse represents the response for fetching builder registrations for a cluster. type FeeRecipientFetchResponse struct { Registrations []*eth2api.VersionedSignedValidatorRegistration `json:"registrations"` Validators []FeeRecipientValidatorStatus `json:"status"` diff --git a/cmd/feerecipient.go b/cmd/feerecipient.go index 5ab54f083..a7654e04d 100644 --- a/cmd/feerecipient.go +++ b/cmd/feerecipient.go @@ -24,7 +24,7 @@ type feerecipientConfig struct { func newFeeRecipientCmd(cmds ...*cobra.Command) *cobra.Command { root := &cobra.Command{ Use: "feerecipient", - Short: "Sign and fetch updated fee recipient registrations.", + Short: "Sign and fetch updated builder registrations.", Long: "Sign and fetch updated builder registration messages with new fee recipients using a remote API, enabling the modification of fee recipient addresses without cluster restart.", } diff --git a/cmd/feerecipientfetch.go b/cmd/feerecipientfetch.go index ce6dfbf54..31ea335f7 100644 --- a/cmd/feerecipientfetch.go +++ b/cmd/feerecipientfetch.go @@ -26,7 +26,7 @@ func newFeeRecipientFetchCmd(runFunc func(context.Context, feerecipientFetchConf cmd := &cobra.Command{ Use: "fetch", - Short: "Fetch aggregated fee recipient registrations.", + Short: "Fetch aggregated builder registrations.", Long: "Fetches aggregated builder registration messages with updated fee recipients from a remote API for validators that have had partial signatures submitted, and writes them to a local JSON file.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { @@ -70,6 +70,7 @@ func runFeeRecipientFetch(ctx context.Context, config feerecipientFetchConfig) e for _, vs := range vals { log.Info(ctx, " Complete registration", z.Str("pubkey", vs.Pubkey), + z.Str("fee_recipient", vs.FeeRecipient), z.I64("timestamp_unix", vs.Timestamp.UTC().Unix()), z.Str("timestamp", vs.Timestamp.String())) } @@ -81,6 +82,7 @@ func runFeeRecipientFetch(ctx context.Context, config feerecipientFetchConfig) e for _, vs := range vals { log.Info(ctx, " Partial registration", z.Str("pubkey", vs.Pubkey), + z.Str("fee_recipient", vs.FeeRecipient), z.I64("timestamp_unix", vs.Timestamp.UTC().Unix()), z.Str("timestamp", vs.Timestamp.String()), z.Int("partial_count", vs.PartialCount)) diff --git a/cmd/feerecipientfetch_internal_test.go b/cmd/feerecipientfetch_internal_test.go index f1268c9fa..ca7ee478e 100644 --- a/cmd/feerecipientfetch_internal_test.go +++ b/cmd/feerecipientfetch_internal_test.go @@ -78,7 +78,6 @@ func TestFeeRecipientFetchValid(t *testing.T) { PublishTimeout: 10 * time.Second, }, FeeRecipient: newFeeRecipient, - Timestamp: "9999999999", } require.NoError(t, runFeeRecipientSign(ctx, signConfig), "operator %d submit feerecipient sign", opIdx) diff --git a/cmd/feerecipientsign.go b/cmd/feerecipientsign.go index d71d78a19..5121087d6 100644 --- a/cmd/feerecipientsign.go +++ b/cmd/feerecipientsign.go @@ -6,7 +6,6 @@ import ( "bytes" "context" "encoding/hex" - "strconv" "strings" "time" @@ -31,7 +30,6 @@ type feerecipientSignConfig struct { feerecipientConfig FeeRecipient string - Timestamp string } func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig) error) *cobra.Command { @@ -39,7 +37,7 @@ func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig cmd := &cobra.Command{ Use: "sign", - Short: "Sign partial fee recipient registration messages.", + Short: "Sign partial builder registration messages.", Long: "Signs new partial builder registration messages with updated fee recipients and publishes them to a remote API.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { @@ -49,12 +47,13 @@ func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig bindFeeRecipientCharonFilesFlags(cmd, &config.feerecipientConfig) bindFeeRecipientRemoteAPIFlags(cmd, &config.feerecipientConfig) - bindFeeRecipientSignFlags(cmd, &config) + + cmd.Flags().StringSliceVar(&config.ValidatorPublicKeys, "validator-public-keys", nil, "[REQUIRED] Comma-separated list of validator public keys to sign builder registrations for.") + cmd.Flags().StringVar(&config.FeeRecipient, "fee-recipient", "", "[REQUIRED] New fee recipient address to be applied to all specified validators.") wrapPreRunE(cmd, func(cmd *cobra.Command, _ []string) error { mustMarkFlagRequired(cmd, "validator-public-keys") mustMarkFlagRequired(cmd, "fee-recipient") - mustMarkFlagRequired(cmd, "timestamp") return nil }) @@ -62,13 +61,7 @@ func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig return cmd } -func bindFeeRecipientSignFlags(cmd *cobra.Command, config *feerecipientSignConfig) { - cmd.Flags().StringVar(&config.FeeRecipient, "fee-recipient", "", "[REQUIRED] New fee recipient address to be applied to all specified validators.") - cmd.Flags().StringVar(&config.Timestamp, "timestamp", "", "[REQUIRED] Unix timestamp for the builder registration message (e.g. 1704067200).") -} - func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) error { - // Validate fee recipient address. if _, err := eth2util.ChecksumAddress(config.FeeRecipient); err != nil { return errors.Wrap(err, "invalid fee recipient address", z.Str("fee_recipient", config.FeeRecipient)) } @@ -108,39 +101,127 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err return errors.Wrap(err, "match local validator key shares with their counterparty in cluster lock") } - // Parse requested validator pubkeys. - pubkeys := make([]eth2p0.BLSPubKey, 0, len(config.ValidatorPublicKeys)) - for _, valPubKey := range config.ValidatorPublicKeys { - pubkey, err := hex.DecodeString(strings.TrimPrefix(valPubKey, "0x")) - if err != nil { - return errors.Wrap(err, "decode pubkey", z.Str("validator_public_key", valPubKey)) - } + // Filter pubkeys based on their current status on the remote API. + pubkeysToSign, err := filterPubkeysByStatus(ctx, oAPI, cl.LockHash, config.ValidatorPublicKeys, config.FeeRecipient) + if err != nil { + return err + } - pubkeys = append(pubkeys, eth2p0.BLSPubKey(pubkey)) + if len(pubkeysToSign) == 0 { + log.Info(ctx, "No validators require signing") + return nil } - // Parse fee recipient address. - feeRecipientBytes, err := hex.DecodeString(strings.TrimPrefix(config.FeeRecipient, "0x")) + // Build and sign partial registrations. + partialRegs, err := buildPartialRegistrations(config.FeeRecipient, time.Now(), pubkeysToSign, *cl, shares) if err != nil { - return errors.Wrap(err, "decode fee recipient address") + return err } - var feeRecipient [20]byte - copy(feeRecipient[:], feeRecipientBytes) + for _, reg := range partialRegs { + log.Info(ctx, "Signed partial builder registration", + z.Str("validator_pubkey", hex.EncodeToString(reg.Message.Pubkey[:])), + z.Str("fee_recipient", config.FeeRecipient), + z.I64("timestamp", reg.Message.Timestamp.Unix()), + ) + } + + log.Info(ctx, "Submitting partial builder registrations", z.Int("count", len(partialRegs))) + + err = oAPI.PostPartialFeeRecipients(ctx, cl.LockHash, shareIdx, partialRegs) + if err != nil { + return errors.Wrap(err, "submit partial builder registrations to Obol API") + } + + log.Info(ctx, "Successfully submitted partial builder registrations", z.Int("count", len(partialRegs))) + + return nil +} - // Parse timestamp. - unixTimestamp, err := strconv.ParseInt(config.Timestamp, 10, 64) +// filterPubkeysByStatus fetches the current status for each pubkey from the remote API and returns +// only those that need signing. Complete registrations are skipped, partial registrations with +// mismatched fee recipients cause an error, and unknown/partial with matching fee recipients proceed. +func filterPubkeysByStatus( + ctx context.Context, + oAPI obolapi.Client, + lockHash []byte, + requestedPubkeys []string, + feeRecipient string, +) ([]eth2p0.BLSPubKey, error) { + resp, err := oAPI.PostFeeRecipientsFetch(ctx, lockHash, requestedPubkeys) if err != nil { - return errors.Wrap(err, "invalid timestamp, expected unix timestamp", z.Str("timestamp", config.Timestamp)) + return nil, errors.Wrap(err, "fetch builder registration status from Obol API") + } + + statusByPubkey := make(map[string]obolapi.FeeRecipientValidatorStatus) + for _, vs := range resp.Validators { + statusByPubkey[strings.ToLower(vs.Pubkey)] = vs } - timestamp := time.Unix(unixTimestamp, 0) + var pubkeysToSign []eth2p0.BLSPubKey + + for _, valPubKey := range requestedPubkeys { + normalizedKey := strings.ToLower(valPubKey) + if !strings.HasPrefix(normalizedKey, "0x") { + normalizedKey = "0x" + normalizedKey + } + + vs, ok := statusByPubkey[normalizedKey] + + if ok && vs.Status == obolapi.FeeRecipientStatusComplete { + log.Info(ctx, "Validator already has a complete builder registration, skipping", + z.Str("pubkey", valPubKey), + z.Str("fee_recipient", vs.FeeRecipient)) + + continue + } + + if ok && vs.Status == obolapi.FeeRecipientStatusPartial { + if !strings.EqualFold(vs.FeeRecipient, feeRecipient) { + return nil, errors.New("fee recipient mismatch with existing partial registration", + z.Str("pubkey", valPubKey), + z.Str("existing_fee_recipient", vs.FeeRecipient), + z.Str("requested_fee_recipient", feeRecipient), + ) + } + + log.Info(ctx, "Validator has partial builder registration with matching fee recipient, proceeding", + z.Str("pubkey", valPubKey), + z.Str("fee_recipient", vs.FeeRecipient), + z.Int("partial_count", vs.PartialCount)) + } + + pubkeyBytes, err := hex.DecodeString(strings.TrimPrefix(valPubKey, "0x")) + if err != nil { + return nil, errors.Wrap(err, "decode pubkey", z.Str("validator_public_key", valPubKey)) + } + + pubkeysToSign = append(pubkeysToSign, eth2p0.BLSPubKey(pubkeyBytes)) + } + + return pubkeysToSign, nil +} + +// buildPartialRegistrations creates partial builder registration messages for each pubkey, +// signs them with the operator's key share, and returns the signed partial registrations. +func buildPartialRegistrations( + feeRecipientHex string, + timestamp time.Time, + pubkeys []eth2p0.BLSPubKey, + cl cluster.Lock, + shares keystore.ValidatorShares, +) ([]obolapi.PartialRegistration, error) { + feeRecipientBytes, err := hex.DecodeString(strings.TrimPrefix(feeRecipientHex, "0x")) + if err != nil { + return nil, errors.Wrap(err, "decode fee recipient address") + } + + var feeRecipient [20]byte + copy(feeRecipient[:], feeRecipientBytes) - // Build partial registrations. partialRegs := make([]obolapi.PartialRegistration, 0, len(pubkeys)) for _, pubkey := range pubkeys { - // Find existing builder registration in cluster lock. var existingReg *cluster.BuilderRegistration for _, dv := range cl.Validators { @@ -151,18 +232,9 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err } if existingReg == nil || existingReg.Message.Timestamp.IsZero() { - return errors.New("no existing builder registration found for validator", z.Str("pubkey", hex.EncodeToString(pubkey[:]))) - } - - if !timestamp.After(existingReg.Message.Timestamp) { - return errors.New("timestamp must be higher than existing builder registration timestamp", - z.Str("pubkey", hex.EncodeToString(pubkey[:])), - z.I64("existing_timestamp", existingReg.Message.Timestamp.Unix()), - z.I64("provided_timestamp", timestamp.Unix()), - ) + return nil, errors.New("no existing builder registration found for validator", z.Str("pubkey", hex.EncodeToString(pubkey[:]))) } - // Create new registration with updated fee recipient and timestamp, keeping gas limit. regMsg := ð2v1.ValidatorRegistration{ FeeRecipient: feeRecipient, GasLimit: uint64(existingReg.Message.GasLimit), @@ -170,50 +242,31 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err Pubkey: pubkey, } - // Get signing root. sigRoot, err := registration.GetMessageSigningRoot(regMsg, eth2p0.Version(cl.ForkVersion)) if err != nil { - return errors.Wrap(err, "get signing root for registration message") + return nil, errors.Wrap(err, "get signing root for registration message") } - // Get the secret share for this validator. corePubkey, err := core.PubKeyFromBytes(pubkey[:]) if err != nil { - return errors.Wrap(err, "convert pubkey to core pubkey") + return nil, errors.Wrap(err, "convert pubkey to core pubkey") } secretShare, ok := shares[corePubkey] if !ok { - return errors.New("no key share found for validator pubkey", z.Str("pubkey", hex.EncodeToString(pubkey[:]))) + return nil, errors.New("no key share found for validator pubkey", z.Str("pubkey", hex.EncodeToString(pubkey[:]))) } - // Sign with threshold BLS. sig, err := tbls.Sign(secretShare.Share, sigRoot[:]) if err != nil { - return errors.Wrap(err, "sign registration message") + return nil, errors.Wrap(err, "sign registration message") } partialRegs = append(partialRegs, obolapi.PartialRegistration{ Message: regMsg, Signature: sig, }) - - log.Info(ctx, "Signed partial fee recipient registration", - z.Str("validator_pubkey", hex.EncodeToString(pubkey[:])), - z.Str("fee_recipient", config.FeeRecipient), - ) } - log.Info(ctx, "Submitting partial fee recipient registrations") - - err = oAPI.PostPartialFeeRecipients(ctx, cl.LockHash, shareIdx, partialRegs) - if err != nil { - return errors.Wrap(err, "submit partial fee recipient registrations to Obol API") - } - - log.Info(ctx, "Successfully submitted partial fee recipient registrations", - z.Int("count", len(partialRegs)), - ) - - return nil + return partialRegs, nil } diff --git a/cmd/feerecipientsign_internal_test.go b/cmd/feerecipientsign_internal_test.go index 0d1d807fa..312ff7cb1 100644 --- a/cmd/feerecipientsign_internal_test.go +++ b/cmd/feerecipientsign_internal_test.go @@ -76,7 +76,6 @@ func TestFeeRecipientSignValid(t *testing.T) { signConfig := feerecipientSignConfig{ feerecipientConfig: config, FeeRecipient: "0x0000000000000000000000000000000000001234", - Timestamp: "9999999999", } require.NoError(t, runFeeRecipientSign(ctx, signConfig), "operator index submit feerecipient sign: %v", idx) @@ -94,7 +93,6 @@ func TestFeeRecipientSignCLI(t *testing.T) { flags: []string{ "--validator-public-keys=test", "--fee-recipient=0x0000000000000000000000000000000000001234", - "--timestamp=9999999999", "--private-key-file=test", "--validator-keys-dir=test", "--lock-file=test", @@ -107,7 +105,6 @@ func TestFeeRecipientSignCLI(t *testing.T) { expectedErr: "required flag(s) \"validator-public-keys\" not set", flags: []string{ "--fee-recipient=0x0000000000000000000000000000000000001234", - "--timestamp=9999999999", "--private-key-file=test", "--validator-keys-dir=test", "--lock-file=test", @@ -120,20 +117,6 @@ func TestFeeRecipientSignCLI(t *testing.T) { expectedErr: "required flag(s) \"fee-recipient\" not set", flags: []string{ "--validator-public-keys=test", - "--timestamp=9999999999", - "--private-key-file=test", - "--validator-keys-dir=test", - "--lock-file=test", - "--publish-address=test", - "--publish-timeout=1ms", - }, - }, - { - name: "missing timestamp", - expectedErr: "required flag(s) \"timestamp\" not set", - flags: []string{ - "--validator-public-keys=test", - "--fee-recipient=0x0000000000000000000000000000000000001234", "--private-key-file=test", "--validator-keys-dir=test", "--lock-file=test", diff --git a/testutil/obolapimock/feerecipient.go b/testutil/obolapimock/feerecipient.go index 5eaaa4e99..614833e3c 100644 --- a/testutil/obolapimock/feerecipient.go +++ b/testutil/obolapimock/feerecipient.go @@ -5,6 +5,7 @@ package obolapimock import ( "encoding/hex" "encoding/json" + "fmt" "net/http" "strconv" "strings" @@ -27,14 +28,14 @@ const ( fetchFeeRecipientTmpl = "/fee_recipient/" + lockHashPath ) -// feeRecipientPartial represents a single partial fee recipient registration. +// feeRecipientPartial represents a single partial builder registration. type feeRecipientPartial struct { ShareIdx int Message *eth2v1.ValidatorRegistration Signature []byte } -// feeRecipientBlob represents partial fee recipient registrations for a validator. +// feeRecipientBlob represents partial builder registrations for a validator. type feeRecipientBlob struct { partials map[int]feeRecipientPartial // keyed by share index } @@ -229,9 +230,17 @@ func (ts *testServer) HandlePostFeeRecipientFetch(writer http.ResponseWriter, re status = obolapi.FeeRecipientStatusComplete } + // Extract fee recipient from the first partial (all partials have the same message). + var feeRecipient string + for _, p := range existing.partials { + feeRecipient = fmt.Sprintf("0x%x", p.Message.FeeRecipient) + break + } + validators = append(validators, obolapi.FeeRecipientValidatorStatus{ Pubkey: "0x" + t.pubkeyHex, Status: status, + FeeRecipient: feeRecipient, PartialCount: partialCount, }) diff --git a/testutil/obolapimock/obolapi.go b/testutil/obolapimock/obolapi.go index 6addfbe11..d4de57baa 100644 --- a/testutil/obolapimock/obolapi.go +++ b/testutil/obolapimock/obolapi.go @@ -58,7 +58,7 @@ type testServer struct { // store the partial deposits by the validator pubkey partialDeposits map[string]depositBlob - // store the partial fee recipient registrations by lock_hash/validator_pubkey + // store the partial builder registrations by lock_hash/validator_pubkey partialFeeRecipients map[string]feeRecipientBlob // store the lock file by its lock hash From 3a25d283a3aff64dd4c1880ca8de6c6338e15aa0 Mon Sep 17 00:00:00 2001 From: Andrei Smirnov Date: Tue, 10 Mar 2026 13:38:52 +0300 Subject: [PATCH 07/16] Refactoring --- cmd/feerecipient.go | 1 - cmd/feerecipientfetch.go | 5 +++++ cmd/feerecipientsign.go | 5 +++++ testutil/obolapimock/feerecipient.go | 12 ++++++++++-- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/cmd/feerecipient.go b/cmd/feerecipient.go index a7654e04d..dba99a877 100644 --- a/cmd/feerecipient.go +++ b/cmd/feerecipient.go @@ -35,7 +35,6 @@ func newFeeRecipientCmd(cmds ...*cobra.Command) *cobra.Command { func bindFeeRecipientCharonFilesFlags(cmd *cobra.Command, config *feerecipientConfig) { cmd.Flags().StringVar(&config.PrivateKeyPath, privateKeyPath.String(), ".charon/charon-enr-private-key", "Path to the charon enr private key file.") - cmd.Flags().StringVar(&config.ValidatorKeysDir, validatorKeysDir.String(), ".charon/validator_keys", "Path to the directory containing the validator private key share files and passwords.") cmd.Flags().StringVar(&config.LockFilePath, lockFilePath.String(), ".charon/cluster-lock.json", "Path to the cluster lock file defining the distributed validator cluster.") cmd.Flags().StringVar(&config.OverridesFilePath, "overrides-file", ".charon/builder_registrations_overrides.json", "Path to the builder registrations overrides file.") } diff --git a/cmd/feerecipientfetch.go b/cmd/feerecipientfetch.go index 31ea335f7..8e75bbf61 100644 --- a/cmd/feerecipientfetch.go +++ b/cmd/feerecipientfetch.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "os" + "path/filepath" eth2api "github.com/attestantio/go-eth2-client/api" "github.com/spf13/cobra" @@ -122,6 +123,10 @@ func writeSignedValidatorRegistrations(filename string, regs []*eth2api.Versione return errors.Wrap(err, "marshal registrations to JSON") } + if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil { + return errors.Wrap(err, "create output directory") + } + err = os.WriteFile(filename, data, 0o644) //nolint:gosec // G306: world-readable output file is intentional if err != nil { return errors.Wrap(err, "write registrations overrides file") diff --git a/cmd/feerecipientsign.go b/cmd/feerecipientsign.go index 5121087d6..c02819a34 100644 --- a/cmd/feerecipientsign.go +++ b/cmd/feerecipientsign.go @@ -48,6 +48,7 @@ func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig bindFeeRecipientCharonFilesFlags(cmd, &config.feerecipientConfig) bindFeeRecipientRemoteAPIFlags(cmd, &config.feerecipientConfig) + cmd.Flags().StringVar(&config.ValidatorKeysDir, validatorKeysDir.String(), ".charon/validator_keys", "Path to the directory containing the validator private key share files and passwords.") cmd.Flags().StringSliceVar(&config.ValidatorPublicKeys, "validator-public-keys", nil, "[REQUIRED] Comma-separated list of validator public keys to sign builder registrations for.") cmd.Flags().StringVar(&config.FeeRecipient, "fee-recipient", "", "[REQUIRED] New fee recipient address to be applied to all specified validators.") @@ -196,6 +197,10 @@ func filterPubkeysByStatus( return nil, errors.Wrap(err, "decode pubkey", z.Str("validator_public_key", valPubKey)) } + if len(pubkeyBytes) != len(eth2p0.BLSPubKey{}) { + return nil, errors.New("invalid pubkey length", z.Int("length", len(pubkeyBytes)), z.Str("validator_public_key", valPubKey)) + } + pubkeysToSign = append(pubkeysToSign, eth2p0.BLSPubKey(pubkeyBytes)) } diff --git a/testutil/obolapimock/feerecipient.go b/testutil/obolapimock/feerecipient.go index 614833e3c..1a9059bf5 100644 --- a/testutil/obolapimock/feerecipient.go +++ b/testutil/obolapimock/feerecipient.go @@ -9,6 +9,7 @@ import ( "net/http" "strconv" "strings" + "time" eth2api "github.com/attestantio/go-eth2-client/api" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" @@ -230,10 +231,16 @@ func (ts *testServer) HandlePostFeeRecipientFetch(writer http.ResponseWriter, re status = obolapi.FeeRecipientStatusComplete } - // Extract fee recipient from the first partial (all partials have the same message). - var feeRecipient string + // Extract fee recipient and timestamp from the first partial (all partials have the same message). + var ( + feeRecipient string + timestamp time.Time + ) + for _, p := range existing.partials { feeRecipient = fmt.Sprintf("0x%x", p.Message.FeeRecipient) + timestamp = p.Message.Timestamp + break } @@ -241,6 +248,7 @@ func (ts *testServer) HandlePostFeeRecipientFetch(writer http.ResponseWriter, re Pubkey: "0x" + t.pubkeyHex, Status: status, FeeRecipient: feeRecipient, + Timestamp: timestamp, PartialCount: partialCount, }) From ade3ba5c730d3c9770e1ebfd69711eb21f68c244 Mon Sep 17 00:00:00 2001 From: Andrei Smirnov Date: Tue, 10 Mar 2026 14:14:27 +0300 Subject: [PATCH 08/16] Refactoring --- cmd/feerecipient.go | 7 --- cmd/feerecipientfetch.go | 3 +- cmd/feerecipientfetch_internal_test.go | 4 +- cmd/feerecipientsign.go | 80 ++++++++++++++++++++------ cmd/feerecipientsign_internal_test.go | 20 +++---- 5 files changed, 74 insertions(+), 40 deletions(-) diff --git a/cmd/feerecipient.go b/cmd/feerecipient.go index dba99a877..3624dfa29 100644 --- a/cmd/feerecipient.go +++ b/cmd/feerecipient.go @@ -14,7 +14,6 @@ type feerecipientConfig struct { ValidatorPublicKeys []string PrivateKeyPath string LockFilePath string - ValidatorKeysDir string OverridesFilePath string PublishAddress string PublishTimeout time.Duration @@ -33,12 +32,6 @@ func newFeeRecipientCmd(cmds ...*cobra.Command) *cobra.Command { return root } -func bindFeeRecipientCharonFilesFlags(cmd *cobra.Command, config *feerecipientConfig) { - cmd.Flags().StringVar(&config.PrivateKeyPath, privateKeyPath.String(), ".charon/charon-enr-private-key", "Path to the charon enr private key file.") - cmd.Flags().StringVar(&config.LockFilePath, lockFilePath.String(), ".charon/cluster-lock.json", "Path to the cluster lock file defining the distributed validator cluster.") - cmd.Flags().StringVar(&config.OverridesFilePath, "overrides-file", ".charon/builder_registrations_overrides.json", "Path to the builder registrations overrides file.") -} - func bindFeeRecipientRemoteAPIFlags(cmd *cobra.Command, config *feerecipientConfig) { cmd.Flags().StringVar(&config.PublishAddress, publishAddress.String(), "https://api.obol.tech/v1", "The URL of the remote API.") cmd.Flags().DurationVar(&config.PublishTimeout, publishTimeout.String(), 5*time.Minute, "Timeout for accessing the remote API.") diff --git a/cmd/feerecipientfetch.go b/cmd/feerecipientfetch.go index 8e75bbf61..e7f696571 100644 --- a/cmd/feerecipientfetch.go +++ b/cmd/feerecipientfetch.go @@ -36,8 +36,9 @@ func newFeeRecipientFetchCmd(runFunc func(context.Context, feerecipientFetchConf } cmd.Flags().StringSliceVar(&config.ValidatorPublicKeys, "validator-public-keys", []string{}, "Optional comma-separated list of validator public keys to fetch builder registrations for.") + cmd.Flags().StringVar(&config.LockFilePath, lockFilePath.String(), ".charon/cluster-lock.json", "Path to the cluster lock file defining the distributed validator cluster.") + cmd.Flags().StringVar(&config.OverridesFilePath, "overrides-file", ".charon/builder_registrations_overrides.json", "Path to the builder registrations overrides file.") - bindFeeRecipientCharonFilesFlags(cmd, &config.feerecipientConfig) bindFeeRecipientRemoteAPIFlags(cmd, &config.feerecipientConfig) return cmd diff --git a/cmd/feerecipientfetch_internal_test.go b/cmd/feerecipientfetch_internal_test.go index ca7ee478e..84e4000f1 100644 --- a/cmd/feerecipientfetch_internal_test.go +++ b/cmd/feerecipientfetch_internal_test.go @@ -72,12 +72,12 @@ func TestFeeRecipientFetchValid(t *testing.T) { feerecipientConfig: feerecipientConfig{ ValidatorPublicKeys: []string{validatorPubkey}, PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), - ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), PublishAddress: srv.URL, PublishTimeout: 10 * time.Second, }, - FeeRecipient: newFeeRecipient, + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + FeeRecipient: newFeeRecipient, } require.NoError(t, runFeeRecipientSign(ctx, signConfig), "operator %d submit feerecipient sign", opIdx) diff --git a/cmd/feerecipientsign.go b/cmd/feerecipientsign.go index c02819a34..35db8b63e 100644 --- a/cmd/feerecipientsign.go +++ b/cmd/feerecipientsign.go @@ -26,10 +26,20 @@ import ( "github.com/obolnetwork/charon/tbls" ) +// pubkeyToSign pairs a validator public key with the timestamp to use when signing its registration. +// For validators with no existing partial registration (unknown status), the timestamp is set to +// time.Now() by the first operator. For validators already in partial status, the timestamp is +// adopted from the existing partial registration so all operators sign the same message. +type pubkeyToSign struct { + Pubkey eth2p0.BLSPubKey + Timestamp time.Time +} + type feerecipientSignConfig struct { feerecipientConfig - FeeRecipient string + ValidatorKeysDir string + FeeRecipient string } func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig) error) *cobra.Command { @@ -45,9 +55,10 @@ func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig }, } - bindFeeRecipientCharonFilesFlags(cmd, &config.feerecipientConfig) bindFeeRecipientRemoteAPIFlags(cmd, &config.feerecipientConfig) + cmd.Flags().StringVar(&config.LockFilePath, lockFilePath.String(), ".charon/cluster-lock.json", "Path to the cluster lock file defining the distributed validator cluster.") + cmd.Flags().StringVar(&config.PrivateKeyPath, privateKeyPath.String(), ".charon/charon-enr-private-key", "Path to the charon enr private key file.") cmd.Flags().StringVar(&config.ValidatorKeysDir, validatorKeysDir.String(), ".charon/validator_keys", "Path to the directory containing the validator private key share files and passwords.") cmd.Flags().StringSliceVar(&config.ValidatorPublicKeys, "validator-public-keys", nil, "[REQUIRED] Comma-separated list of validator public keys to sign builder registrations for.") cmd.Flags().StringVar(&config.FeeRecipient, "fee-recipient", "", "[REQUIRED] New fee recipient address to be applied to all specified validators.") @@ -87,6 +98,23 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err return errors.Wrap(err, "determine operator index from cluster lock for supplied identity key") } + // Validate all requested pubkeys exist in the cluster lock before making any API calls. + clusterPubkeys := make(map[string]struct{}, len(cl.Validators)) + for _, dv := range cl.Validators { + clusterPubkeys[strings.ToLower(dv.PublicKeyHex())] = struct{}{} + } + + for _, valPubKey := range config.ValidatorPublicKeys { + normalized := strings.ToLower(valPubKey) + if !strings.HasPrefix(normalized, "0x") { + normalized = "0x" + normalized + } + + if _, ok := clusterPubkeys[normalized]; !ok { + return errors.New("validator pubkey not found in cluster lock", z.Str("pubkey", valPubKey)) + } + } + rawValKeys, err := keystore.LoadFilesUnordered(config.ValidatorKeysDir) if err != nil { return errors.Wrap(err, "load keystore, check if path exists", z.Str("validator_keys_dir", config.ValidatorKeysDir)) @@ -103,7 +131,7 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err } // Filter pubkeys based on their current status on the remote API. - pubkeysToSign, err := filterPubkeysByStatus(ctx, oAPI, cl.LockHash, config.ValidatorPublicKeys, config.FeeRecipient) + pubkeysToSign, err := filterPubkeysByStatus(ctx, oAPI, cl.LockHash, config.ValidatorPublicKeys, config.FeeRecipient, time.Now) if err != nil { return err } @@ -114,7 +142,7 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err } // Build and sign partial registrations. - partialRegs, err := buildPartialRegistrations(config.FeeRecipient, time.Now(), pubkeysToSign, *cl, shares) + partialRegs, err := buildPartialRegistrations(config.FeeRecipient, pubkeysToSign, *cl, shares) if err != nil { return err } @@ -140,15 +168,19 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err } // filterPubkeysByStatus fetches the current status for each pubkey from the remote API and returns -// only those that need signing. Complete registrations are skipped, partial registrations with -// mismatched fee recipients cause an error, and unknown/partial with matching fee recipients proceed. +// only those that need signing, each paired with the timestamp to use for signing. +// Complete registrations are skipped. Partial registrations with mismatched fee recipients cause +// an error. For unknown validators, now() is used as the timestamp (first signer anchors it). +// For partial validators with a matching fee recipient, the existing timestamp from the API is +// adopted so all operators sign the identical message. func filterPubkeysByStatus( ctx context.Context, oAPI obolapi.Client, lockHash []byte, requestedPubkeys []string, feeRecipient string, -) ([]eth2p0.BLSPubKey, error) { + now func() time.Time, +) ([]pubkeyToSign, error) { resp, err := oAPI.PostFeeRecipientsFetch(ctx, lockHash, requestedPubkeys) if err != nil { return nil, errors.Wrap(err, "fetch builder registration status from Obol API") @@ -159,7 +191,7 @@ func filterPubkeysByStatus( statusByPubkey[strings.ToLower(vs.Pubkey)] = vs } - var pubkeysToSign []eth2p0.BLSPubKey + var pubkeysToSign []pubkeyToSign for _, valPubKey := range requestedPubkeys { normalizedKey := strings.ToLower(valPubKey) @@ -177,19 +209,27 @@ func filterPubkeysByStatus( continue } + var timestamp time.Time + if ok && vs.Status == obolapi.FeeRecipientStatusPartial { if !strings.EqualFold(vs.FeeRecipient, feeRecipient) { - return nil, errors.New("fee recipient mismatch with existing partial registration", + return nil, errors.New("fee recipient mismatch with existing partial registration; wait for the in-progress registration to complete or coordinate with your cluster operators", z.Str("pubkey", valPubKey), z.Str("existing_fee_recipient", vs.FeeRecipient), z.Str("requested_fee_recipient", feeRecipient), ) } + // Adopt the timestamp from the existing partial so all operators sign the same message. + timestamp = vs.Timestamp + log.Info(ctx, "Validator has partial builder registration with matching fee recipient, proceeding", z.Str("pubkey", valPubKey), z.Str("fee_recipient", vs.FeeRecipient), z.Int("partial_count", vs.PartialCount)) + } else { + // First signer for this validator: anchor the timestamp now. + timestamp = now() } pubkeyBytes, err := hex.DecodeString(strings.TrimPrefix(valPubKey, "0x")) @@ -201,7 +241,10 @@ func filterPubkeysByStatus( return nil, errors.New("invalid pubkey length", z.Int("length", len(pubkeyBytes)), z.Str("validator_public_key", valPubKey)) } - pubkeysToSign = append(pubkeysToSign, eth2p0.BLSPubKey(pubkeyBytes)) + pubkeysToSign = append(pubkeysToSign, pubkeyToSign{ + Pubkey: eth2p0.BLSPubKey(pubkeyBytes), + Timestamp: timestamp, + }) } return pubkeysToSign, nil @@ -211,8 +254,7 @@ func filterPubkeysByStatus( // signs them with the operator's key share, and returns the signed partial registrations. func buildPartialRegistrations( feeRecipientHex string, - timestamp time.Time, - pubkeys []eth2p0.BLSPubKey, + pubkeys []pubkeyToSign, cl cluster.Lock, shares keystore.ValidatorShares, ) ([]obolapi.PartialRegistration, error) { @@ -226,25 +268,25 @@ func buildPartialRegistrations( partialRegs := make([]obolapi.PartialRegistration, 0, len(pubkeys)) - for _, pubkey := range pubkeys { + for _, p := range pubkeys { var existingReg *cluster.BuilderRegistration for _, dv := range cl.Validators { - if bytes.Equal(dv.PubKey, pubkey[:]) { + if bytes.Equal(dv.PubKey, p.Pubkey[:]) { existingReg = &dv.BuilderRegistration break } } if existingReg == nil || existingReg.Message.Timestamp.IsZero() { - return nil, errors.New("no existing builder registration found for validator", z.Str("pubkey", hex.EncodeToString(pubkey[:]))) + return nil, errors.New("no existing builder registration found for validator", z.Str("pubkey", hex.EncodeToString(p.Pubkey[:]))) } regMsg := ð2v1.ValidatorRegistration{ FeeRecipient: feeRecipient, GasLimit: uint64(existingReg.Message.GasLimit), - Timestamp: timestamp, - Pubkey: pubkey, + Timestamp: p.Timestamp, + Pubkey: p.Pubkey, } sigRoot, err := registration.GetMessageSigningRoot(regMsg, eth2p0.Version(cl.ForkVersion)) @@ -252,14 +294,14 @@ func buildPartialRegistrations( return nil, errors.Wrap(err, "get signing root for registration message") } - corePubkey, err := core.PubKeyFromBytes(pubkey[:]) + corePubkey, err := core.PubKeyFromBytes(p.Pubkey[:]) if err != nil { return nil, errors.Wrap(err, "convert pubkey to core pubkey") } secretShare, ok := shares[corePubkey] if !ok { - return nil, errors.New("no key share found for validator pubkey", z.Str("pubkey", hex.EncodeToString(pubkey[:]))) + return nil, errors.New("no key share found for validator pubkey", z.Str("pubkey", hex.EncodeToString(p.Pubkey[:]))) } sig, err := tbls.Sign(secretShare.Share, sigRoot[:]) diff --git a/cmd/feerecipientsign_internal_test.go b/cmd/feerecipientsign_internal_test.go index 312ff7cb1..66d97fd77 100644 --- a/cmd/feerecipientsign_internal_test.go +++ b/cmd/feerecipientsign_internal_test.go @@ -64,18 +64,16 @@ func TestFeeRecipientSignValid(t *testing.T) { baseDir := filepath.Join(root, fmt.Sprintf("op%d", idx)) - config := feerecipientConfig{ - ValidatorPublicKeys: []string{lock.Validators[0].PublicKeyHex()}, - PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), - ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), - LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), - PublishAddress: srv.URL, - PublishTimeout: 10 * time.Second, - } - signConfig := feerecipientSignConfig{ - feerecipientConfig: config, - FeeRecipient: "0x0000000000000000000000000000000000001234", + feerecipientConfig: feerecipientConfig{ + ValidatorPublicKeys: []string{lock.Validators[0].PublicKeyHex()}, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, + }, + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + FeeRecipient: "0x0000000000000000000000000000000000001234", } require.NoError(t, runFeeRecipientSign(ctx, signConfig), "operator index submit feerecipient sign: %v", idx) From 539988840ba4de81d5c0140e48d6bc25eb72232f Mon Sep 17 00:00:00 2001 From: Andrei Smirnov Date: Wed, 11 Mar 2026 14:01:43 +0300 Subject: [PATCH 09/16] Commands matching the new API spec --- app/obolapi/feerecipient_model.go | 75 +++++++--- cmd/feerecipientfetch.go | 94 ++++++++----- cmd/feerecipientsign.go | 99 +++++++++----- testutil/obolapimock/feerecipient.go | 198 ++++++++++----------------- 4 files changed, 253 insertions(+), 213 deletions(-) diff --git a/app/obolapi/feerecipient_model.go b/app/obolapi/feerecipient_model.go index 6ea980dbf..f9d9b3b31 100644 --- a/app/obolapi/feerecipient_model.go +++ b/app/obolapi/feerecipient_model.go @@ -3,9 +3,9 @@ package obolapi import ( - "time" + "encoding/json" + "fmt" - eth2api "github.com/attestantio/go-eth2-client/api" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/obolnetwork/charon/tbls" @@ -29,29 +29,60 @@ type FeeRecipientFetchRequest struct { Pubkeys []string `json:"pubkeys"` } -// FeeRecipientStatus represents the aggregation status for a validator's builder registration. -type FeeRecipientStatus string +// FeeRecipientPartialSig is a partial BLS signature with its share index. +// The signature is encoded as a 0x-prefixed hex string on the wire. +type FeeRecipientPartialSig struct { + ShareIndex int + Signature tbls.Signature +} -const ( - // FeeRecipientStatusUnknown indicates no partial signatures received. - FeeRecipientStatusUnknown FeeRecipientStatus = "unknown" - // FeeRecipientStatusPartial indicates some but not all partial signatures received. - FeeRecipientStatusPartial FeeRecipientStatus = "partial" - // FeeRecipientStatusComplete indicates enough partial signatures received to produce a complete signature. - FeeRecipientStatusComplete FeeRecipientStatus = "complete" -) +// feeRecipientPartialSigDTO is the wire representation of FeeRecipientPartialSig. +type feeRecipientPartialSigDTO struct { + ShareIndex int `json:"share_index"` + Signature string `json:"signature"` +} + +func (f *FeeRecipientPartialSig) UnmarshalJSON(data []byte) error { + var dto feeRecipientPartialSigDTO + if err := json.Unmarshal(data, &dto); err != nil { + //nolint:wrapcheck // caller will wrap + return err + } + + sigBytes, err := from0x(dto.Signature, 96) + if err != nil { + return err + } + + f.ShareIndex = dto.ShareIndex + copy(f.Signature[:], sigBytes) + + return nil +} + +func (f FeeRecipientPartialSig) MarshalJSON() ([]byte, error) { + //nolint:wrapcheck // caller will wrap + return json.Marshal(feeRecipientPartialSigDTO{ + ShareIndex: f.ShareIndex, + Signature: fmt.Sprintf("%#x", f.Signature), + }) +} + +// FeeRecipientBuilderRegistration is one registration group sharing the same message, +// with partial signatures from individual operators. +type FeeRecipientBuilderRegistration struct { + Message *eth2v1.ValidatorRegistration `json:"message"` + PartialSignatures []FeeRecipientPartialSig `json:"partial_signatures"` + Quorum bool `json:"quorum"` +} -// FeeRecipientValidatorStatus represents the aggregation status for a single validator. -type FeeRecipientValidatorStatus struct { - Pubkey string `json:"pubkey"` - Status FeeRecipientStatus `json:"status"` - FeeRecipient string `json:"fee_recipient"` - Timestamp time.Time `json:"timestamp"` - PartialCount int `json:"partial_count"` +// FeeRecipientValidator is the per-validator entry in the fetch response. +type FeeRecipientValidator struct { + Pubkey string `json:"pubkey"` + BuilderRegistrations []FeeRecipientBuilderRegistration `json:"builder_registrations"` } -// FeeRecipientFetchResponse represents the response for fetching builder registrations for a cluster. +// FeeRecipientFetchResponse is the response for the fee recipient fetch endpoint. type FeeRecipientFetchResponse struct { - Registrations []*eth2api.VersionedSignedValidatorRegistration `json:"registrations"` - Validators []FeeRecipientValidatorStatus `json:"status"` + Validators []FeeRecipientValidator `json:"validators"` } diff --git a/cmd/feerecipientfetch.go b/cmd/feerecipientfetch.go index e7f696571..8a2239cae 100644 --- a/cmd/feerecipientfetch.go +++ b/cmd/feerecipientfetch.go @@ -9,6 +9,9 @@ import ( "path/filepath" eth2api "github.com/attestantio/go-eth2-client/api" + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + eth2spec "github.com/attestantio/go-eth2-client/spec" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/spf13/cobra" "github.com/obolnetwork/charon/app/errors" @@ -16,6 +19,8 @@ import ( "github.com/obolnetwork/charon/app/obolapi" "github.com/obolnetwork/charon/app/z" "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/tbls/tblsconv" ) type feerecipientFetchConfig struct { @@ -60,58 +65,83 @@ func runFeeRecipientFetch(ctx context.Context, config feerecipientFetchConfig) e return errors.Wrap(err, "fetch builder registrations from Obol API") } - // Group validators by status. - grouped := make(map[obolapi.FeeRecipientStatus][]obolapi.FeeRecipientValidatorStatus) - for _, vs := range resp.Validators { - grouped[vs.Status] = append(grouped[vs.Status], vs) - } + var ( + aggregatedRegs []*eth2api.VersionedSignedValidatorRegistration + completeCount int + incompleteCount int + noRegCount int + ) - if vals := grouped[obolapi.FeeRecipientStatusComplete]; len(vals) > 0 { - log.Info(ctx, "Validators with complete builder registrations", z.Int("count", len(vals))) + for _, val := range resp.Validators { + var hasQuorum, hasIncomplete bool + + for _, reg := range val.BuilderRegistrations { + if reg.Quorum { + hasQuorum = true + + partialSigs := make(map[int]tbls.Signature) + + for _, ps := range reg.PartialSignatures { + sig, err := tblsconv.SignatureFromBytes(ps.Signature[:]) + if err != nil { + return errors.Wrap(err, "parse partial signature", z.Str("pubkey", val.Pubkey)) + } + + partialSigs[ps.ShareIndex] = sig + } + + fullSig, err := tbls.ThresholdAggregate(partialSigs) + if err != nil { + return errors.Wrap(err, "aggregate partial signatures", z.Str("pubkey", val.Pubkey)) + } + + aggregatedRegs = append(aggregatedRegs, ð2api.VersionedSignedValidatorRegistration{ + Version: eth2spec.BuilderVersionV1, + V1: ð2v1.SignedValidatorRegistration{ + Message: reg.Message, + Signature: eth2p0.BLSSignature(fullSig), + }, + }) + } else { + hasIncomplete = true + } + } - for _, vs := range vals { - log.Info(ctx, " Complete registration", - z.Str("pubkey", vs.Pubkey), - z.Str("fee_recipient", vs.FeeRecipient), - z.I64("timestamp_unix", vs.Timestamp.UTC().Unix()), - z.Str("timestamp", vs.Timestamp.String())) + switch { + case hasQuorum: + completeCount++ + case hasIncomplete: + incompleteCount++ + default: + noRegCount++ } } - if vals := grouped[obolapi.FeeRecipientStatusPartial]; len(vals) > 0 { - log.Info(ctx, "Validators with partial builder registrations", z.Int("count", len(vals))) - - for _, vs := range vals { - log.Info(ctx, " Partial registration", - z.Str("pubkey", vs.Pubkey), - z.Str("fee_recipient", vs.FeeRecipient), - z.I64("timestamp_unix", vs.Timestamp.UTC().Unix()), - z.Str("timestamp", vs.Timestamp.String()), - z.Int("partial_count", vs.PartialCount)) - } + if completeCount > 0 { + log.Info(ctx, "Validators with complete builder registrations", z.Int("count", completeCount)) } - if vals := grouped[obolapi.FeeRecipientStatusUnknown]; len(vals) > 0 { - log.Info(ctx, "Validators unknown to the API", z.Int("count", len(vals))) + if incompleteCount > 0 { + log.Info(ctx, "Validators with partial builder registrations", z.Int("count", incompleteCount)) + } - for _, vs := range vals { - log.Info(ctx, " Unknown validator", z.Str("pubkey", vs.Pubkey)) - } + if noRegCount > 0 { + log.Info(ctx, "Validators unknown to the API", z.Int("count", noRegCount)) } - if len(resp.Registrations) == 0 { + if len(aggregatedRegs) == 0 { log.Warn(ctx, "No fully signed builder registrations available yet", nil) return nil } - err = writeSignedValidatorRegistrations(config.OverridesFilePath, resp.Registrations) + err = writeSignedValidatorRegistrations(config.OverridesFilePath, aggregatedRegs) if err != nil { return errors.Wrap(err, "write builder registrations overrides", z.Str("path", config.OverridesFilePath)) } log.Info(ctx, "Successfully wrote builder registrations overrides", - z.Int("count", len(resp.Registrations)), + z.Int("count", len(aggregatedRegs)), z.Str("path", config.OverridesFilePath), ) diff --git a/cmd/feerecipientsign.go b/cmd/feerecipientsign.go index 35db8b63e..bf4929e44 100644 --- a/cmd/feerecipientsign.go +++ b/cmd/feerecipientsign.go @@ -40,6 +40,7 @@ type feerecipientSignConfig struct { ValidatorKeysDir string FeeRecipient string + GasLimit uint64 } func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig) error) *cobra.Command { @@ -62,6 +63,7 @@ func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig cmd.Flags().StringVar(&config.ValidatorKeysDir, validatorKeysDir.String(), ".charon/validator_keys", "Path to the directory containing the validator private key share files and passwords.") cmd.Flags().StringSliceVar(&config.ValidatorPublicKeys, "validator-public-keys", nil, "[REQUIRED] Comma-separated list of validator public keys to sign builder registrations for.") cmd.Flags().StringVar(&config.FeeRecipient, "fee-recipient", "", "[REQUIRED] New fee recipient address to be applied to all specified validators.") + cmd.Flags().Uint64Var(&config.GasLimit, "gas-limit", 0, "Optional gas limit override for builder registrations. If not set, the existing gas limit from the cluster lock is used.") wrapPreRunE(cmd, func(cmd *cobra.Command, _ []string) error { mustMarkFlagRequired(cmd, "validator-public-keys") @@ -142,7 +144,7 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err } // Build and sign partial registrations. - partialRegs, err := buildPartialRegistrations(config.FeeRecipient, pubkeysToSign, *cl, shares) + partialRegs, err := buildPartialRegistrations(config.FeeRecipient, config.GasLimit, pubkeysToSign, *cl, shares) if err != nil { return err } @@ -151,6 +153,7 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err log.Info(ctx, "Signed partial builder registration", z.Str("validator_pubkey", hex.EncodeToString(reg.Message.Pubkey[:])), z.Str("fee_recipient", config.FeeRecipient), + z.U64("gas_limit", reg.Message.GasLimit), z.I64("timestamp", reg.Message.Timestamp.Unix()), ) } @@ -167,12 +170,12 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err return nil } -// filterPubkeysByStatus fetches the current status for each pubkey from the remote API and returns -// only those that need signing, each paired with the timestamp to use for signing. -// Complete registrations are skipped. Partial registrations with mismatched fee recipients cause -// an error. For unknown validators, now() is used as the timestamp (first signer anchors it). -// For partial validators with a matching fee recipient, the existing timestamp from the API is -// adopted so all operators sign the identical message. +// filterPubkeysByStatus fetches the current registration groups for each pubkey from the remote +// API and returns only those that need signing, each paired with the timestamp to use for signing. +// Validators with a quorum-complete registration for the requested fee recipient are skipped. +// In-progress (non-quorum) registrations with a mismatched fee recipient cause an error. +// For validators with a matching in-progress registration, the existing timestamp is adopted so +// all operators sign the identical message. For unknown validators, now() is used. func filterPubkeysByStatus( ctx context.Context, oAPI obolapi.Client, @@ -186,53 +189,68 @@ func filterPubkeysByStatus( return nil, errors.Wrap(err, "fetch builder registration status from Obol API") } - statusByPubkey := make(map[string]obolapi.FeeRecipientValidatorStatus) - for _, vs := range resp.Validators { - statusByPubkey[strings.ToLower(vs.Pubkey)] = vs + validatorByPubkey := make(map[string]obolapi.FeeRecipientValidator) + + for _, v := range resp.Validators { + normalizedKey := strings.ToLower(strings.TrimPrefix(v.Pubkey, "0x")) + validatorByPubkey[normalizedKey] = v } var pubkeysToSign []pubkeyToSign for _, valPubKey := range requestedPubkeys { - normalizedKey := strings.ToLower(valPubKey) - if !strings.HasPrefix(normalizedKey, "0x") { - normalizedKey = "0x" + normalizedKey - } + normalizedKey := strings.ToLower(strings.TrimPrefix(valPubKey, "0x")) - vs, ok := statusByPubkey[normalizedKey] + v, ok := validatorByPubkey[normalizedKey] - if ok && vs.Status == obolapi.FeeRecipientStatusComplete { - log.Info(ctx, "Validator already has a complete builder registration, skipping", - z.Str("pubkey", valPubKey), - z.Str("fee_recipient", vs.FeeRecipient)) + var timestamp time.Time - continue - } + if ok { + var quorumGroup, incompleteGroup *obolapi.FeeRecipientBuilderRegistration - var timestamp time.Time + for i := range v.BuilderRegistrations { + reg := &v.BuilderRegistrations[i] + if reg.Quorum && quorumGroup == nil { + quorumGroup = reg + } else if !reg.Quorum && incompleteGroup == nil { + incompleteGroup = reg + } + } - if ok && vs.Status == obolapi.FeeRecipientStatusPartial { - if !strings.EqualFold(vs.FeeRecipient, feeRecipient) { - return nil, errors.New("fee recipient mismatch with existing partial registration; wait for the in-progress registration to complete or coordinate with your cluster operators", + if quorumGroup != nil && strings.EqualFold(quorumGroup.Message.FeeRecipient.String(), feeRecipient) { + log.Info(ctx, "Validator already has a complete builder registration, skipping", z.Str("pubkey", valPubKey), - z.Str("existing_fee_recipient", vs.FeeRecipient), - z.Str("requested_fee_recipient", feeRecipient), - ) + z.Str("fee_recipient", quorumGroup.Message.FeeRecipient.String())) + + continue } - // Adopt the timestamp from the existing partial so all operators sign the same message. - timestamp = vs.Timestamp + if incompleteGroup != nil { + if !strings.EqualFold(incompleteGroup.Message.FeeRecipient.String(), feeRecipient) { + return nil, errors.New("fee recipient mismatch with existing partial registration; wait for the in-progress registration to complete or coordinate with your cluster operators", + z.Str("pubkey", valPubKey), + z.Str("existing_fee_recipient", incompleteGroup.Message.FeeRecipient.String()), + z.Str("requested_fee_recipient", feeRecipient), + ) + } + + // Adopt the timestamp from the in-progress group so all operators sign the same message. + timestamp = incompleteGroup.Message.Timestamp - log.Info(ctx, "Validator has partial builder registration with matching fee recipient, proceeding", - z.Str("pubkey", valPubKey), - z.Str("fee_recipient", vs.FeeRecipient), - z.Int("partial_count", vs.PartialCount)) + log.Info(ctx, "Validator has partial builder registration with matching fee recipient, proceeding", + z.Str("pubkey", valPubKey), + z.Str("fee_recipient", incompleteGroup.Message.FeeRecipient.String()), + z.Int("partial_count", len(incompleteGroup.PartialSignatures))) + } else { + // No in-progress group: anchor the timestamp now. + timestamp = now() + } } else { - // First signer for this validator: anchor the timestamp now. + // Unknown validator: first signer anchors the timestamp. timestamp = now() } - pubkeyBytes, err := hex.DecodeString(strings.TrimPrefix(valPubKey, "0x")) + pubkeyBytes, err := hex.DecodeString(normalizedKey) if err != nil { return nil, errors.Wrap(err, "decode pubkey", z.Str("validator_public_key", valPubKey)) } @@ -252,8 +270,10 @@ func filterPubkeysByStatus( // buildPartialRegistrations creates partial builder registration messages for each pubkey, // signs them with the operator's key share, and returns the signed partial registrations. +// If gasLimitOverride is non-zero it is used; otherwise the existing gas limit from the cluster lock is used. func buildPartialRegistrations( feeRecipientHex string, + gasLimitOverride uint64, pubkeys []pubkeyToSign, cl cluster.Lock, shares keystore.ValidatorShares, @@ -282,9 +302,14 @@ func buildPartialRegistrations( return nil, errors.New("no existing builder registration found for validator", z.Str("pubkey", hex.EncodeToString(p.Pubkey[:]))) } + gasLimit := uint64(existingReg.Message.GasLimit) + if gasLimitOverride != 0 { + gasLimit = gasLimitOverride + } + regMsg := ð2v1.ValidatorRegistration{ FeeRecipient: feeRecipient, - GasLimit: uint64(existingReg.Message.GasLimit), + GasLimit: gasLimit, Timestamp: p.Timestamp, Pubkey: p.Pubkey, } diff --git a/testutil/obolapimock/feerecipient.go b/testutil/obolapimock/feerecipient.go index 1a9059bf5..a0df985fa 100644 --- a/testutil/obolapimock/feerecipient.go +++ b/testutil/obolapimock/feerecipient.go @@ -9,11 +9,8 @@ import ( "net/http" "strconv" "strings" - "time" - eth2api "github.com/attestantio/go-eth2-client/api" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" - eth2spec "github.com/attestantio/go-eth2-client/spec" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/gorilla/mux" @@ -21,7 +18,6 @@ import ( "github.com/obolnetwork/charon/cluster" "github.com/obolnetwork/charon/eth2util/registration" "github.com/obolnetwork/charon/tbls" - "github.com/obolnetwork/charon/tbls/tblsconv" ) const ( @@ -36,9 +32,15 @@ type feeRecipientPartial struct { Signature []byte } -// feeRecipientBlob represents partial builder registrations for a validator. +// feeRecipientBlob holds partial registrations for a validator, grouped by message identity. +// The outer key is a message hash (fee_recipient|timestamp|gas_limit), the inner key is share index. type feeRecipientBlob struct { - partials map[int]feeRecipientPartial // keyed by share index + groups map[string]map[int]feeRecipientPartial +} + +// msgKey returns a stable string key identifying a registration message's content. +func msgKey(msg *eth2v1.ValidatorRegistration) string { + return fmt.Sprintf("%s|%d|%d", msg.FeeRecipient.String(), msg.Timestamp.Unix(), msg.GasLimit) } func (ts *testServer) HandleSubmitPartialFeeRecipient(writer http.ResponseWriter, request *http.Request) { @@ -78,14 +80,12 @@ func (ts *testServer) HandleSubmitPartialFeeRecipient(writer http.ResponseWriter return } - // check that share index is valid if shareIndex <= 0 || shareIndex > len(lock.Operators) { writeErr(writer, http.StatusBadRequest, "invalid share index") return } for _, partialReg := range data.PartialRegistrations { - // Verify the partial signature using the public share. sigRoot, err := registration.GetMessageSigningRoot(partialReg.Message, eth2p0.Version(lock.ForkVersion)) if err != nil { writeErr(writer, http.StatusInternalServerError, "cannot calculate signing root") @@ -118,42 +118,29 @@ func (ts *testServer) HandleSubmitPartialFeeRecipient(writer http.ResponseWriter return } - // Store the partial registration. key := lockHash + "/" + validatorPubkeyHex existing, ok := ts.partialFeeRecipients[key] if !ok { existing = feeRecipientBlob{ - partials: make(map[int]feeRecipientPartial), + groups: make(map[string]map[int]feeRecipientPartial), } } - // Check message consistency with existing partials. - for _, p := range existing.partials { - if p.Message.FeeRecipient != partialReg.Message.FeeRecipient { - writeErr(writer, http.StatusBadRequest, "fee_recipient mismatch with existing partial") - return - } - - if !p.Message.Timestamp.Equal(partialReg.Message.Timestamp) { - writeErr(writer, http.StatusBadRequest, "timestamp mismatch with existing partial") - return - } - - if p.Message.GasLimit != partialReg.Message.GasLimit { - writeErr(writer, http.StatusBadRequest, "gas_limit mismatch with existing partial") - return - } + mk := msgKey(partialReg.Message) - break // Only need to check against one existing partial. + group, ok := existing.groups[mk] + if !ok { + group = make(map[int]feeRecipientPartial) } - existing.partials[shareIndex] = feeRecipientPartial{ + group[shareIndex] = feeRecipientPartial{ ShareIdx: shareIndex, Message: partialReg.Message, Signature: partialReg.Signature[:], } + existing.groups[mk] = group ts.partialFeeRecipients[key] = existing } @@ -184,13 +171,11 @@ func (ts *testServer) HandlePostFeeRecipientFetch(writer http.ResponseWriter, re return } - // Build a set of requested pubkeys for filtering. pubkeyFilter := make(map[string]bool) for _, pk := range fetchReq.Pubkeys { pubkeyFilter[strings.ToLower(strings.TrimPrefix(pk, "0x"))] = true } - // Determine which validators to report on: filtered list or all in the cluster. type validatorInfo struct { pubkeyHex string validator *cluster.DistValidator @@ -210,124 +195,93 @@ func (ts *testServer) HandlePostFeeRecipientFetch(writer http.ResponseWriter, re }) } - var ( - registrations []*eth2api.VersionedSignedValidatorRegistration - validators []obolapi.FeeRecipientValidatorStatus - ) + var validators []obolapi.FeeRecipientValidator for _, t := range targets { key := lockHash + "/" + t.pubkeyHex existing, hasPartials := ts.partialFeeRecipients[key] - partialCount := 0 - if hasPartials { - partialCount = len(existing.partials) + if !hasPartials || len(existing.groups) == 0 { + continue // omit validators with no registration data } - status := obolapi.FeeRecipientStatusUnknown - if partialCount > 0 && partialCount < lock.Threshold { - status = obolapi.FeeRecipientStatusPartial - } else if partialCount >= lock.Threshold { - status = obolapi.FeeRecipientStatusComplete - } + var builderRegs []obolapi.FeeRecipientBuilderRegistration - // Extract fee recipient and timestamp from the first partial (all partials have the same message). var ( - feeRecipient string - timestamp time.Time + latestQuorum *obolapi.FeeRecipientBuilderRegistration + latestIncomplete *obolapi.FeeRecipientBuilderRegistration ) - for _, p := range existing.partials { - feeRecipient = fmt.Sprintf("0x%x", p.Message.FeeRecipient) - timestamp = p.Message.Timestamp - - break - } - - validators = append(validators, obolapi.FeeRecipientValidatorStatus{ - Pubkey: "0x" + t.pubkeyHex, - Status: status, - FeeRecipient: feeRecipient, - Timestamp: timestamp, - PartialCount: partialCount, - }) + for _, group := range existing.groups { + // Pick a representative message from the group (all entries share the same message). + var msg *eth2v1.ValidatorRegistration + for _, p := range group { + msg = p.Message + break + } - if status != obolapi.FeeRecipientStatusComplete { - continue - } + // Build partial_signatures list; apply dropOnePsig if set. + partials := make([]feeRecipientPartial, 0, len(group)) + for _, p := range group { + partials = append(partials, p) + } - // Aggregate partial signatures server-side. - signedReg, err := ts.aggregateFeeRecipient(lock, *t.validator, existing) - if err != nil { - writeErr(writer, http.StatusInternalServerError, "aggregate error: "+err.Error()) - return - } + if ts.dropOnePsig && len(partials) > 0 { + partials = partials[:len(partials)-1] + } - registrations = append(registrations, signedReg) - } + partialSigs := make([]obolapi.FeeRecipientPartialSig, 0, len(partials)) + for _, p := range partials { + var sig tbls.Signature + copy(sig[:], p.Signature) - resp := obolapi.FeeRecipientFetchResponse{ - Registrations: registrations, - Validators: validators, - } + partialSigs = append(partialSigs, obolapi.FeeRecipientPartialSig{ + ShareIndex: p.ShareIdx, + Signature: sig, + }) + } - if err := json.NewEncoder(writer).Encode(resp); err != nil { - writeErr(writer, http.StatusInternalServerError, "cannot encode response") - } -} + quorum := len(group) >= lock.Threshold -// aggregateFeeRecipient aggregates partial BLS signatures into a fully signed registration. -func (ts *testServer) aggregateFeeRecipient(lock cluster.Lock, v cluster.DistValidator, blob feeRecipientBlob) (*eth2api.VersionedSignedValidatorRegistration, error) { - // Use the message from the first partial (all should have the same message). - var msg *eth2v1.ValidatorRegistration - for _, p := range blob.partials { - msg = p.Message - break - } + reg := obolapi.FeeRecipientBuilderRegistration{ + Message: msg, + PartialSignatures: partialSigs, + Quorum: quorum, + } - // Collect partial signatures. - partialSigs := make(map[int]tbls.Signature) - for _, p := range blob.partials { - if ts.dropOnePsig && len(partialSigs) == len(blob.partials)-1 { - continue + if quorum { + if latestQuorum == nil || msg.Timestamp.After(latestQuorum.Message.Timestamp) { + regCopy := reg + latestQuorum = ®Copy + } + } else { + if latestIncomplete == nil || msg.Timestamp.After(latestIncomplete.Message.Timestamp) { + regCopy := reg + latestIncomplete = ®Copy + } + } } - sig, err := tblsconv.SignatureFromBytes(p.Signature) - if err != nil { - return nil, err + // Return at most one quorum group and one incomplete group per spec. + if latestQuorum != nil { + builderRegs = append(builderRegs, *latestQuorum) } - partialSigs[p.ShareIdx] = sig - } - - // Aggregate signatures. - fullSig, err := tbls.ThresholdAggregate(partialSigs) - if err != nil { - return nil, err - } - - // Verify aggregated signature against the group public key. - pubkeyBytes := v.PubKey + if latestIncomplete != nil { + builderRegs = append(builderRegs, *latestIncomplete) + } - groupPubkey, err := tblsconv.PubkeyFromBytes(pubkeyBytes) - if err != nil { - return nil, err + validators = append(validators, obolapi.FeeRecipientValidator{ + Pubkey: t.pubkeyHex, // no 0x prefix per spec + BuilderRegistrations: builderRegs, + }) } - sigRoot, err := registration.GetMessageSigningRoot(msg, eth2p0.Version(lock.ForkVersion)) - if err != nil { - return nil, err + resp := obolapi.FeeRecipientFetchResponse{ + Validators: validators, } - if err := tbls.Verify(groupPubkey, sigRoot[:], fullSig); err != nil { - return nil, err + if err := json.NewEncoder(writer).Encode(resp); err != nil { + writeErr(writer, http.StatusInternalServerError, "cannot encode response") } - - return ð2api.VersionedSignedValidatorRegistration{ - Version: eth2spec.BuilderVersionV1, - V1: ð2v1.SignedValidatorRegistration{ - Message: msg, - Signature: eth2p0.BLSSignature(fullSig), - }, - }, nil } From c03b378b75a2a7981de8d1ceeef351719a31d3af Mon Sep 17 00:00:00 2001 From: Andrei Smirnov Date: Wed, 11 Mar 2026 15:37:51 +0300 Subject: [PATCH 10/16] Reading overrides file --- cmd/feerecipientsign.go | 137 +++++++++++++++++++++++++++++----------- 1 file changed, 99 insertions(+), 38 deletions(-) diff --git a/cmd/feerecipientsign.go b/cmd/feerecipientsign.go index bf4929e44..da7fba343 100644 --- a/cmd/feerecipientsign.go +++ b/cmd/feerecipientsign.go @@ -3,12 +3,14 @@ package cmd import ( - "bytes" "context" "encoding/hex" + "encoding/json" + "os" "strings" "time" + eth2api "github.com/attestantio/go-eth2-client/api" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/spf13/cobra" @@ -26,13 +28,15 @@ import ( "github.com/obolnetwork/charon/tbls" ) -// pubkeyToSign pairs a validator public key with the timestamp to use when signing its registration. -// For validators with no existing partial registration (unknown status), the timestamp is set to -// time.Now() by the first operator. For validators already in partial status, the timestamp is -// adopted from the existing partial registration so all operators sign the same message. +// pubkeyToSign pairs a validator public key with the timestamp and gas limit to use when signing +// its registration. For validators with no existing partial registration (unknown status), the +// timestamp is set to time.Now() by the first operator and the gas limit is resolved from the +// config override or cluster lock. For validators already in partial status, the timestamp and +// gas limit are adopted from the existing partial registration so all operators sign the same message. type pubkeyToSign struct { Pubkey eth2p0.BLSPubKey Timestamp time.Time + GasLimit uint64 } type feerecipientSignConfig struct { @@ -59,11 +63,12 @@ func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig bindFeeRecipientRemoteAPIFlags(cmd, &config.feerecipientConfig) cmd.Flags().StringVar(&config.LockFilePath, lockFilePath.String(), ".charon/cluster-lock.json", "Path to the cluster lock file defining the distributed validator cluster.") + cmd.Flags().StringVar(&config.OverridesFilePath, "overrides-file", ".charon/builder_registrations_overrides.json", "Path to the builder registrations overrides file.") cmd.Flags().StringVar(&config.PrivateKeyPath, privateKeyPath.String(), ".charon/charon-enr-private-key", "Path to the charon enr private key file.") cmd.Flags().StringVar(&config.ValidatorKeysDir, validatorKeysDir.String(), ".charon/validator_keys", "Path to the directory containing the validator private key share files and passwords.") cmd.Flags().StringSliceVar(&config.ValidatorPublicKeys, "validator-public-keys", nil, "[REQUIRED] Comma-separated list of validator public keys to sign builder registrations for.") cmd.Flags().StringVar(&config.FeeRecipient, "fee-recipient", "", "[REQUIRED] New fee recipient address to be applied to all specified validators.") - cmd.Flags().Uint64Var(&config.GasLimit, "gas-limit", 0, "Optional gas limit override for builder registrations. If not set, the existing gas limit from the cluster lock is used.") + cmd.Flags().Uint64Var(&config.GasLimit, "gas-limit", 0, "Optional gas limit override for builder registrations. If not set, the existing gas limit from the cluster lock or overrides file is used.") wrapPreRunE(cmd, func(cmd *cobra.Command, _ []string) error { mustMarkFlagRequired(cmd, "validator-public-keys") @@ -132,8 +137,13 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err return errors.Wrap(err, "match local validator key shares with their counterparty in cluster lock") } + overrides, err := loadOverridesRegistrations(config.OverridesFilePath) + if err != nil { + return err + } + // Filter pubkeys based on their current status on the remote API. - pubkeysToSign, err := filterPubkeysByStatus(ctx, oAPI, cl.LockHash, config.ValidatorPublicKeys, config.FeeRecipient, time.Now) + pubkeysToSign, err := filterPubkeysByStatus(ctx, oAPI, cl.LockHash, config.ValidatorPublicKeys, config.FeeRecipient, config.GasLimit, *cl, overrides, time.Now) if err != nil { return err } @@ -144,7 +154,7 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err } // Build and sign partial registrations. - partialRegs, err := buildPartialRegistrations(config.FeeRecipient, config.GasLimit, pubkeysToSign, *cl, shares) + partialRegs, err := buildPartialRegistrations(config.FeeRecipient, pubkeysToSign, *cl, shares) if err != nil { return err } @@ -171,17 +181,21 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err } // filterPubkeysByStatus fetches the current registration groups for each pubkey from the remote -// API and returns only those that need signing, each paired with the timestamp to use for signing. -// Validators with a quorum-complete registration for the requested fee recipient are skipped. -// In-progress (non-quorum) registrations with a mismatched fee recipient cause an error. -// For validators with a matching in-progress registration, the existing timestamp is adopted so -// all operators sign the identical message. For unknown validators, now() is used. +// API and returns only those that need signing, each paired with the timestamp and gas limit to +// use for signing. Validators with a quorum-complete registration for the requested fee recipient +// are skipped. In-progress (non-quorum) registrations with a mismatched fee recipient cause an error. +// For validators with a matching in-progress registration, the existing timestamp and gas limit are +// adopted so all operators sign the identical message. For unknown validators, now() and the +// gas limit from the config override or cluster lock are used. func filterPubkeysByStatus( ctx context.Context, oAPI obolapi.Client, lockHash []byte, requestedPubkeys []string, feeRecipient string, + gasLimitOverride uint64, + cl cluster.Lock, + overrides map[string]eth2v1.ValidatorRegistration, now func() time.Time, ) ([]pubkeyToSign, error) { resp, err := oAPI.PostFeeRecipientsFetch(ctx, lockHash, requestedPubkeys) @@ -203,7 +217,10 @@ func filterPubkeysByStatus( v, ok := validatorByPubkey[normalizedKey] - var timestamp time.Time + var ( + timestamp time.Time + gasLimit uint64 + ) if ok { var quorumGroup, incompleteGroup *obolapi.FeeRecipientBuilderRegistration @@ -234,20 +251,23 @@ func filterPubkeysByStatus( ) } - // Adopt the timestamp from the in-progress group so all operators sign the same message. + // Adopt the timestamp and gas limit from the in-progress group so all operators sign the same message. timestamp = incompleteGroup.Message.Timestamp + gasLimit = incompleteGroup.Message.GasLimit log.Info(ctx, "Validator has partial builder registration with matching fee recipient, proceeding", z.Str("pubkey", valPubKey), z.Str("fee_recipient", incompleteGroup.Message.FeeRecipient.String()), z.Int("partial_count", len(incompleteGroup.PartialSignatures))) } else { - // No in-progress group: anchor the timestamp now. + // No in-progress group: anchor the timestamp and resolve gas limit now. timestamp = now() + gasLimit = resolveGasLimit(gasLimitOverride, cl, overrides, normalizedKey) } } else { - // Unknown validator: first signer anchors the timestamp. + // Unknown validator: first signer anchors the timestamp and resolves gas limit. timestamp = now() + gasLimit = resolveGasLimit(gasLimitOverride, cl, overrides, normalizedKey) } pubkeyBytes, err := hex.DecodeString(normalizedKey) @@ -262,18 +282,77 @@ func filterPubkeysByStatus( pubkeysToSign = append(pubkeysToSign, pubkeyToSign{ Pubkey: eth2p0.BLSPubKey(pubkeyBytes), Timestamp: timestamp, + GasLimit: gasLimit, }) } return pubkeysToSign, nil } +// resolveGasLimit returns gasLimitOverride if non-zero. Otherwise it picks the gas limit from +// whichever source (cluster lock or overrides file) has the higher timestamp for the given +// validator pubkey. This ensures the most recent registration's gas limit is used. +func resolveGasLimit(gasLimitOverride uint64, cl cluster.Lock, overrides map[string]eth2v1.ValidatorRegistration, normalizedPubkeyHex string) uint64 { + if gasLimitOverride != 0 { + return gasLimitOverride + } + + var ( + bestGasLimit uint64 + bestTimestamp time.Time + ) + + for _, dv := range cl.Validators { + if strings.EqualFold(dv.PublicKeyHex(), "0x"+normalizedPubkeyHex) { + bestGasLimit = uint64(dv.BuilderRegistration.Message.GasLimit) + bestTimestamp = dv.BuilderRegistration.Message.Timestamp + + break + } + } + + if override, ok := overrides[normalizedPubkeyHex]; ok { + if override.Timestamp.After(bestTimestamp) { + bestGasLimit = override.GasLimit + } + } + + return bestGasLimit +} + +// loadOverridesRegistrations reads the builder registrations overrides file and returns +// a map keyed by normalized (lowercase, no 0x prefix) validator pubkey hex. If the file +// does not exist, an empty map is returned. +func loadOverridesRegistrations(path string) (map[string]eth2v1.ValidatorRegistration, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return make(map[string]eth2v1.ValidatorRegistration), nil + } else if err != nil { + return nil, errors.Wrap(err, "read overrides file", z.Str("path", path)) + } + + var regs []*eth2api.VersionedSignedValidatorRegistration + if err := json.Unmarshal(data, ®s); err != nil { + return nil, errors.Wrap(err, "unmarshal overrides file", z.Str("path", path)) + } + + result := make(map[string]eth2v1.ValidatorRegistration, len(regs)) + for _, reg := range regs { + if reg == nil || reg.V1 == nil || reg.V1.Message == nil { + continue + } + + key := strings.ToLower(hex.EncodeToString(reg.V1.Message.Pubkey[:])) + result[key] = *reg.V1.Message + } + + return result, nil +} + // buildPartialRegistrations creates partial builder registration messages for each pubkey, // signs them with the operator's key share, and returns the signed partial registrations. -// If gasLimitOverride is non-zero it is used; otherwise the existing gas limit from the cluster lock is used. func buildPartialRegistrations( feeRecipientHex string, - gasLimitOverride uint64, pubkeys []pubkeyToSign, cl cluster.Lock, shares keystore.ValidatorShares, @@ -289,27 +368,9 @@ func buildPartialRegistrations( partialRegs := make([]obolapi.PartialRegistration, 0, len(pubkeys)) for _, p := range pubkeys { - var existingReg *cluster.BuilderRegistration - - for _, dv := range cl.Validators { - if bytes.Equal(dv.PubKey, p.Pubkey[:]) { - existingReg = &dv.BuilderRegistration - break - } - } - - if existingReg == nil || existingReg.Message.Timestamp.IsZero() { - return nil, errors.New("no existing builder registration found for validator", z.Str("pubkey", hex.EncodeToString(p.Pubkey[:]))) - } - - gasLimit := uint64(existingReg.Message.GasLimit) - if gasLimitOverride != 0 { - gasLimit = gasLimitOverride - } - regMsg := ð2v1.ValidatorRegistration{ FeeRecipient: feeRecipient, - GasLimit: gasLimit, + GasLimit: p.GasLimit, Timestamp: p.Timestamp, Pubkey: p.Pubkey, } From 260a527ec59cdc9b89d7ba544719ad403b7cd29a Mon Sep 17 00:00:00 2001 From: Andrei Smirnov Date: Thu, 12 Mar 2026 11:35:14 +0300 Subject: [PATCH 11/16] aligned with obol api --- app/obolapi/api.go | 29 ------------------- app/obolapi/feerecipient.go | 44 +++++++++++++++++++++++----- app/obolapi/feerecipient_model.go | 35 +++++++++++++++++++++- cmd/feerecipient.go | 3 +- cmd/feerecipientfetch.go | 48 ++++++++++++++++++++++--------- core/parsigex/parsigex.go | 2 ++ 6 files changed, 109 insertions(+), 52 deletions(-) diff --git a/app/obolapi/api.go b/app/obolapi/api.go index 4b071db40..529612ab7 100644 --- a/app/obolapi/api.go +++ b/app/obolapi/api.go @@ -117,35 +117,6 @@ func httpPost(ctx context.Context, url *url.URL, body []byte, headers map[string return nil } -func httpPostWithResponse(ctx context.Context, url *url.URL, body []byte, headers map[string]string) (io.ReadCloser, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url.String(), bytes.NewReader(body)) - if err != nil { - return nil, errors.Wrap(err, "create POST request") - } - - req.Header.Add("Content-Type", "application/json") - - for key, val := range headers { - req.Header.Set(key, val) - } - - res, err := new(http.Client).Do(req) - if err != nil { - return nil, errors.Wrap(err, "call POST endpoint") - } - - if res.StatusCode/100 != 2 { - data, err := io.ReadAll(res.Body) - if err != nil { - return nil, errors.Wrap(err, "read POST response", z.Int("status", res.StatusCode)) - } - - return nil, errors.New("http POST failed", z.Int("status", res.StatusCode), z.Str("body", string(data))) - } - - return res.Body, nil -} - func httpGet(ctx context.Context, url *url.URL, headers map[string]string) (io.ReadCloser, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil) if err != nil { diff --git a/app/obolapi/feerecipient.go b/app/obolapi/feerecipient.go index b49e1958f..dc710bd4a 100644 --- a/app/obolapi/feerecipient.go +++ b/app/obolapi/feerecipient.go @@ -3,19 +3,26 @@ package obolapi import ( + "bytes" "context" "encoding/hex" "encoding/json" + "io" + "net/http" "net/url" "strconv" "strings" "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/z" ) const ( submitPartialFeeRecipientTmpl = "/fee_recipient/partial/" + lockHashPath + "/" + shareIndexPath fetchFeeRecipientTmpl = "/fee_recipient/" + lockHashPath + + errNoPartialsRegistrations = "no partial registrations found" + errLockNotFound = "lock not found" ) // submitPartialFeeRecipientURL returns the partial fee recipient Obol API URL for a given lock hash. @@ -41,14 +48,12 @@ func fetchFeeRecipientURL(lockHash string) string { func (c Client) PostPartialFeeRecipients(ctx context.Context, lockHash []byte, shareIndex uint64, partialRegs []PartialRegistration) error { lockHashStr := "0x" + hex.EncodeToString(lockHash) - path := submitPartialFeeRecipientURL(lockHashStr, shareIndex) - u, err := url.ParseRequestURI(c.baseURL) if err != nil { return errors.Wrap(err, "bad Obol API url") } - u.Path = path + u.Path = submitPartialFeeRecipientURL(lockHashStr, shareIndex) req := PartialFeeRecipientRequest{PartialRegistrations: partialRegs} @@ -92,15 +97,40 @@ func (c Client) PostFeeRecipientsFetch(ctx context.Context, lockHash []byte, pub ctx, cancel := context.WithTimeout(ctx, c.reqTimeout) defer cancel() - respBody, err := httpPostWithResponse(ctx, u, data, nil) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(data)) if err != nil { - return FeeRecipientFetchResponse{}, errors.Wrap(err, "http Obol API POST request") + return FeeRecipientFetchResponse{}, errors.Wrap(err, "create POST request") } - defer respBody.Close() + httpReq.Header.Add("Content-Type", "application/json") + + httpResp, err := new(http.Client).Do(httpReq) + if err != nil { + return FeeRecipientFetchResponse{}, errors.Wrap(err, "call POST endpoint") + } + defer httpResp.Body.Close() + + if httpResp.StatusCode/100 != 2 { + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return FeeRecipientFetchResponse{}, errors.Wrap(err, "read response", z.Int("status", httpResp.StatusCode)) + } + + if httpResp.StatusCode == http.StatusNotFound { + if strings.Contains(string(body), errNoPartialsRegistrations) { + return FeeRecipientFetchResponse{}, nil + } + + if strings.Contains(string(body), errLockNotFound) { + return FeeRecipientFetchResponse{}, errors.New("cluster is unknown to the API, publish the lock file first") + } + } + + return FeeRecipientFetchResponse{}, errors.New("http POST failed", z.Int("status", httpResp.StatusCode), z.Str("body", string(body))) + } var resp FeeRecipientFetchResponse - if err := json.NewDecoder(respBody).Decode(&resp); err != nil { + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { return FeeRecipientFetchResponse{}, errors.Wrap(err, "unmarshal response") } diff --git a/app/obolapi/feerecipient_model.go b/app/obolapi/feerecipient_model.go index f9d9b3b31..4be8d5d35 100644 --- a/app/obolapi/feerecipient_model.go +++ b/app/obolapi/feerecipient_model.go @@ -12,9 +12,42 @@ import ( ) // PartialRegistration represents a partial builder registration with a partial BLS signature. +// The signature is encoded as a 0x-prefixed hex string on the wire. type PartialRegistration struct { + Message *eth2v1.ValidatorRegistration + Signature tbls.Signature +} + +// partialRegistrationDTO is the wire representation of PartialRegistration. +type partialRegistrationDTO struct { Message *eth2v1.ValidatorRegistration `json:"message"` - Signature tbls.Signature `json:"signature"` + Signature string `json:"signature"` +} + +func (p PartialRegistration) MarshalJSON() ([]byte, error) { + //nolint:wrapcheck // caller will wrap + return json.Marshal(partialRegistrationDTO{ + Message: p.Message, + Signature: fmt.Sprintf("%#x", p.Signature), + }) +} + +func (p *PartialRegistration) UnmarshalJSON(data []byte) error { + var dto partialRegistrationDTO + if err := json.Unmarshal(data, &dto); err != nil { + //nolint:wrapcheck // caller will wrap + return err + } + + sigBytes, err := from0x(dto.Signature, 96) + if err != nil { + return err + } + + p.Message = dto.Message + copy(p.Signature[:], sigBytes) + + return nil } // PartialFeeRecipientRequest represents the request body for posting partial builder registrations. diff --git a/cmd/feerecipient.go b/cmd/feerecipient.go index 3624dfa29..7cdd30e2f 100644 --- a/cmd/feerecipient.go +++ b/cmd/feerecipient.go @@ -33,6 +33,7 @@ func newFeeRecipientCmd(cmds ...*cobra.Command) *cobra.Command { } func bindFeeRecipientRemoteAPIFlags(cmd *cobra.Command, config *feerecipientConfig) { - cmd.Flags().StringVar(&config.PublishAddress, publishAddress.String(), "https://api.obol.tech/v1", "The URL of the remote API.") + cmd.Flags().StringVar(&config.PublishAddress, publishAddress.String(), "https://obol-api-nonprod-dev.dev.obol.tech/v1", "The URL of the remote API.") + // cmd.Flags().StringVar(&config.PublishAddress, publishAddress.String(), "https://api.obol.tech/v1", "The URL of the remote API.") cmd.Flags().DurationVar(&config.PublishTimeout, publishTimeout.String(), 5*time.Minute, "Timeout for accessing the remote API.") } diff --git a/cmd/feerecipientfetch.go b/cmd/feerecipientfetch.go index 8a2239cae..1152339fd 100644 --- a/cmd/feerecipientfetch.go +++ b/cmd/feerecipientfetch.go @@ -66,12 +66,16 @@ func runFeeRecipientFetch(ctx context.Context, config feerecipientFetchConfig) e } var ( - aggregatedRegs []*eth2api.VersionedSignedValidatorRegistration - completeCount int - incompleteCount int - noRegCount int + aggregatedRegs []*eth2api.VersionedSignedValidatorRegistration + completePubkeys []string + incompletePubkeys []string + noRegPubkeys []string ) + // maxPartialSigs tracks the highest partial signature count across + // all incomplete registrations for a given validator pubkey. + maxPartialSigs := make(map[string]int) + for _, val := range resp.Validators { var hasQuorum, hasIncomplete bool @@ -104,33 +108,49 @@ func runFeeRecipientFetch(ctx context.Context, config feerecipientFetchConfig) e }) } else { hasIncomplete = true + + if n := len(reg.PartialSignatures); n > maxPartialSigs[val.Pubkey] { + maxPartialSigs[val.Pubkey] = n + } } } switch { case hasQuorum: - completeCount++ + completePubkeys = append(completePubkeys, val.Pubkey) case hasIncomplete: - incompleteCount++ + incompletePubkeys = append(incompletePubkeys, val.Pubkey) default: - noRegCount++ + noRegPubkeys = append(noRegPubkeys, val.Pubkey) } } - if completeCount > 0 { - log.Info(ctx, "Validators with complete builder registrations", z.Int("count", completeCount)) + if len(completePubkeys) > 0 { + log.Info(ctx, "Validators with complete builder registrations", z.Int("count", len(completePubkeys))) + + for _, pubkey := range completePubkeys { + log.Info(ctx, " Complete", z.Str("pubkey", pubkey)) + } } - if incompleteCount > 0 { - log.Info(ctx, "Validators with partial builder registrations", z.Int("count", incompleteCount)) + if len(incompletePubkeys) > 0 { + log.Info(ctx, "Validators with partial builder registrations", z.Int("count", len(incompletePubkeys))) + + for _, pubkey := range incompletePubkeys { + log.Info(ctx, " Incomplete", z.Str("pubkey", pubkey), z.Int("partial_signatures", maxPartialSigs[pubkey])) + } } - if noRegCount > 0 { - log.Info(ctx, "Validators unknown to the API", z.Int("count", noRegCount)) + if len(noRegPubkeys) > 0 { + log.Info(ctx, "Validators unknown to the API", z.Int("count", len(noRegPubkeys))) + + for _, pubkey := range noRegPubkeys { + log.Info(ctx, " No registrations", z.Str("pubkey", pubkey)) + } } if len(aggregatedRegs) == 0 { - log.Warn(ctx, "No fully signed builder registrations available yet", nil) + log.Info(ctx, "No fully signed builder registrations available yet") return nil } diff --git a/core/parsigex/parsigex.go b/core/parsigex/parsigex.go index acdb77cb0..35f90d9fc 100644 --- a/core/parsigex/parsigex.go +++ b/core/parsigex/parsigex.go @@ -108,11 +108,13 @@ func (m *ParSigEx) handle(ctx context.Context, _ peer.ID, req proto.Message) (pr // Verify partial signatures and record timing verifyStart := time.Now() + for pubkey, data := range set { if err = m.verifyFunc(ctx, duty, pubkey, data); err != nil { return nil, false, errors.Wrap(err, "invalid partial signature") } } + setVerificationDuration.WithLabelValues(duty.Type.String()).Observe(time.Since(verifyStart).Seconds()) for _, sub := range m.subs { From 85952bf0e9b4b17d1014e49f18784479a9eaacf6 Mon Sep 17 00:00:00 2001 From: Andrei Smirnov Date: Thu, 12 Mar 2026 13:41:54 +0300 Subject: [PATCH 12/16] fixed sign bug --- cmd/feerecipientsign.go | 48 ++++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/cmd/feerecipientsign.go b/cmd/feerecipientsign.go index da7fba343..9d7eddf62 100644 --- a/cmd/feerecipientsign.go +++ b/cmd/feerecipientsign.go @@ -223,14 +223,20 @@ func filterPubkeysByStatus( ) if ok { - var quorumGroup, incompleteGroup *obolapi.FeeRecipientBuilderRegistration + var quorumGroup *obolapi.FeeRecipientBuilderRegistration + + // Find the first incomplete group whose fee matches the requested one. + // Stale incompletes (different fee) are ignored — they may linger on the + // API after quorum was reached for a previous fee and must not block new + // fee recipient changes. + var matchingIncomplete *obolapi.FeeRecipientBuilderRegistration for i := range v.BuilderRegistrations { reg := &v.BuilderRegistrations[i] if reg.Quorum && quorumGroup == nil { quorumGroup = reg - } else if !reg.Quorum && incompleteGroup == nil { - incompleteGroup = reg + } else if !reg.Quorum && matchingIncomplete == nil && strings.EqualFold(reg.Message.FeeRecipient.String(), feeRecipient) { + matchingIncomplete = reg } } @@ -242,25 +248,33 @@ func filterPubkeysByStatus( continue } - if incompleteGroup != nil { - if !strings.EqualFold(incompleteGroup.Message.FeeRecipient.String(), feeRecipient) { - return nil, errors.New("fee recipient mismatch with existing partial registration; wait for the in-progress registration to complete or coordinate with your cluster operators", - z.Str("pubkey", valPubKey), - z.Str("existing_fee_recipient", incompleteGroup.Message.FeeRecipient.String()), - z.Str("requested_fee_recipient", feeRecipient), - ) - } - + if matchingIncomplete != nil { // Adopt the timestamp and gas limit from the in-progress group so all operators sign the same message. - timestamp = incompleteGroup.Message.Timestamp - gasLimit = incompleteGroup.Message.GasLimit + timestamp = matchingIncomplete.Message.Timestamp + gasLimit = matchingIncomplete.Message.GasLimit log.Info(ctx, "Validator has partial builder registration with matching fee recipient, proceeding", z.Str("pubkey", valPubKey), - z.Str("fee_recipient", incompleteGroup.Message.FeeRecipient.String()), - z.Int("partial_count", len(incompleteGroup.PartialSignatures))) + z.Str("fee_recipient", matchingIncomplete.Message.FeeRecipient.String()), + z.Int("partial_count", len(matchingIncomplete.PartialSignatures))) + } else if quorumGroup == nil { + // Check if there's ANY incomplete group (with a different fee) and no quorum yet. + // This means another operator started a fee change that hasn't completed — block. + for _, reg := range v.BuilderRegistrations { + if !reg.Quorum { + return nil, errors.New("fee recipient mismatch with existing partial registration; wait for the in-progress registration to complete or coordinate with your cluster operators", + z.Str("pubkey", valPubKey), + z.Str("existing_fee_recipient", reg.Message.FeeRecipient.String()), + z.Str("requested_fee_recipient", feeRecipient), + ) + } + } + + // No in-progress group and no quorum: anchor the timestamp and resolve gas limit now. + timestamp = now() + gasLimit = resolveGasLimit(gasLimitOverride, cl, overrides, normalizedKey) } else { - // No in-progress group: anchor the timestamp and resolve gas limit now. + // Quorum exists with different fee, no matching incomplete: start fresh. timestamp = now() gasLimit = resolveGasLimit(gasLimitOverride, cl, overrides, normalizedKey) } From 6351c32f94137da58625dd29d701e8079fe11d98 Mon Sep 17 00:00:00 2001 From: Andrei Smirnov Date: Thu, 12 Mar 2026 15:07:40 +0300 Subject: [PATCH 13/16] Refactoring --- app/obolapi/feerecipient.go | 6 +- cmd/feerecipient.go | 3 +- cmd/feerecipientfetch.go | 117 +++++++++++-------- cmd/feerecipientfetch_internal_test.go | 125 ++++++++++++++++++++ cmd/feerecipientsign.go | 156 ++++++++++++++----------- cmd/feerecipientsign_internal_test.go | 127 ++++++++++++++++++++ 6 files changed, 414 insertions(+), 120 deletions(-) diff --git a/app/obolapi/feerecipient.go b/app/obolapi/feerecipient.go index dc710bd4a..3f6cc4729 100644 --- a/app/obolapi/feerecipient.go +++ b/app/obolapi/feerecipient.go @@ -73,19 +73,17 @@ func (c Client) PostPartialFeeRecipients(ctx context.Context, lockHash []byte, s return nil } -// PostFeeRecipientsFetch fetches aggregated builder registrations and per-validator status from the Obol API. +// PostFeeRecipientsFetch fetches builder registrations from the Obol API. // If pubkeys is non-empty, only the specified validators are included in the response. // If pubkeys is empty, status for all validators in the cluster is returned. // It respects the timeout specified in the Client instance. func (c Client) PostFeeRecipientsFetch(ctx context.Context, lockHash []byte, pubkeys []string) (FeeRecipientFetchResponse, error) { - path := fetchFeeRecipientURL("0x" + hex.EncodeToString(lockHash)) - u, err := url.ParseRequestURI(c.baseURL) if err != nil { return FeeRecipientFetchResponse{}, errors.Wrap(err, "bad Obol API url") } - u.Path = path + u.Path = fetchFeeRecipientURL("0x" + hex.EncodeToString(lockHash)) req := FeeRecipientFetchRequest{Pubkeys: pubkeys} diff --git a/cmd/feerecipient.go b/cmd/feerecipient.go index 7cdd30e2f..3624dfa29 100644 --- a/cmd/feerecipient.go +++ b/cmd/feerecipient.go @@ -33,7 +33,6 @@ func newFeeRecipientCmd(cmds ...*cobra.Command) *cobra.Command { } func bindFeeRecipientRemoteAPIFlags(cmd *cobra.Command, config *feerecipientConfig) { - cmd.Flags().StringVar(&config.PublishAddress, publishAddress.String(), "https://obol-api-nonprod-dev.dev.obol.tech/v1", "The URL of the remote API.") - // cmd.Flags().StringVar(&config.PublishAddress, publishAddress.String(), "https://api.obol.tech/v1", "The URL of the remote API.") + cmd.Flags().StringVar(&config.PublishAddress, publishAddress.String(), "https://api.obol.tech/v1", "The URL of the remote API.") cmd.Flags().DurationVar(&config.PublishTimeout, publishTimeout.String(), 5*time.Minute, "Timeout for accessing the remote API.") } diff --git a/cmd/feerecipientfetch.go b/cmd/feerecipientfetch.go index 1152339fd..2f9d28ba3 100644 --- a/cmd/feerecipientfetch.go +++ b/cmd/feerecipientfetch.go @@ -20,7 +20,6 @@ import ( "github.com/obolnetwork/charon/app/z" "github.com/obolnetwork/charon/cluster" "github.com/obolnetwork/charon/tbls" - "github.com/obolnetwork/charon/tbls/tblsconv" ) type feerecipientFetchConfig struct { @@ -33,7 +32,7 @@ func newFeeRecipientFetchCmd(runFunc func(context.Context, feerecipientFetchConf cmd := &cobra.Command{ Use: "fetch", Short: "Fetch aggregated builder registrations.", - Long: "Fetches aggregated builder registration messages with updated fee recipients from a remote API for validators that have had partial signatures submitted, and writes them to a local JSON file.", + Long: "Fetches builder registration messages from a remote API and aggregates those with quorum, writing fully signed registrations to a local JSON file.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return runFunc(cmd.Context(), config) @@ -49,61 +48,56 @@ func newFeeRecipientFetchCmd(runFunc func(context.Context, feerecipientFetchConf return cmd } -func runFeeRecipientFetch(ctx context.Context, config feerecipientFetchConfig) error { - cl, err := cluster.LoadClusterLockAndVerify(ctx, config.LockFilePath) - if err != nil { - return err - } +// validatorCategories holds categorized validator public keys by registration status. +type validatorCategories struct { + Complete []string + Incomplete []string + NoReg []string +} - oAPI, err := obolapi.New(config.PublishAddress, obolapi.WithTimeout(config.PublishTimeout)) - if err != nil { - return errors.Wrap(err, "create Obol API client", z.Str("publish_address", config.PublishAddress)) +// aggregatePartialSignatures converts partial signatures into a full aggregated signature. +func aggregatePartialSignatures(partialSigs []obolapi.FeeRecipientPartialSig, pubkey string) (eth2p0.BLSSignature, error) { + sigsMap := make(map[int]tbls.Signature) + + for _, ps := range partialSigs { + sigsMap[ps.ShareIndex] = ps.Signature } - resp, err := oAPI.PostFeeRecipientsFetch(ctx, cl.LockHash, config.ValidatorPublicKeys) + fullSig, err := tbls.ThresholdAggregate(sigsMap) if err != nil { - return errors.Wrap(err, "fetch builder registrations from Obol API") + return eth2p0.BLSSignature{}, errors.Wrap(err, "aggregate partial signatures", z.Str("pubkey", pubkey)) } + return eth2p0.BLSSignature(fullSig), nil +} + +// processValidators aggregates signatures for validators with quorum and categorizes all validators by status. +func processValidators(validators []obolapi.FeeRecipientValidator) ([]*eth2api.VersionedSignedValidatorRegistration, validatorCategories, map[string]int, error) { var ( - aggregatedRegs []*eth2api.VersionedSignedValidatorRegistration - completePubkeys []string - incompletePubkeys []string - noRegPubkeys []string + aggregatedRegs []*eth2api.VersionedSignedValidatorRegistration + cats validatorCategories + // maxPartialSigs tracks the highest partial signature count across + // all incomplete registrations for a given validator pubkey. + maxPartialSigs = make(map[string]int) ) - // maxPartialSigs tracks the highest partial signature count across - // all incomplete registrations for a given validator pubkey. - maxPartialSigs := make(map[string]int) - - for _, val := range resp.Validators { + for _, val := range validators { var hasQuorum, hasIncomplete bool for _, reg := range val.BuilderRegistrations { if reg.Quorum { hasQuorum = true - partialSigs := make(map[int]tbls.Signature) - - for _, ps := range reg.PartialSignatures { - sig, err := tblsconv.SignatureFromBytes(ps.Signature[:]) - if err != nil { - return errors.Wrap(err, "parse partial signature", z.Str("pubkey", val.Pubkey)) - } - - partialSigs[ps.ShareIndex] = sig - } - - fullSig, err := tbls.ThresholdAggregate(partialSigs) + fullSig, err := aggregatePartialSignatures(reg.PartialSignatures, val.Pubkey) if err != nil { - return errors.Wrap(err, "aggregate partial signatures", z.Str("pubkey", val.Pubkey)) + return nil, validatorCategories{}, nil, err } aggregatedRegs = append(aggregatedRegs, ð2api.VersionedSignedValidatorRegistration{ Version: eth2spec.BuilderVersionV1, V1: ð2v1.SignedValidatorRegistration{ Message: reg.Message, - Signature: eth2p0.BLSSignature(fullSig), + Signature: fullSig, }, }) } else { @@ -117,37 +111,66 @@ func runFeeRecipientFetch(ctx context.Context, config feerecipientFetchConfig) e switch { case hasQuorum: - completePubkeys = append(completePubkeys, val.Pubkey) + cats.Complete = append(cats.Complete, val.Pubkey) case hasIncomplete: - incompletePubkeys = append(incompletePubkeys, val.Pubkey) + cats.Incomplete = append(cats.Incomplete, val.Pubkey) default: - noRegPubkeys = append(noRegPubkeys, val.Pubkey) + cats.NoReg = append(cats.NoReg, val.Pubkey) } } - if len(completePubkeys) > 0 { - log.Info(ctx, "Validators with complete builder registrations", z.Int("count", len(completePubkeys))) + return aggregatedRegs, cats, maxPartialSigs, nil +} - for _, pubkey := range completePubkeys { +// logValidatorStatus logs categorized validators with their current registration status. +func logValidatorStatus(ctx context.Context, cats validatorCategories, maxPartialSigs map[string]int) { + if len(cats.Complete) > 0 { + log.Info(ctx, "Validators with complete builder registrations", z.Int("count", len(cats.Complete))) + + for _, pubkey := range cats.Complete { log.Info(ctx, " Complete", z.Str("pubkey", pubkey)) } } - if len(incompletePubkeys) > 0 { - log.Info(ctx, "Validators with partial builder registrations", z.Int("count", len(incompletePubkeys))) + if len(cats.Incomplete) > 0 { + log.Info(ctx, "Validators with partial builder registrations", z.Int("count", len(cats.Incomplete))) - for _, pubkey := range incompletePubkeys { + for _, pubkey := range cats.Incomplete { log.Info(ctx, " Incomplete", z.Str("pubkey", pubkey), z.Int("partial_signatures", maxPartialSigs[pubkey])) } } - if len(noRegPubkeys) > 0 { - log.Info(ctx, "Validators unknown to the API", z.Int("count", len(noRegPubkeys))) + if len(cats.NoReg) > 0 { + log.Info(ctx, "Validators unknown to the API", z.Int("count", len(cats.NoReg))) - for _, pubkey := range noRegPubkeys { + for _, pubkey := range cats.NoReg { log.Info(ctx, " No registrations", z.Str("pubkey", pubkey)) } } +} + +func runFeeRecipientFetch(ctx context.Context, config feerecipientFetchConfig) error { + cl, err := cluster.LoadClusterLockAndVerify(ctx, config.LockFilePath) + if err != nil { + return err + } + + oAPI, err := obolapi.New(config.PublishAddress, obolapi.WithTimeout(config.PublishTimeout)) + if err != nil { + return errors.Wrap(err, "create Obol API client", z.Str("publish_address", config.PublishAddress)) + } + + resp, err := oAPI.PostFeeRecipientsFetch(ctx, cl.LockHash, config.ValidatorPublicKeys) + if err != nil { + return errors.Wrap(err, "fetch builder registrations from Obol API") + } + + aggregatedRegs, cats, maxPartialSigs, err := processValidators(resp.Validators) + if err != nil { + return err + } + + logValidatorStatus(ctx, cats, maxPartialSigs) if len(aggregatedRegs) == 0 { log.Info(ctx, "No fully signed builder registrations available yet") diff --git a/cmd/feerecipientfetch_internal_test.go b/cmd/feerecipientfetch_internal_test.go index 84e4000f1..9eb93c9cf 100644 --- a/cmd/feerecipientfetch_internal_test.go +++ b/cmd/feerecipientfetch_internal_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "math/rand" + "net/http" "net/http/httptest" "os" "path/filepath" @@ -104,6 +105,130 @@ func TestFeeRecipientFetchValid(t *testing.T) { require.NotEmpty(t, data) } +func TestFeeRecipientFetchInvalidLockFile(t *testing.T) { + config := feerecipientFetchConfig{ + feerecipientConfig: feerecipientConfig{ + LockFilePath: "nonexistent-lock.json", + PublishAddress: "http://localhost:0", + PublishTimeout: time.Second, + }, + } + + err := runFeeRecipientFetch(t.Context(), config) + require.ErrorContains(t, err, "no such file or directory") +} + +func TestFeeRecipientFetchAPIUnreachable(t *testing.T) { + ctx := t.Context() + + valAmt := 1 + operatorAmt := 4 + + random := rand.New(rand.NewSource(0)) + + lock, enrs, keyShares := cluster.NewForT(t, valAmt, operatorAmt, operatorAmt, 0, random) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + for opIdx := range operatorAmt { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + lockJSON, err := json.Marshal(lock) + require.NoError(t, err) + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, lockJSON) + + // Start and immediately close the server so the URL is unreachable. + srv := httptest.NewServer(http.NotFoundHandler()) + srv.Close() + + config := feerecipientFetchConfig{ + feerecipientConfig: feerecipientConfig{ + LockFilePath: filepath.Join(root, "op0", "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: time.Second, + }, + } + + err = runFeeRecipientFetch(ctx, config) + require.ErrorContains(t, err, "fetch builder registrations from Obol API") +} + +func TestFeeRecipientFetchNoQuorum(t *testing.T) { + ctx := t.Context() + ctx = log.WithCtx(ctx, z.Str("test_case", t.Name())) + + valAmt := 1 + operatorAmt := 4 + + random := rand.New(rand.NewSource(0)) + + lock, enrs, keyShares := cluster.NewForT(t, valAmt, operatorAmt, operatorAmt, 0, random) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + for opIdx := range operatorAmt { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + lockJSON, err := json.Marshal(lock) + require.NoError(t, err) + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, lockJSON) + + // dropOnePsig=true causes the mock to drop one partial, preventing quorum. + handler, addLockFiles := obolapimock.MockServer(true, nil) + + srv := httptest.NewServer(handler) + defer srv.Close() + + addLockFiles(lock) + + // Submit from only one operator (below threshold). + newFeeRecipient := "0x0000000000000000000000000000000000001234" + validatorPubkey := lock.Validators[0].PublicKeyHex() + baseDir := filepath.Join(root, "op0") + + signConfig := feerecipientSignConfig{ + feerecipientConfig: feerecipientConfig{ + ValidatorPublicKeys: []string{validatorPubkey}, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, + }, + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + FeeRecipient: newFeeRecipient, + } + + require.NoError(t, runFeeRecipientSign(ctx, signConfig)) + + // Fetch should succeed but produce no output file. + overridesFile := filepath.Join(root, "output", "builder_registrations_overrides.json") + + fetchConfig := feerecipientFetchConfig{ + feerecipientConfig: feerecipientConfig{ + LockFilePath: filepath.Join(root, "op0", "cluster-lock.json"), + OverridesFilePath: overridesFile, + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, + }, + } + + require.NoError(t, runFeeRecipientFetch(ctx, fetchConfig)) + + // No quorum means no output file should be written. + _, err = os.Stat(overridesFile) + require.True(t, os.IsNotExist(err), "overrides file should not exist when no quorum") +} + func TestFeeRecipientFetchCLI(t *testing.T) { tests := []struct { name string diff --git a/cmd/feerecipientsign.go b/cmd/feerecipientsign.go index 9d7eddf62..fda34403d 100644 --- a/cmd/feerecipientsign.go +++ b/cmd/feerecipientsign.go @@ -29,10 +29,9 @@ import ( ) // pubkeyToSign pairs a validator public key with the timestamp and gas limit to use when signing -// its registration. For validators with no existing partial registration (unknown status), the -// timestamp is set to time.Now() by the first operator and the gas limit is resolved from the -// config override or cluster lock. For validators already in partial status, the timestamp and -// gas limit are adopted from the existing partial registration so all operators sign the same message. +// its registration. For validators with no existing partial registration, the timestamp is set to time.Now() by the first operator. +// For validators already having partials, the timestamp and gas limit are adopted from the existing partial registration, +// so all operators sign the same message. type pubkeyToSign struct { Pubkey eth2p0.BLSPubKey Timestamp time.Time @@ -80,6 +79,73 @@ func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig return cmd } +// normalizePubkey converts a validator public key to lowercase and removes the 0x prefix. +func normalizePubkey(pubkey string) string { + return strings.ToLower(strings.TrimPrefix(pubkey, "0x")) +} + +// parsePubkey decodes a hex-encoded validator public key and validates its length. +func parsePubkey(pubkeyHex string) (eth2p0.BLSPubKey, error) { + normalizedKey := normalizePubkey(pubkeyHex) + + pubkeyBytes, err := hex.DecodeString(normalizedKey) + if err != nil { + return eth2p0.BLSPubKey{}, errors.Wrap(err, "decode pubkey", z.Str("validator_public_key", pubkeyHex)) + } + + if len(pubkeyBytes) != len(eth2p0.BLSPubKey{}) { + return eth2p0.BLSPubKey{}, errors.New("invalid pubkey length", z.Int("length", len(pubkeyBytes)), z.Str("validator_public_key", pubkeyHex)) + } + + return eth2p0.BLSPubKey(pubkeyBytes), nil +} + +// validatePubkeysInCluster verifies that all requested validator public keys exist in the cluster lock. +func validatePubkeysInCluster(pubkeys []string, cl cluster.Lock) error { + clusterPubkeys := make(map[string]struct{}, len(cl.Validators)) + for _, dv := range cl.Validators { + clusterPubkeys[strings.ToLower(dv.PublicKeyHex())] = struct{}{} + } + + for _, valPubKey := range pubkeys { + normalized := strings.ToLower(valPubKey) + if !strings.HasPrefix(normalized, "0x") { + normalized = "0x" + normalized + } + + if _, ok := clusterPubkeys[normalized]; !ok { + return errors.New("validator pubkey not found in cluster lock", z.Str("pubkey", valPubKey)) + } + } + + return nil +} + +// buildValidatorLookup creates a map of validators keyed by normalized public key. +func buildValidatorLookup(validators []obolapi.FeeRecipientValidator) map[string]obolapi.FeeRecipientValidator { + result := make(map[string]obolapi.FeeRecipientValidator, len(validators)) + for _, v := range validators { + normalizedKey := normalizePubkey(v.Pubkey) + result[normalizedKey] = v + } + + return result +} + +// findRegistrationGroups finds the quorum and matching incomplete registration groups for a validator. +func findRegistrationGroups(v *obolapi.FeeRecipientValidator, feeRecipient string) (quorum, matchingIncomplete *obolapi.FeeRecipientBuilderRegistration) { + for i := range v.BuilderRegistrations { + reg := &v.BuilderRegistrations[i] + if reg.Quorum && quorum == nil { + quorum = reg + } else if !reg.Quorum && matchingIncomplete == nil && strings.EqualFold(reg.Message.FeeRecipient.String(), feeRecipient) { + matchingIncomplete = reg + } + } + + return quorum, matchingIncomplete +} + func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) error { if _, err := eth2util.ChecksumAddress(config.FeeRecipient); err != nil { return errors.Wrap(err, "invalid fee recipient address", z.Str("fee_recipient", config.FeeRecipient)) @@ -105,21 +171,8 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err return errors.Wrap(err, "determine operator index from cluster lock for supplied identity key") } - // Validate all requested pubkeys exist in the cluster lock before making any API calls. - clusterPubkeys := make(map[string]struct{}, len(cl.Validators)) - for _, dv := range cl.Validators { - clusterPubkeys[strings.ToLower(dv.PublicKeyHex())] = struct{}{} - } - - for _, valPubKey := range config.ValidatorPublicKeys { - normalized := strings.ToLower(valPubKey) - if !strings.HasPrefix(normalized, "0x") { - normalized = "0x" + normalized - } - - if _, ok := clusterPubkeys[normalized]; !ok { - return errors.New("validator pubkey not found in cluster lock", z.Str("pubkey", valPubKey)) - } + if err := validatePubkeysInCluster(config.ValidatorPublicKeys, *cl); err != nil { + return err } rawValKeys, err := keystore.LoadFilesUnordered(config.ValidatorKeysDir) @@ -142,7 +195,6 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err return err } - // Filter pubkeys based on their current status on the remote API. pubkeysToSign, err := filterPubkeysByStatus(ctx, oAPI, cl.LockHash, config.ValidatorPublicKeys, config.FeeRecipient, config.GasLimit, *cl, overrides, time.Now) if err != nil { return err @@ -153,7 +205,6 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err return nil } - // Build and sign partial registrations. partialRegs, err := buildPartialRegistrations(config.FeeRecipient, pubkeysToSign, *cl, shares) if err != nil { return err @@ -203,42 +254,26 @@ func filterPubkeysByStatus( return nil, errors.Wrap(err, "fetch builder registration status from Obol API") } - validatorByPubkey := make(map[string]obolapi.FeeRecipientValidator) - - for _, v := range resp.Validators { - normalizedKey := strings.ToLower(strings.TrimPrefix(v.Pubkey, "0x")) - validatorByPubkey[normalizedKey] = v - } + validatorByPubkey := buildValidatorLookup(resp.Validators) var pubkeysToSign []pubkeyToSign for _, valPubKey := range requestedPubkeys { - normalizedKey := strings.ToLower(strings.TrimPrefix(valPubKey, "0x")) + normalizedKey := normalizePubkey(valPubKey) v, ok := validatorByPubkey[normalizedKey] - var ( - timestamp time.Time - gasLimit uint64 - ) + // Default: anchor new timestamp and resolve gas limit. + // These will be overridden if there's a matching incomplete registration. + timestamp := now() + gasLimit := resolveGasLimit(gasLimitOverride, cl, overrides, normalizedKey) if ok { - var quorumGroup *obolapi.FeeRecipientBuilderRegistration - - // Find the first incomplete group whose fee matches the requested one. - // Stale incompletes (different fee) are ignored — they may linger on the - // API after quorum was reached for a previous fee and must not block new + // Find the first incomplete group whose fee recipient matches the requested one. + // Stale incompletes (different fee recipient) are ignored — they may linger on the + // API after quorum was reached for a previous fee recipient and must not block new // fee recipient changes. - var matchingIncomplete *obolapi.FeeRecipientBuilderRegistration - - for i := range v.BuilderRegistrations { - reg := &v.BuilderRegistrations[i] - if reg.Quorum && quorumGroup == nil { - quorumGroup = reg - } else if !reg.Quorum && matchingIncomplete == nil && strings.EqualFold(reg.Message.FeeRecipient.String(), feeRecipient) { - matchingIncomplete = reg - } - } + quorumGroup, matchingIncomplete := findRegistrationGroups(&v, feeRecipient) if quorumGroup != nil && strings.EqualFold(quorumGroup.Message.FeeRecipient.String(), feeRecipient) { log.Info(ctx, "Validator already has a complete builder registration, skipping", @@ -258,7 +293,7 @@ func filterPubkeysByStatus( z.Str("fee_recipient", matchingIncomplete.Message.FeeRecipient.String()), z.Int("partial_count", len(matchingIncomplete.PartialSignatures))) } else if quorumGroup == nil { - // Check if there's ANY incomplete group (with a different fee) and no quorum yet. + // Check if there's any incomplete group (with a different fee recipient) and no quorum yet. // This means another operator started a fee change that hasn't completed — block. for _, reg := range v.BuilderRegistrations { if !reg.Quorum { @@ -269,32 +304,19 @@ func filterPubkeysByStatus( ) } } - - // No in-progress group and no quorum: anchor the timestamp and resolve gas limit now. - timestamp = now() - gasLimit = resolveGasLimit(gasLimitOverride, cl, overrides, normalizedKey) - } else { - // Quorum exists with different fee, no matching incomplete: start fresh. - timestamp = now() - gasLimit = resolveGasLimit(gasLimitOverride, cl, overrides, normalizedKey) + // No in-progress group and no quorum: use defaults set above. } - } else { - // Unknown validator: first signer anchors the timestamp and resolves gas limit. - timestamp = now() - gasLimit = resolveGasLimit(gasLimitOverride, cl, overrides, normalizedKey) + // else: Quorum exists with different fee, no matching incomplete: use defaults set above. } + // else: Unknown validator: use defaults set above. - pubkeyBytes, err := hex.DecodeString(normalizedKey) + pubkey, err := parsePubkey(valPubKey) if err != nil { - return nil, errors.Wrap(err, "decode pubkey", z.Str("validator_public_key", valPubKey)) - } - - if len(pubkeyBytes) != len(eth2p0.BLSPubKey{}) { - return nil, errors.New("invalid pubkey length", z.Int("length", len(pubkeyBytes)), z.Str("validator_public_key", valPubKey)) + return nil, err } pubkeysToSign = append(pubkeysToSign, pubkeyToSign{ - Pubkey: eth2p0.BLSPubKey(pubkeyBytes), + Pubkey: pubkey, Timestamp: timestamp, GasLimit: gasLimit, }) diff --git a/cmd/feerecipientsign_internal_test.go b/cmd/feerecipientsign_internal_test.go index 66d97fd77..465e49402 100644 --- a/cmd/feerecipientsign_internal_test.go +++ b/cmd/feerecipientsign_internal_test.go @@ -6,8 +6,10 @@ import ( "encoding/json" "fmt" "math/rand" + "net/http" "net/http/httptest" "path/filepath" + "strings" "testing" "time" @@ -79,6 +81,131 @@ func TestFeeRecipientSignValid(t *testing.T) { require.NoError(t, runFeeRecipientSign(ctx, signConfig), "operator index submit feerecipient sign: %v", idx) } +func TestFeeRecipientSignInvalidFeeRecipient(t *testing.T) { + config := feerecipientSignConfig{ + feerecipientConfig: feerecipientConfig{ + PrivateKeyPath: "nonexistent", + LockFilePath: "nonexistent", + PublishAddress: "http://localhost:0", + PublishTimeout: time.Second, + }, + FeeRecipient: "not-an-address", + } + + err := runFeeRecipientSign(t.Context(), config) + require.ErrorContains(t, err, "invalid fee recipient address") +} + +func TestFeeRecipientSignInvalidLockFile(t *testing.T) { + config := feerecipientSignConfig{ + feerecipientConfig: feerecipientConfig{ + PrivateKeyPath: "nonexistent", + LockFilePath: "nonexistent-lock.json", + PublishAddress: "http://localhost:0", + PublishTimeout: time.Second, + }, + FeeRecipient: "0x0000000000000000000000000000000000001234", + } + + err := runFeeRecipientSign(t.Context(), config) + require.ErrorContains(t, err, "read private key from disk") +} + +func TestFeeRecipientSignAPIUnreachable(t *testing.T) { + ctx := t.Context() + + valAmt := 1 + operatorAmt := 4 + + random := rand.New(rand.NewSource(0)) + + lock, enrs, keyShares := cluster.NewForT(t, valAmt, operatorAmt, operatorAmt, 0, random) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + for opIdx := range operatorAmt { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + lockJSON, err := json.Marshal(lock) + require.NoError(t, err) + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, lockJSON) + + // Start and immediately close the server so the URL is unreachable. + srv := httptest.NewServer(http.NotFoundHandler()) + srv.Close() + + baseDir := filepath.Join(root, "op0") + + config := feerecipientSignConfig{ + feerecipientConfig: feerecipientConfig{ + ValidatorPublicKeys: []string{lock.Validators[0].PublicKeyHex()}, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: time.Second, + }, + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + FeeRecipient: "0x0000000000000000000000000000000000001234", + } + + err = runFeeRecipientSign(ctx, config) + require.ErrorContains(t, err, "fetch builder registration status from Obol API") +} + +func TestFeeRecipientSignPubkeyNotInCluster(t *testing.T) { + ctx := t.Context() + + valAmt := 1 + operatorAmt := 4 + + random := rand.New(rand.NewSource(0)) + + lock, enrs, keyShares := cluster.NewForT(t, valAmt, operatorAmt, operatorAmt, 0, random) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + for opIdx := range operatorAmt { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + lockJSON, err := json.Marshal(lock) + require.NoError(t, err) + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, lockJSON) + + handler, addLockFiles := obolapimock.MockServer(false, nil) + + srv := httptest.NewServer(handler) + defer srv.Close() + + addLockFiles(lock) + + baseDir := filepath.Join(root, "op0") + + config := feerecipientSignConfig{ + feerecipientConfig: feerecipientConfig{ + ValidatorPublicKeys: []string{"0x" + strings.Repeat("ab", 48)}, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, + }, + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + FeeRecipient: "0x0000000000000000000000000000000000001234", + } + + err = runFeeRecipientSign(ctx, config) + require.ErrorContains(t, err, "validator pubkey not found in cluster lock") +} + func TestFeeRecipientSignCLI(t *testing.T) { tests := []struct { name string From 9f1f938c6c72c97e19ec63037872fa7fc4ac86c4 Mon Sep 17 00:00:00 2001 From: Andrei Smirnov Date: Fri, 13 Mar 2026 16:21:19 +0300 Subject: [PATCH 14/16] improved ux --- cmd/feerecipientfetch.go | 103 +++++++++++++++++++------- cmd/feerecipientsign.go | 50 ++++++++++++- cmd/feerecipientsign_internal_test.go | 71 ++++++++++++++++++ 3 files changed, 195 insertions(+), 29 deletions(-) diff --git a/cmd/feerecipientfetch.go b/cmd/feerecipientfetch.go index 2f9d28ba3..6fc946dbf 100644 --- a/cmd/feerecipientfetch.go +++ b/cmd/feerecipientfetch.go @@ -71,15 +71,25 @@ func aggregatePartialSignatures(partialSigs []obolapi.FeeRecipientPartialSig, pu return eth2p0.BLSSignature(fullSig), nil } +// processedValidators holds the results of processing the API response. +type processedValidators struct { + AggregatedRegs []*eth2api.VersionedSignedValidatorRegistration + Categories validatorCategories + PartialSigIndices map[string][]int + // QuorumMessages maps validator pubkey to the quorum registration message details. + QuorumMessages map[string]*eth2v1.ValidatorRegistration + // IncompleteMessages maps validator pubkey to the incomplete registration message + // with the most partial signatures. + IncompleteMessages map[string]*eth2v1.ValidatorRegistration +} + // processValidators aggregates signatures for validators with quorum and categorizes all validators by status. -func processValidators(validators []obolapi.FeeRecipientValidator) ([]*eth2api.VersionedSignedValidatorRegistration, validatorCategories, map[string]int, error) { - var ( - aggregatedRegs []*eth2api.VersionedSignedValidatorRegistration - cats validatorCategories - // maxPartialSigs tracks the highest partial signature count across - // all incomplete registrations for a given validator pubkey. - maxPartialSigs = make(map[string]int) - ) +func processValidators(validators []obolapi.FeeRecipientValidator) (processedValidators, error) { + result := processedValidators{ + PartialSigIndices: make(map[string][]int), + QuorumMessages: make(map[string]*eth2v1.ValidatorRegistration), + IncompleteMessages: make(map[string]*eth2v1.ValidatorRegistration), + } for _, val := range validators { var hasQuorum, hasIncomplete bool @@ -90,45 +100,67 @@ func processValidators(validators []obolapi.FeeRecipientValidator) ([]*eth2api.V fullSig, err := aggregatePartialSignatures(reg.PartialSignatures, val.Pubkey) if err != nil { - return nil, validatorCategories{}, nil, err + return processedValidators{}, err } - aggregatedRegs = append(aggregatedRegs, ð2api.VersionedSignedValidatorRegistration{ + result.AggregatedRegs = append(result.AggregatedRegs, ð2api.VersionedSignedValidatorRegistration{ Version: eth2spec.BuilderVersionV1, V1: ð2v1.SignedValidatorRegistration{ Message: reg.Message, Signature: fullSig, }, }) + + result.QuorumMessages[val.Pubkey] = reg.Message } else { hasIncomplete = true - if n := len(reg.PartialSignatures); n > maxPartialSigs[val.Pubkey] { - maxPartialSigs[val.Pubkey] = n + if len(reg.PartialSignatures) > len(result.PartialSigIndices[val.Pubkey]) { + indices := make([]int, 0, len(reg.PartialSignatures)) + for _, ps := range reg.PartialSignatures { + indices = append(indices, ps.ShareIndex) + } + + result.PartialSigIndices[val.Pubkey] = indices + result.IncompleteMessages[val.Pubkey] = reg.Message } } } - switch { - case hasQuorum: - cats.Complete = append(cats.Complete, val.Pubkey) - case hasIncomplete: - cats.Incomplete = append(cats.Incomplete, val.Pubkey) - default: - cats.NoReg = append(cats.NoReg, val.Pubkey) + if hasQuorum { + result.Categories.Complete = append(result.Categories.Complete, val.Pubkey) + } + + if hasIncomplete { + result.Categories.Incomplete = append(result.Categories.Incomplete, val.Pubkey) + } + + if !hasQuorum && !hasIncomplete { + result.Categories.NoReg = append(result.Categories.NoReg, val.Pubkey) } } - return aggregatedRegs, cats, maxPartialSigs, nil + return result, nil } // logValidatorStatus logs categorized validators with their current registration status. -func logValidatorStatus(ctx context.Context, cats validatorCategories, maxPartialSigs map[string]int) { +func logValidatorStatus(ctx context.Context, pv processedValidators) { + cats := pv.Categories + if len(cats.Complete) > 0 { log.Info(ctx, "Validators with complete builder registrations", z.Int("count", len(cats.Complete))) for _, pubkey := range cats.Complete { - log.Info(ctx, " Complete", z.Str("pubkey", pubkey)) + if msg := pv.QuorumMessages[pubkey]; msg != nil { + log.Info(ctx, " Complete", + z.Str("pubkey", pubkey), + z.Str("fee_recipient", msg.FeeRecipient.String()), + z.U64("gas_limit", msg.GasLimit), + z.I64("timestamp", msg.Timestamp.Unix()), + ) + } else { + log.Info(ctx, " Complete", z.Str("pubkey", pubkey)) + } } } @@ -136,7 +168,22 @@ func logValidatorStatus(ctx context.Context, cats validatorCategories, maxPartia log.Info(ctx, "Validators with partial builder registrations", z.Int("count", len(cats.Incomplete))) for _, pubkey := range cats.Incomplete { - log.Info(ctx, " Incomplete", z.Str("pubkey", pubkey), z.Int("partial_signatures", maxPartialSigs[pubkey])) + indices := pv.PartialSigIndices[pubkey] + fields := []z.Field{ + z.Str("pubkey", pubkey), + z.Int("partial_signatures", len(indices)), + z.Any("submitted_indices", indices), + } + + if msg := pv.IncompleteMessages[pubkey]; msg != nil { + fields = append(fields, + z.Str("fee_recipient", msg.FeeRecipient.String()), + z.U64("gas_limit", msg.GasLimit), + z.I64("timestamp", msg.Timestamp.Unix()), + ) + } + + log.Info(ctx, " Incomplete", fields...) } } @@ -165,26 +212,26 @@ func runFeeRecipientFetch(ctx context.Context, config feerecipientFetchConfig) e return errors.Wrap(err, "fetch builder registrations from Obol API") } - aggregatedRegs, cats, maxPartialSigs, err := processValidators(resp.Validators) + pv, err := processValidators(resp.Validators) if err != nil { return err } - logValidatorStatus(ctx, cats, maxPartialSigs) + logValidatorStatus(ctx, pv) - if len(aggregatedRegs) == 0 { + if len(pv.AggregatedRegs) == 0 { log.Info(ctx, "No fully signed builder registrations available yet") return nil } - err = writeSignedValidatorRegistrations(config.OverridesFilePath, aggregatedRegs) + err = writeSignedValidatorRegistrations(config.OverridesFilePath, pv.AggregatedRegs) if err != nil { return errors.Wrap(err, "write builder registrations overrides", z.Str("path", config.OverridesFilePath)) } log.Info(ctx, "Successfully wrote builder registrations overrides", - z.Int("count", len(aggregatedRegs)), + z.Int("count", len(pv.AggregatedRegs)), z.Str("path", config.OverridesFilePath), ) diff --git a/cmd/feerecipientsign.go b/cmd/feerecipientsign.go index fda34403d..917096eac 100644 --- a/cmd/feerecipientsign.go +++ b/cmd/feerecipientsign.go @@ -44,6 +44,7 @@ type feerecipientSignConfig struct { ValidatorKeysDir string FeeRecipient string GasLimit uint64 + Timestamp int64 } func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig) error) *cobra.Command { @@ -68,6 +69,7 @@ func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig cmd.Flags().StringSliceVar(&config.ValidatorPublicKeys, "validator-public-keys", nil, "[REQUIRED] Comma-separated list of validator public keys to sign builder registrations for.") cmd.Flags().StringVar(&config.FeeRecipient, "fee-recipient", "", "[REQUIRED] New fee recipient address to be applied to all specified validators.") cmd.Flags().Uint64Var(&config.GasLimit, "gas-limit", 0, "Optional gas limit override for builder registrations. If not set, the existing gas limit from the cluster lock or overrides file is used.") + cmd.Flags().Int64Var(&config.Timestamp, "timestamp", 0, "Optional Unix timestamp for the builder registration message. When set, all operators can sign independently with the same timestamp. If not set, the current time is used for new registrations.") wrapPreRunE(cmd, func(cmd *cobra.Command, _ []string) error { mustMarkFlagRequired(cmd, "validator-public-keys") @@ -195,7 +197,17 @@ func runFeeRecipientSign(ctx context.Context, config feerecipientSignConfig) err return err } - pubkeysToSign, err := filterPubkeysByStatus(ctx, oAPI, cl.LockHash, config.ValidatorPublicKeys, config.FeeRecipient, config.GasLimit, *cl, overrides, time.Now) + nowFunc := time.Now + + if config.Timestamp != 0 { + if err := validateTimestamp(config.Timestamp, config.ValidatorPublicKeys, *cl, overrides); err != nil { + return err + } + + nowFunc = func() time.Time { return time.Unix(config.Timestamp, 0) } + } + + pubkeysToSign, err := filterPubkeysByStatus(ctx, oAPI, cl.LockHash, config.ValidatorPublicKeys, config.FeeRecipient, config.GasLimit, *cl, overrides, nowFunc) if err != nil { return err } @@ -325,6 +337,42 @@ func filterPubkeysByStatus( return pubkeysToSign, nil } +// validateTimestamp checks that the provided timestamp is strictly greater than any existing +// registration timestamp (from the cluster lock or overrides file) for the requested validators. +func validateTimestamp(timestamp int64, pubkeys []string, cl cluster.Lock, overrides map[string]eth2v1.ValidatorRegistration) error { + ts := time.Unix(timestamp, 0) + + for _, pubkey := range pubkeys { + normalized := normalizePubkey(pubkey) + + for _, dv := range cl.Validators { + if strings.EqualFold(dv.PublicKeyHex(), "0x"+normalized) { + if !ts.After(dv.BuilderRegistration.Message.Timestamp) { + return errors.New("timestamp must be greater than existing registration timestamp", + z.Str("pubkey", pubkey), + z.I64("provided_timestamp", timestamp), + z.I64("existing_timestamp", dv.BuilderRegistration.Message.Timestamp.Unix()), + ) + } + + break + } + } + + if override, ok := overrides[normalized]; ok { + if !ts.After(override.Timestamp) { + return errors.New("timestamp must be greater than existing overrides registration timestamp", + z.Str("pubkey", pubkey), + z.I64("provided_timestamp", timestamp), + z.I64("existing_timestamp", override.Timestamp.Unix()), + ) + } + } + } + + return nil +} + // resolveGasLimit returns gasLimitOverride if non-zero. Otherwise it picks the gas limit from // whichever source (cluster lock or overrides file) has the higher timestamp for the given // validator pubkey. This ensures the most recent registration's gas limit is used. diff --git a/cmd/feerecipientsign_internal_test.go b/cmd/feerecipientsign_internal_test.go index 465e49402..85f221c14 100644 --- a/cmd/feerecipientsign_internal_test.go +++ b/cmd/feerecipientsign_internal_test.go @@ -81,6 +81,63 @@ func TestFeeRecipientSignValid(t *testing.T) { require.NoError(t, runFeeRecipientSign(ctx, signConfig), "operator index submit feerecipient sign: %v", idx) } +func TestFeeRecipientSignWithTimestamp(t *testing.T) { + ctx := t.Context() + ctx = log.WithCtx(ctx, z.Str("test_case", t.Name())) + + valAmt := 1 + operatorAmt := 4 + + random := rand.New(rand.NewSource(0)) + + lock, enrs, keyShares := cluster.NewForT(t, valAmt, operatorAmt, operatorAmt, 0, random) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + for opIdx := range operatorAmt { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + lockJSON, err := json.Marshal(lock) + require.NoError(t, err) + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, lockJSON) + + handler, addLockFiles := obolapimock.MockServer(false, nil) + + srv := httptest.NewServer(handler) + defer srv.Close() + + addLockFiles(lock) + + // All operators sign independently with the same fixed timestamp. + newFeeRecipient := "0x0000000000000000000000000000000000001234" + validatorPubkey := lock.Validators[0].PublicKeyHex() + fixedTimestamp := int64(1700000000) + + for opIdx := range lock.Threshold { + baseDir := filepath.Join(root, fmt.Sprintf("op%d", opIdx)) + + signConfig := feerecipientSignConfig{ + feerecipientConfig: feerecipientConfig{ + ValidatorPublicKeys: []string{validatorPubkey}, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, + }, + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + FeeRecipient: newFeeRecipient, + Timestamp: fixedTimestamp, + } + + require.NoError(t, runFeeRecipientSign(ctx, signConfig), "operator %d sign with timestamp", opIdx) + } +} + func TestFeeRecipientSignInvalidFeeRecipient(t *testing.T) { config := feerecipientSignConfig{ feerecipientConfig: feerecipientConfig{ @@ -225,6 +282,20 @@ func TestFeeRecipientSignCLI(t *testing.T) { "--publish-timeout=1ms", }, }, + { + name: "correct flags with timestamp", + expectedErr: "load identity key: read private key from disk: open test: no such file or directory", + flags: []string{ + "--validator-public-keys=test", + "--fee-recipient=0x0000000000000000000000000000000000001234", + "--private-key-file=test", + "--validator-keys-dir=test", + "--lock-file=test", + "--publish-address=test", + "--publish-timeout=1ms", + "--timestamp=1700000000", + }, + }, { name: "missing validator public keys", expectedErr: "required flag(s) \"validator-public-keys\" not set", From 936889c00bd0078d3ff9996b76d3f39b23d898c2 Mon Sep 17 00:00:00 2001 From: Andrei Smirnov Date: Mon, 16 Mar 2026 12:52:15 +0300 Subject: [PATCH 15/16] Update cmd/feerecipientsign.go Co-authored-by: kalo <24719519+KaloyanTanev@users.noreply.github.com> Signed-off-by: Andrei Smirnov --- cmd/feerecipientsign.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/feerecipientsign.go b/cmd/feerecipientsign.go index 917096eac..5301f84fd 100644 --- a/cmd/feerecipientsign.go +++ b/cmd/feerecipientsign.go @@ -69,7 +69,7 @@ func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig cmd.Flags().StringSliceVar(&config.ValidatorPublicKeys, "validator-public-keys", nil, "[REQUIRED] Comma-separated list of validator public keys to sign builder registrations for.") cmd.Flags().StringVar(&config.FeeRecipient, "fee-recipient", "", "[REQUIRED] New fee recipient address to be applied to all specified validators.") cmd.Flags().Uint64Var(&config.GasLimit, "gas-limit", 0, "Optional gas limit override for builder registrations. If not set, the existing gas limit from the cluster lock or overrides file is used.") - cmd.Flags().Int64Var(&config.Timestamp, "timestamp", 0, "Optional Unix timestamp for the builder registration message. When set, all operators can sign independently with the same timestamp. If not set, the current time is used for new registrations.") + cmd.Flags().Int64Var(&config.Timestamp, "timestamp", 0, "Optional Unix timestamp for the builder registration message. When set, all operators can sign independently with the same timestamp. If not set, either the current time is used for new registrations or if another peer already submitted partial signature to the API, its timestamp is used.") wrapPreRunE(cmd, func(cmd *cobra.Command, _ []string) error { mustMarkFlagRequired(cmd, "validator-public-keys") From 81e38d222144cf53cf44758c3891bacc609f7e48 Mon Sep 17 00:00:00 2001 From: Andrei Smirnov Date: Mon, 16 Mar 2026 17:36:44 +0300 Subject: [PATCH 16/16] Fixed copy --- cmd/feerecipient.go | 4 ++-- cmd/feerecipientfetch.go | 4 ++-- cmd/feerecipientsign.go | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/feerecipient.go b/cmd/feerecipient.go index 3624dfa29..81c18e605 100644 --- a/cmd/feerecipient.go +++ b/cmd/feerecipient.go @@ -23,8 +23,8 @@ type feerecipientConfig struct { func newFeeRecipientCmd(cmds ...*cobra.Command) *cobra.Command { root := &cobra.Command{ Use: "feerecipient", - Short: "Sign and fetch updated builder registrations.", - Long: "Sign and fetch updated builder registration messages with new fee recipients using a remote API, enabling the modification of fee recipient addresses without cluster restart.", + Short: "Manage the preferred fee recipient addresses for the cluster.", + Long: "Manage the preferred fee recipient addresses for the cluster. These addresses receive transaction tips and MEV when a validator makes a proposal.", } root.AddCommand(cmds...) diff --git a/cmd/feerecipientfetch.go b/cmd/feerecipientfetch.go index 6fc946dbf..8a2e9704c 100644 --- a/cmd/feerecipientfetch.go +++ b/cmd/feerecipientfetch.go @@ -31,8 +31,8 @@ func newFeeRecipientFetchCmd(runFunc func(context.Context, feerecipientFetchConf cmd := &cobra.Command{ Use: "fetch", - Short: "Fetch aggregated builder registrations.", - Long: "Fetches builder registration messages from a remote API and aggregates those with quorum, writing fully signed registrations to a local JSON file.", + Short: "Fetch new fee recipients (builder registrations).", + Long: "Fetches builder registration messages from a remote API and aggregates those with quorum, writing them to a local JSON file.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return runFunc(cmd.Context(), config) diff --git a/cmd/feerecipientsign.go b/cmd/feerecipientsign.go index 5301f84fd..06bf1458a 100644 --- a/cmd/feerecipientsign.go +++ b/cmd/feerecipientsign.go @@ -52,8 +52,8 @@ func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig cmd := &cobra.Command{ Use: "sign", - Short: "Sign partial builder registration messages.", - Long: "Signs new partial builder registration messages with updated fee recipients and publishes them to a remote API.", + Short: "Sign new builder registration messages.", + Long: "Signs new builder registration messages to update the preferred fee recipient and publishes them to a remote API.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return runFunc(cmd.Context(), config)