diff --git a/cmd/dora-explorer/main.go b/cmd/dora-explorer/main.go index 11dda26d1..c61dc13b4 100644 --- a/cmd/dora-explorer/main.go +++ b/cmd/dora-explorer/main.go @@ -214,6 +214,7 @@ func startFrontend(router *mux.Router) { router.HandleFunc("/validators/summary", handlers.ValidatorsSummary).Methods("GET") } router.HandleFunc("/validators/activity", handlers.ValidatorsActivity).Methods("GET") + router.HandleFunc("/validators/withdrawal-dashboard", handlers.ValidatorsWithdrawalDashboard).Methods("GET") router.HandleFunc("/validators/offline", handlers.ValidatorsOffline).Methods("GET") router.HandleFunc("/validators/deposits", handlers.Deposits).Methods("GET") router.HandleFunc("/validators/deposits/submit", handlers.SubmitDeposit).Methods("GET", "POST") diff --git a/db/schema/pgsql/20260511120000_validator-withdrawal-credentials-index.sql b/db/schema/pgsql/20260511120000_validator-withdrawal-credentials-index.sql new file mode 100644 index 000000000..053866710 --- /dev/null +++ b/db/schema/pgsql/20260511120000_validator-withdrawal-credentials-index.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin + +CREATE INDEX IF NOT EXISTS "validators_withdrawal_credentials_idx" + ON public."validators" ("withdrawal_credentials"); + +-- +goose StatementEnd +-- +goose Down +-- +goose StatementBegin +SELECT 'NOT SUPPORTED'; +-- +goose StatementEnd diff --git a/db/schema/sqlite/20260511120000_validator-withdrawal-credentials-index.sql b/db/schema/sqlite/20260511120000_validator-withdrawal-credentials-index.sql new file mode 100644 index 000000000..798c0674c --- /dev/null +++ b/db/schema/sqlite/20260511120000_validator-withdrawal-credentials-index.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin + +CREATE INDEX IF NOT EXISTS "validators_withdrawal_credentials_idx" + ON "validators" ("withdrawal_credentials"); + +-- +goose StatementEnd +-- +goose Down +-- +goose StatementBegin +SELECT 'NOT SUPPORTED'; +-- +goose StatementEnd diff --git a/docs/docs.go b/docs/docs.go index 943583f90..65dd330df 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -2079,6 +2079,12 @@ const docTemplate = `{ "name": "status", "in": "query" }, + { + "type": "string", + "description": "Filter by withdrawal address or withdrawal credentials", + "name": "withdrawal", + "in": "query" + }, { "type": "string", "description": "Sort order: index, index-d, pubkey, pubkey-d, balance, balance-d, activation, activation-d, exit, exit-d", @@ -2143,13 +2149,13 @@ const docTemplate = `{ }, { "type": "integer", - "description": "Grouping option: 1=by 100k indexes, 2=by 10k indexes, 3=by validator names (default: 3 if names available, else 1)", + "description": "Grouping option: 1=by 100k indexes, 2=by 10k indexes, 3=by validator names, 4=by withdrawal address (default: 3 if names available, else 1)", "name": "group", "in": "query" }, { "type": "string", - "description": "Search term for group names (supports regex)", + "description": "Search term for group names, withdrawal addresses, or withdrawal credentials (supports regex for non-exact address searches)", "name": "search", "in": "query" }, diff --git a/docs/swagger.json b/docs/swagger.json index 4241809f4..1954bd4fd 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2076,6 +2076,12 @@ "name": "status", "in": "query" }, + { + "type": "string", + "description": "Filter by withdrawal address or withdrawal credentials", + "name": "withdrawal", + "in": "query" + }, { "type": "string", "description": "Sort order: index, index-d, pubkey, pubkey-d, balance, balance-d, activation, activation-d, exit, exit-d", @@ -2140,13 +2146,13 @@ }, { "type": "integer", - "description": "Grouping option: 1=by 100k indexes, 2=by 10k indexes, 3=by validator names (default: 3 if names available, else 1)", + "description": "Grouping option: 1=by 100k indexes, 2=by 10k indexes, 3=by validator names, 4=by withdrawal address (default: 3 if names available, else 1)", "name": "group", "in": "query" }, { "type": "string", - "description": "Search term for group names (supports regex)", + "description": "Search term for group names, withdrawal addresses, or withdrawal credentials (supports regex for non-exact address searches)", "name": "search", "in": "query" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 011848853..886c1729e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -3229,6 +3229,10 @@ paths: in: query name: status type: string + - description: Filter by withdrawal address or withdrawal credentials + in: query + name: withdrawal + type: string - description: 'Sort order: index, index-d, pubkey, pubkey-d, balance, balance-d, activation, activation-d, exit, exit-d' in: query @@ -3273,11 +3277,12 @@ paths: name: page type: integer - description: 'Grouping option: 1=by 100k indexes, 2=by 10k indexes, 3=by validator - names (default: 3 if names available, else 1)' + names, 4=by withdrawal address (default: 3 if names available, else 1)' in: query name: group type: integer - - description: Search term for group names (supports regex) + - description: Search term for group names, withdrawal addresses, or withdrawal + credentials (supports regex for non-exact address searches) in: query name: search type: string diff --git a/handlers/api/validator_withdrawalcredentials_v1.go b/handlers/api/validator_withdrawalcredentials_v1.go index 7ba1c216a..38a6bcbb9 100644 --- a/handlers/api/validator_withdrawalcredentials_v1.go +++ b/handlers/api/validator_withdrawalcredentials_v1.go @@ -1,14 +1,13 @@ package api import ( - "encoding/hex" "encoding/json" "net/http" "strconv" - "strings" "github.com/ethpandaops/dora/dbtypes" "github.com/ethpandaops/dora/services" + "github.com/ethpandaops/dora/utils" "github.com/gorilla/mux" "github.com/sirupsen/logrus" ) @@ -35,56 +34,37 @@ func ApiWithdrawalCredentialsValidatorsV1(w http.ResponseWriter, r *http.Request limitQuery := q.Get("limit") offsetQuery := q.Get("offset") - limit, err := strconv.ParseInt(limitQuery, 10, 64) + limit, err := strconv.ParseUint(limitQuery, 10, 64) if err != nil { limit = 2000 } - offset, err := strconv.ParseInt(offsetQuery, 10, 64) + offset, err := strconv.ParseUint(offsetQuery, 10, 64) if err != nil { offset = 0 } - if offset < 0 { - offset = 0 - } - - if limit > (2000+offset) || limit <= 0 || limit <= offset { - limit = 2000 + offset + if limit == 0 || limit > 2000 { + limit = 2000 } vars := mux.Vars(r) search := vars["withdrawalCredentialsOrEth1address"] - searchBytes, err := hex.DecodeString(strings.Replace(search, "0x", "", -1)) + withdrawalAddress, withdrawalCreds, err := utils.ParseWithdrawalAddressOrCredentials(search) if err != nil { - sendBadRequestResponse(w, r.URL.String(), "invalid eth1 address provided") + sendBadRequestResponse(w, r.URL.String(), "invalid withdrawal address or credentials provided") return } - filter := &dbtypes.ValidatorFilter{} - - if len(searchBytes) == 20 { - filter.WithdrawalAddress = searchBytes - } else { - filter.WithdrawalCreds = searchBytes + filter := &dbtypes.ValidatorFilter{ + WithdrawalAddress: withdrawalAddress, + WithdrawalCreds: withdrawalCreds, + Limit: limit, + Offset: offset, } relevantValidators, _ := services.GlobalBeaconService.GetFilteredValidatorSet(r.Context(), filter, true) - if offset > 0 { - if int(offset) > len(relevantValidators) { - relevantValidators = relevantValidators[offset:] - } else { - relevantValidators = relevantValidators[:0] - } - } - - if limit > 0 { - if len(relevantValidators) > int(limit) { - relevantValidators = relevantValidators[:limit] - } - } - data := []*ApiWithdrawalCredentialsResponseV1{} for _, validator := range relevantValidators { data = append(data, &ApiWithdrawalCredentialsResponseV1{ diff --git a/handlers/api/validators_activity_v1.go b/handlers/api/validators_activity_v1.go index 48d63370d..58a9c61a1 100644 --- a/handlers/api/validators_activity_v1.go +++ b/handlers/api/validators_activity_v1.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "fmt" "net/http" @@ -10,9 +11,12 @@ import ( "strings" "time" + "github.com/ethpandaops/dora/dbtypes" "github.com/ethpandaops/dora/indexer/beacon" "github.com/ethpandaops/dora/services" "github.com/ethpandaops/dora/types/models" + "github.com/ethpandaops/dora/utils" + v1 "github.com/ethpandaops/go-eth2-client/api/v1" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/sirupsen/logrus" ) @@ -60,8 +64,8 @@ type APIValidatorActivityGroup struct { // @Produce json // @Param limit query int false "Number of groups to return (max 1000, default 50)" // @Param page query int false "Page number (starts at 1)" -// @Param group query int false "Grouping option: 1=by 100k indexes, 2=by 10k indexes, 3=by validator names (default: 3 if names available, else 1)" -// @Param search query string false "Search term for group names (supports regex)" +// @Param group query int false "Grouping option: 1=by 100k indexes, 2=by 10k indexes, 3=by validator names, 4=by withdrawal address (default: 3 if names available, else 1)" +// @Param search query string false "Search term for group names, withdrawal addresses, or withdrawal credentials (supports regex for non-exact address searches)" // @Param order query string false "Sort order: group, group-d, count, count-d, active, active-d, online, online-d, offline, offline-d, exited, exited-d, slashed, slashed-d (default: group)" // @Success 200 {object} APIValidatorsActivityResponse // @Failure 400 {object} map[string]string "Invalid parameters" @@ -125,8 +129,8 @@ func APIValidatorsActivityV1(w http.ResponseWriter, r *http.Request) { groupBy = 1 } } - if groupBy < 1 || groupBy > 3 { - http.Error(w, `{"status": "ERROR: group parameter must be 1, 2, or 3"}`, http.StatusBadRequest) + if groupBy < 1 || groupBy > 4 { + http.Error(w, `{"status": "ERROR: group parameter must be 1, 2, 3, or 4"}`, http.StatusBadRequest) return } @@ -240,8 +244,15 @@ func buildValidatorsActivityAPIData(pageIdx uint64, pageSize uint64, sortOrder s // Group validators validatorGroupMap := map[string]*models.ValidatorsActiviyPageDataGroup{} currentEpoch := services.GlobalBeaconService.GetChainState().CurrentEpoch() + var withdrawalAddressFilter []byte + var withdrawalCredsFilter []byte + exactWithdrawalSearch := false + if groupBy == 4 && searchTerm != "" { + withdrawalAddressFilter, withdrawalCredsFilter, _ = utils.ParseWithdrawalAddressOrCredentials(searchTerm) + exactWithdrawalSearch = len(withdrawalAddressFilter) > 0 || len(withdrawalCredsFilter) > 0 + } - services.GlobalBeaconService.StreamActiveValidatorData(false, func(index phase0.ValidatorIndex, validatorFlags uint16, activeData *beacon.ValidatorData, validator *phase0.Validator) error { + addGroupValidator := func(index phase0.ValidatorIndex, validatorFlags uint16, activeData *beacon.ValidatorData, validator *phase0.Validator) { var groupKey string var groupName string @@ -257,8 +268,15 @@ func buildValidatorsActivityAPIData(pageIdx uint64, pageSize uint64, sortOrder s case 3: groupName = services.GlobalBeaconService.GetValidatorName(uint64(index)) groupKey = strings.ToLower(groupName) + case 4: + if validator != nil { + groupKey, groupName = utils.WithdrawalCredentialsGroup(validator.WithdrawalCredentials) + } } + if groupKey == "" { + groupKey = "unknown" + } validatorGroup := validatorGroupMap[groupKey] if validatorGroup == nil { validatorGroup = &models.ValidatorsActiviyPageDataGroup{ @@ -301,16 +319,48 @@ func buildValidatorsActivityAPIData(pageIdx uint64, pageSize uint64, sortOrder s if isExited { validatorGroup.Exited++ } + } - return nil - }) + if groupBy == 4 && exactWithdrawalSearch { + filteredValidators, _ := services.GlobalBeaconService.GetFilteredValidatorSet(context.Background(), &dbtypes.ValidatorFilter{ + WithdrawalAddress: withdrawalAddressFilter, + WithdrawalCreds: withdrawalCredsFilter, + OrderBy: dbtypes.ValidatorOrderIndexAsc, + }, false) + for _, validator := range filteredValidators { + if validator.Validator == nil { + continue + } + + flags := uint16(0) + if validator.Validator.Slashed { + flags |= beacon.ValidatorStatusSlashed + } + activeData := (*beacon.ValidatorData)(nil) + if validator.Status == v1.ValidatorStateActiveOngoing || validator.Status == v1.ValidatorStateActiveExiting || validator.Status == v1.ValidatorStateActiveSlashed { + activeData = &beacon.ValidatorData{ + ActivationEpoch: validator.Validator.ActivationEpoch, + ExitEpoch: validator.Validator.ExitEpoch, + } + } else if validator.Status == v1.ValidatorStateExitedUnslashed || validator.Status == v1.ValidatorStateExitedSlashed { + flags |= beacon.ValidatorStatusExited + } + + addGroupValidator(validator.Index, flags, activeData, validator.Validator) + } + } else { + services.GlobalBeaconService.StreamActiveValidatorData(false, func(index phase0.ValidatorIndex, validatorFlags uint16, activeData *beacon.ValidatorData, validator *phase0.Validator) error { + addGroupValidator(index, validatorFlags, activeData, validator) + return nil + }) + } // Filter groups based on search term validatorGroups := []*models.ValidatorsActiviyPageDataGroup{} // Check if search term is a valid regex pattern var searchRegex *regexp.Regexp - if searchTerm != "" { + if searchTerm != "" && !exactWithdrawalSearch { // Try to compile as regex var err error searchRegex, err = regexp.Compile("(?i)" + searchTerm) // Case-insensitive regex @@ -322,7 +372,7 @@ func buildValidatorsActivityAPIData(pageIdx uint64, pageSize uint64, sortOrder s for _, group := range validatorGroupMap { // Apply search filter - if searchTerm != "" { + if searchTerm != "" && !exactWithdrawalSearch { matched := false if searchRegex != nil { @@ -421,7 +471,9 @@ func buildValidatorsActivityAPIData(pageIdx uint64, pageSize uint64, sortOrder s if groupCount%pageSize != 0 { pageData.TotalPages++ } - pageData.LastPageIndex = pageData.TotalPages - 1 + if pageData.TotalPages > 0 { + pageData.LastPageIndex = pageData.TotalPages - 1 + } pageData.FirstGroup = startIdx pageData.LastGroup = endIdx diff --git a/handlers/api/validators_v1.go b/handlers/api/validators_v1.go index abebf5240..c4eafcfd2 100644 --- a/handlers/api/validators_v1.go +++ b/handlers/api/validators_v1.go @@ -10,6 +10,7 @@ import ( "github.com/ethpandaops/dora/dbtypes" "github.com/ethpandaops/dora/services" + "github.com/ethpandaops/dora/utils" v1 "github.com/ethpandaops/go-eth2-client/api/v1" "github.com/sirupsen/logrus" ) @@ -63,6 +64,7 @@ type APIValidatorInfo struct { // @Param index query string false "Filter by validator index" // @Param name query string false "Filter by validator name" // @Param status query string false "Filter by validator status (comma-separated)" +// @Param withdrawal query string false "Filter by withdrawal address or withdrawal credentials" // @Param order query string false "Sort order: index, index-d, pubkey, pubkey-d, balance, balance-d, activation, activation-d, exit, exit-d" // @Success 200 {object} APIValidatorsResponse // @Failure 400 {object} map[string]string "Invalid parameters" @@ -113,6 +115,7 @@ func APIValidatorsV1(w http.ResponseWriter, r *http.Request) { filterIndex := query.Get("index") filterName := query.Get("name") filterStatus := query.Get("status") + filterWithdrawal := query.Get("withdrawal") sortOrder := query.Get("order") // Build validator filter @@ -160,6 +163,16 @@ func APIValidatorsV1(w http.ResponseWriter, r *http.Request) { } } + if filterWithdrawal != "" { + withdrawalAddress, withdrawalCreds, err := utils.ParseWithdrawalAddressOrCredentials(filterWithdrawal) + if err != nil { + http.Error(w, `{"status": "ERROR: invalid withdrawal address or credentials"}`, http.StatusBadRequest) + return + } + validatorFilter.WithdrawalAddress = withdrawalAddress + validatorFilter.WithdrawalCreds = withdrawalCreds + } + // Apply sort order switch sortOrder { case "index-d": diff --git a/handlers/pageData.go b/handlers/pageData.go index 1aca021b5..61df23800 100644 --- a/handlers/pageData.go +++ b/handlers/pageData.go @@ -202,6 +202,11 @@ func createMenuItems(active string) []types.MainMenuItem { Path: "/validators/activity", Icon: "fa-tachometer", }) + validatorMenuLinks = append(validatorMenuLinks, types.NavigationLink{ + Label: "Withdrawal Dashboard", + Path: "/validators/withdrawal-dashboard", + Icon: "fa-wallet", + }) validatorMenu = append(validatorMenu, types.NavigationGroup{ Links: validatorMenuLinks, diff --git a/handlers/validators.go b/handlers/validators.go index c9c5c9e57..1d68ec42a 100644 --- a/handlers/validators.go +++ b/handlers/validators.go @@ -18,6 +18,7 @@ import ( "github.com/ethpandaops/dora/services" "github.com/ethpandaops/dora/templates" "github.com/ethpandaops/dora/types/models" + "github.com/ethpandaops/dora/utils" "github.com/sirupsen/logrus" ) @@ -50,6 +51,7 @@ func Validators(w http.ResponseWriter, r *http.Request) { var filterIndex string var filterName string var filterStatus string + var filterWithdrawal string if urlArgs.Has("f") { if urlArgs.Has("f.pubkey") { filterPubKey = urlArgs.Get("f.pubkey") @@ -63,6 +65,9 @@ func Validators(w http.ResponseWriter, r *http.Request) { if urlArgs.Has("f.status") { filterStatus = strings.Join(urlArgs["f.status"], ",") } + if urlArgs.Has("f.withdrawal") { + filterWithdrawal = urlArgs.Get("f.withdrawal") + } } var sortOrder string if urlArgs.Has("o") { @@ -72,7 +77,7 @@ func Validators(w http.ResponseWriter, r *http.Request) { var pageError error pageError = services.GlobalCallRateLimiter.CheckCallLimit(r, 1) if pageError == nil { - data.Data, pageError = getValidatorsPageData(pageNumber, pageSize, sortOrder, filterPubKey, filterIndex, filterName, filterStatus) + data.Data, pageError = getValidatorsPageData(pageNumber, pageSize, sortOrder, filterPubKey, filterIndex, filterName, filterStatus, filterWithdrawal) } if pageError != nil { handlePageError(w, r, pageError) @@ -94,11 +99,11 @@ func Validators(w http.ResponseWriter, r *http.Request) { } } -func getValidatorsPageData(pageNumber uint64, pageSize uint64, sortOrder string, filterPubKey string, filterIndex string, filterName string, filterStatus string) (*models.ValidatorsPageData, error) { +func getValidatorsPageData(pageNumber uint64, pageSize uint64, sortOrder string, filterPubKey string, filterIndex string, filterName string, filterStatus string, filterWithdrawal string) (*models.ValidatorsPageData, error) { pageData := &models.ValidatorsPageData{} - pageCacheKey := fmt.Sprintf("validators:%v:%v:%v:%v:%v:%v:%v", pageNumber, pageSize, sortOrder, filterPubKey, filterIndex, filterName, filterStatus) + pageCacheKey := fmt.Sprintf("validators:%v:%v:%v:%v:%v:%v:%v:%v", pageNumber, pageSize, sortOrder, filterPubKey, filterIndex, filterName, filterStatus, filterWithdrawal) pageRes, pageErr := services.GlobalFrontendCache.ProcessCachedPage(pageCacheKey, true, pageData, func(pageCall *services.FrontendCacheProcessingPage) interface{} { - pageData, cacheTimeout := buildValidatorsPageData(pageCall.CallCtx, pageNumber, pageSize, sortOrder, filterPubKey, filterIndex, filterName, filterStatus) + pageData, cacheTimeout := buildValidatorsPageData(pageCall.CallCtx, pageNumber, pageSize, sortOrder, filterPubKey, filterIndex, filterName, filterStatus, filterWithdrawal) pageCall.CacheTimeout = cacheTimeout return pageData }) @@ -112,8 +117,8 @@ func getValidatorsPageData(pageNumber uint64, pageSize uint64, sortOrder string, return pageData, pageErr } -func buildValidatorsPageData(ctx context.Context, pageNumber uint64, pageSize uint64, sortOrder string, filterPubKey string, filterIndex string, filterName string, filterStatus string) (*models.ValidatorsPageData, time.Duration) { - logrus.Debugf("validators page called: %v:%v:%v:%v:%v:%v:%v", pageNumber, pageSize, sortOrder, filterPubKey, filterIndex, filterName, filterStatus) +func buildValidatorsPageData(ctx context.Context, pageNumber uint64, pageSize uint64, sortOrder string, filterPubKey string, filterIndex string, filterName string, filterStatus string, filterWithdrawal string) (*models.ValidatorsPageData, time.Duration) { + logrus.Debugf("validators page called: %v:%v:%v:%v:%v:%v:%v:%v", pageNumber, pageSize, sortOrder, filterPubKey, filterIndex, filterName, filterStatus, filterWithdrawal) pageData := &models.ValidatorsPageData{} cacheTime := 10 * time.Minute @@ -125,7 +130,7 @@ func buildValidatorsPageData(ctx context.Context, pageNumber uint64, pageSize ui } filterArgs := url.Values{} - if filterPubKey != "" || filterIndex != "" || filterName != "" || filterStatus != "" { + if filterPubKey != "" || filterIndex != "" || filterName != "" || filterStatus != "" || filterWithdrawal != "" { if filterPubKey != "" { pageData.FilterPubKey = filterPubKey filterArgs.Add("f.pubkey", filterPubKey) @@ -157,6 +162,17 @@ func buildValidatorsPageData(ctx context.Context, pageNumber uint64, pageSize ui } } } + if filterWithdrawal != "" { + pageData.FilterWithdrawal = filterWithdrawal + filterArgs.Add("f.withdrawal", filterWithdrawal) + withdrawalAddress, withdrawalCreds, err := utils.ParseWithdrawalAddressOrCredentials(filterWithdrawal) + if err == nil { + validatorFilter.WithdrawalAddress = withdrawalAddress + validatorFilter.WithdrawalCreds = withdrawalCreds + } else { + validatorFilter.WithdrawalCreds = []byte{0xff} + } + } } // apply sort order diff --git a/handlers/validators_activity.go b/handlers/validators_activity.go index 6487e536f..0cee368ab 100644 --- a/handlers/validators_activity.go +++ b/handlers/validators_activity.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "fmt" "net/http" "net/url" @@ -10,10 +11,13 @@ import ( "strings" "time" + "github.com/ethpandaops/dora/dbtypes" "github.com/ethpandaops/dora/indexer/beacon" "github.com/ethpandaops/dora/services" "github.com/ethpandaops/dora/templates" "github.com/ethpandaops/dora/types/models" + "github.com/ethpandaops/dora/utils" + v1 "github.com/ethpandaops/go-eth2-client/api/v1" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/sirupsen/logrus" ) @@ -57,6 +61,9 @@ func ValidatorsActivity(w http.ResponseWriter, r *http.Request) { groupBy = 1 } } + if groupBy < 1 || groupBy > 4 { + groupBy = 1 + } // Parse filter parameters var searchTerm string @@ -126,8 +133,15 @@ func buildValidatorsActivityPageData(pageIdx uint64, pageSize uint64, sortOrder // group validators validatorGroupMap := map[string]*models.ValidatorsActiviyPageDataGroup{} currentEpoch := services.GlobalBeaconService.GetChainState().CurrentEpoch() + var withdrawalAddressFilter []byte + var withdrawalCredsFilter []byte + exactWithdrawalSearch := false + if groupBy == 4 && searchTerm != "" { + withdrawalAddressFilter, withdrawalCredsFilter, _ = utils.ParseWithdrawalAddressOrCredentials(searchTerm) + exactWithdrawalSearch = len(withdrawalAddressFilter) > 0 || len(withdrawalCredsFilter) > 0 + } - services.GlobalBeaconService.StreamActiveValidatorData(false, func(index phase0.ValidatorIndex, validatorFlags uint16, activeData *beacon.ValidatorData, validator *phase0.Validator) error { + addGroupValidator := func(index phase0.ValidatorIndex, validatorFlags uint16, activeData *beacon.ValidatorData, validator *phase0.Validator) { var groupKey string var groupName string @@ -143,8 +157,15 @@ func buildValidatorsActivityPageData(pageIdx uint64, pageSize uint64, sortOrder case 3: groupName = services.GlobalBeaconService.GetValidatorName(uint64(index)) groupKey = strings.ToLower(groupName) + case 4: + if validator != nil { + groupKey, groupName = utils.WithdrawalCredentialsGroup(validator.WithdrawalCredentials) + } } + if groupKey == "" { + groupKey = "unknown" + } validatorGroup := validatorGroupMap[groupKey] if validatorGroup == nil { validatorGroup = &models.ValidatorsActiviyPageDataGroup{ @@ -187,16 +208,48 @@ func buildValidatorsActivityPageData(pageIdx uint64, pageSize uint64, sortOrder if isExited { validatorGroup.Exited++ } + } - return nil - }) + if groupBy == 4 && exactWithdrawalSearch { + filteredValidators, _ := services.GlobalBeaconService.GetFilteredValidatorSet(context.Background(), &dbtypes.ValidatorFilter{ + WithdrawalAddress: withdrawalAddressFilter, + WithdrawalCreds: withdrawalCredsFilter, + OrderBy: dbtypes.ValidatorOrderIndexAsc, + }, false) + for _, validator := range filteredValidators { + if validator.Validator == nil { + continue + } + + flags := uint16(0) + if validator.Validator.Slashed { + flags |= beacon.ValidatorStatusSlashed + } + activeData := (*beacon.ValidatorData)(nil) + if validator.Status == v1.ValidatorStateActiveOngoing || validator.Status == v1.ValidatorStateActiveExiting || validator.Status == v1.ValidatorStateActiveSlashed { + activeData = &beacon.ValidatorData{ + ActivationEpoch: validator.Validator.ActivationEpoch, + ExitEpoch: validator.Validator.ExitEpoch, + } + } else if validator.Status == v1.ValidatorStateExitedUnslashed || validator.Status == v1.ValidatorStateExitedSlashed { + flags |= beacon.ValidatorStatusExited + } + + addGroupValidator(validator.Index, flags, activeData, validator.Validator) + } + } else { + services.GlobalBeaconService.StreamActiveValidatorData(false, func(index phase0.ValidatorIndex, validatorFlags uint16, activeData *beacon.ValidatorData, validator *phase0.Validator) error { + addGroupValidator(index, validatorFlags, activeData, validator) + return nil + }) + } // filter groups based on search term validatorGroups := []*models.ValidatorsActiviyPageDataGroup{} // Check if search term is a valid regex pattern var searchRegex *regexp.Regexp - if searchTerm != "" { + if searchTerm != "" && !exactWithdrawalSearch { // Try to compile as regex var err error searchRegex, err = regexp.Compile("(?i)" + searchTerm) // Case-insensitive regex @@ -208,7 +261,7 @@ func buildValidatorsActivityPageData(pageIdx uint64, pageSize uint64, sortOrder for _, group := range validatorGroupMap { // Apply search filter - if searchTerm != "" { + if searchTerm != "" && !exactWithdrawalSearch { matched := false if searchRegex != nil { @@ -308,7 +361,9 @@ func buildValidatorsActivityPageData(pageIdx uint64, pageSize uint64, sortOrder if groupCount%pageSize != 0 { pageData.TotalPages++ } - pageData.LastPageIndex = pageData.TotalPages - 1 + if pageData.TotalPages > 0 { + pageData.LastPageIndex = pageData.TotalPages - 1 + } pageData.FirstGroup = startIdx pageData.LastGroup = endIdx diff --git a/handlers/validators_offline.go b/handlers/validators_offline.go index e058db87b..1b0fe0d6a 100644 --- a/handlers/validators_offline.go +++ b/handlers/validators_offline.go @@ -14,6 +14,7 @@ import ( "github.com/ethpandaops/dora/services" "github.com/ethpandaops/dora/templates" "github.com/ethpandaops/dora/types/models" + "github.com/ethpandaops/dora/utils" v1 "github.com/ethpandaops/go-eth2-client/api/v1" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/sirupsen/logrus" @@ -58,6 +59,9 @@ func ValidatorsOffline(w http.ResponseWriter, r *http.Request) { groupBy = 1 } } + if groupBy < 1 || groupBy > 4 { + groupBy = 1 + } var groupKey string if urlArgs.Has("key") { @@ -133,8 +137,26 @@ func buildValidatorsOfflinePageData(ctx context.Context, pageIdx uint64, pageSiz groupName = fmt.Sprintf("%v - %v", groupIdx*10000, (groupIdx+1)*10000) case 3: groupName = groupKey + case 4: + if groupKey == "no-address" { + groupName = "no address" + } else { + withdrawalAddress, withdrawalCreds, err := utils.ParseWithdrawalAddressOrCredentials(groupKey) + if err == nil { + if len(withdrawalAddress) > 0 { + groupKey = fmt.Sprintf("%x", withdrawalAddress) + groupName = fmt.Sprintf("0x%x", withdrawalAddress) + } else { + groupKey = fmt.Sprintf("%x", withdrawalCreds) + groupName = fmt.Sprintf("0x%x", withdrawalCreds) + } + } else { + groupName = groupKey + } + } } pageData.GroupName = groupName + pageData.GroupKey = groupKey // collect offline validators offlineIndices := []phase0.ValidatorIndex{} @@ -152,6 +174,10 @@ func buildValidatorsOfflinePageData(ctx context.Context, pageIdx uint64, pageSiz validatorGroupKey = fmt.Sprintf("%06d", groupIdx) case 3: validatorGroupKey = strings.ToLower(services.GlobalBeaconService.GetValidatorName(uint64(index))) + case 4: + if validator != nil { + validatorGroupKey, _ = utils.WithdrawalCredentialsGroup(validator.WithdrawalCredentials) + } } if validatorGroupKey != groupKey { diff --git a/handlers/validators_withdrawal_dashboard.go b/handlers/validators_withdrawal_dashboard.go new file mode 100644 index 000000000..709fff73a --- /dev/null +++ b/handlers/validators_withdrawal_dashboard.go @@ -0,0 +1,341 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/ethpandaops/dora/dbtypes" + "github.com/ethpandaops/dora/services" + "github.com/ethpandaops/dora/templates" + "github.com/ethpandaops/dora/types/models" + "github.com/ethpandaops/dora/utils" + v1 "github.com/ethpandaops/go-eth2-client/api/v1" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/sirupsen/logrus" +) + +const validatorsWithdrawalDashboardTableLimit = 250 +const validatorsWithdrawalDashboardLivenessEpochs = 3 + +// ValidatorsWithdrawalDashboard returns a validator performance dashboard scoped to one withdrawal address. +func ValidatorsWithdrawalDashboard(w http.ResponseWriter, r *http.Request) { + var templateFiles = append(layoutTemplateFiles, + "validators_withdrawal_dashboard/validators_withdrawal_dashboard.html", + "_svg/professor.html", + ) + + pageTemplate := templates.GetTemplate(templateFiles...) + data := InitPageData(w, r, "validators", "/validators/withdrawal-dashboard", "Withdrawal Dashboard", templateFiles) + + urlArgs := r.URL.Query() + query := strings.TrimSpace(urlArgs.Get("withdrawal")) + if query == "" { + query = strings.TrimSpace(urlArgs.Get("address")) + } + if query == "" { + query = strings.TrimSpace(urlArgs.Get("q")) + } + + var pageError error + pageError = services.GlobalCallRateLimiter.CheckCallLimit(r, 1) + if pageError == nil { + data.Data, pageError = getValidatorsWithdrawalDashboardPageData(query) + } + if pageError != nil { + handlePageError(w, r, pageError) + return + } + + if urlArgs.Has("json") { + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(data.Data) + if err != nil { + logrus.WithError(err).Error("error encoding withdrawal dashboard data") + http.Error(w, "Internal server error", http.StatusServiceUnavailable) + } + return + } + + w.Header().Set("Content-Type", "text/html") + if handleTemplateError(w, r, "validators_withdrawal_dashboard.go", "ValidatorsWithdrawalDashboard", "", pageTemplate.ExecuteTemplate(w, "layout", data)) != nil { + return + } +} + +func getValidatorsWithdrawalDashboardPageData(query string) (*models.ValidatorsWithdrawalDashboardPageData, error) { + pageData := &models.ValidatorsWithdrawalDashboardPageData{} + pageCacheKey := fmt.Sprintf("validators_withdrawal_dashboard:%v", query) + pageRes, pageErr := services.GlobalFrontendCache.ProcessCachedPage(pageCacheKey, true, pageData, func(pageCall *services.FrontendCacheProcessingPage) interface{} { + pageData, cacheTimeout := buildValidatorsWithdrawalDashboardPageData(query) + pageCall.CacheTimeout = cacheTimeout + return pageData + }) + if pageErr == nil && pageRes != nil { + resData, resOk := pageRes.(*models.ValidatorsWithdrawalDashboardPageData) + if !resOk { + return nil, ErrInvalidPageModel + } + pageData = resData + } + return pageData, pageErr +} + +func buildValidatorsWithdrawalDashboardPageData(query string) (*models.ValidatorsWithdrawalDashboardPageData, time.Duration) { + pageData := &models.ValidatorsWithdrawalDashboardPageData{ + Query: query, + HasQuery: query != "", + Validators: []*models.ValidatorsWithdrawalDashboardValidator{}, + } + cacheTime := 10 * time.Second + + if query == "" { + return pageData, cacheTime + } + + withdrawalAddress, withdrawalCreds, err := utils.ParseWithdrawalAddressOrCredentials(query) + if err != nil { + pageData.Error = err.Error() + return pageData, cacheTime + } + + pageData.NormalizedAddress = withdrawalAddress + pageData.Credentials = withdrawalCreds + if len(withdrawalAddress) == 0 && len(withdrawalCreds) == 32 { + pageData.NormalizedAddress = utils.WithdrawalCredentialsAddress(withdrawalCreds) + } + + filter := dbtypes.ValidatorFilter{ + WithdrawalAddress: withdrawalAddress, + WithdrawalCreds: withdrawalCreds, + OrderBy: dbtypes.ValidatorOrderIndexAsc, + } + validators, validatorCount := services.GlobalBeaconService.GetFilteredValidatorSet(context.Background(), &filter, true) + pageData.ValidatorCount = validatorCount + sort.SliceStable(validators, func(i, j int) bool { + iRank := validatorWithdrawalDashboardStateRank(validators[i].Status) + jRank := validatorWithdrawalDashboardStateRank(validators[j].Status) + if iRank != jRank { + return iRank < jRank + } + return validators[i].Index < validators[j].Index + }) + + queryArgs := url.Values{} + queryArgs.Set("f.withdrawal", query) + pageData.ValidatorsLink = fmt.Sprintf("/validators?f&%v", queryArgs.Encode()) + activityArgs := url.Values{} + activityArgs.Set("group", "4") + activityArgs.Set("search", query) + pageData.ActivityLink = fmt.Sprintf("/validators/activity?%v", activityArgs.Encode()) + + currentEpoch := services.GlobalBeaconService.GetChainState().CurrentEpoch() + livenessStartEpoch := validatorsWithdrawalDashboardLivenessStartEpoch(currentEpoch) + livenessEpochs, participationLoading, participationLoadingPct := validatorsWithdrawalDashboardLivenessWindow(currentEpoch, livenessStartEpoch) + + livenessTotal := uint64(0) + for _, validator := range validators { + if validator.Validator == nil { + continue + } + + validatorData := &models.ValidatorsWithdrawalDashboardValidator{ + Index: uint64(validator.Index), + Name: services.GlobalBeaconService.GetValidatorName(uint64(validator.Index)), + PublicKey: validator.Validator.PublicKey[:], + Balance: uint64(validator.Balance), + EffectiveBalance: uint64(validator.Validator.EffectiveBalance), + State: validatorWithdrawalDashboardState(validator.Status), + LivenessMax: uint8(livenessEpochs), + WithdrawalAddress: utils.WithdrawalCredentialsAddress(validator.Validator.WithdrawalCredentials), + } + + pageData.TotalBalance += validatorData.Balance + pageData.TotalEffectiveBalance += validatorData.EffectiveBalance + + isActive := validator.Status == v1.ValidatorStateActiveOngoing || + validator.Status == v1.ValidatorStateActiveExiting || + validator.Status == v1.ValidatorStateActiveSlashed + if isActive { + pageData.ActiveCount++ + validatorData.ShowLiveness = true + liveness, _ := services.GlobalBeaconService.GetBeaconIndexer().GetValidatorActivityCount(validator.Index, livenessStartEpoch) + if liveness > uint64(livenessEpochs) { + liveness = uint64(livenessEpochs) + } + validatorData.Liveness = uint8(liveness) + validatorData.LivenessPercent = float64(liveness) * 100 / float64(livenessEpochs) + livenessTotal += liveness + if liveness > 0 { + pageData.OnlineCount++ + } else { + pageData.OfflineCount++ + } + } + + switch validatorData.State { + case "Pending": + pageData.PendingCount++ + case "Exiting": + pageData.ExitingCount++ + case "Exited": + pageData.ExitedCount++ + case "Withdrawable": + pageData.WithdrawableCount++ + case "Withdrawn": + pageData.WithdrawnCount++ + case "Slashed": + pageData.SlashedCount++ + } + + if len(pageData.Validators) < validatorsWithdrawalDashboardTableLimit { + pageData.Validators = append(pageData.Validators, validatorData) + } + } + pageData.QueuedDepositCount = getWithdrawalDashboardQueuedDepositCount(withdrawalAddress, withdrawalCreds) + pageData.PendingTotalCount = pageData.PendingCount + pageData.QueuedDepositCount + + pageData.DisplayedValidatorCount = uint64(len(pageData.Validators)) + if pageData.ActiveCount > 0 { + pageData.OnlineRate = float64(pageData.OnlineCount) * 100 / float64(pageData.ActiveCount) + pageData.OfflineRate = float64(pageData.OfflineCount) * 100 / float64(pageData.ActiveCount) + pageData.ParticipationRate = float64(livenessTotal) * 100 / float64(pageData.ActiveCount*uint64(livenessEpochs)) + } + pageData.ParticipationLoading = pageData.ActiveCount > 0 && participationLoading + if pageData.ParticipationLoading { + pageData.ParticipationLoadingPct = participationLoadingPct + pageData.ParticipationLoadingText = "Still indexing all validators. This will auto-update when ready." + } + pageData.HealthStatus = validatorsWithdrawalDashboardHealth(pageData.ParticipationRate) + + return pageData, cacheTime +} + +func getWithdrawalDashboardQueuedDepositCount(withdrawalAddress []byte, withdrawalCreds []byte) uint64 { + depositQueue := services.GlobalBeaconService.GetBeaconIndexer().GetLatestDepositQueue(nil) + if len(depositQueue) == 0 { + return 0 + } + + newValidators := map[string]bool{} + for _, deposit := range depositQueue { + if deposit == nil { + continue + } + if len(withdrawalAddress) > 0 { + wdcreds := deposit.WithdrawalCredentials[:] + if wdcreds[0] != 0x01 && wdcreds[0] != 0x02 { + continue + } + if !bytes.Equal(wdcreds[12:], withdrawalAddress) { + continue + } + } + if len(withdrawalCreds) > 0 && !bytes.Equal(deposit.WithdrawalCredentials[:], withdrawalCreds) { + continue + } + + if _, found := services.GlobalBeaconService.GetValidatorIndexByPubkey(deposit.Pubkey); found { + continue + } + newValidators[string(deposit.Pubkey[:])] = true + } + + return uint64(len(newValidators)) +} + +func validatorWithdrawalDashboardState(status v1.ValidatorState) string { + switch status { + case v1.ValidatorStatePendingInitialized, v1.ValidatorStatePendingQueued: + return "Pending" + } + switch status { + case v1.ValidatorStateActiveOngoing: + return "Active" + case v1.ValidatorStateActiveExiting: + return "Exiting" + case v1.ValidatorStateActiveSlashed: + return "Slashed" + case v1.ValidatorStateExitedUnslashed: + return "Exited" + case v1.ValidatorStateExitedSlashed: + return "Slashed" + case v1.ValidatorStateWithdrawalPossible: + return "Withdrawable" + case v1.ValidatorStateWithdrawalDone: + return "Withdrawn" + default: + return status.String() + } +} + +func validatorWithdrawalDashboardStateRank(status v1.ValidatorState) int { + switch status { + case v1.ValidatorStateActiveOngoing: + return 0 + case v1.ValidatorStateActiveExiting: + return 1 + case v1.ValidatorStateActiveSlashed: + return 2 + case v1.ValidatorStatePendingInitialized, v1.ValidatorStatePendingQueued: + return 3 + case v1.ValidatorStateExitedUnslashed: + return 4 + case v1.ValidatorStateExitedSlashed: + return 5 + case v1.ValidatorStateWithdrawalPossible: + return 6 + case v1.ValidatorStateWithdrawalDone: + return 7 + default: + return 8 + } +} + +func validatorsWithdrawalDashboardHealth(participationRate float64) string { + if participationRate >= 95 { + return "healthy" + } + if participationRate >= 90 { + return "warning" + } + if participationRate > 0 { + return "critical" + } + return "unknown" +} + +func validatorsWithdrawalDashboardLivenessStartEpoch(currentEpoch phase0.Epoch) phase0.Epoch { + if currentEpoch > validatorsWithdrawalDashboardLivenessEpochs { + return currentEpoch - validatorsWithdrawalDashboardLivenessEpochs + } + return 0 +} + +func validatorsWithdrawalDashboardLivenessWindow(currentEpoch phase0.Epoch, startEpoch phase0.Epoch) (phase0.Epoch, bool, float64) { + targetEpochs := phase0.Epoch(validatorsWithdrawalDashboardLivenessEpochs) + _, oldestActivityEpoch := services.GlobalBeaconService.GetBeaconIndexer().GetValidatorActivityCount(0, startEpoch) + if oldestActivityEpoch <= startEpoch { + return targetEpochs, false, 100 + } + if oldestActivityEpoch > currentEpoch { + return 1, true, 0 + } + + readyEpochs := currentEpoch - oldestActivityEpoch + if readyEpochs >= targetEpochs { + return targetEpochs, false, 100 + } + + livenessEpochs := readyEpochs + if livenessEpochs < 1 { + livenessEpochs = 1 + } + return livenessEpochs, true, float64(readyEpochs) * 100 / float64(targetEpochs) +} diff --git a/services/chainservice_deposits.go b/services/chainservice_deposits.go index e951189a6..2db9d1c43 100644 --- a/services/chainservice_deposits.go +++ b/services/chainservice_deposits.go @@ -635,12 +635,14 @@ func (bs *ChainService) getLastIncludedDeposit(ctx context.Context, headRoot pha } type QueuedDepositFilter struct { - MinIndex uint64 - MaxIndex uint64 - NoIndex bool - PublicKey []byte - MinAmount uint64 - MaxAmount uint64 + MinIndex uint64 + MaxIndex uint64 + NoIndex bool + PublicKey []byte + WithdrawalAddress []byte + WithdrawalCreds []byte + MinAmount uint64 + MaxAmount uint64 } func (bs *ChainService) GetFilteredQueuedDeposits(ctx context.Context, filter *QueuedDepositFilter) []*IndexedDepositQueueEntry { @@ -669,6 +671,18 @@ func (bs *ChainService) GetFilteredQueuedDeposits(ctx context.Context, filter *Q if len(filter.PublicKey) > 0 && !bytes.Equal(filter.PublicKey, entry.PendingDeposit.Pubkey[:]) { continue } + if len(filter.WithdrawalAddress) > 0 { + wdcreds := entry.PendingDeposit.WithdrawalCredentials[:] + if wdcreds[0] != 0x01 && wdcreds[0] != 0x02 { + continue + } + if !bytes.Equal(wdcreds[12:], filter.WithdrawalAddress) { + continue + } + } + if len(filter.WithdrawalCreds) > 0 && !bytes.Equal(entry.PendingDeposit.WithdrawalCredentials[:], filter.WithdrawalCreds) { + continue + } if filter.MinAmount > 0 && uint64(entry.PendingDeposit.Amount) < filter.MinAmount { continue } diff --git a/templates/validators/validators.html b/templates/validators/validators.html index cec987fac..c64e11f50 100644 --- a/templates/validators/validators.html +++ b/templates/validators/validators.html @@ -47,6 +47,14 @@