Skip to content
Open
136 changes: 136 additions & 0 deletions app/obolapi/feerecipient.go
Original file line number Diff line number Diff line change
@@ -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) {

Check failure on line 80 in app/obolapi/feerecipient.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ObolNetwork_charon&issues=AZzhpC-Tn1Kc99pl4aMN&open=AZzhpC-Tn1Kc99pl4aMN&pullRequest=4341
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
}
121 changes: 121 additions & 0 deletions app/obolapi/feerecipient_model.go
Original file line number Diff line number Diff line change
@@ -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"`
}
4 changes: 4 additions & 0 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ func New() *cobra.Command {
newDepositSignCmd(runDepositSign),
newDepositFetchCmd(runDepositFetch),
),
newFeeRecipientCmd(
newFeeRecipientSignCmd(runFeeRecipientSign),
newFeeRecipientFetchCmd(runFeeRecipientFetch),
),
newUnsafeCmd(newRunCmd(app.Run, true)),
)
}
Expand Down
38 changes: 38 additions & 0 deletions cmd/feerecipient.go
Original file line number Diff line number Diff line change
@@ -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.")
}
Loading
Loading