diff --git a/README.md b/README.md index 718c1978..2c2cdb1f 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,10 @@ Things that might be worth adding at some time * Validator Overview & Details\ The current validator set is actually already maintained in memory. So it should be easy to add pages for basic validator related stuff. - * [ ] Page: Validators List (`/validators`) - * [ ] Page: Validator Details (`/validator/{validator_index}`) - * [ ] Rough overview with status (activated, slashed, ...) & current balance - * [ ] Recent Blocks (from db) + * [x] Page: Validators List (`/validators`) + * [x] Page: Validator Details (`/validator/{validator_index}`) + * [x] Rough overview with status (activated, slashed, ...) & current balance + * [x] Recent Blocks (from db) * [ ] Recent Attestations (from cache) * Track Sync Committees * [ ] Database: table sync_committees (Sync Committee index) diff --git a/cmd/explorer/main.go b/cmd/explorer/main.go index 025ff945..71d6ed90 100644 --- a/cmd/explorer/main.go +++ b/cmd/explorer/main.go @@ -63,6 +63,8 @@ func startFrontend() { router.HandleFunc("/slot/{slotOrHash}", handlers.Slot).Methods("GET") router.HandleFunc("/search", handlers.Search).Methods("GET") router.HandleFunc("/search/{type}", handlers.SearchAhead).Methods("GET") + router.HandleFunc("/validators", handlers.Validators).Methods("GET") + router.HandleFunc("/validator/{idxOrPubKey}", handlers.Validator).Methods("GET") if utils.Config.Frontend.Debug { // serve files from local directory when debugging, instead of from go embed file diff --git a/config/default.config.yml b/config/default.config.yml index 53d31403..7502f3e4 100644 --- a/config/default.config.yml +++ b/config/default.config.yml @@ -3,7 +3,6 @@ chain: name: "mainnet" #genesisTimestamp: 1688126460 - #genesisValidatorsRoot: "0xbf3c3d4683a5a4d286cd2a5ef7a5c1702f649eee82cdc7e87e05030102d12ccf" #configPath: "../ephemery/config.yaml" #displayName: "Ephemery Iteration xy" @@ -31,10 +30,10 @@ beaconapi: # CL Client RPC endpoint: "http://127.0.0.1:5052" - # local cache for RPC calls + # local cache for page models localCacheSize: 100 # 100MB - # remote cache for RPC calls & page models + # remote cache for page models redisCacheAddr: "" redisCachePrefix: "" diff --git a/db/db.go b/db/db.go index d894aa7f..14050f9e 100644 --- a/db/db.go +++ b/db/db.go @@ -18,6 +18,7 @@ import ( "github.com/jackc/pgx/v4/pgxpool" _ "github.com/jackc/pgx/v4/stdlib" + "github.com/mitchellh/mapstructure" ) //go:embed migrations/*.sql @@ -358,6 +359,73 @@ func GetBlocksWithGraffiti(graffiti string, firstSlot uint64, offset uint64, lim return blocks } +func GetAssignedBlocks(proposer uint64, firstSlot uint64, offset uint64, limit uint32, withOrphaned bool) []*dbtypes.AssignedBlock { + blockAssignments := []*dbtypes.AssignedBlock{} + orphanedLimit := "" + if !withOrphaned { + orphanedLimit = "AND NOT orphaned" + } + var sql strings.Builder + fmt.Fprintf(&sql, `SELECT slot_assignments.slot, slot_assignments.proposer`) + blockFields := []string{ + "root", "slot", "parent_root", "state_root", "orphaned", "proposer", "graffiti", "graffiti_text", + "attestation_count", "deposit_count", "exit_count", "withdraw_count", "withdraw_amount", "attester_slashing_count", + "proposer_slashing_count", "bls_change_count", "eth_transaction_count", "eth_block_number", "eth_block_hash", "sync_participation", + } + for _, blockField := range blockFields { + fmt.Fprintf(&sql, ", blocks.%v AS \"block.%v\"", blockField, blockField) + } + fmt.Fprintf(&sql, ` + FROM slot_assignments + LEFT JOIN blocks ON blocks.slot = slot_assignments.slot + WHERE slot_assignments.proposer = $1 AND slot_assignments.slot < $2 `+orphanedLimit+` + ORDER BY slot_assignments.slot DESC + LIMIT $3 OFFSET $4 + `) + rows, err := ReaderDb.Query(sql.String(), proposer, firstSlot, limit, offset) + if err != nil { + logger.Errorf("Error while fetching blocks: %v", err) + return nil + } + + scanArgs := make([]interface{}, len(blockFields)+2) + for rows.Next() { + scanVals := make([]interface{}, len(blockFields)+2) + for i := range scanArgs { + scanArgs[i] = &scanVals[i] + } + err := rows.Scan(scanArgs...) + if err != nil { + logger.Errorf("Error while parsing block: %v", err) + continue + } + + blockAssignment := dbtypes.AssignedBlock{} + blockAssignment.Slot = uint64(scanVals[0].(int64)) + blockAssignment.Proposer = uint64(scanVals[1].(int64)) + + if scanVals[2] != nil { + blockValMap := map[string]interface{}{} + for idx, fName := range blockFields { + blockValMap[fName] = scanVals[idx+2] + } + var block dbtypes.Block + cfg := &mapstructure.DecoderConfig{ + Metadata: nil, + Result: &block, + TagName: "db", + } + decoder, _ := mapstructure.NewDecoder(cfg) + decoder.Decode(blockValMap) + blockAssignment.Block = &block + } + + blockAssignments = append(blockAssignments, &blockAssignment) + } + + return blockAssignments +} + func GetSlotAssignmentsForSlots(firstSlot uint64, lastSlot uint64) []*dbtypes.SlotAssignment { assignments := []*dbtypes.SlotAssignment{} err := ReaderDb.Select(&assignments, ` diff --git a/dbtypes/other.go b/dbtypes/other.go new file mode 100644 index 00000000..277cd1c1 --- /dev/null +++ b/dbtypes/other.go @@ -0,0 +1,7 @@ +package dbtypes + +type AssignedBlock struct { + Slot uint64 `db:"slot"` + Proposer uint64 `db:"proposer"` + Block *Block `db:"block"` +} diff --git a/go.mod b/go.mod index 42bce145..5a06227c 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +require github.com/mitchellh/mapstructure v1.5.0 // indirect + require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect diff --git a/go.sum b/go.sum index 51e7d51e..89918d67 100644 --- a/go.sum +++ b/go.sum @@ -118,6 +118,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= diff --git a/handlers/pageData.go b/handlers/pageData.go index 0d5f6771..1eae4a05 100644 --- a/handlers/pageData.go +++ b/handlers/pageData.go @@ -103,6 +103,15 @@ func createMenuItems(active string, isMain bool) []types.MainMenuItem { }, }, }, + { + Links: []types.NavigationLink{ + { + Label: "Validators", + Path: "/validators", + Icon: "fa-table", + }, + }, + }, }, }, } diff --git a/handlers/validator.go b/handlers/validator.go new file mode 100644 index 00000000..e6f2c199 --- /dev/null +++ b/handlers/validator.go @@ -0,0 +1,174 @@ +package handlers + +import ( + "bytes" + "encoding/hex" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gorilla/mux" + "github.com/sirupsen/logrus" + + "github.com/pk910/light-beaconchain-explorer/rpctypes" + "github.com/pk910/light-beaconchain-explorer/services" + "github.com/pk910/light-beaconchain-explorer/templates" + "github.com/pk910/light-beaconchain-explorer/types/models" + "github.com/pk910/light-beaconchain-explorer/utils" +) + +// Validator will return the main "validator" page using a go template +func Validator(w http.ResponseWriter, r *http.Request) { + var validatorTemplateFiles = append(layoutTemplateFiles, + "validator/validator.html", + "validator/recentBlocks.html", + "_svg/timeline.html", + ) + var notfoundTemplateFiles = append(layoutTemplateFiles, + "validator/notfound.html", + ) + + var pageTemplate = templates.GetTemplate(validatorTemplateFiles...) + + w.Header().Set("Content-Type", "text/html") + data := InitPageData(w, r, "validators", "/validator", "Validator", validatorTemplateFiles) + + validatorSetRsp := services.GlobalBeaconService.GetCachedValidatorSet() + var validator *rpctypes.ValidatorEntry + if validatorSetRsp != nil { + vars := mux.Vars(r) + idxOrPubKey := strings.Replace(vars["idxOrPubKey"], "0x", "", -1) + validatorPubKey, err := hex.DecodeString(idxOrPubKey) + if err != nil || len(validatorPubKey) != 48 { + // search by index^ + validatorIndex, err := strconv.ParseUint(vars["idxOrPubKey"], 10, 64) + if err == nil && validatorIndex < uint64(len(validatorSetRsp.Data)) { + validator = &validatorSetRsp.Data[validatorIndex] + } + } else { + // search by pubkey + for _, val := range validatorSetRsp.Data { + if bytes.Equal(val.Validator.PubKey, validatorPubKey) { + validator = &val + break + } + } + } + } + + if validator == nil { + data := InitPageData(w, r, "blockchain", "/validator", "Validator not found", notfoundTemplateFiles) + if handleTemplateError(w, r, "validator.go", "Validator", "", templates.GetTemplate(notfoundTemplateFiles...).ExecuteTemplate(w, "layout", data)) != nil { + return // an error has occurred and was processed + } + return + } + + data.Data = getValidatorPageData(uint64(validator.Index)) + + if handleTemplateError(w, r, "validators.go", "Validators", "", pageTemplate.ExecuteTemplate(w, "layout", data)) != nil { + return // an error has occurred and was processed + } +} + +func getValidatorPageData(validatorIndex uint64) *models.ValidatorPageData { + pageData := &models.ValidatorPageData{} + pageCacheKey := fmt.Sprintf("validator:%v", validatorIndex) + if !utils.Config.Frontend.Debug && services.GlobalBeaconService.GetFrontendCache(pageCacheKey, pageData) == nil { + logrus.Printf("validator page served from cache: %v", validatorIndex) + return pageData + } + logrus.Printf("validator page called: %v", validatorIndex) + + validatorSetRsp := services.GlobalBeaconService.GetCachedValidatorSet() + validator := validatorSetRsp.Data[validatorIndex] + + pageData = &models.ValidatorPageData{ + CurrentEpoch: uint64(utils.TimeToEpoch(time.Now())), + Index: uint64(validator.Index), + Name: services.GlobalBeaconService.GetValidatorName(uint64(validator.Index)), + PublicKey: validator.Validator.PubKey, + Balance: uint64(validator.Balance), + EffectiveBalance: uint64(validator.Validator.EffectiveBalance), + BeaconState: validator.Status, + WithdrawCredentials: validator.Validator.WithdrawalCredentials, + } + if strings.HasPrefix(validator.Status, "pending") { + pageData.State = "Pending" + } else if validator.Status == "active_ongoing" { + pageData.State = "Active" + pageData.IsActive = true + } else if validator.Status == "active_exiting" { + pageData.State = "Exiting" + pageData.IsActive = true + } else if validator.Status == "active_slashed" { + pageData.State = "Slashed" + pageData.IsActive = true + } else if validator.Status == "exited_unslashed" { + pageData.State = "Exited" + } else if validator.Status == "exited_slashed" { + pageData.State = "Slashed" + } else { + pageData.State = validator.Status + } + + if pageData.IsActive { + // load activity map + activityMap, maxActivity := services.GlobalBeaconService.GetValidatorActivity() + pageData.UpcheckActivity = activityMap[uint64(validator.Index)] + pageData.UpcheckMaximum = uint8(maxActivity) + } + + if validator.Validator.ActivationEligibilityEpoch < 18446744073709551615 { + pageData.ShowEligible = true + pageData.EligibleEpoch = uint64(validator.Validator.ActivationEligibilityEpoch) + pageData.EligibleTs = utils.EpochToTime(uint64(validator.Validator.ActivationEligibilityEpoch)) + } + if validator.Validator.ActivationEpoch < 18446744073709551615 { + pageData.ShowActivation = true + pageData.ActivationEpoch = uint64(validator.Validator.ActivationEpoch) + pageData.ActivationTs = utils.EpochToTime(uint64(validator.Validator.ActivationEpoch)) + } + if validator.Validator.ExitEpoch < 18446744073709551615 { + pageData.ShowExit = true + pageData.WasActive = true + pageData.ExitEpoch = uint64(validator.Validator.ExitEpoch) + pageData.ExitTs = utils.EpochToTime(uint64(validator.Validator.ExitEpoch)) + } + if validator.Validator.WithdrawalCredentials[0] == 0x01 { + pageData.ShowWithdrawAddress = true + pageData.WithdrawAddress = validator.Validator.WithdrawalCredentials[12:] + } + + // load latest blocks + pageData.RecentBlocks = make([]*models.ValidatorPageDataBlocks, 0) + blocksData := services.GlobalBeaconService.GetDbBlocksByProposer(validatorIndex, 0, 10, true, true) + for _, blockData := range blocksData { + blockStatus := 1 + if blockData.Block == nil { + blockStatus = 0 + } else if blockData.Block.Orphaned { + blockStatus = 2 + } + blockEntry := models.ValidatorPageDataBlocks{ + Epoch: utils.EpochOfSlot(blockData.Slot), + Slot: blockData.Slot, + Ts: utils.SlotToTime(blockData.Slot), + Status: uint64(blockStatus), + } + if blockData.Block != nil { + blockEntry.Graffiti = blockData.Block.Graffiti + blockEntry.EthBlock = blockData.Block.EthBlockNumber + blockEntry.BlockRoot = fmt.Sprintf("0x%x", blockData.Block.Root) + } + pageData.RecentBlocks = append(pageData.RecentBlocks, &blockEntry) + } + pageData.RecentBlockCount = uint64(len(pageData.RecentBlocks)) + + if pageCacheKey != "" { + services.GlobalBeaconService.SetFrontendCache(pageCacheKey, pageData, 10*time.Minute) + } + return pageData +} diff --git a/handlers/validators.go b/handlers/validators.go new file mode 100644 index 00000000..628c19d7 --- /dev/null +++ b/handlers/validators.go @@ -0,0 +1,168 @@ +package handlers + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/pk910/light-beaconchain-explorer/services" + "github.com/pk910/light-beaconchain-explorer/templates" + "github.com/pk910/light-beaconchain-explorer/types/models" + "github.com/pk910/light-beaconchain-explorer/utils" + "github.com/sirupsen/logrus" +) + +// Validators will return the main "validators" page using a go template +func Validators(w http.ResponseWriter, r *http.Request) { + var validatorsTemplateFiles = append(layoutTemplateFiles, + "validators/validators.html", + "_svg/professor.html", + ) + + var pageTemplate = templates.GetTemplate(validatorsTemplateFiles...) + + w.Header().Set("Content-Type", "text/html") + data := InitPageData(w, r, "validators", "/validators", "Validators", validatorsTemplateFiles) + + urlArgs := r.URL.Query() + var firstIdx uint64 = 0 + if urlArgs.Has("s") { + firstIdx, _ = strconv.ParseUint(urlArgs.Get("s"), 10, 64) + } + var pageSize uint64 = 50 + if urlArgs.Has("c") { + pageSize, _ = strconv.ParseUint(urlArgs.Get("c"), 10, 64) + } + var stateFilter string + if urlArgs.Has("q") { + stateFilter = urlArgs.Get("q") + } + data.Data = getValidatorsPageData(firstIdx, pageSize, stateFilter) + + if handleTemplateError(w, r, "validators.go", "Validators", "", pageTemplate.ExecuteTemplate(w, "layout", data)) != nil { + return // an error has occurred and was processed + } +} + +func getValidatorsPageData(firstValIdx uint64, pageSize uint64, stateFilter string) *models.ValidatorsPageData { + pageData := &models.ValidatorsPageData{} + pageCacheKey := fmt.Sprintf("validators:%v:%v:%v", firstValIdx, pageSize, stateFilter) + if !utils.Config.Frontend.Debug && services.GlobalBeaconService.GetFrontendCache(pageCacheKey, pageData) == nil { + logrus.Printf("validators page served from cache: %v:%v", firstValIdx, pageSize) + return pageData + } + logrus.Printf("validators page called: %v:%v:%v", firstValIdx, pageSize, stateFilter) + + // get latest validator set + validatorSetRsp := services.GlobalBeaconService.GetCachedValidatorSet() + if validatorSetRsp == nil { + return pageData + } + validatorSet := validatorSetRsp.Data + if stateFilter != "" { + // TODO: apply filter + } + + totalValidatorCount := uint64(len(validatorSet)) + if firstValIdx == 0 { + pageData.IsDefaultPage = true + } else if firstValIdx > totalValidatorCount { + firstValIdx = totalValidatorCount + } + + if pageSize > 100 { + pageSize = 100 + } + + pagesBefore := firstValIdx / pageSize + if (firstValIdx % pageSize) > 0 { + pagesBefore++ + } + pagesAfter := (totalValidatorCount - firstValIdx) / pageSize + if ((totalValidatorCount - firstValIdx) % pageSize) > 0 { + pagesAfter++ + } + pageData.PageSize = pageSize + pageData.TotalPages = pagesBefore + pagesAfter + pageData.CurrentPageIndex = pagesBefore + 1 + pageData.CurrentPageValIdx = firstValIdx + if pagesBefore > 0 { + pageData.PrevPageIndex = pageData.CurrentPageIndex - 1 + pageData.PrevPageValIdx = pageData.CurrentPageValIdx - pageSize + } + if pagesAfter > 1 { + pageData.NextPageIndex = pageData.CurrentPageIndex + 1 + pageData.NextPageValIdx = pageData.CurrentPageValIdx + pageSize + } + pageData.LastPageValIdx = totalValidatorCount - pageSize + + // load activity map + activityMap, maxActivity := services.GlobalBeaconService.GetValidatorActivity() + + // get validators + lastValIdx := firstValIdx + pageSize + if lastValIdx >= totalValidatorCount { + lastValIdx = totalValidatorCount + } + pageData.Validators = make([]*models.ValidatorsPageDataValidator, 0) + + for _, validator := range validatorSet[firstValIdx:lastValIdx] { + validatorData := &models.ValidatorsPageDataValidator{ + Index: uint64(validator.Index), + Name: services.GlobalBeaconService.GetValidatorName(uint64(validator.Index)), + PublicKey: validator.Validator.PubKey, + Balance: uint64(validator.Balance), + EffectiveBalance: uint64(validator.Validator.EffectiveBalance), + } + if strings.HasPrefix(validator.Status, "pending") { + validatorData.State = "Pending" + } else if validator.Status == "active_ongoing" { + validatorData.State = "Active" + validatorData.ShowUpcheck = true + } else if validator.Status == "active_exiting" { + validatorData.State = "Exiting" + validatorData.ShowUpcheck = true + } else if validator.Status == "active_slashed" { + validatorData.State = "Slashed" + validatorData.ShowUpcheck = true + } else if validator.Status == "exited_unslashed" { + validatorData.State = "Exited" + } else if validator.Status == "exited_slashed" { + validatorData.State = "Slashed" + } else { + validatorData.State = validator.Status + } + + if validatorData.ShowUpcheck { + validatorData.UpcheckActivity = activityMap[uint64(validator.Index)] + validatorData.UpcheckMaximum = uint8(maxActivity) + } + + if validator.Validator.ActivationEpoch < 18446744073709551615 { + validatorData.ShowActivation = true + validatorData.ActivationEpoch = uint64(validator.Validator.ActivationEpoch) + validatorData.ActivationTs = utils.EpochToTime(uint64(validator.Validator.ActivationEpoch)) + } + if validator.Validator.ExitEpoch < 18446744073709551615 { + validatorData.ShowExit = true + validatorData.ExitEpoch = uint64(validator.Validator.ExitEpoch) + validatorData.ExitTs = utils.EpochToTime(uint64(validator.Validator.ExitEpoch)) + } + if validator.Validator.WithdrawalCredentials[0] == 0x01 { + validatorData.ShowWithdrawAddress = true + validatorData.WithdrawAddress = validator.Validator.WithdrawalCredentials[12:] + } + + pageData.Validators = append(pageData.Validators, validatorData) + } + pageData.ValidatorCount = uint64(len(pageData.Validators)) + pageData.FirstValidator = firstValIdx + pageData.LastValidator = lastValIdx + + if pageCacheKey != "" { + services.GlobalBeaconService.SetFrontendCache(pageCacheKey, pageData, 10*time.Minute) + } + return pageData +} diff --git a/indexer/indexer.go b/indexer/indexer.go index 55085b06..1a37b6ac 100644 --- a/indexer/indexer.go +++ b/indexer/indexer.go @@ -185,7 +185,7 @@ func (indexer *Indexer) GetCachedValidatorSet() *rpctypes.StandardV1StateValidat return indexer.state.headValidators } -func (indexer *Indexer) BuildLiveEpoch(epoch uint64) *dbtypes.Epoch { +func (indexer *Indexer) GetEpochVotes(epoch uint64) *EpochVotes { indexer.state.cacheMutex.RLock() defer indexer.state.cacheMutex.RUnlock() @@ -194,6 +194,10 @@ func (indexer *Indexer) BuildLiveEpoch(epoch uint64) *dbtypes.Epoch { return nil } + return indexer.getEpochVotes(epoch, epochStats) +} + +func (indexer *Indexer) getEpochVotes(epoch uint64, epochStats *EpochStats) *EpochVotes { var firstBlock *BlockInfo firstSlot := epoch * utils.Config.Chain.Config.SlotsPerEpoch lastSlot := firstSlot + (utils.Config.Chain.Config.SlotsPerEpoch) - 1 @@ -219,8 +223,18 @@ slotLoop: } else { targetRoot = firstBlock.Header.Data.Header.Message.ParentRoot } - epochVotes := aggregateEpochVotes(indexer.state.cachedBlocks, epoch, epochStats, targetRoot, false) + return aggregateEpochVotes(indexer.state.cachedBlocks, epoch, epochStats, targetRoot, false) +} + +func (indexer *Indexer) BuildLiveEpoch(epoch uint64) *dbtypes.Epoch { + indexer.state.cacheMutex.RLock() + defer indexer.state.cacheMutex.RUnlock() + epochStats := indexer.state.epochStats[epoch] + if epochStats == nil { + return nil + } + epochVotes := indexer.getEpochVotes(epoch, epochStats) return buildDbEpoch(epoch, indexer.state.cachedBlocks, epochStats, epochVotes, nil) } diff --git a/indexer/votes.go b/indexer/votes.go index 8bf86002..cffeec29 100644 --- a/indexer/votes.go +++ b/indexer/votes.go @@ -18,6 +18,7 @@ type EpochVotes struct { headVoteAmount uint64 totalVoteAmount uint64 } + ActivityMap map[uint64]bool } func aggregateEpochVotes(blockMap map[uint64][]*BlockInfo, epoch uint64, epochStats *EpochStats, targetRoot []byte, currentOnly bool) *EpochVotes { @@ -28,7 +29,9 @@ func aggregateEpochVotes(blockMap map[uint64][]*BlockInfo, epoch uint64, epochSt lastSlot += utils.Config.Chain.Config.SlotsPerEpoch } - votes := EpochVotes{} + votes := EpochVotes{ + ActivityMap: map[uint64]bool{}, + } votedBitsets := make(map[string][]byte) for slot := firstSlot; slot <= lastSlot; slot++ { @@ -59,6 +62,7 @@ func aggregateEpochVotes(blockMap map[uint64][]*BlockInfo, epoch uint64, epochSt } if utils.BitAtVector(voteBitset, bitIdx) { voteAmount += uint64(epochStats.Validators.ValidatorBalances[validatorIdx]) + votes.ActivityMap[validatorIdx] = true } } } diff --git a/rpctypes/beaconapi.go b/rpctypes/beaconapi.go index f12ccb38..e7becf53 100644 --- a/rpctypes/beaconapi.go +++ b/rpctypes/beaconapi.go @@ -79,12 +79,7 @@ type EpochAssignments struct { } type StandardV1StateValidatorsResponse struct { - Data []struct { - Index Uint64Str `json:"index"` - Balance Uint64Str `json:"balance"` - Status string `json:"status"` - Validator Validator `json:"validator"` - } `json:"data"` + Data []ValidatorEntry `json:"data"` } type StandardV1GenesisResponse struct { diff --git a/rpctypes/beacontypes.go b/rpctypes/beacontypes.go index a0e3fc26..3afaef47 100644 --- a/rpctypes/beacontypes.go +++ b/rpctypes/beacontypes.go @@ -165,6 +165,13 @@ type Validator struct { WithdrawableEpoch Uint64Str `json:"withdrawable_epoch"` } +type ValidatorEntry struct { + Index Uint64Str `json:"index"` + Balance Uint64Str `json:"balance"` + Status string `json:"status"` + Validator Validator `json:"validator"` +} + type BlobSidecar struct { BlockRoot BytesHexStr `json:"block_root"` Index Uint64Str `json:"index"` diff --git a/services/beaconservice.go b/services/beaconservice.go index b57cf369..9f9dcf0d 100644 --- a/services/beaconservice.go +++ b/services/beaconservice.go @@ -3,6 +3,7 @@ package services import ( "fmt" "strings" + "sync" "time" "github.com/pk910/light-beaconchain-explorer/cache" @@ -19,6 +20,13 @@ type BeaconService struct { frontendCache *cache.TieredCache indexer *indexer.Indexer validatorNames *ValidatorNames + + validatorActivityMutex sync.Mutex + validatorActivityStats struct { + cacheEpoch uint64 + epochLimit uint64 + activity map[uint64]uint8 + } } var GlobalBeaconService *BeaconService @@ -484,3 +492,170 @@ func (bs *BeaconService) GetDbBlocksByGraffiti(graffiti string, pageIdx uint64, return resBlocks } + +func (bs *BeaconService) GetDbBlocksByProposer(proposer uint64, pageIdx uint64, pageSize uint32, withMissing bool, withOrphaned bool) []*dbtypes.AssignedBlock { + cachedMatches := make([]struct { + slot uint64 + block *indexer.BlockInfo + }, 0) + idxMinSlot := bs.indexer.GetLowestCachedSlot() + idxHeadSlot := bs.indexer.GetHeadSlot() + if idxMinSlot >= 0 { + idxHeadEpoch := utils.EpochOfSlot(idxHeadSlot) + idxMinEpoch := utils.EpochOfSlot(uint64(idxMinSlot)) + for epochIdx := int64(idxHeadEpoch); epochIdx >= int64(idxMinEpoch); epochIdx-- { + epoch := uint64(epochIdx) + epochStats := bs.indexer.GetCachedEpochStats(epoch) + if epochStats == nil || epochStats.Assignments == nil { + continue + } + + for slot, assigned := range epochStats.Assignments.ProposerAssignments { + if assigned != proposer { + continue + } + blocks := bs.indexer.GetCachedBlocks(slot) + haveBlock := false + if blocks != nil { + for bidx := 0; bidx < len(blocks); bidx++ { + block := blocks[bidx] + if block.Orphaned && !withOrphaned { + continue + } + if uint64(block.Block.Data.Message.ProposerIndex) != proposer { + continue + } + cachedMatches = append(cachedMatches, struct { + slot uint64 + block *indexer.BlockInfo + }{ + slot: slot, + block: block, + }) + haveBlock = true + } + } + if !haveBlock && withMissing { + cachedMatches = append(cachedMatches, struct { + slot uint64 + block *indexer.BlockInfo + }{ + slot: slot, + block: nil, + }) + } + } + + } + } + + cachedMatchesLen := uint64(len(cachedMatches)) + cachedPages := cachedMatchesLen / uint64(pageSize) + resBlocks := make([]*dbtypes.AssignedBlock, 0) + resIdx := 0 + + cachedStart := pageIdx * uint64(pageSize) + cachedEnd := cachedStart + uint64(pageSize) + if cachedEnd+1 < cachedMatchesLen { + cachedEnd++ + } + + if cachedPages > 0 && pageIdx < cachedPages { + for _, block := range cachedMatches[cachedStart:cachedEnd] { + assignedBlock := dbtypes.AssignedBlock{ + Slot: block.slot, + Proposer: proposer, + } + if block.block != nil { + assignedBlock.Block = bs.indexer.BuildLiveBlock(block.block) + } + resBlocks = append(resBlocks, &assignedBlock) + + resIdx++ + } + } else if pageIdx == cachedPages { + start := pageIdx * uint64(pageSize) + for _, block := range cachedMatches[start:] { + assignedBlock := dbtypes.AssignedBlock{ + Slot: block.slot, + Proposer: proposer, + } + if block.block != nil { + assignedBlock.Block = bs.indexer.BuildLiveBlock(block.block) + } + resBlocks = append(resBlocks, &assignedBlock) + resIdx++ + } + } + if resIdx > int(pageSize) { + return resBlocks + } + + // load from db + var dbMinSlot uint64 + if idxMinSlot < 0 { + dbMinSlot = utils.TimeToSlot(uint64(time.Now().Unix())) + } else { + dbMinSlot = uint64(idxMinSlot) + } + + dbPage := pageIdx - cachedPages + dbCacheOffset := uint64(pageSize) - (cachedMatchesLen % uint64(pageSize)) + var dbBlocks []*dbtypes.AssignedBlock + if dbPage == 0 { + dbBlocks = db.GetAssignedBlocks(proposer, dbMinSlot, 0, uint32(dbCacheOffset)+1, withOrphaned) + } else { + dbBlocks = db.GetAssignedBlocks(proposer, dbMinSlot, (dbPage-1)*uint64(pageSize)+dbCacheOffset, pageSize+1, withOrphaned) + } + if dbBlocks != nil { + for _, dbBlock := range dbBlocks { + resBlocks = append(resBlocks, dbBlock) + } + } + + return resBlocks +} + +func (bs *BeaconService) GetValidatorActivity() (map[uint64]uint8, uint64) { + activityMap := map[uint64]uint8{} + epochLimit := uint64(3) + + idxHeadSlot := bs.indexer.GetHeadSlot() + idxHeadEpoch := utils.EpochOfSlot(idxHeadSlot) + if idxHeadEpoch < 1 { + return activityMap, 0 + } + idxHeadEpoch-- + idxMinSlot := bs.indexer.GetLowestCachedSlot() + if idxMinSlot < 0 { + return activityMap, 0 + } + idxMinEpoch := utils.EpochOfSlot(uint64(idxMinSlot)) + + activityEpoch := utils.EpochOfSlot(idxHeadSlot - 1) + bs.validatorActivityMutex.Lock() + defer bs.validatorActivityMutex.Unlock() + if bs.validatorActivityStats.activity != nil && bs.validatorActivityStats.cacheEpoch == activityEpoch { + return bs.validatorActivityStats.activity, bs.validatorActivityStats.epochLimit + } + + actualEpochCount := idxHeadEpoch - idxMinEpoch + 1 + if actualEpochCount > epochLimit { + idxMinEpoch = idxHeadEpoch - epochLimit + 1 + } else if actualEpochCount < epochLimit { + epochLimit = actualEpochCount + } + + for epochIdx := int64(idxHeadEpoch); epochIdx >= int64(idxMinEpoch); epochIdx-- { + epoch := uint64(epochIdx) + epochVotes := bs.indexer.GetEpochVotes(epoch) + for valIdx := range epochVotes.ActivityMap { + activityMap[valIdx]++ + } + } + + bs.validatorActivityStats.cacheEpoch = activityEpoch + bs.validatorActivityStats.epochLimit = epochLimit + bs.validatorActivityStats.activity = activityMap + return activityMap, epochLimit +} diff --git a/static/css/layout.css b/static/css/layout.css index dfc999b1..0e14982f 100644 --- a/static/css/layout.css +++ b/static/css/layout.css @@ -267,3 +267,8 @@ span.validator-label { border: hidden; } } + +.text-truncate { + text-overflow: ellipsis; + overflow: hidden; +} diff --git a/static/css/validator.css b/static/css/validator.css new file mode 100644 index 00000000..4fd570a8 --- /dev/null +++ b/static/css/validator.css @@ -0,0 +1,215 @@ +/* begin validator lifecycle */ +.validator__lifecycle-container { + position: relative; + display: flex; + align-items: center; + margin: 24px 20px 8px 20px; + font-size: .85rem; +} +.validator__lifecycle-content { + display: flex; + max-width: 600px; + width: 100%; +} +.validator__lifecycle-node-container { + position: relative; +} +.validator__lifecycle-node-header { + position: absolute; + top: -28px; + left: 50%; + transform: translateX(-50%); + opacity: 60%; +} +.validator__lifecycle-node::before { + content: ''; + position: absolute; + opacity: 20%; + height: 8px; + border-right: solid 1px var(--bs-body-color, black); + top: -12px; +} +.validator__lifecycle-node { + width: 18px; + height: 18px; + position: relative; + left: 50%; + transform: translateX(-50%); + border-radius: 50%; + border: solid 4px var(--bs-tertiary-color, #00000033); + display: flex; + align-items: center; + justify-content: center; +} +.validator__lifecycle-node, .validator__lifecycle-node > * { + box-sizing: content-box; +} +.validator__lifecycle-progress { + position: relative; + flex: 1 1 20px; +} +.validator__lifecycle-progress::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translateX(-50%); + border-bottom: solid 1px var(--bs-body-color, black); + width: 85%; + opacity: 20%; +} +.validator__lifecycle-progress-epoch { + position: absolute; + left: 50%; + transform: translateX(-50%); + bottom: -10px; + color: black; + width: 85%; + font-size: 90%; +} +.validator__lifecycle-progress-epoch > div { + display: flex; + justify-content: center; + width: 100%; +} +.validator__lifecycle-container .validator__lifecycle-progress::after { + content: ''; + position: absolute; + top: 50%; +} +.validator__lifecycle-container .active.validator__lifecycle-progress::after { + position: absolute; + left: 7.5%; + border-bottom: solid 1px var(--bs-body-color, black); + width: 45%; +} +.validator__lifecycle-container .complete.validator__lifecycle-progress::after { + position: absolute; + border-bottom: solid 1px var(--bs-body-color, black); + left: 7.5%; + width: 85%; +} +.validator__lifecycle-container .active .validator__lifecycle-node-header, +.validator__lifecycle-container .failed .validator__lifecycle-node-header, +.validator__lifecycle-container .online .validator__lifecycle-node-header, +.validator__lifecycle-container .offline .validator__lifecycle-node-header, +.validator__lifecycle-container .done .validator__lifecycle-node-header { + opacity: 1; +} +.validator__lifecycle-container .done .validator__lifecycle-node, +.validator__lifecycle-container .online.validator__lifecycle-active .validator__lifecycle-node { + border-color: var(--success, green); + background-color: var(--success, green); +} +.validator__lifecycle-container .failed .validator__lifecycle-node, +.validator__lifecycle-container .offline.validator__lifecycle-active .validator__lifecycle-node { + border-color: var(--red); + background-color: var(--red); +} +.validator__lifecycle-container i { + visibility: hidden; + position: absolute; + color: white; +} +.validator__lifecycle-container .done.validator__lifecycle-deposited .deposit-success, +.validator__lifecycle-container .failed.validator__lifecycle-deposited .deposit-fail, +.validator__lifecycle-container .done .fa-check, +.validator__lifecycle-container .done .fa-door-open, +.validator__lifecycle-container .failed .fa-door-open, +.validator__lifecycle-container .online.validator__lifecycle-active .online, +.validator__lifecycle-container .offline.validator__lifecycle-active .offline { + visibility: visible; +} +.validator__lifecycle-container .spinner { + position: absolute; + width: 100%; + height: 100%; +} +.validator__lifecycle-container .double-bounce1, +.validator__lifecycle-container .double-bounce2 { + position: absolute; + width: 100%; + height: 100%; + border-radius: 50%; + background-color: #219653; + opacity: 0.6; + top: 0; + left: 0; + visibility: hidden; + -webkit-animation: sk-bounce 3.0s infinite ease-in-out; + animation: sk-bounce 3.0s infinite ease-in-out; +} +.validator__lifecycle-container .active .double-bounce1, +.validator__lifecycle-container .active .double-bounce2 { + visibility: visible; +} +.validator__lifecycle-container .active.slashed .double-bounce1, +.validator__lifecycle-container .active.slashed .double-bounce2 { + background-color: var(--red); +} +.validator__lifecycle-container .active .double-bounce2 { + -webkit-animation-delay: -1.5s; + animation-delay: -1.5s; +} +@-webkit-keyframes sk-bounce { + 0%, + 100% { + -webkit-transform: scale(0.0); + } + + 50% { + -webkit-transform: scale(1.5); + } +} +@keyframes sk-bounce { + 0%, + 100% { + transform: scale(0.0); + -webkit-transform: scale(0.0); + } + + 50% { + transform: scale(1.5); + -webkit-transform: scale(1.5); + } +} +@media screen and (min-width: 960px) { + .validator__lifecycle-container i { + margin-bottom: 0; + } + .validator__lifecycle-progress-epoch { + bottom: -2px; + } + .validator__lifecycle-node { + width: 24px; + height: 24px; + border-width: 5px; + } +} +/* end validator lifecycle */ + +/* responsive tabs for mobile screens (shows icons when tab is not selected and text when selected */ +@media screen and (max-width: 1450px) { + .validator__lifecycle-container { + width: 80%; + } + .nav-tabs .tab-text { + display: none; + } + .nav-tabs .active .tab-text { + display: initial; + } + .nav-tabs .active .tab-icon { + display: none; + } + .nav-tabs .active i { + margin-right: .25rem; + } + .nav-tabs .tab-text + .badge { + display: none; + } +} +.validator-card { + border-top-left-radius: 0; + border-top-right-radius: 0; +} diff --git a/templates/validator/notfound.html b/templates/validator/notfound.html new file mode 100644 index 00000000..4f20bb53 --- /dev/null +++ b/templates/validator/notfound.html @@ -0,0 +1,27 @@ +{{ define "js" }} +{{ end }} + +{{ define "css" }} +{{ end }} + +{{ define "page" }} +
+
+
+

Validator not found

+ +
+
+
+
+
Sorry but we could not find the validator you are looking for
+
+
+
+{{ end }} diff --git a/templates/validator/recentAttestations.html b/templates/validator/recentAttestations.html new file mode 100644 index 00000000..51fdcc17 --- /dev/null +++ b/templates/validator/recentAttestations.html @@ -0,0 +1,38 @@ +{{ define "recentAttestations" }} +
+
+

+ Most recent attestations +

+
+
+
+ + + + + + + + + + + + + + + + + + + + +
EpochSlotBlockStatusTimeDistance
+
+ {{ template "timeline_svg" }} +
+
+
+
+
+{{ end }} diff --git a/templates/validator/recentBlocks.html b/templates/validator/recentBlocks.html new file mode 100644 index 00000000..c63f09f7 --- /dev/null +++ b/templates/validator/recentBlocks.html @@ -0,0 +1,67 @@ +{{ define "recentBlocks" }} +
+
+

+ Most recent blocks +

+
+
+
+ + + + + + + + + + + + {{ if gt .RecentBlockCount 0 }} + + {{ range $i, $block := .RecentBlocks }} + + + {{ if eq .Status 2 }} + + {{ else }} + + {{ end }} + + + + + + {{ end }} + + {{ else }} + + + + + + + + {{ end }} +
EpochSlotBlockStatusTimeGraffiti
{{ formatAddCommas $block.Epoch }}{{ formatAddCommas $block.Slot }}{{ formatAddCommas $block.Slot }}{{ ethBlockLink $block.EthBlock }} + {{ if eq $block.Slot 0 }} + Genesis + {{ else if eq .Status 0 }} + Missed + {{ else if eq .Status 1 }} + Proposed + {{ else if eq .Status 2 }} + Missed (Orphaned) + {{ else }} + Unknown + {{ end }} + {{ formatRecentTimeShort $block.Ts }}{{ formatGraffiti $block.Graffiti }}
+
+ {{ template "timeline_svg" }} +
+
+
+
+
+{{ end }} diff --git a/templates/validator/validator.html b/templates/validator/validator.html new file mode 100644 index 00000000..04aae938 --- /dev/null +++ b/templates/validator/validator.html @@ -0,0 +1,170 @@ +{{ define "page" }} +
+
+

Validator {{ formatValidatorWithIndex .Index .Name }}

+ +
+ +
+
+ +
+
+ Status: +
+
+ +
+
+
+
Deposited
+
+ + +
+
+
+
+
+
+
+ +
+
+
+
Pending
+
+ + +
+
+
+
+
+
+
+
+ +
+
+
+
Active
+
+ + + +
+
+
+
+
+
+
+
+
+ {{ if .ShowExit }} + + {{ end }} +
+
+
+
+
Exited
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
Index:
+
+ {{ formatValidatorWithIndex .Index .Name }} + +
+
+
+
Public Key:
+
+ 0x{{ printf "%x" .PublicKey }} + +
+
+
+
Status:
+
+ {{ .BeaconState }} +
+
+
+
Balance:
+
+ {{ formatEthFromGwei .Balance }} + +
+
+
+
Effective Balance:
+
+ {{ formatEthAddCommasFromGwei .EffectiveBalance }} ETH +
+
+
+
W/Credentials:
+
+ 0x{{ printf "%x" .WithdrawCredentials }} + +
+
+ {{ if .ShowWithdrawAddress }} +
+
W/Address:
+
+ {{ ethAddressLink .WithdrawAddress }} +
+
+ {{ end }} + +
+
+ +
+
+ {{ template "recentBlocks" . }} +
+
+
+{{ end }} +{{ define "js" }} +{{ end }} +{{ define "css" }} + +{{ end }} \ No newline at end of file diff --git a/templates/validators/validators.html b/templates/validators/validators.html new file mode 100644 index 00000000..6033c6f4 --- /dev/null +++ b/templates/validators/validators.html @@ -0,0 +1,155 @@ +{{ define "page" }} +
+
+

Validators Overview

+ +
+ +
+
+
+
+
+
+ +
+
+ +
+
+ + + + + + + + + + + + + {{ if gt .ValidatorCount 0 }} + + {{ range $i, $validator := .Validators }} + + + + + + + + + + {{ end }} + + {{ else }} + + + + + + + + {{ end }} +
IndexPublic KeyBalanceStateActivationExitW/address
{{ formatValidatorWithIndex $validator.Index $validator.Name }}0x{{ printf "%x" $validator.PublicKey }}{{ formatEthFromGwei $validator.Balance }} ({{ formatEthAddCommasFromGwei $validator.EffectiveBalance }} ETH) + {{- $validator.State -}} + {{- if $validator.ShowUpcheck -}} + {{- if eq $validator.UpcheckActivity $validator.UpcheckMaximum }} + + {{- else if gt $validator.UpcheckActivity 0 }} + + {{- else }} + + {{- end -}} + {{- end -}} + + {{- if $validator.ShowActivation -}} + {{ formatRecentTimeShort $validator.ActivationTs }} + (Epoch {{ formatAddCommas $validator.ActivationEpoch }}) + {{- else -}} + - + {{- end -}} + + {{- if $validator.ShowExit -}} + {{ formatRecentTimeShort $validator.ExitTs }} + (Epoch {{ formatAddCommas $validator.ExitEpoch }}) + {{- else -}} + - + {{- end -}} + {{ ethAddressLink .WithdrawAddress }}
+
+ {{ template "professor_svg" }} +
+
+
+ {{ if gt .TotalPages 1 }} +
+
+
+
Showing validator {{ .FirstValidator }} to {{ .LastValidator }}
+
+
+
+
+ +
+
+
+ {{ end }} +
+ +
+
+{{ end }} +{{ define "js" }} +{{ end }} +{{ define "css" }} +{{ end }} \ No newline at end of file diff --git a/types/models/validator.go b/types/models/validator.go new file mode 100644 index 00000000..b85955f5 --- /dev/null +++ b/types/models/validator.go @@ -0,0 +1,46 @@ +package models + +import ( + "time" +) + +// ValidatorPageData is a struct to hold info for the validator page +type ValidatorPageData struct { + CurrentEpoch uint64 `json:"current_epoch"` + Index uint64 `json:"index"` + Name string `json:"name"` + PublicKey []byte `json:"pubkey"` + Balance uint64 `json:"balance"` + EffectiveBalance uint64 `json:"eff_balance"` + State string `json:"state"` + BeaconState string `json:"beacon_state"` + ShowEligible bool `json:"show_eligible"` + EligibleTs time.Time `json:"eligible_ts"` + EligibleEpoch uint64 `json:"eligible_epoch"` + ShowActivation bool `json:"show_activation"` + ActivationTs time.Time `json:"activation_ts"` + ActivationEpoch uint64 `json:"activation_epoch"` + IsActive bool `json:"is_active"` + WasActive bool `json:"was_active"` + UpcheckActivity uint8 `json:"upcheck_act"` + UpcheckMaximum uint8 `json:"upcheck_max"` + ShowExit bool `json:"show_exit"` + ExitTs time.Time `json:"exit_ts"` + ExitEpoch uint64 `json:"exit_epoch"` + WithdrawCredentials []byte `json:"withdraw_credentials"` + ShowWithdrawAddress bool `json:"show_withdraw_address"` + WithdrawAddress []byte `json:"withdraw_address"` + + RecentBlocks []*ValidatorPageDataBlocks `json:"recent_blocks"` + RecentBlockCount uint64 `json:"recent_block_count"` +} + +type ValidatorPageDataBlocks struct { + Epoch uint64 `json:"epoch"` + Slot uint64 `json:"slot"` + EthBlock uint64 `json:"eth_block"` + Ts time.Time `json:"ts"` + Status uint64 `json:"status"` + BlockRoot string `json:"block_root"` + Graffiti []byte `json:"graffiti"` +} diff --git a/types/models/validators.go b/types/models/validators.go new file mode 100644 index 00000000..2c5d807d --- /dev/null +++ b/types/models/validators.go @@ -0,0 +1,45 @@ +package models + +import ( + "time" +) + +// ValidatorsPageData is a struct to hold info for the validators page +type ValidatorsPageData struct { + Validators []*ValidatorsPageDataValidator `json:"validators"` + ValidatorCount uint64 `json:"validator_count"` + FirstValidator uint64 `json:"first_validx"` + LastValidator uint64 `json:"last_validx"` + StateFilter string `json:"state_filter"` + + IsDefaultPage bool `json:"default_page"` + TotalPages uint64 `json:"total_pages"` + PageSize uint64 `json:"page_size"` + CurrentPageIndex uint64 `json:"page_index"` + CurrentPageValIdx uint64 `json:"page_validx"` + PrevPageIndex uint64 `json:"prev_page_index"` + PrevPageValIdx uint64 `json:"prev_page_validx"` + NextPageIndex uint64 `json:"next_page_index"` + NextPageValIdx uint64 `json:"next_page_validx"` + LastPageValIdx uint64 `json:"last_page_validx"` +} + +type ValidatorsPageDataValidator struct { + Index uint64 `json:"index"` + Name string `json:"name"` + PublicKey []byte `json:"pubkey"` + Balance uint64 `json:"balance"` + EffectiveBalance uint64 `json:"eff_balance"` + State string `json:"state"` + ShowUpcheck bool `json:"show_upcheck"` + UpcheckActivity uint8 `json:"upcheck_act"` + UpcheckMaximum uint8 `json:"upcheck_max"` + ShowActivation bool `json:"show_activation"` + ActivationTs time.Time `json:"activation_ts"` + ActivationEpoch uint64 `json:"activation_epoch"` + ShowExit bool `json:"show_exit"` + ExitTs time.Time `json:"exit_ts"` + ExitEpoch uint64 `json:"exit_epoch"` + ShowWithdrawAddress bool `json:"show_withdraw_address"` + WithdrawAddress []byte `json:"withdraw_address"` +} diff --git a/utils/format.go b/utils/format.go index 35f91e9d..798e5125 100644 --- a/utils/format.go +++ b/utils/format.go @@ -300,9 +300,16 @@ func FormatSlashedValidator(index uint64, name string) template.HTML { func formatValidator(index uint64, name string, icon string) template.HTML { if name != "" { - return template.HTML(fmt.Sprintf(" %v", index, icon, html.EscapeString(name))) + return template.HTML(fmt.Sprintf(" %v", index, icon, index, html.EscapeString(name))) } - return template.HTML(fmt.Sprintf(" %v", icon, index)) + return template.HTML(fmt.Sprintf(" %v", icon, index, index)) +} + +func FormatValidatorWithIndex(index uint64, name string) template.HTML { + if name != "" { + return template.HTML(fmt.Sprintf("%v (%v)", html.EscapeString(name), index)) + } + return template.HTML(fmt.Sprintf("%v", index)) } func FormatRecentTimeShort(ts time.Time) template.HTML { diff --git a/utils/templateFucs.go b/utils/templateFucs.go index 0f1465cf..75501c00 100644 --- a/utils/templateFucs.go +++ b/utils/templateFucs.go @@ -49,6 +49,7 @@ func GetTemplateFuncs() template.FuncMap { "ethBlockHashLink": FormatEthBlockHashLink, "ethAddressLink": FormatEthAddressLink, "formatValidator": FormatValidator, + "formatValidatorWithIndex": FormatValidatorWithIndex, "formatSlashedValidator": FormatSlashedValidator, "formatRecentTimeShort": FormatRecentTimeShort, "formatGraffiti": FormatGraffiti,