Skip to content

Commit 7cfda9c

Browse files
*: v1.10.0-rc6 (#4496)
* Fix SSE proxying (#4495) * cmd: surface remote state in list (#4497) * cmd: surface remote state in list * addressed copilot finding * dump available pubkeys * fixed tests to satisfy copilot --------- Co-authored-by: Andrei Smirnov <andrei@obol.tech>
1 parent 97778b8 commit 7cfda9c

4 files changed

Lines changed: 739 additions & 39 deletions

File tree

cmd/feerecipientlist.go

Lines changed: 144 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ import (
1313
"github.com/spf13/cobra"
1414

1515
"github.com/obolnetwork/charon/app"
16+
"github.com/obolnetwork/charon/app/errors"
1617
"github.com/obolnetwork/charon/app/log"
18+
"github.com/obolnetwork/charon/app/obolapi"
1719
"github.com/obolnetwork/charon/app/z"
1820
"github.com/obolnetwork/charon/cluster"
1921
)
2022

2123
type feerecipientListConfig struct {
22-
ValidatorPublicKeys []string
23-
LockFilePath string
24-
OverridesFilePath string
24+
feerecipientConfig
2525
}
2626

2727
func newFeeRecipientListCmd(runFunc func(context.Context, feerecipientListConfig) error) *cobra.Command {
@@ -30,7 +30,7 @@ func newFeeRecipientListCmd(runFunc func(context.Context, feerecipientListConfig
3030
cmd := &cobra.Command{
3131
Use: "list",
3232
Short: "Display the latest builder registration details for each validator.",
33-
Long: "Displays the most recent builder registration for each validator, selecting the entry with the highest timestamp from either the cluster lock file or the overrides file.",
33+
Long: "Displays the most recent builder registration for each validator, selecting the entry with the highest timestamp from the cluster lock file, the overrides file, or the remote API.",
3434
Args: cobra.NoArgs,
3535
RunE: func(cmd *cobra.Command, _ []string) error {
3636
return runFunc(cmd.Context(), config)
@@ -41,6 +41,8 @@ func newFeeRecipientListCmd(runFunc func(context.Context, feerecipientListConfig
4141
cmd.Flags().StringVar(&config.LockFilePath, lockFilePath.String(), ".charon/cluster-lock.json", "Path to the cluster lock file defining the distributed validator cluster.")
4242
cmd.Flags().StringVar(&config.OverridesFilePath, "overrides-file", ".charon/builder_registrations_overrides.json", "Path to the builder registrations overrides file.")
4343

44+
bindFeeRecipientRemoteAPIFlags(cmd, &config.feerecipientConfig)
45+
4446
return cmd
4547
}
4648

@@ -50,11 +52,20 @@ type registrationEntry struct {
5052
FeeRecipient string
5153
GasLimit uint64
5254
Timestamp time.Time
55+
// Sources lists the source names (lock, overrides, remote) whose record
56+
// is equivalent to this winning entry, in canonical order.
57+
Sources []string
5358
}
5459

60+
const (
61+
sourceLock = "lock"
62+
sourceOverrides = "overrides"
63+
sourceRemote = "remote"
64+
)
65+
5566
// resolveLatestRegistrations returns the latest builder registration for each validator
56-
// by comparing timestamps from the cluster lock and overrides file.
57-
func resolveLatestRegistrations(cl cluster.Lock, overrides map[string]registrationEntry, pubkeyFilter map[string]struct{}) []registrationEntry {
67+
// by comparing timestamps from the cluster lock, overrides file, and remote API quorum map.
68+
func resolveLatestRegistrations(cl cluster.Lock, overrides, remote map[string]registrationEntry, pubkeyFilter map[string]struct{}) []registrationEntry {
5869
var entries []registrationEntry
5970

6071
for _, dv := range cl.Validators {
@@ -67,29 +78,81 @@ func resolveLatestRegistrations(cl cluster.Lock, overrides map[string]registrati
6778
}
6879
}
6980

70-
feeRecipient := "0x" + hex.EncodeToString(dv.BuilderRegistration.Message.FeeRecipient)
71-
gasLimit := uint64(dv.BuilderRegistration.Message.GasLimit)
72-
timestamp := dv.BuilderRegistration.Message.Timestamp
81+
lockEntry := registrationEntry{
82+
FeeRecipient: "0x" + hex.EncodeToString(dv.BuilderRegistration.Message.FeeRecipient),
83+
GasLimit: uint64(dv.BuilderRegistration.Message.GasLimit),
84+
Timestamp: dv.BuilderRegistration.Message.Timestamp,
85+
}
7386

74-
if override, ok := overrides[normalized]; ok {
75-
if override.Timestamp.After(timestamp) {
76-
feeRecipient = override.FeeRecipient
77-
gasLimit = override.GasLimit
78-
timestamp = override.Timestamp
79-
}
87+
override, hasOverride := overrides[normalized]
88+
remoteEntry, hasRemote := remote[normalized]
89+
90+
winner := lockEntry
91+
if hasOverride && override.Timestamp.After(winner.Timestamp) {
92+
winner = override
93+
}
94+
95+
if hasRemote && remoteEntry.Timestamp.After(winner.Timestamp) {
96+
winner = remoteEntry
97+
}
98+
99+
var sources []string
100+
if entriesEquivalent(winner, lockEntry) {
101+
sources = append(sources, sourceLock)
102+
}
103+
104+
if hasOverride && entriesEquivalent(winner, override) {
105+
sources = append(sources, sourceOverrides)
106+
}
107+
108+
if hasRemote && entriesEquivalent(winner, remoteEntry) {
109+
sources = append(sources, sourceRemote)
80110
}
81111

82112
entries = append(entries, registrationEntry{
83113
Pubkey: pubkeyHex,
84-
FeeRecipient: feeRecipient,
85-
GasLimit: gasLimit,
86-
Timestamp: timestamp,
114+
FeeRecipient: winner.FeeRecipient,
115+
GasLimit: winner.GasLimit,
116+
Timestamp: winner.Timestamp,
117+
Sources: sources,
87118
})
88119
}
89120

90121
return entries
91122
}
92123

124+
// remoteOnlyPubkeys returns the pubkeys of resolved entries whose winning
125+
// record comes strictly from the remote API — neither the lock default nor
126+
// the overrides file carries an equivalent record. These are the only cases
127+
// where running fetch would add information the operator doesn't already
128+
// have.
129+
func remoteOnlyPubkeys(entries []registrationEntry) []string {
130+
var pubkeys []string
131+
132+
for _, e := range entries {
133+
if len(e.Sources) == 1 && e.Sources[0] == sourceRemote {
134+
pubkeys = append(pubkeys, e.Pubkey)
135+
}
136+
}
137+
138+
return pubkeys
139+
}
140+
141+
// entriesEquivalent reports whether two candidate entries represent the same
142+
// record (same fee recipient, gas limit, and timestamp). Pubkey is keyed by
143+
// the caller so is not compared.
144+
func entriesEquivalent(a, b registrationEntry) bool {
145+
if a.GasLimit != b.GasLimit {
146+
return false
147+
}
148+
149+
if !a.Timestamp.Equal(b.Timestamp) {
150+
return false
151+
}
152+
153+
return strings.EqualFold(a.FeeRecipient, b.FeeRecipient)
154+
}
155+
93156
func runFeeRecipientList(ctx context.Context, config feerecipientListConfig) error {
94157
cl, err := cluster.LoadClusterLockAndVerify(ctx, config.LockFilePath)
95158
if err != nil {
@@ -112,7 +175,14 @@ func runFeeRecipientList(ctx context.Context, config feerecipientListConfig) err
112175
pubkeyFilter[normalizePubkey(pk)] = struct{}{}
113176
}
114177

115-
entries := resolveLatestRegistrations(*cl, overrides, pubkeyFilter)
178+
remote, incomplete, noReg, err := fetchRemoteQuorums(ctx, config, cl.LockHash)
179+
if err != nil {
180+
log.Warn(ctx, "Unable to fetch remote builder registrations; showing local data only", err)
181+
182+
remote, incomplete, noReg = nil, nil, nil
183+
}
184+
185+
entries := resolveLatestRegistrations(*cl, overrides, remote, pubkeyFilter)
116186

117187
if len(entries) == 0 {
118188
log.Info(ctx, "No builder registrations found", nil)
@@ -132,12 +202,67 @@ func runFeeRecipientList(ctx context.Context, config feerecipientListConfig) err
132202
z.Str("fee_recipient", e.FeeRecipient),
133203
z.U64("gas_limit", e.GasLimit),
134204
z.I64("timestamp", e.Timestamp.Unix()),
205+
z.Str("source", strings.Join(e.Sources, "+")),
206+
)
207+
}
208+
209+
if len(incomplete) > 0 {
210+
log.Info(ctx, "Validators with partial builder registrations on remote", z.Int("total", len(incomplete)))
211+
}
212+
213+
if len(noReg) > 0 {
214+
log.Info(ctx, "Validators unknown to remote API", z.Int("total", len(noReg)))
215+
}
216+
217+
remoteOnly := remoteOnlyPubkeys(entries)
218+
219+
if len(remoteOnly) > 0 {
220+
log.Info(ctx, "Updated registrations are available. "+
221+
"Use 'charon feerecipient fetch' to save them locally, "+
222+
"or use 'charon run --fetch-feerecipient-updates' to have Charon check for updates daily.",
223+
z.Any("pubkeys", remoteOnly),
135224
)
136225
}
137226

138227
return nil
139228
}
140229

230+
// fetchRemoteQuorums calls the Obol API and converts the response into a
231+
// pubkey-keyed quorum map plus the incomplete / no-registration pubkey
232+
// lists. Returns an error if the API is unreachable or the response can't
233+
// be processed; callers may choose to treat the error as soft.
234+
func fetchRemoteQuorums(ctx context.Context, config feerecipientListConfig, lockHash []byte) (remote map[string]registrationEntry, incomplete, noReg []string, err error) {
235+
oAPI, err := obolapi.New(config.PublishAddress, obolapi.WithTimeout(config.PublishTimeout))
236+
if err != nil {
237+
return nil, nil, nil, errors.Wrap(err, "create Obol API client", z.Str("publish_address", config.PublishAddress))
238+
}
239+
240+
resp, err := oAPI.PostFeeRecipientsFetch(ctx, lockHash, config.ValidatorPublicKeys)
241+
if err != nil {
242+
return nil, nil, nil, errors.Wrap(err, "fetch builder registrations from Obol API")
243+
}
244+
245+
pv, err := app.ProcessValidators(resp.Validators)
246+
if err != nil {
247+
return nil, nil, nil, err
248+
}
249+
250+
remote = make(map[string]registrationEntry, len(pv.QuorumMessages))
251+
for pk, msg := range pv.QuorumMessages {
252+
if msg == nil {
253+
continue
254+
}
255+
256+
remote[normalizePubkey(pk)] = registrationEntry{
257+
FeeRecipient: msg.FeeRecipient.String(),
258+
GasLimit: msg.GasLimit,
259+
Timestamp: msg.Timestamp,
260+
}
261+
}
262+
263+
return remote, pv.Categories.Incomplete, pv.Categories.NoReg, nil
264+
}
265+
141266
// loadOverrides reads the builder registrations overrides file and returns
142267
// a map keyed by normalized validator pubkey hex with the registration details needed for comparison.
143268
func loadOverrides(path string, forkVersion []byte) (map[string]registrationEntry, error) {

0 commit comments

Comments
 (0)