@@ -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
2123type feerecipientListConfig struct {
22- ValidatorPublicKeys []string
23- LockFilePath string
24- OverridesFilePath string
24+ feerecipientConfig
2525}
2626
2727func 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+
93156func 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.
143268func loadOverrides (path string , forkVersion []byte ) (map [string ]registrationEntry , error ) {
0 commit comments