Skip to content

Commit e9ba833

Browse files
authored
Merge pull request #1099 from rocket-pool/monitor-ongoing-exits
Monitor ongoing exits
2 parents 10c2fd4 + 9472eb9 commit e9ba833

8 files changed

Lines changed: 467 additions & 3 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: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ package megapool
33
import (
44
"fmt"
55
"sort"
6+
"strconv"
7+
"strings"
8+
"time"
69

10+
"github.com/rocket-pool/smartnode/shared/services/beacon"
711
"github.com/rocket-pool/smartnode/shared/services/rocketpool"
812
"github.com/rocket-pool/smartnode/shared/types/api"
913
"github.com/rocket-pool/smartnode/shared/utils/cli/color"
@@ -16,30 +20,142 @@ func (a ByIndex) Len() int { return len(a) }
1620
func (a ByIndex) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
1721
func (a ByIndex) Less(i, j int) bool { return a[i].ValidatorIndex < a[j].ValidatorIndex }
1822

19-
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) {
2071
// Get RP client
2172
rp, err := rocketpool.NewClient().WithReady()
2273
if err != nil {
2374
return 0, false, err
2475
}
2576
defer rp.Close()
2677

78+
// Get the latest block and identify the withdrawals present in it
79+
var lastWithdrawnIndex uint64
80+
var hasLastWithdrawnIndex bool
81+
withdrawalsResp, err := rp.GetLatestBlockWithdrawals()
82+
if err != nil {
83+
fmt.Printf("Warning: could not fetch latest beacon block withdrawals: %s\n\n", err.Error())
84+
} else if len(withdrawalsResp.Withdrawals) == 0 {
85+
fmt.Printf("Latest beacon block (slot %d, exec block %d) has no validator withdrawals.\n\n",
86+
withdrawalsResp.Slot, withdrawalsResp.BlockNumber)
87+
} else {
88+
indexes := make([]string, 0, len(withdrawalsResp.Withdrawals))
89+
seen := make(map[string]struct{}, len(withdrawalsResp.Withdrawals))
90+
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+
}
97+
if _, ok := seen[wd.ValidatorIndex]; ok {
98+
continue
99+
}
100+
seen[wd.ValidatorIndex] = struct{}{}
101+
indexes = append(indexes, wd.ValidatorIndex)
102+
}
103+
fmt.Printf("Latest beacon block (slot %d, exec block %d) processed withdrawals for %d validator(s):\n",
104+
withdrawalsResp.Slot, withdrawalsResp.BlockNumber, len(indexes))
105+
fmt.Printf(" %s\n\n", strings.Join(indexes, ", "))
106+
}
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+
}
128+
27129
// Get Megapool status
28130
status, err := rp.MegapoolStatus(false)
29131
if err != nil {
30132
return 0, false, err
31133
}
32134

33135
activeValidators := []api.MegapoolValidatorDetails{}
136+
exitingValidators := []api.MegapoolValidatorDetails{}
34137

35138
for _, validator := range status.Megapool.Validators {
36-
if validator.Activated && !validator.Exiting && !validator.Exited {
139+
if validator.Activated && !validator.Exiting && !validator.Exited && validator.BeaconStatus.Status != beacon.ValidatorState_ActiveExiting {
37140
// Check if validator is old enough to exit
38141
earliestExitEpoch := validator.BeaconStatus.ActivationEpoch + 256
39142
if status.BeaconHead.Epoch >= earliestExitEpoch {
40143
activeValidators = append(activeValidators, validator)
41144
}
42145
}
146+
if validator.BeaconStatus.Status == beacon.ValidatorState_ActiveExiting {
147+
exitingValidators = append(exitingValidators, validator)
148+
}
149+
}
150+
151+
// Print exiting validators
152+
if len(exitingValidators) > 0 {
153+
sortExitingValidatorsBySweep(exitingValidators, lastWithdrawnIndex, hasLastWithdrawnIndex)
154+
fmt.Println("The following validators are still active and have already received their exit request on the Beacon Chain:")
155+
for _, v := range exitingValidators {
156+
fmt.Printf("ID %d: - Index %d Pubkey: 0x%s\n", v.ValidatorId, v.ValidatorIndex, v.PubKey.String())
157+
}
158+
fmt.Println()
43159
}
44160
if len(activeValidators) > 0 {
45161
sort.Sort(ByIndex(activeValidators))
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+
}

0 commit comments

Comments
 (0)