From cdec3b81eec54e90ad0da862f799a25989f11a54 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+fornax2@users.noreply.github.com> Date: Wed, 6 May 2026 15:21:39 -0300 Subject: [PATCH 1/3] Show a list of ValidatorState_ActiveExiting validators --- rocketpool-cli/megapool/exit-validator.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/rocketpool-cli/megapool/exit-validator.go b/rocketpool-cli/megapool/exit-validator.go index 06d3eb47c..d75cbb471 100644 --- a/rocketpool-cli/megapool/exit-validator.go +++ b/rocketpool-cli/megapool/exit-validator.go @@ -4,6 +4,7 @@ import ( "fmt" "sort" + "github.com/rocket-pool/smartnode/shared/services/beacon" "github.com/rocket-pool/smartnode/shared/services/rocketpool" "github.com/rocket-pool/smartnode/shared/types/api" "github.com/rocket-pool/smartnode/shared/utils/cli/color" @@ -24,6 +25,9 @@ func getExitableValidator() (uint64, bool, error) { } defer rp.Close() + // Get the latest block and identify the withdrawals present in it + + // Get Megapool status status, err := rp.MegapoolStatus(false) if err != nil { @@ -31,15 +35,27 @@ func getExitableValidator() (uint64, bool, error) { } activeValidators := []api.MegapoolValidatorDetails{} + exitingValidators := []api.MegapoolValidatorDetails{} for _, validator := range status.Megapool.Validators { - if validator.Activated && !validator.Exiting && !validator.Exited { + if validator.Activated && !validator.Exiting && !validator.Exited && validator.BeaconStatus.Status != beacon.ValidatorState_ActiveExiting { // Check if validator is old enough to exit earliestExitEpoch := validator.BeaconStatus.ActivationEpoch + 256 if status.BeaconHead.Epoch >= earliestExitEpoch { activeValidators = append(activeValidators, validator) } } + if validator.BeaconStatus.Status == beacon.ValidatorState_ActiveExiting { + exitingValidators = append(exitingValidators, validator) + } + } + if len(exitingValidators) > 0 { + sort.Sort(ByIndex(exitingValidators)) + fmt.Println("The following validators are still active and have already received their exit request on the Beacon Chain:") + for _, v := range exitingValidators { + fmt.Printf("ID %d: - Index %d Pubkey: 0x%s\n", v.ValidatorId, v.ValidatorIndex, v.PubKey.String()) + } + fmt.Println() } if len(activeValidators) > 0 { sort.Sort(ByIndex(activeValidators)) From 601242256e2bd8676852dfdcad0cd3dc1469d4f5 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+fornax2@users.noreply.github.com> Date: Wed, 6 May 2026 15:46:12 -0300 Subject: [PATCH 2/3] Show latest block withdrawls processed --- rocketpool-cli/megapool/exit-validator.go | 26 +++++++- .../api/megapool/latest-block-withdrawals.go | 60 +++++++++++++++++++ rocketpool/api/megapool/routes.go | 5 ++ shared/services/rocketpool/megapool.go | 16 +++++ shared/types/api/megapool.go | 8 +++ 5 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 rocketpool/api/megapool/latest-block-withdrawals.go diff --git a/rocketpool-cli/megapool/exit-validator.go b/rocketpool-cli/megapool/exit-validator.go index d75cbb471..0aec0083b 100644 --- a/rocketpool-cli/megapool/exit-validator.go +++ b/rocketpool-cli/megapool/exit-validator.go @@ -3,6 +3,7 @@ package megapool import ( "fmt" "sort" + "strings" "github.com/rocket-pool/smartnode/shared/services/beacon" "github.com/rocket-pool/smartnode/shared/services/rocketpool" @@ -26,7 +27,26 @@ func getExitableValidator() (uint64, bool, error) { defer rp.Close() // Get the latest block and identify the withdrawals present in it - + withdrawalsResp, err := rp.GetLatestBlockWithdrawals() + if err != nil { + fmt.Printf("Warning: could not fetch latest beacon block withdrawals: %s\n\n", err.Error()) + } else if len(withdrawalsResp.Withdrawals) == 0 { + fmt.Printf("Latest beacon block (slot %d, exec block %d) has no validator withdrawals.\n\n", + withdrawalsResp.Slot, withdrawalsResp.BlockNumber) + } else { + indexes := make([]string, 0, len(withdrawalsResp.Withdrawals)) + seen := make(map[string]struct{}, len(withdrawalsResp.Withdrawals)) + for _, wd := range withdrawalsResp.Withdrawals { + if _, ok := seen[wd.ValidatorIndex]; ok { + continue + } + seen[wd.ValidatorIndex] = struct{}{} + indexes = append(indexes, wd.ValidatorIndex) + } + fmt.Printf("Latest beacon block (slot %d, exec block %d) processed withdrawals for %d validator(s):\n", + withdrawalsResp.Slot, withdrawalsResp.BlockNumber, len(indexes)) + fmt.Printf(" %s\n\n", strings.Join(indexes, ", ")) + } // Get Megapool status status, err := rp.MegapoolStatus(false) @@ -50,7 +70,9 @@ func getExitableValidator() (uint64, bool, error) { } } if len(exitingValidators) > 0 { - sort.Sort(ByIndex(exitingValidators)) + // Make sure that exitingValidators is sorted by validator index ascending from the last withdrawal index + + //sort.Sort(ByIndex(exitingValidators)) fmt.Println("The following validators are still active and have already received their exit request on the Beacon Chain:") for _, v := range exitingValidators { fmt.Printf("ID %d: - Index %d Pubkey: 0x%s\n", v.ValidatorId, v.ValidatorIndex, v.PubKey.String()) diff --git a/rocketpool/api/megapool/latest-block-withdrawals.go b/rocketpool/api/megapool/latest-block-withdrawals.go new file mode 100644 index 000000000..d953569ba --- /dev/null +++ b/rocketpool/api/megapool/latest-block-withdrawals.go @@ -0,0 +1,60 @@ +package megapool + +import ( + "fmt" + + "github.com/urfave/cli/v3" + + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/types/api" +) + +// getLatestBlockWithdrawals returns the validator withdrawals processed in the +// latest beacon block that contains an execution payload. If the head slot has +// no execution payload (e.g. it was a missed slot), it walks backwards a few +// slots until it finds one. +func getLatestBlockWithdrawals(c *cli.Command) (*api.LatestBlockWithdrawalsResponse, error) { + if err := services.RequireBeaconClientSynced(c); err != nil { + return nil, err + } + bc, err := services.GetBeaconClient(c) + if err != nil { + return nil, err + } + + blockToRequest := "head" + const maxAttempts = 8 + var ( + block beacon.BeaconBlock + exists bool + ) + for attempts := 0; attempts < maxAttempts; attempts++ { + block, exists, err = bc.GetBeaconBlock(blockToRequest) + if err != nil { + return nil, fmt.Errorf("error getting beacon block %s: %w", blockToRequest, err) + } + if exists && block.HasExecutionPayload { + break + } + // Walk backwards by slot number; if we don't yet have one, fall back. + var nextSlot uint64 + if block.Slot > 0 { + nextSlot = block.Slot - 1 + } else if attempts == 0 { + // We never resolved the head, give up + return nil, fmt.Errorf("could not resolve the head beacon block") + } + if attempts == maxAttempts-1 { + return nil, fmt.Errorf("could not find a beacon block with an execution payload after %d attempts", maxAttempts) + } + blockToRequest = fmt.Sprintf("%d", nextSlot) + } + + response := &api.LatestBlockWithdrawalsResponse{ + Slot: block.Slot, + BlockNumber: block.ExecutionBlockNumber, + Withdrawals: block.Withdrawals, + } + return response, nil +} diff --git a/rocketpool/api/megapool/routes.go b/rocketpool/api/megapool/routes.go index 43612511b..ccffaec68 100644 --- a/rocketpool/api/megapool/routes.go +++ b/rocketpool/api/megapool/routes.go @@ -362,6 +362,11 @@ func RegisterRoutes(mux *http.ServeMux, c *cli.Command) { resp, err := getEffectiveDelegate(c) apiutils.WriteResponse(w, resp, err) }) + + mux.HandleFunc("/api/megapool/latest-block-withdrawals", func(w http.ResponseWriter, r *http.Request) { + resp, err := getLatestBlockWithdrawals(c) + apiutils.WriteResponse(w, resp, err) + }) } func parseUint64(r *http.Request, name string) (uint64, error) { diff --git a/shared/services/rocketpool/megapool.go b/shared/services/rocketpool/megapool.go index 403569268..aa7204964 100644 --- a/shared/services/rocketpool/megapool.go +++ b/shared/services/rocketpool/megapool.go @@ -557,6 +557,22 @@ func (c *Client) DistributeMegapool() (api.DistributeMegapoolResponse, error) { return response, nil } +// Get the validator withdrawals processed in the latest beacon block (with execution payload) +func (c *Client) GetLatestBlockWithdrawals() (api.LatestBlockWithdrawalsResponse, error) { + responseBytes, err := c.callHTTPAPI("GET", "/api/megapool/latest-block-withdrawals", nil) + if err != nil { + return api.LatestBlockWithdrawalsResponse{}, fmt.Errorf("Could not get latest block withdrawals: %w", err) + } + var response api.LatestBlockWithdrawalsResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return api.LatestBlockWithdrawalsResponse{}, fmt.Errorf("Could not decode latest block withdrawals response: %w", err) + } + if response.Error != "" { + return api.LatestBlockWithdrawalsResponse{}, fmt.Errorf("Could not get latest block withdrawals: %s", response.Error) + } + return response, nil +} + // Get the bond amount required for the megapool's next validator func (c *Client) GetNewValidatorBondRequirement() (api.GetNewValidatorBondRequirementResponse, error) { responseBytes, err := c.callHTTPAPI("GET", "/api/megapool/get-new-validator-bond-requirement", nil) diff --git a/shared/types/api/megapool.go b/shared/types/api/megapool.go index bfefdbd61..42d5ad513 100644 --- a/shared/types/api/megapool.go +++ b/shared/types/api/megapool.go @@ -178,3 +178,11 @@ type GetNodeMegapoolEthBondedResponse struct { Error string `json:"error"` EthBonded *big.Int `json:"ethBonded"` } + +type LatestBlockWithdrawalsResponse struct { + Status string `json:"status"` + Error string `json:"error"` + Slot uint64 `json:"slot"` + BlockNumber uint64 `json:"blockNumber"` + Withdrawals []beacon.WithdrawalInfo `json:"withdrawals"` +} From 9472eb943cd39bc41fb15eb3fe611962a4bedf4e Mon Sep 17 00:00:00 2001 From: Fornax <23104993+fornax2@users.noreply.github.com> Date: Thu, 7 May 2026 16:14:08 -0300 Subject: [PATCH 3/3] Order validator index considering the last sweep. add fetch-estimate flag --- rocketpool-cli/megapool/commands.go | 6 +- rocketpool-cli/megapool/exit-validator.go | 86 +++++++++- .../megapool/exit-validator_test.go | 153 ++++++++++++++++++ .../beacon-withdrawal-queue-estimate.go | 71 ++++++++ rocketpool/api/megapool/routes.go | 5 + shared/services/rocketpool/megapool.go | 16 ++ shared/types/api/megapool.go | 10 ++ 7 files changed, 342 insertions(+), 5 deletions(-) create mode 100644 rocketpool-cli/megapool/exit-validator_test.go create mode 100644 rocketpool/api/megapool/beacon-withdrawal-queue-estimate.go diff --git a/rocketpool-cli/megapool/commands.go b/rocketpool-cli/megapool/commands.go index cc4ba4a12..e47f1cb5e 100644 --- a/rocketpool-cli/megapool/commands.go +++ b/rocketpool-cli/megapool/commands.go @@ -277,6 +277,10 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { Name: "yes", Usage: "Automatically confirm the action", }, + &cli.BoolFlag{ + Name: "fetch-estimate", + Usage: "Fetch an estimate of the beacon chain exit queue time", + }, &cli.Uint64Flag{ Name: "validator-id", Usage: "The validator id to exit", @@ -293,7 +297,7 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { if !c.IsSet("validator-id") { var err error var found bool - validatorId, found, err = getExitableValidator() + validatorId, found, err = getExitableValidator(c.Bool("fetch-estimate")) if err != nil { return err } diff --git a/rocketpool-cli/megapool/exit-validator.go b/rocketpool-cli/megapool/exit-validator.go index 0aec0083b..a754f78cd 100644 --- a/rocketpool-cli/megapool/exit-validator.go +++ b/rocketpool-cli/megapool/exit-validator.go @@ -3,7 +3,9 @@ package megapool import ( "fmt" "sort" + "strconv" "strings" + "time" "github.com/rocket-pool/smartnode/shared/services/beacon" "github.com/rocket-pool/smartnode/shared/services/rocketpool" @@ -18,7 +20,54 @@ func (a ByIndex) Len() int { return len(a) } func (a ByIndex) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a ByIndex) Less(i, j int) bool { return a[i].ValidatorIndex < a[j].ValidatorIndex } -func getExitableValidator() (uint64, bool, error) { +func formatGwei(gwei uint64) string { + eth := float64(gwei) / 1e9 + if eth == float64(uint64(eth)) { + return fmt.Sprintf("%d", uint64(eth)) + } + return fmt.Sprintf("%.2f", eth) +} + +// formatDaysHours formats a duration as a "Xd Yh" string. Sub-hour values are +// rendered in minutes; sub-minute values fall back to "<1m". +func formatDaysHours(d time.Duration) string { + totalSeconds := int64(d.Seconds()) + if totalSeconds < 60 { + return "<1m" + } + const secondsPerHour = 3600 + const secondsPerDay = 24 * secondsPerHour + days := totalSeconds / secondsPerDay + hours := (totalSeconds % secondsPerDay) / secondsPerHour + if days > 0 { + return fmt.Sprintf("%dd %dh", days, hours) + } + if hours > 0 { + return fmt.Sprintf("%dh", hours) + } + minutes := totalSeconds / 60 + return fmt.Sprintf("%dm", minutes) +} + +// sortExitingValidatorsBySweep orders validators in withdrawal-sweep order relative to a given last validator index +func sortExitingValidatorsBySweep(validators []api.MegapoolValidatorDetails, lastWithdrawnIndex uint64, hasLastWithdrawnIndex bool) { + if !hasLastWithdrawnIndex { + sort.Sort(ByIndex(validators)) + return + } + sort.SliceStable(validators, func(i, j int) bool { + ai := validators[i].ValidatorIndex + aj := validators[j].ValidatorIndex + iAfter := ai > lastWithdrawnIndex + jAfter := aj > lastWithdrawnIndex + if iAfter != jAfter { + return iAfter + } + return ai < aj + }) +} + +func getExitableValidator(fetchExitQueueEstimate bool) (uint64, bool, error) { // Get RP client rp, err := rocketpool.NewClient().WithReady() if err != nil { @@ -27,6 +76,8 @@ func getExitableValidator() (uint64, bool, error) { defer rp.Close() // Get the latest block and identify the withdrawals present in it + var lastWithdrawnIndex uint64 + var hasLastWithdrawnIndex bool withdrawalsResp, err := rp.GetLatestBlockWithdrawals() if err != nil { fmt.Printf("Warning: could not fetch latest beacon block withdrawals: %s\n\n", err.Error()) @@ -37,6 +88,12 @@ func getExitableValidator() (uint64, bool, error) { indexes := make([]string, 0, len(withdrawalsResp.Withdrawals)) seen := make(map[string]struct{}, len(withdrawalsResp.Withdrawals)) for _, wd := range withdrawalsResp.Withdrawals { + if idx, perr := strconv.ParseUint(wd.ValidatorIndex, 10, 64); perr == nil { + if !hasLastWithdrawnIndex || idx > lastWithdrawnIndex { + lastWithdrawnIndex = idx + hasLastWithdrawnIndex = true + } + } if _, ok := seen[wd.ValidatorIndex]; ok { continue } @@ -47,6 +104,27 @@ func getExitableValidator() (uint64, bool, error) { withdrawalsResp.Slot, withdrawalsResp.BlockNumber, len(indexes)) fmt.Printf(" %s\n\n", strings.Join(indexes, ", ")) } + var estimate api.BeaconWithdrawalQueueEstimateResponse + if fetchExitQueueEstimate { + // Print an estimate of the beacon chain withdrawal queue time + fmt.Println("Fetching beacon chain exit queue estimate... This may take a while...") + if estimate, err = rp.GetBeaconWithdrawalQueueEstimate(); err != nil { + fmt.Printf("Warning: could not fetch beacon chain exit queue estimate: %s\n\n", err.Error()) + } else if estimate.ExitQueueGwei == 0 { + fmt.Println("The beacon chain exit queue is currently empty.") + 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", + formatGwei(estimate.ChurnPerEpochGwei), (time.Duration(estimate.SecondsPerEpoch) * time.Second).Round(time.Second)) + } else { + wait := formatDaysHours(time.Duration(estimate.EstimatedQueueSeconds) * time.Second) + fmt.Printf("Beacon chain exit queue: %s ETH waiting to exit.\n", + formatGwei(estimate.ExitQueueGwei)) + fmt.Printf("Churn limit: %s ETH/epoch -> estimated %d epochs (~%s) to process the queue.\n\n", + formatGwei(estimate.ChurnPerEpochGwei), estimate.EstimatedQueueEpochs, wait) + } + } else { + fmt.Println("Skipping the beacon chain exit queue estimate. Use the --fetch-estimate flag to fetch it.") + fmt.Println() + } // Get Megapool status status, err := rp.MegapoolStatus(false) @@ -69,10 +147,10 @@ func getExitableValidator() (uint64, bool, error) { exitingValidators = append(exitingValidators, validator) } } - if len(exitingValidators) > 0 { - // Make sure that exitingValidators is sorted by validator index ascending from the last withdrawal index - //sort.Sort(ByIndex(exitingValidators)) + // Print exiting validators + if len(exitingValidators) > 0 { + sortExitingValidatorsBySweep(exitingValidators, lastWithdrawnIndex, hasLastWithdrawnIndex) fmt.Println("The following validators are still active and have already received their exit request on the Beacon Chain:") for _, v := range exitingValidators { fmt.Printf("ID %d: - Index %d Pubkey: 0x%s\n", v.ValidatorId, v.ValidatorIndex, v.PubKey.String()) diff --git a/rocketpool-cli/megapool/exit-validator_test.go b/rocketpool-cli/megapool/exit-validator_test.go new file mode 100644 index 000000000..ab000403e --- /dev/null +++ b/rocketpool-cli/megapool/exit-validator_test.go @@ -0,0 +1,153 @@ +package megapool + +import ( + "fmt" + "reflect" + "strings" + "testing" + + "github.com/rocket-pool/smartnode/shared/types/api" +) + +// makeValidators builds a slice of MegapoolValidatorDetails with only the +// fields that sortExitingValidatorsBySweep cares about populated. ValidatorId +// is set to a distinct value derived from the index so we can also assert that +// the original elements (not just their indices) are preserved. +func makeValidators(indices ...uint64) []api.MegapoolValidatorDetails { + out := make([]api.MegapoolValidatorDetails, 0, len(indices)) + for i, idx := range indices { + out = append(out, api.MegapoolValidatorDetails{ + ValidatorId: uint32(1000 + i), + ValidatorIndex: idx, + }) + } + return out +} + +func indexesOf(validators []api.MegapoolValidatorDetails) []uint64 { + out := make([]uint64, len(validators)) + for i, v := range validators { + out[i] = v.ValidatorIndex + } + return out +} + +func TestSortExitingValidatorsBySweep(t *testing.T) { + tests := []struct { + name string + input []uint64 + lastWithdrawnIndex uint64 + hasLastWithdrawnIndex bool + want []uint64 + }{ + { + name: "empty slice does not panic", + input: []uint64{}, + lastWithdrawnIndex: 100, + hasLastWithdrawnIndex: true, + want: []uint64{}, + }, + { + name: "single validator above pivot", + input: []uint64{200}, + lastWithdrawnIndex: 100, + hasLastWithdrawnIndex: true, + want: []uint64{200}, + }, + { + name: "single validator below pivot", + input: []uint64{50}, + lastWithdrawnIndex: 100, + hasLastWithdrawnIndex: true, + want: []uint64{50}, + }, + { + name: "no pivot falls back to ascending order", + input: []uint64{300, 50, 200, 100}, + hasLastWithdrawnIndex: false, + want: []uint64{50, 100, 200, 300}, + }, + { + name: "pivot in the middle splits and sorts each half", + input: []uint64{300, 50, 200, 100, 400, 25}, + lastWithdrawnIndex: 150, + hasLastWithdrawnIndex: true, + want: []uint64{200, 300, 400, 25, 50, 100}, + }, + { + name: "pivot equal to a validator index puts that validator after the wrap", + input: []uint64{100, 50, 150, 200}, + lastWithdrawnIndex: 100, + hasLastWithdrawnIndex: true, + want: []uint64{150, 200, 50, 100}, + }, + { + name: "pivot above all validators wraps everyone (plain ascending)", + input: []uint64{50, 30, 10, 20}, + lastWithdrawnIndex: 1000, + hasLastWithdrawnIndex: true, + want: []uint64{10, 20, 30, 50}, + }, + { + name: "pivot below all validators leaves them all 'after' (plain ascending)", + input: []uint64{50, 30, 10, 20}, + lastWithdrawnIndex: 0, + hasLastWithdrawnIndex: true, + want: []uint64{10, 20, 30, 50}, + }, + { + name: "already in sweep order is unchanged", + input: []uint64{200, 300, 400, 25, 50, 100}, + lastWithdrawnIndex: 150, + hasLastWithdrawnIndex: true, + want: []uint64{200, 300, 400, 25, 50, 100}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + validators := makeValidators(tt.input...) + + sortExitingValidatorsBySweep(validators, tt.lastWithdrawnIndex, tt.hasLastWithdrawnIndex) + + got := indexesOf(validators) + // Full struct is unreadable in %v; print index order and compact id@index chain. + t.Logf("final sweep order (validator indices): %v", got) + parts := make([]string, len(validators)) + for i, v := range validators { + parts[i] = fmt.Sprintf("%d@%d", v.ValidatorId, v.ValidatorIndex) + } + idIndexLine := strings.Join(parts, " → ") + if idIndexLine == "" { + idIndexLine = "(empty)" + } + t.Logf("final sweep order (id@index): %s", idIndexLine) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("unexpected order: got %v, want %v", got, tt.want) + } + }) + } +} + +// TestSortExitingValidatorsBySweepPreservesElements confirms that the helper +// reorders the original elements (preserving fields like ValidatorId) rather +// than producing copies that drop unrelated data. +func TestSortExitingValidatorsBySweepPreservesElements(t *testing.T) { + validators := []api.MegapoolValidatorDetails{ + {ValidatorId: 11, ValidatorIndex: 300}, + {ValidatorId: 22, ValidatorIndex: 50}, + {ValidatorId: 33, ValidatorIndex: 200}, + {ValidatorId: 44, ValidatorIndex: 100}, + } + + sortExitingValidatorsBySweep(validators, 150, true) + + wantIndexes := []uint64{200, 300, 50, 100} + wantIDs := []uint32{33, 11, 22, 44} + for i, v := range validators { + if v.ValidatorIndex != wantIndexes[i] || v.ValidatorId != wantIDs[i] { + t.Fatalf("position %d: got (id=%d, index=%d), want (id=%d, index=%d)", + i, v.ValidatorId, v.ValidatorIndex, wantIDs[i], wantIndexes[i]) + } + } +} diff --git a/rocketpool/api/megapool/beacon-withdrawal-queue-estimate.go b/rocketpool/api/megapool/beacon-withdrawal-queue-estimate.go new file mode 100644 index 000000000..4818d7b18 --- /dev/null +++ b/rocketpool/api/megapool/beacon-withdrawal-queue-estimate.go @@ -0,0 +1,71 @@ +package megapool + +import ( + "fmt" + "math" + + "github.com/urfave/cli/v3" + + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/types/api" +) + +const ( + farFutureEpoch uint64 = math.MaxUint64 + perEpochActivationExitChurnLimit uint64 = 256_000_000_000 +) + +// getBeaconWithdrawalQueueEstimate estimates how long the current beacon-chain +// exit queue will take to be processed. +func getBeaconWithdrawalQueueEstimate(c *cli.Command) (*api.BeaconWithdrawalQueueEstimateResponse, error) { + if err := services.RequireBeaconClientSynced(c); err != nil { + return nil, err + } + bc, err := services.GetBeaconClient(c) + if err != nil { + return nil, err + } + + eth2Config, err := bc.GetEth2Config() + if err != nil { + return nil, fmt.Errorf("error getting eth2 config: %w", err) + } + + head, err := bc.GetBeaconHead() + if err != nil { + return nil, fmt.Errorf("error getting beacon head: %w", err) + } + currentEpoch := head.Epoch + + validators, err := bc.GetAllValidators() + if err != nil { + return nil, fmt.Errorf("error getting validator set: %w", err) + } + + // Walk the validator set once and collect the effective balance of validators currently waiting to exit + var exitQueueGwei uint64 + for _, v := range validators { + + // In the exit queue if exit_epoch is set and still in the future. + if v.ExitEpoch != farFutureEpoch && v.ExitEpoch > currentEpoch { + exitQueueGwei += v.EffectiveBalance + } + } + + churnPerEpochGwei := perEpochActivationExitChurnLimit + + // epochs needed to process the queue, rounded up + var estimatedEpochs uint64 + if churnPerEpochGwei > 0 && exitQueueGwei > 0 { + estimatedEpochs = (exitQueueGwei + churnPerEpochGwei - 1) / churnPerEpochGwei + } + estimatedSeconds := estimatedEpochs * eth2Config.SecondsPerEpoch + + return &api.BeaconWithdrawalQueueEstimateResponse{ + ExitQueueGwei: exitQueueGwei, + ChurnPerEpochGwei: churnPerEpochGwei, + SecondsPerEpoch: eth2Config.SecondsPerEpoch, + EstimatedQueueEpochs: estimatedEpochs, + EstimatedQueueSeconds: estimatedSeconds, + }, nil +} diff --git a/rocketpool/api/megapool/routes.go b/rocketpool/api/megapool/routes.go index ccffaec68..ec4085677 100644 --- a/rocketpool/api/megapool/routes.go +++ b/rocketpool/api/megapool/routes.go @@ -367,6 +367,11 @@ func RegisterRoutes(mux *http.ServeMux, c *cli.Command) { resp, err := getLatestBlockWithdrawals(c) apiutils.WriteResponse(w, resp, err) }) + + mux.HandleFunc("/api/megapool/beacon-withdrawal-queue-estimate", func(w http.ResponseWriter, r *http.Request) { + resp, err := getBeaconWithdrawalQueueEstimate(c) + apiutils.WriteResponse(w, resp, err) + }) } func parseUint64(r *http.Request, name string) (uint64, error) { diff --git a/shared/services/rocketpool/megapool.go b/shared/services/rocketpool/megapool.go index aa7204964..fb4745ae5 100644 --- a/shared/services/rocketpool/megapool.go +++ b/shared/services/rocketpool/megapool.go @@ -573,6 +573,22 @@ func (c *Client) GetLatestBlockWithdrawals() (api.LatestBlockWithdrawalsResponse return response, nil } +// Get an estimate of the beacon chain withdrawal-sweep cycle time +func (c *Client) GetBeaconWithdrawalQueueEstimate() (api.BeaconWithdrawalQueueEstimateResponse, error) { + responseBytes, err := c.callHTTPAPI("GET", "/api/megapool/beacon-withdrawal-queue-estimate", nil) + if err != nil { + return api.BeaconWithdrawalQueueEstimateResponse{}, fmt.Errorf("Could not get beacon withdrawal queue estimate: %w", err) + } + var response api.BeaconWithdrawalQueueEstimateResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return api.BeaconWithdrawalQueueEstimateResponse{}, fmt.Errorf("Could not decode beacon withdrawal queue estimate response: %w", err) + } + if response.Error != "" { + return api.BeaconWithdrawalQueueEstimateResponse{}, fmt.Errorf("Could not get beacon withdrawal queue estimate: %s", response.Error) + } + return response, nil +} + // Get the bond amount required for the megapool's next validator func (c *Client) GetNewValidatorBondRequirement() (api.GetNewValidatorBondRequirementResponse, error) { responseBytes, err := c.callHTTPAPI("GET", "/api/megapool/get-new-validator-bond-requirement", nil) diff --git a/shared/types/api/megapool.go b/shared/types/api/megapool.go index 42d5ad513..ed0e4906b 100644 --- a/shared/types/api/megapool.go +++ b/shared/types/api/megapool.go @@ -186,3 +186,13 @@ type LatestBlockWithdrawalsResponse struct { BlockNumber uint64 `json:"blockNumber"` Withdrawals []beacon.WithdrawalInfo `json:"withdrawals"` } + +type BeaconWithdrawalQueueEstimateResponse struct { + Status string `json:"status"` + Error string `json:"error"` + ExitQueueGwei uint64 `json:"exitQueueGwei"` + ChurnPerEpochGwei uint64 `json:"churnPerEpochGwei"` + SecondsPerEpoch uint64 `json:"secondsPerEpoch"` + EstimatedQueueEpochs uint64 `json:"estimatedQueueEpochs"` + EstimatedQueueSeconds uint64 `json:"estimatedQueueSeconds"` +}