Skip to content

Commit 9472eb9

Browse files
committed
Order validator index considering the last sweep. add fetch-estimate flag
1 parent 6012422 commit 9472eb9

7 files changed

Lines changed: 342 additions & 5 deletions

File tree

rocketpool-cli/megapool/commands.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,10 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) {
277277
Name: "yes",
278278
Usage: "Automatically confirm the action",
279279
},
280+
&cli.BoolFlag{
281+
Name: "fetch-estimate",
282+
Usage: "Fetch an estimate of the beacon chain exit queue time",
283+
},
280284
&cli.Uint64Flag{
281285
Name: "validator-id",
282286
Usage: "The validator id to exit",
@@ -293,7 +297,7 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) {
293297
if !c.IsSet("validator-id") {
294298
var err error
295299
var found bool
296-
validatorId, found, err = getExitableValidator()
300+
validatorId, found, err = getExitableValidator(c.Bool("fetch-estimate"))
297301
if err != nil {
298302
return err
299303
}

rocketpool-cli/megapool/exit-validator.go

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package megapool
33
import (
44
"fmt"
55
"sort"
6+
"strconv"
67
"strings"
8+
"time"
79

810
"github.com/rocket-pool/smartnode/shared/services/beacon"
911
"github.com/rocket-pool/smartnode/shared/services/rocketpool"
@@ -18,7 +20,54 @@ func (a ByIndex) Len() int { return len(a) }
1820
func (a ByIndex) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
1921
func (a ByIndex) Less(i, j int) bool { return a[i].ValidatorIndex < a[j].ValidatorIndex }
2022

21-
func getExitableValidator() (uint64, bool, error) {
23+
func formatGwei(gwei uint64) string {
24+
eth := float64(gwei) / 1e9
25+
if eth == float64(uint64(eth)) {
26+
return fmt.Sprintf("%d", uint64(eth))
27+
}
28+
return fmt.Sprintf("%.2f", eth)
29+
}
30+
31+
// formatDaysHours formats a duration as a "Xd Yh" string. Sub-hour values are
32+
// rendered in minutes; sub-minute values fall back to "<1m".
33+
func formatDaysHours(d time.Duration) string {
34+
totalSeconds := int64(d.Seconds())
35+
if totalSeconds < 60 {
36+
return "<1m"
37+
}
38+
const secondsPerHour = 3600
39+
const secondsPerDay = 24 * secondsPerHour
40+
days := totalSeconds / secondsPerDay
41+
hours := (totalSeconds % secondsPerDay) / secondsPerHour
42+
if days > 0 {
43+
return fmt.Sprintf("%dd %dh", days, hours)
44+
}
45+
if hours > 0 {
46+
return fmt.Sprintf("%dh", hours)
47+
}
48+
minutes := totalSeconds / 60
49+
return fmt.Sprintf("%dm", minutes)
50+
}
51+
52+
// sortExitingValidatorsBySweep orders validators in withdrawal-sweep order relative to a given last validator index
53+
func sortExitingValidatorsBySweep(validators []api.MegapoolValidatorDetails, lastWithdrawnIndex uint64, hasLastWithdrawnIndex bool) {
54+
if !hasLastWithdrawnIndex {
55+
sort.Sort(ByIndex(validators))
56+
return
57+
}
58+
sort.SliceStable(validators, func(i, j int) bool {
59+
ai := validators[i].ValidatorIndex
60+
aj := validators[j].ValidatorIndex
61+
iAfter := ai > lastWithdrawnIndex
62+
jAfter := aj > lastWithdrawnIndex
63+
if iAfter != jAfter {
64+
return iAfter
65+
}
66+
return ai < aj
67+
})
68+
}
69+
70+
func getExitableValidator(fetchExitQueueEstimate bool) (uint64, bool, error) {
2271
// Get RP client
2372
rp, err := rocketpool.NewClient().WithReady()
2473
if err != nil {
@@ -27,6 +76,8 @@ func getExitableValidator() (uint64, bool, error) {
2776
defer rp.Close()
2877

2978
// Get the latest block and identify the withdrawals present in it
79+
var lastWithdrawnIndex uint64
80+
var hasLastWithdrawnIndex bool
3081
withdrawalsResp, err := rp.GetLatestBlockWithdrawals()
3182
if err != nil {
3283
fmt.Printf("Warning: could not fetch latest beacon block withdrawals: %s\n\n", err.Error())
@@ -37,6 +88,12 @@ func getExitableValidator() (uint64, bool, error) {
3788
indexes := make([]string, 0, len(withdrawalsResp.Withdrawals))
3889
seen := make(map[string]struct{}, len(withdrawalsResp.Withdrawals))
3990
for _, wd := range withdrawalsResp.Withdrawals {
91+
if idx, perr := strconv.ParseUint(wd.ValidatorIndex, 10, 64); perr == nil {
92+
if !hasLastWithdrawnIndex || idx > lastWithdrawnIndex {
93+
lastWithdrawnIndex = idx
94+
hasLastWithdrawnIndex = true
95+
}
96+
}
4097
if _, ok := seen[wd.ValidatorIndex]; ok {
4198
continue
4299
}
@@ -47,6 +104,27 @@ func getExitableValidator() (uint64, bool, error) {
47104
withdrawalsResp.Slot, withdrawalsResp.BlockNumber, len(indexes))
48105
fmt.Printf(" %s\n\n", strings.Join(indexes, ", "))
49106
}
107+
var estimate api.BeaconWithdrawalQueueEstimateResponse
108+
if fetchExitQueueEstimate {
109+
// Print an estimate of the beacon chain withdrawal queue time
110+
fmt.Println("Fetching beacon chain exit queue estimate... This may take a while...")
111+
if estimate, err = rp.GetBeaconWithdrawalQueueEstimate(); err != nil {
112+
fmt.Printf("Warning: could not fetch beacon chain exit queue estimate: %s\n\n", err.Error())
113+
} else if estimate.ExitQueueGwei == 0 {
114+
fmt.Println("The beacon chain exit queue is currently empty.")
115+
fmt.Printf("At the current churn limit of %s ETH/epoch, a new exit request would be processed in the next epoch (~%s).\n\n",
116+
formatGwei(estimate.ChurnPerEpochGwei), (time.Duration(estimate.SecondsPerEpoch) * time.Second).Round(time.Second))
117+
} else {
118+
wait := formatDaysHours(time.Duration(estimate.EstimatedQueueSeconds) * time.Second)
119+
fmt.Printf("Beacon chain exit queue: %s ETH waiting to exit.\n",
120+
formatGwei(estimate.ExitQueueGwei))
121+
fmt.Printf("Churn limit: %s ETH/epoch -> estimated %d epochs (~%s) to process the queue.\n\n",
122+
formatGwei(estimate.ChurnPerEpochGwei), estimate.EstimatedQueueEpochs, wait)
123+
}
124+
} else {
125+
fmt.Println("Skipping the beacon chain exit queue estimate. Use the --fetch-estimate flag to fetch it.")
126+
fmt.Println()
127+
}
50128

51129
// Get Megapool status
52130
status, err := rp.MegapoolStatus(false)
@@ -69,10 +147,10 @@ func getExitableValidator() (uint64, bool, error) {
69147
exitingValidators = append(exitingValidators, validator)
70148
}
71149
}
72-
if len(exitingValidators) > 0 {
73-
// Make sure that exitingValidators is sorted by validator index ascending from the last withdrawal index
74150

75-
//sort.Sort(ByIndex(exitingValidators))
151+
// Print exiting validators
152+
if len(exitingValidators) > 0 {
153+
sortExitingValidatorsBySweep(exitingValidators, lastWithdrawnIndex, hasLastWithdrawnIndex)
76154
fmt.Println("The following validators are still active and have already received their exit request on the Beacon Chain:")
77155
for _, v := range exitingValidators {
78156
fmt.Printf("ID %d: - Index %d Pubkey: 0x%s\n", v.ValidatorId, v.ValidatorIndex, v.PubKey.String())
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package megapool
2+
3+
import (
4+
"fmt"
5+
"reflect"
6+
"strings"
7+
"testing"
8+
9+
"github.com/rocket-pool/smartnode/shared/types/api"
10+
)
11+
12+
// makeValidators builds a slice of MegapoolValidatorDetails with only the
13+
// fields that sortExitingValidatorsBySweep cares about populated. ValidatorId
14+
// is set to a distinct value derived from the index so we can also assert that
15+
// the original elements (not just their indices) are preserved.
16+
func makeValidators(indices ...uint64) []api.MegapoolValidatorDetails {
17+
out := make([]api.MegapoolValidatorDetails, 0, len(indices))
18+
for i, idx := range indices {
19+
out = append(out, api.MegapoolValidatorDetails{
20+
ValidatorId: uint32(1000 + i),
21+
ValidatorIndex: idx,
22+
})
23+
}
24+
return out
25+
}
26+
27+
func indexesOf(validators []api.MegapoolValidatorDetails) []uint64 {
28+
out := make([]uint64, len(validators))
29+
for i, v := range validators {
30+
out[i] = v.ValidatorIndex
31+
}
32+
return out
33+
}
34+
35+
func TestSortExitingValidatorsBySweep(t *testing.T) {
36+
tests := []struct {
37+
name string
38+
input []uint64
39+
lastWithdrawnIndex uint64
40+
hasLastWithdrawnIndex bool
41+
want []uint64
42+
}{
43+
{
44+
name: "empty slice does not panic",
45+
input: []uint64{},
46+
lastWithdrawnIndex: 100,
47+
hasLastWithdrawnIndex: true,
48+
want: []uint64{},
49+
},
50+
{
51+
name: "single validator above pivot",
52+
input: []uint64{200},
53+
lastWithdrawnIndex: 100,
54+
hasLastWithdrawnIndex: true,
55+
want: []uint64{200},
56+
},
57+
{
58+
name: "single validator below pivot",
59+
input: []uint64{50},
60+
lastWithdrawnIndex: 100,
61+
hasLastWithdrawnIndex: true,
62+
want: []uint64{50},
63+
},
64+
{
65+
name: "no pivot falls back to ascending order",
66+
input: []uint64{300, 50, 200, 100},
67+
hasLastWithdrawnIndex: false,
68+
want: []uint64{50, 100, 200, 300},
69+
},
70+
{
71+
name: "pivot in the middle splits and sorts each half",
72+
input: []uint64{300, 50, 200, 100, 400, 25},
73+
lastWithdrawnIndex: 150,
74+
hasLastWithdrawnIndex: true,
75+
want: []uint64{200, 300, 400, 25, 50, 100},
76+
},
77+
{
78+
name: "pivot equal to a validator index puts that validator after the wrap",
79+
input: []uint64{100, 50, 150, 200},
80+
lastWithdrawnIndex: 100,
81+
hasLastWithdrawnIndex: true,
82+
want: []uint64{150, 200, 50, 100},
83+
},
84+
{
85+
name: "pivot above all validators wraps everyone (plain ascending)",
86+
input: []uint64{50, 30, 10, 20},
87+
lastWithdrawnIndex: 1000,
88+
hasLastWithdrawnIndex: true,
89+
want: []uint64{10, 20, 30, 50},
90+
},
91+
{
92+
name: "pivot below all validators leaves them all 'after' (plain ascending)",
93+
input: []uint64{50, 30, 10, 20},
94+
lastWithdrawnIndex: 0,
95+
hasLastWithdrawnIndex: true,
96+
want: []uint64{10, 20, 30, 50},
97+
},
98+
{
99+
name: "already in sweep order is unchanged",
100+
input: []uint64{200, 300, 400, 25, 50, 100},
101+
lastWithdrawnIndex: 150,
102+
hasLastWithdrawnIndex: true,
103+
want: []uint64{200, 300, 400, 25, 50, 100},
104+
},
105+
}
106+
107+
for _, tt := range tests {
108+
t.Run(tt.name, func(t *testing.T) {
109+
validators := makeValidators(tt.input...)
110+
111+
sortExitingValidatorsBySweep(validators, tt.lastWithdrawnIndex, tt.hasLastWithdrawnIndex)
112+
113+
got := indexesOf(validators)
114+
// Full struct is unreadable in %v; print index order and compact id@index chain.
115+
t.Logf("final sweep order (validator indices): %v", got)
116+
parts := make([]string, len(validators))
117+
for i, v := range validators {
118+
parts[i] = fmt.Sprintf("%d@%d", v.ValidatorId, v.ValidatorIndex)
119+
}
120+
idIndexLine := strings.Join(parts, " → ")
121+
if idIndexLine == "" {
122+
idIndexLine = "(empty)"
123+
}
124+
t.Logf("final sweep order (id@index): %s", idIndexLine)
125+
if !reflect.DeepEqual(got, tt.want) {
126+
t.Fatalf("unexpected order: got %v, want %v", got, tt.want)
127+
}
128+
})
129+
}
130+
}
131+
132+
// TestSortExitingValidatorsBySweepPreservesElements confirms that the helper
133+
// reorders the original elements (preserving fields like ValidatorId) rather
134+
// than producing copies that drop unrelated data.
135+
func TestSortExitingValidatorsBySweepPreservesElements(t *testing.T) {
136+
validators := []api.MegapoolValidatorDetails{
137+
{ValidatorId: 11, ValidatorIndex: 300},
138+
{ValidatorId: 22, ValidatorIndex: 50},
139+
{ValidatorId: 33, ValidatorIndex: 200},
140+
{ValidatorId: 44, ValidatorIndex: 100},
141+
}
142+
143+
sortExitingValidatorsBySweep(validators, 150, true)
144+
145+
wantIndexes := []uint64{200, 300, 50, 100}
146+
wantIDs := []uint32{33, 11, 22, 44}
147+
for i, v := range validators {
148+
if v.ValidatorIndex != wantIndexes[i] || v.ValidatorId != wantIDs[i] {
149+
t.Fatalf("position %d: got (id=%d, index=%d), want (id=%d, index=%d)",
150+
i, v.ValidatorId, v.ValidatorIndex, wantIDs[i], wantIndexes[i])
151+
}
152+
}
153+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package megapool
2+
3+
import (
4+
"fmt"
5+
"math"
6+
7+
"github.com/urfave/cli/v3"
8+
9+
"github.com/rocket-pool/smartnode/shared/services"
10+
"github.com/rocket-pool/smartnode/shared/types/api"
11+
)
12+
13+
const (
14+
farFutureEpoch uint64 = math.MaxUint64
15+
perEpochActivationExitChurnLimit uint64 = 256_000_000_000
16+
)
17+
18+
// getBeaconWithdrawalQueueEstimate estimates how long the current beacon-chain
19+
// exit queue will take to be processed.
20+
func getBeaconWithdrawalQueueEstimate(c *cli.Command) (*api.BeaconWithdrawalQueueEstimateResponse, error) {
21+
if err := services.RequireBeaconClientSynced(c); err != nil {
22+
return nil, err
23+
}
24+
bc, err := services.GetBeaconClient(c)
25+
if err != nil {
26+
return nil, err
27+
}
28+
29+
eth2Config, err := bc.GetEth2Config()
30+
if err != nil {
31+
return nil, fmt.Errorf("error getting eth2 config: %w", err)
32+
}
33+
34+
head, err := bc.GetBeaconHead()
35+
if err != nil {
36+
return nil, fmt.Errorf("error getting beacon head: %w", err)
37+
}
38+
currentEpoch := head.Epoch
39+
40+
validators, err := bc.GetAllValidators()
41+
if err != nil {
42+
return nil, fmt.Errorf("error getting validator set: %w", err)
43+
}
44+
45+
// Walk the validator set once and collect the effective balance of validators currently waiting to exit
46+
var exitQueueGwei uint64
47+
for _, v := range validators {
48+
49+
// In the exit queue if exit_epoch is set and still in the future.
50+
if v.ExitEpoch != farFutureEpoch && v.ExitEpoch > currentEpoch {
51+
exitQueueGwei += v.EffectiveBalance
52+
}
53+
}
54+
55+
churnPerEpochGwei := perEpochActivationExitChurnLimit
56+
57+
// epochs needed to process the queue, rounded up
58+
var estimatedEpochs uint64
59+
if churnPerEpochGwei > 0 && exitQueueGwei > 0 {
60+
estimatedEpochs = (exitQueueGwei + churnPerEpochGwei - 1) / churnPerEpochGwei
61+
}
62+
estimatedSeconds := estimatedEpochs * eth2Config.SecondsPerEpoch
63+
64+
return &api.BeaconWithdrawalQueueEstimateResponse{
65+
ExitQueueGwei: exitQueueGwei,
66+
ChurnPerEpochGwei: churnPerEpochGwei,
67+
SecondsPerEpoch: eth2Config.SecondsPerEpoch,
68+
EstimatedQueueEpochs: estimatedEpochs,
69+
EstimatedQueueSeconds: estimatedSeconds,
70+
}, nil
71+
}

rocketpool/api/megapool/routes.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,11 @@ func RegisterRoutes(mux *http.ServeMux, c *cli.Command) {
367367
resp, err := getLatestBlockWithdrawals(c)
368368
apiutils.WriteResponse(w, resp, err)
369369
})
370+
371+
mux.HandleFunc("/api/megapool/beacon-withdrawal-queue-estimate", func(w http.ResponseWriter, r *http.Request) {
372+
resp, err := getBeaconWithdrawalQueueEstimate(c)
373+
apiutils.WriteResponse(w, resp, err)
374+
})
370375
}
371376

372377
func parseUint64(r *http.Request, name string) (uint64, error) {

0 commit comments

Comments
 (0)