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..81c18e605 --- /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: "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...) + + 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..8a2e9704c --- /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 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) + }, + } + + 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..06bf1458a --- /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 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) + }, + } + + 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, 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") + 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 }