diff --git a/app/obolapi/feerecipient.go b/app/obolapi/feerecipient.go new file mode 100644 index 000000000..3f6cc4729 --- /dev/null +++ b/app/obolapi/feerecipient.go @@ -0,0 +1,136 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +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. +func submitPartialFeeRecipientURL(lockHash string, shareIndex uint64) string { + return strings.NewReplacer( + lockHashPath, + lockHash, + shareIndexPath, + strconv.FormatUint(shareIndex, 10), + ).Replace(submitPartialFeeRecipientTmpl) +} + +// fetchFeeRecipientURL returns the fee recipient Obol API URL for a given lock hash. +func fetchFeeRecipientURL(lockHash string) string { + return strings.NewReplacer( + lockHashPath, + lockHash, + ).Replace(fetchFeeRecipientTmpl) +} + +// 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) + + u, err := url.ParseRequestURI(c.baseURL) + if err != nil { + return errors.Wrap(err, "bad Obol API url") + } + + u.Path = submitPartialFeeRecipientURL(lockHashStr, shareIndex) + + 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 +} + +// 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) { + u, err := url.ParseRequestURI(c.baseURL) + if err != nil { + return FeeRecipientFetchResponse{}, errors.Wrap(err, "bad Obol API url") + } + + u.Path = fetchFeeRecipientURL("0x" + hex.EncodeToString(lockHash)) + + 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() + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(data)) + if err != nil { + return FeeRecipientFetchResponse{}, errors.Wrap(err, "create POST request") + } + + 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(httpResp.Body).Decode(&resp); err != nil { + return FeeRecipientFetchResponse{}, errors.Wrap(err, "unmarshal response") + } + + return resp, nil +} diff --git a/app/obolapi/feerecipient_model.go b/app/obolapi/feerecipient_model.go new file mode 100644 index 000000000..4be8d5d35 --- /dev/null +++ b/app/obolapi/feerecipient_model.go @@ -0,0 +1,121 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package obolapi + +import ( + "encoding/json" + "fmt" + + 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. +// 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 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. +type PartialFeeRecipientRequest struct { + PartialRegistrations []PartialRegistration `json:"partial_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"` +} + +// 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 +} + +// 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"` +} + +// FeeRecipientValidator is the per-validator entry in the fetch response. +type FeeRecipientValidator struct { + Pubkey string `json:"pubkey"` + BuilderRegistrations []FeeRecipientBuilderRegistration `json:"builder_registrations"` +} + +// FeeRecipientFetchResponse is the response for the fee recipient fetch endpoint. +type FeeRecipientFetchResponse struct { + Validators []FeeRecipientValidator `json:"validators"` +} 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..3624dfa29 --- /dev/null +++ b/cmd/feerecipient.go @@ -0,0 +1,38 @@ +// 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 + OverridesFilePath 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 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.", + } + + root.AddCommand(cmds...) + + return root +} + +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 new file mode 100644 index 000000000..6fc946dbf --- /dev/null +++ b/cmd/feerecipientfetch.go @@ -0,0 +1,257 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "encoding/json" + "os" + "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" + "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/tbls" +) + +type feerecipientFetchConfig struct { + feerecipientConfig +} + +func newFeeRecipientFetchCmd(runFunc func(context.Context, feerecipientFetchConfig) error) *cobra.Command { + var config feerecipientFetchConfig + + 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.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runFunc(cmd.Context(), config) + }, + } + + 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.") + + bindFeeRecipientRemoteAPIFlags(cmd, &config.feerecipientConfig) + + return cmd +} + +// validatorCategories holds categorized validator public keys by registration status. +type validatorCategories struct { + Complete []string + Incomplete []string + NoReg []string +} + +// 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 + } + + fullSig, err := tbls.ThresholdAggregate(sigsMap) + if err != nil { + return eth2p0.BLSSignature{}, errors.Wrap(err, "aggregate partial signatures", z.Str("pubkey", pubkey)) + } + + 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) (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 + + for _, reg := range val.BuilderRegistrations { + if reg.Quorum { + hasQuorum = true + + fullSig, err := aggregatePartialSignatures(reg.PartialSignatures, val.Pubkey) + if err != nil { + return processedValidators{}, err + } + + 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 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 + } + } + } + + 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 result, nil +} + +// logValidatorStatus logs categorized validators with their current registration status. +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 { + 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)) + } + } + } + + if len(cats.Incomplete) > 0 { + log.Info(ctx, "Validators with partial builder registrations", z.Int("count", len(cats.Incomplete))) + + for _, pubkey := range cats.Incomplete { + 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...) + } + } + + if len(cats.NoReg) > 0 { + log.Info(ctx, "Validators unknown to the API", z.Int("count", len(cats.NoReg))) + + 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") + } + + pv, err := processValidators(resp.Validators) + if err != nil { + return err + } + + logValidatorStatus(ctx, pv) + + if len(pv.AggregatedRegs) == 0 { + log.Info(ctx, "No fully signed builder registrations available yet") + + return nil + } + + 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(pv.AggregatedRegs)), + z.Str("path", config.OverridesFilePath), + ) + + return nil +} + +func writeSignedValidatorRegistrations(filename string, regs []*eth2api.VersionedSignedValidatorRegistration) error { + data, err := json.MarshalIndent(regs, "", " ") + if err != nil { + 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") + } + + return nil +} diff --git a/cmd/feerecipientfetch_internal_test.go b/cmd/feerecipientfetch_internal_test.go new file mode 100644 index 000000000..9eb93c9cf --- /dev/null +++ b/cmd/feerecipientfetch_internal_test.go @@ -0,0 +1,264 @@ +// 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" + "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"), + 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), "operator %d submit feerecipient sign", opIdx) + } + + // Now fetch the aggregated registrations. + 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"), + OverridesFilePath: overridesFile, + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, + }, + } + + require.NoError(t, runFeeRecipientFetch(ctx, fetchConfig)) + + // Verify output file exists and contains registrations. + data, err := os.ReadFile(overridesFile) + require.NoError(t, err) + 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 + 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", + "--overrides-file=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..917096eac --- /dev/null +++ b/cmd/feerecipientsign.go @@ -0,0 +1,489 @@ +// 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" + "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" + + "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" +) + +// 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, 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 + GasLimit uint64 +} + +type feerecipientSignConfig struct { + feerecipientConfig + + ValidatorKeysDir string + FeeRecipient string + GasLimit uint64 + Timestamp int64 +} + +func newFeeRecipientSignCmd(runFunc func(context.Context, feerecipientSignConfig) error) *cobra.Command { + var config 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.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runFunc(cmd.Context(), config) + }, + } + + 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 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") + mustMarkFlagRequired(cmd, "fee-recipient") + + return nil + }) + + 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)) + } + + 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") + } + + if err := validatePubkeysInCluster(config.ValidatorPublicKeys, *cl); err != nil { + return err + } + + 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") + } + + overrides, err := loadOverridesRegistrations(config.OverridesFilePath) + if err != nil { + return err + } + + 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 + } + + if len(pubkeysToSign) == 0 { + log.Info(ctx, "No validators require signing") + return nil + } + + partialRegs, err := buildPartialRegistrations(config.FeeRecipient, pubkeysToSign, *cl, shares) + if err != nil { + return err + } + + 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.U64("gas_limit", reg.Message.GasLimit), + 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 +} + +// 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 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) + if err != nil { + return nil, errors.Wrap(err, "fetch builder registration status from Obol API") + } + + validatorByPubkey := buildValidatorLookup(resp.Validators) + + var pubkeysToSign []pubkeyToSign + + for _, valPubKey := range requestedPubkeys { + normalizedKey := normalizePubkey(valPubKey) + + v, ok := validatorByPubkey[normalizedKey] + + // 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 { + // 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. + 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", + z.Str("pubkey", valPubKey), + z.Str("fee_recipient", quorumGroup.Message.FeeRecipient.String())) + + continue + } + + if matchingIncomplete != nil { + // Adopt the timestamp and gas limit from the in-progress group so all operators sign the same message. + 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", 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 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 { + 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: use defaults set above. + } + // else: Quorum exists with different fee, no matching incomplete: use defaults set above. + } + // else: Unknown validator: use defaults set above. + + pubkey, err := parsePubkey(valPubKey) + if err != nil { + return nil, err + } + + pubkeysToSign = append(pubkeysToSign, pubkeyToSign{ + Pubkey: pubkey, + Timestamp: timestamp, + GasLimit: gasLimit, + }) + } + + 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. +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. +func buildPartialRegistrations( + feeRecipientHex string, + pubkeys []pubkeyToSign, + 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) + + partialRegs := make([]obolapi.PartialRegistration, 0, len(pubkeys)) + + for _, p := range pubkeys { + regMsg := ð2v1.ValidatorRegistration{ + FeeRecipient: feeRecipient, + GasLimit: p.GasLimit, + Timestamp: p.Timestamp, + Pubkey: p.Pubkey, + } + + sigRoot, err := registration.GetMessageSigningRoot(regMsg, eth2p0.Version(cl.ForkVersion)) + if err != nil { + return nil, errors.Wrap(err, "get signing root for registration message") + } + + 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(p.Pubkey[:]))) + } + + sig, err := tbls.Sign(secretShare.Share, sigRoot[:]) + if err != nil { + return nil, errors.Wrap(err, "sign registration message") + } + + partialRegs = append(partialRegs, obolapi.PartialRegistration{ + Message: regMsg, + Signature: sig, + }) + } + + return partialRegs, nil +} diff --git a/cmd/feerecipientsign_internal_test.go b/cmd/feerecipientsign_internal_test.go new file mode 100644 index 000000000..85f221c14 --- /dev/null +++ b/cmd/feerecipientsign_internal_test.go @@ -0,0 +1,339 @@ +// 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" + "net/http/httptest" + "path/filepath" + "strings" + "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)) + + signConfig := 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: 10 * time.Second, + }, + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + FeeRecipient: "0x0000000000000000000000000000000000001234", + } + + 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{ + 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 + 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: "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", + 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/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 { diff --git a/testutil/obolapimock/feerecipient.go b/testutil/obolapimock/feerecipient.go new file mode 100644 index 000000000..a0df985fa --- /dev/null +++ b/testutil/obolapimock/feerecipient.go @@ -0,0 +1,287 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package obolapimock + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + 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" +) + +const ( + submitPartialFeeRecipientTmpl = "/fee_recipient/partial/" + lockHashPath + "/" + shareIndexPath + fetchFeeRecipientTmpl = "/fee_recipient/" + lockHashPath +) + +// feeRecipientPartial represents a single partial builder registration. +type feeRecipientPartial struct { + ShareIdx int + Message *eth2v1.ValidatorRegistration + Signature []byte +} + +// 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 { + 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) { + 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.Atoi(shareIndexVar) + if err != nil { + writeErr(writer, http.StatusBadRequest, "malformed share index") + return + } + + if shareIndex <= 0 || shareIndex > len(lock.Operators) { + writeErr(writer, http.StatusBadRequest, "invalid share index") + return + } + + for _, partialReg := range data.PartialRegistrations { + 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(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 + } + + key := lockHash + "/" + validatorPubkeyHex + + existing, ok := ts.partialFeeRecipients[key] + if !ok { + existing = feeRecipientBlob{ + groups: make(map[string]map[int]feeRecipientPartial), + } + } + + mk := msgKey(partialReg.Message) + + group, ok := existing.groups[mk] + if !ok { + group = make(map[int]feeRecipientPartial) + } + + group[shareIndex] = feeRecipientPartial{ + ShareIdx: shareIndex, + Message: partialReg.Message, + Signature: partialReg.Signature[:], + } + + existing.groups[mk] = group + ts.partialFeeRecipients[key] = existing + } + + writer.WriteHeader(http.StatusOK) +} + +func (ts *testServer) HandlePostFeeRecipientFetch(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 + } + + lock, ok := ts.lockFiles[lockHash] + if !ok { + writeErr(writer, http.StatusNotFound, "lock not found") + return + } + + var fetchReq obolapi.FeeRecipientFetchRequest + if err := json.NewDecoder(request.Body).Decode(&fetchReq); err != nil { + writeErr(writer, http.StatusBadRequest, "invalid body") + return + } + + pubkeyFilter := make(map[string]bool) + for _, pk := range fetchReq.Pubkeys { + pubkeyFilter[strings.ToLower(strings.TrimPrefix(pk, "0x"))] = true + } + + 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 validators []obolapi.FeeRecipientValidator + + for _, t := range targets { + key := lockHash + "/" + t.pubkeyHex + existing, hasPartials := ts.partialFeeRecipients[key] + + if !hasPartials || len(existing.groups) == 0 { + continue // omit validators with no registration data + } + + var builderRegs []obolapi.FeeRecipientBuilderRegistration + + var ( + latestQuorum *obolapi.FeeRecipientBuilderRegistration + latestIncomplete *obolapi.FeeRecipientBuilderRegistration + ) + + 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 + } + + // Build partial_signatures list; apply dropOnePsig if set. + partials := make([]feeRecipientPartial, 0, len(group)) + for _, p := range group { + partials = append(partials, p) + } + + if ts.dropOnePsig && len(partials) > 0 { + partials = partials[:len(partials)-1] + } + + partialSigs := make([]obolapi.FeeRecipientPartialSig, 0, len(partials)) + for _, p := range partials { + var sig tbls.Signature + copy(sig[:], p.Signature) + + partialSigs = append(partialSigs, obolapi.FeeRecipientPartialSig{ + ShareIndex: p.ShareIdx, + Signature: sig, + }) + } + + quorum := len(group) >= lock.Threshold + + reg := obolapi.FeeRecipientBuilderRegistration{ + Message: msg, + PartialSignatures: partialSigs, + Quorum: quorum, + } + + 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 + } + } + } + + // Return at most one quorum group and one incomplete group per spec. + if latestQuorum != nil { + builderRegs = append(builderRegs, *latestQuorum) + } + + if latestIncomplete != nil { + builderRegs = append(builderRegs, *latestIncomplete) + } + + validators = append(validators, obolapi.FeeRecipientValidator{ + Pubkey: t.pubkeyHex, // no 0x prefix per spec + BuilderRegistrations: builderRegs, + }) + } + + resp := obolapi.FeeRecipientFetchResponse{ + Validators: validators, + } + + if err := json.NewEncoder(writer).Encode(resp); err != nil { + writeErr(writer, http.StatusInternalServerError, "cannot encode response") + } +} diff --git a/testutil/obolapimock/obolapi.go b/testutil/obolapimock/obolapi.go index ad1ce86d2..d4de57baa 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 builder 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(fetchFeeRecipientTmpl, ts.HandlePostFeeRecipientFetch).Methods(http.MethodPost) + return router, ts.addLockFiles }