From 3bc722bd57a17aab79e99c27366886c158a7ce27 Mon Sep 17 00:00:00 2001 From: pschork <354473+pschork@users.noreply.github.com> Date: Mon, 27 Jan 2025 15:51:04 -0800 Subject: [PATCH] Quorum operator stake report cli (#1162) --- tools/quorumscan/Makefile | 12 +++ tools/quorumscan/cmd/main.go | 181 ++++++++++++++++++++++++++++++++ tools/quorumscan/config.go | 65 ++++++++++++ tools/quorumscan/flags/flags.go | 72 +++++++++++++ tools/quorumscan/quorum.go | 43 ++++++++ 5 files changed, 373 insertions(+) create mode 100644 tools/quorumscan/Makefile create mode 100644 tools/quorumscan/cmd/main.go create mode 100644 tools/quorumscan/config.go create mode 100644 tools/quorumscan/flags/flags.go create mode 100644 tools/quorumscan/quorum.go diff --git a/tools/quorumscan/Makefile b/tools/quorumscan/Makefile new file mode 100644 index 0000000000..3b170a6b9d --- /dev/null +++ b/tools/quorumscan/Makefile @@ -0,0 +1,12 @@ +build: clean + go mod tidy + go build -o ./bin/quorumscan ./cmd + +clean: + rm -rf ./bin + +lint: + golangci-lint run ./... + +run: build + ./bin/quorumscan --help diff --git a/tools/quorumscan/cmd/main.go b/tools/quorumscan/cmd/main.go new file mode 100644 index 0000000000..32b4b6adf4 --- /dev/null +++ b/tools/quorumscan/cmd/main.go @@ -0,0 +1,181 @@ +package main + +import ( + "context" + "fmt" + "log" + "math/big" + "os" + "sort" + "strconv" + "strings" + + "github.com/Layr-Labs/eigenda/common" + "github.com/Layr-Labs/eigenda/common/geth" + "github.com/Layr-Labs/eigenda/core" + "github.com/Layr-Labs/eigenda/core/eth" + "github.com/Layr-Labs/eigenda/core/thegraph" + "github.com/Layr-Labs/eigenda/tools/quorumscan" + "github.com/Layr-Labs/eigenda/tools/quorumscan/flags" + gethcommon "github.com/ethereum/go-ethereum/common" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/urfave/cli" +) + +var ( + version = "" + gitCommit = "" + gitDate = "" +) + +func main() { + app := cli.NewApp() + app.Version = fmt.Sprintf("%s,%s,%s", version, gitCommit, gitDate) + app.Name = "quorumscan" + app.Description = "operator quorum scan" + app.Usage = "" + app.Flags = flags.Flags + app.Action = RunScan + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} + +func RunScan(ctx *cli.Context) error { + config, err := quorumscan.NewConfig(ctx) + if err != nil { + return err + } + + logger, err := common.NewLogger(config.LoggerConfig) + if err != nil { + return err + } + + gethClient, err := geth.NewClient(config.EthClientConfig, gethcommon.Address{}, 0, logger) + if err != nil { + logger.Error("Cannot create chain.Client", "err", err) + return err + } + + tx, err := eth.NewReader(logger, gethClient, config.BLSOperatorStateRetrieverAddr, config.EigenDAServiceManagerAddr) + if err != nil { + log.Fatalln("could not start eth.NewReader", err) + } + chainState := eth.NewChainState(tx, gethClient) + + logger.Info("Connecting to subgraph", "url", config.ChainStateConfig.Endpoint) + ics := thegraph.MakeIndexedChainState(config.ChainStateConfig, chainState, logger) + + var blockNumber uint + if config.BlockNumber != 0 { + blockNumber = uint(config.BlockNumber) + } else { + blockNumber, err = ics.GetCurrentBlockNumber() + if err != nil { + return fmt.Errorf("failed to fetch current block number - %s", err) + } + } + logger.Info("Using block number", "block", blockNumber) + + operatorState, err := chainState.GetOperatorState(context.Background(), blockNumber, config.QuorumIDs) + if err != nil { + return fmt.Errorf("failed to fetch operator state - %s", err) + } + operators, err := ics.GetIndexedOperators(context.Background(), blockNumber) + if err != nil { + return fmt.Errorf("failed to fetch indexed operators info - %s", err) + } + + logger.Info("Queried operator state", "count", len(operators)) + + operatorIDs := make([]core.OperatorID, 0, len(operators)) + for opID := range operators { + operatorIDs = append(operatorIDs, opID) + } + operatorAddresses, err := tx.BatchOperatorIDToAddress(context.Background(), operatorIDs) + if err != nil { + return err + } + operatorIdToAddress := make(map[string]string) + for i := range operatorAddresses { + operatorIdToAddress[operatorIDs[i].Hex()] = strings.ToLower(operatorAddresses[i].Hex()) + } + + quorumMetrics := quorumscan.QuorumScan(operators, operatorState, logger) + displayResults(quorumMetrics, operatorIdToAddress, config.TopN) + return nil +} + +func humanizeEth(value *big.Float) string { + v, _ := value.Float64() + switch { + case v >= 1_000_000: + return fmt.Sprintf("%.2fM", v/1_000_000) + case v >= 1_000: + return fmt.Sprintf("%.2fK", v/1_000) + default: + return fmt.Sprintf("%.2f", v) + } +} + +func displayResults(results map[uint8]*quorumscan.QuorumMetrics, operatorIdToAddress map[string]string, topN uint) { + weiToEth := new(big.Float).SetFloat64(1e18) + + // Create sorted list of quorums + quorums := make([]uint8, 0, len(results)) + for quorum := range results { + quorums = append(quorums, quorum) + } + sort.Slice(quorums, func(i, j int) bool { + return quorums[i] < quorums[j] + }) + + for _, quorum := range quorums { + tw := table.NewWriter() + rowAutoMerge := table.RowConfig{AutoMerge: true} + operatorHeader := "OPERATOR" + if topN > 0 { + operatorHeader = "TOP " + strconv.Itoa(int(topN)) + " OPERATORS" + } + tw.AppendHeader(table.Row{"QUORUM", operatorHeader, "ADDRESS", "STAKE", "STAKE"}, rowAutoMerge) + + total_operators := 0 + total_stake_pct := 0.0 + total_stake := new(big.Float) + metrics := results[quorum] + + // Create sorted list of operators by stake + type operatorInfo struct { + id string + stake float64 + pct float64 + } + operators := make([]operatorInfo, 0, len(metrics.OperatorStake)) + for op, stake := range metrics.OperatorStake { + operators = append(operators, operatorInfo{op, stake, metrics.OperatorStakePct[op]}) + } + sort.Slice(operators, func(i, j int) bool { + return operators[i].stake > operators[j].stake + }) + + for _, op := range operators { + if topN > 0 && uint(total_operators) >= topN { + break + } + stakeInEth := new(big.Float).Quo(new(big.Float).SetFloat64(op.stake), weiToEth) + stakeInEth.SetPrec(64) + total_operators += 1 + total_stake.Add(total_stake, stakeInEth) + total_stake_pct += op.pct + + tw.AppendRow(table.Row{quorum, op.id, operatorIdToAddress[op.id], humanizeEth(stakeInEth), fmt.Sprintf("%.2f%%", op.pct)}) + } + total_stake.SetPrec(64) + tw.AppendFooter(table.Row{"TOTAL", total_operators, total_operators, humanizeEth(total_stake), fmt.Sprintf("%.2f%%", total_stake_pct)}) + tw.SetColumnConfigs([]table.ColumnConfig{ + {Number: 1, AutoMerge: true}, + }) + fmt.Println(tw.Render()) + } +} diff --git a/tools/quorumscan/config.go b/tools/quorumscan/config.go new file mode 100644 index 0000000000..2cc049f647 --- /dev/null +++ b/tools/quorumscan/config.go @@ -0,0 +1,65 @@ +package quorumscan + +import ( + "strconv" + "strings" + "time" + + "github.com/Layr-Labs/eigenda/common" + "github.com/Layr-Labs/eigenda/common/geth" + "github.com/Layr-Labs/eigenda/core" + "github.com/Layr-Labs/eigenda/core/thegraph" + "github.com/Layr-Labs/eigenda/tools/quorumscan/flags" + "github.com/urfave/cli" +) + +type Config struct { + LoggerConfig common.LoggerConfig + BlockNumber uint64 + Workers int + Timeout time.Duration + UseRetrievalClient bool + QuorumIDs []core.QuorumID + TopN uint + + ChainStateConfig thegraph.Config + EthClientConfig geth.EthClientConfig + + BLSOperatorStateRetrieverAddr string + EigenDAServiceManagerAddr string +} + +func ReadConfig(ctx *cli.Context) *Config { + quorumIDsStr := ctx.String(flags.QuorumIDsFlag.Name) + quorumIDs := []core.QuorumID{} + + // Parse comma-separated quorum IDs + if quorumIDsStr != "" { + for _, idStr := range strings.Split(quorumIDsStr, ",") { + if id, err := strconv.ParseUint(strings.TrimSpace(idStr), 10, 32); err == nil { + quorumIDs = append(quorumIDs, core.QuorumID(id)) + } + } + } + + return &Config{ + ChainStateConfig: thegraph.ReadCLIConfig(ctx), + EthClientConfig: geth.ReadEthClientConfig(ctx), + BLSOperatorStateRetrieverAddr: ctx.GlobalString(flags.BlsOperatorStateRetrieverFlag.Name), + EigenDAServiceManagerAddr: ctx.GlobalString(flags.EigenDAServiceManagerFlag.Name), + QuorumIDs: quorumIDs, + BlockNumber: ctx.Uint64(flags.BlockNumberFlag.Name), + TopN: ctx.Uint(flags.TopNFlag.Name), + } +} + +func NewConfig(ctx *cli.Context) (*Config, error) { + loggerConfig, err := common.ReadLoggerCLIConfig(ctx, flags.FlagPrefix) + if err != nil { + return nil, err + } + + config := ReadConfig(ctx) + config.LoggerConfig = *loggerConfig + return config, nil +} diff --git a/tools/quorumscan/flags/flags.go b/tools/quorumscan/flags/flags.go new file mode 100644 index 0000000000..8f7bb9eac3 --- /dev/null +++ b/tools/quorumscan/flags/flags.go @@ -0,0 +1,72 @@ +package flags + +import ( + "github.com/Layr-Labs/eigenda/common" + "github.com/Layr-Labs/eigenda/common/geth" + "github.com/Layr-Labs/eigenda/core/thegraph" + "github.com/urfave/cli" +) + +const ( + FlagPrefix = "" + envPrefix = "QUORUMSCAN" +) + +var ( + /* Required Flags*/ + BlsOperatorStateRetrieverFlag = cli.StringFlag{ + Name: common.PrefixFlag(FlagPrefix, "bls-operator-state-retriever"), + Usage: "Address of the BLS Operator State Retriever", + Required: true, + EnvVar: common.PrefixEnvVar(envPrefix, "BLS_OPERATOR_STATE_RETRIVER"), + } + EigenDAServiceManagerFlag = cli.StringFlag{ + Name: common.PrefixFlag(FlagPrefix, "eigenda-service-manager"), + Usage: "Address of the EigenDA Service Manager", + Required: true, + EnvVar: common.PrefixEnvVar(envPrefix, "EIGENDA_SERVICE_MANAGER"), + } + /* Optional Flags*/ + BlockNumberFlag = cli.Uint64Flag{ + Name: common.PrefixFlag(FlagPrefix, "block-number"), + Usage: "Block number to query state from (default: latest)", + Required: false, + EnvVar: common.PrefixEnvVar(envPrefix, "BLOCK_NUMBER"), + Value: 0, + } + QuorumIDsFlag = cli.StringFlag{ + Name: common.PrefixFlag(FlagPrefix, "quorum-ids"), + Usage: "Comma-separated list of quorum IDs to scan (default: 0,1,2)", + Required: false, + EnvVar: common.PrefixEnvVar(envPrefix, "QUORUM_IDS"), + Value: "0,1,2", + } + TopNFlag = cli.UintFlag{ + Name: common.PrefixFlag(FlagPrefix, "top"), + Usage: "Show only top N operators by stake", + Required: false, + EnvVar: common.PrefixEnvVar(envPrefix, "TOP"), + Value: 0, + } +) + +var requiredFlags = []cli.Flag{ + BlsOperatorStateRetrieverFlag, + EigenDAServiceManagerFlag, +} + +var optionalFlags = []cli.Flag{ + BlockNumberFlag, + QuorumIDsFlag, + TopNFlag, +} + +// Flags contains the list of configuration options available to the binary. +var Flags []cli.Flag + +func init() { + Flags = append(requiredFlags, optionalFlags...) + Flags = append(Flags, common.LoggerCLIFlags(envPrefix, FlagPrefix)...) + Flags = append(Flags, geth.EthClientFlags(envPrefix)...) + Flags = append(Flags, thegraph.CLIFlags(envPrefix)...) +} diff --git a/tools/quorumscan/quorum.go b/tools/quorumscan/quorum.go new file mode 100644 index 0000000000..c0ad898e51 --- /dev/null +++ b/tools/quorumscan/quorum.go @@ -0,0 +1,43 @@ +package quorumscan + +import ( + "math/big" + + "github.com/Layr-Labs/eigenda/core" + "github.com/Layr-Labs/eigensdk-go/logging" +) + +type QuorumMetrics struct { + Operators []string `json:"operators"` + OperatorStake map[string]float64 `json:"operator_stake"` + OperatorStakePct map[string]float64 `json:"operator_stake_pct"` +} + +func QuorumScan(operators map[core.OperatorID]*core.IndexedOperatorInfo, operatorState *core.OperatorState, logger logging.Logger) map[uint8]*QuorumMetrics { + metrics := make(map[uint8]*QuorumMetrics) + for operatorId := range operators { + + // Calculate stake percentage for each quorum + for quorum, totalOperatorInfo := range operatorState.Totals { + if _, exists := metrics[quorum]; !exists { + metrics[quorum] = &QuorumMetrics{ + Operators: []string{}, + OperatorStakePct: make(map[string]float64), + OperatorStake: make(map[string]float64), + } + } + stakePercentage := float64(0) + if stake, ok := operatorState.Operators[quorum][operatorId]; ok { + totalStake := new(big.Float).SetInt(totalOperatorInfo.Stake) + operatorStake := new(big.Float).SetInt(stake.Stake) + stakePercentage, _ = new(big.Float).Mul(big.NewFloat(100), new(big.Float).Quo(operatorStake, totalStake)).Float64() + stakeValue, _ := operatorStake.Float64() + metrics[quorum].Operators = append(metrics[quorum].Operators, operatorId.Hex()) + metrics[quorum].OperatorStake[operatorId.Hex()] = stakeValue + metrics[quorum].OperatorStakePct[operatorId.Hex()] = stakePercentage + } + } + } + + return metrics +}