diff --git a/disperser/dataapi/docs/docs.go b/disperser/dataapi/docs/docs.go index ab30ff667..6c8a7c487 100644 --- a/disperser/dataapi/docs/docs.go +++ b/disperser/dataapi/docs/docs.go @@ -992,12 +992,18 @@ const docTemplate = `{ "block_timestamp": { "type": "string" }, + "operator_address": { + "type": "string" + }, "operator_id": { "type": "string" }, "quorum": { "type": "integer" }, + "stake_percentage": { + "type": "number" + }, "transaction_hash": { "type": "string" } diff --git a/disperser/dataapi/docs/swagger.json b/disperser/dataapi/docs/swagger.json index b425b25ec..654ac8073 100644 --- a/disperser/dataapi/docs/swagger.json +++ b/disperser/dataapi/docs/swagger.json @@ -988,12 +988,18 @@ "block_timestamp": { "type": "string" }, + "operator_address": { + "type": "string" + }, "operator_id": { "type": "string" }, "quorum": { "type": "integer" }, + "stake_percentage": { + "type": "number" + }, "transaction_hash": { "type": "string" } diff --git a/disperser/dataapi/docs/swagger.yaml b/disperser/dataapi/docs/swagger.yaml index 8844b4f6f..337ea4172 100644 --- a/disperser/dataapi/docs/swagger.yaml +++ b/disperser/dataapi/docs/swagger.yaml @@ -166,10 +166,14 @@ definitions: type: integer block_timestamp: type: string + operator_address: + type: string operator_id: type: string quorum: type: integer + stake_percentage: + type: number transaction_hash: type: string type: object diff --git a/disperser/dataapi/queried_operators_handlers.go b/disperser/dataapi/queried_operators_handlers.go index c983bdf72..a5e7bd5c5 100644 --- a/disperser/dataapi/queried_operators_handlers.go +++ b/disperser/dataapi/queried_operators_handlers.go @@ -3,8 +3,10 @@ package dataapi import ( "context" "fmt" + "math/big" "net" "sort" + "strings" "time" "github.com/Layr-Labs/eigenda/core" @@ -106,6 +108,70 @@ func (s *server) getOperatorEjections(ctx context.Context, days int32, operatorI return nil, err } + // create a sorted slice from the set of quorums + quorumSet := make(map[uint8]struct{}) + for _, ejection := range operatorEjections { + quorumSet[ejection.Quorum] = struct{}{} + } + quorums := make([]uint8, 0, len(quorumSet)) + for quorum := range quorumSet { + quorums = append(quorums, quorum) + } + sort.Slice(quorums, func(i, j int) bool { + return quorums[i] < quorums[j] + }) + + stateCache := make(map[uint64]*core.OperatorState) + ejectedOperatorIds := make(map[core.OperatorID]struct{}) + for _, ejection := range operatorEjections { + previouseBlock := ejection.BlockNumber - 1 + if _, exists := stateCache[previouseBlock]; !exists { + state, err := s.chainState.GetOperatorState(context.Background(), uint(previouseBlock), quorums) + if err != nil { + return nil, err + } + stateCache[previouseBlock] = state + } + + // construct a set of ejected operator ids for later batch address lookup + opID, err := core.OperatorIDFromHex(ejection.OperatorId) + if err != nil { + return nil, err + } + ejectedOperatorIds[opID] = struct{}{} + } + + // resolve operator id to operator addresses mapping + operatorIDs := make([]core.OperatorID, 0, len(ejectedOperatorIds)) + for opID := range ejectedOperatorIds { + operatorIDs = append(operatorIDs, opID) + } + operatorAddresses, err := s.transactor.BatchOperatorIDToAddress(ctx, operatorIDs) + if err != nil { + return nil, err + } + operatorIdToAddress := make(map[string]string) + for i := range operatorAddresses { + operatorIdToAddress["0x"+operatorIDs[i].Hex()] = strings.ToLower(operatorAddresses[i].Hex()) + } + + for _, ejection := range operatorEjections { + state := stateCache[ejection.BlockNumber-1] + opID, err := core.OperatorIDFromHex(ejection.OperatorId) + if err != nil { + return nil, err + } + + stakePercentage := float64(0) + if stake, ok := state.Operators[ejection.Quorum][opID]; ok { + totalStake := new(big.Float).SetInt(state.Totals[ejection.Quorum].Stake) + operatorStake := new(big.Float).SetInt(stake.Stake) + stakePercentage, _ = new(big.Float).Mul(big.NewFloat(100), new(big.Float).Quo(operatorStake, totalStake)).Float64() + } + ejection.StakePercentage = stakePercentage + ejection.OperatorAddress = operatorIdToAddress[ejection.OperatorId] + } + s.logger.Info("Get operator ejections", "days", days, "operatorId", operatorId, "len", len(operatorEjections), "duration", time.Since(startTime)) return operatorEjections, nil } diff --git a/disperser/dataapi/server.go b/disperser/dataapi/server.go index a4113d44c..9a622f19a 100644 --- a/disperser/dataapi/server.go +++ b/disperser/dataapi/server.go @@ -147,11 +147,13 @@ type ( } QueriedOperatorEjections struct { - OperatorId string `json:"operator_id"` - Quorum uint8 `json:"quorum"` - BlockNumber uint64 `json:"block_number"` - BlockTimestamp string `json:"block_timestamp"` - TransactionHash string `json:"transaction_hash"` + OperatorId string `json:"operator_id"` + OperatorAddress string `json:"operator_address"` + Quorum uint8 `json:"quorum"` + BlockNumber uint64 `json:"block_number"` + BlockTimestamp string `json:"block_timestamp"` + TransactionHash string `json:"transaction_hash"` + StakePercentage float64 `json:"stake_percentage"` } QueriedOperatorEjectionsResponse struct { Ejections []*QueriedOperatorEjections `json:"ejections"` diff --git a/tools/ejections/Makefile b/tools/ejections/Makefile new file mode 100644 index 000000000..f971cf35f --- /dev/null +++ b/tools/ejections/Makefile @@ -0,0 +1,9 @@ +build: clean + go mod tidy + go build -o ./bin/ejections ./cmd + +clean: + rm -rf ./bin + +run: build + ./bin/ejections --help diff --git a/tools/ejections/cmd/main.go b/tools/ejections/cmd/main.go new file mode 100644 index 000000000..a38ac6460 --- /dev/null +++ b/tools/ejections/cmd/main.go @@ -0,0 +1,231 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "math/big" + "os" + "sort" + "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/disperser/dataapi" + "github.com/Layr-Labs/eigenda/disperser/dataapi/subgraph" + "github.com/Layr-Labs/eigenda/tools/ejections" + "github.com/Layr-Labs/eigenda/tools/ejections/flags" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" + "github.com/urfave/cli" + + gethcommon "github.com/ethereum/go-ethereum/common" +) + +var ( + version = "1.0.0" + gitCommit = "" + gitDate = "" +) + +type EjectionTransaction struct { + BlockNumber uint64 `json:"block_number"` + BlockTimestamp string `json:"block_timestamp"` + TransactionHash string `json:"transaction_hash"` + QuorumStakePercentage map[uint8]float64 `json:"stake_percentage"` + QuorumEjections map[uint8]uint8 `json:"ejections"` +} + +func main() { + app := cli.NewApp() + app.Version = fmt.Sprintf("%s,%s,%s", version, gitCommit, gitDate) + app.Name = "ejections report" + app.Description = "operator ejections report" + 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 := ejections.NewConfig(ctx) + if err != nil { + return err + } + + logger, err := common.NewLogger(config.LoggerConfig) + if err != nil { + return err + } + + client, err := geth.NewMultiHomingClient(config.EthClientConfig, gethcommon.Address{}, logger) + if err != nil { + return err + } + + tx, err := eth.NewReader(logger, client, config.BLSOperatorStateRetrieverAddr, config.EigenDAServiceManagerAddr) + if err != nil { + return err + } + + chainState := eth.NewChainState(tx, client) + if chainState == nil { + return errors.New("failed to create chain state") + } + subgraphApi := subgraph.NewApi(config.SubgraphEndpoint, config.SubgraphEndpoint) + subgraphClient := dataapi.NewSubgraphClient(subgraphApi, logger) + + ejections, err := subgraphClient.QueryOperatorEjectionsForTimeWindow(context.Background(), int32(config.Days), config.OperatorId, config.First, config.Skip) + if err != nil { + logger.Warn("failed to fetch operator ejections", "operatorId", config.OperatorId, "error", err) + return errors.New("operator ejections not found") + } + + sort.Slice(ejections, func(i, j int) bool { + return ejections[i].BlockTimestamp > ejections[j].BlockTimestamp + }) + + // Create a sorted slice from the set of quorums + quorumSet := make(map[uint8]struct{}) + for _, ejection := range ejections { + quorumSet[ejection.Quorum] = struct{}{} + } + quorums := make([]uint8, 0, len(quorumSet)) + for quorum := range quorumSet { + quorums = append(quorums, quorum) + } + sort.Slice(quorums, func(i, j int) bool { + return quorums[i] < quorums[j] + }) + + stateCache := make(map[uint64]*core.OperatorState) + ejectedOperatorIds := make(map[core.OperatorID]struct{}) + for _, ejection := range ejections { + previouseBlock := ejection.BlockNumber - 1 + if _, exists := stateCache[previouseBlock]; !exists { + state, err := chainState.GetOperatorState(context.Background(), uint(previouseBlock), quorums) + if err != nil { + return err + } + stateCache[previouseBlock] = state + } + + // construct a set of ejected operator ids for later batch address lookup + opID, err := core.OperatorIDFromHex(ejection.OperatorId) + if err != nil { + return err + } + ejectedOperatorIds[opID] = struct{}{} + } + + // resolve operator id to operator addresses mapping + operatorIDs := make([]core.OperatorID, 0, len(ejectedOperatorIds)) + for opID := range ejectedOperatorIds { + 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["0x"+operatorIDs[i].Hex()] = strings.ToLower(operatorAddresses[i].Hex()) + } + + rowConfigAutoMerge := table.RowConfig{AutoMerge: true} + rowConfigNoAutoMerge := table.RowConfig{AutoMerge: false} + operators := table.NewWriter() + operators.AppendHeader(table.Row{"Operator Address", "Quorum", "Stake %", "Timestamp", "Txn"}, rowConfigAutoMerge) + txns := table.NewWriter() + txns.AppendHeader(table.Row{"Txn", "Timestamp", "Operator Address", "Quorum", "Stake %"}, rowConfigAutoMerge) + txnQuorums := table.NewWriter() + txnQuorums.AppendHeader(table.Row{"Txn", "Timestamp", "Quorum", "Stake %", "Operators"}, rowConfigNoAutoMerge) + + ejectionTransactions := make(map[string]*EjectionTransaction) + for _, ejection := range ejections { + state := stateCache[ejection.BlockNumber-1] + opID, err := core.OperatorIDFromHex(ejection.OperatorId) + if err != nil { + return err + } + + stakePercentage := float64(0) + if stake, ok := state.Operators[ejection.Quorum][opID]; ok { + totalStake := new(big.Float).SetInt(state.Totals[ejection.Quorum].Stake) + operatorStake := new(big.Float).SetInt(stake.Stake) + stakePercentage, _ = new(big.Float).Mul(big.NewFloat(100), new(big.Float).Quo(operatorStake, totalStake)).Float64() + } + + if _, exists := ejectionTransactions[ejection.TransactionHash]; !exists { + ejectionTransactions[ejection.TransactionHash] = &EjectionTransaction{ + BlockNumber: ejection.BlockNumber, + BlockTimestamp: ejection.BlockTimestamp, + TransactionHash: ejection.TransactionHash, + QuorumStakePercentage: make(map[uint8]float64), + QuorumEjections: make(map[uint8]uint8), + } + ejectionTransactions[ejection.TransactionHash].QuorumStakePercentage[ejection.Quorum] = stakePercentage + ejectionTransactions[ejection.TransactionHash].QuorumEjections[ejection.Quorum] = 1 + } else { + ejectionTransactions[ejection.TransactionHash].QuorumStakePercentage[ejection.Quorum] += stakePercentage + ejectionTransactions[ejection.TransactionHash].QuorumEjections[ejection.Quorum] += 1 + } + + operatorAddress := operatorIdToAddress[ejection.OperatorId] + operators.AppendRow(table.Row{operatorAddress, ejection.Quorum, stakePercentage, ejection.BlockTimestamp, ejection.TransactionHash}, rowConfigAutoMerge) + txns.AppendRow(table.Row{ejection.TransactionHash, ejection.BlockTimestamp, operatorAddress, ejection.Quorum, stakePercentage}, rowConfigAutoMerge) + } + + orderedEjectionTransactions := make([]*EjectionTransaction, 0, len(ejectionTransactions)) + for _, txn := range ejectionTransactions { + orderedEjectionTransactions = append(orderedEjectionTransactions, txn) + } + sort.Slice(orderedEjectionTransactions, func(i, j int) bool { + return orderedEjectionTransactions[i].BlockNumber > orderedEjectionTransactions[j].BlockNumber + }) + for _, txn := range orderedEjectionTransactions { + for _, quorum := range quorums { + if _, exists := txn.QuorumEjections[quorum]; exists { + txnQuorums.AppendRow(table.Row{txn.TransactionHash, txn.BlockTimestamp, quorum, txn.QuorumStakePercentage[quorum], txn.QuorumEjections[quorum]}, rowConfigAutoMerge) + } + } + } + + operators.SetAutoIndex(true) + operators.SetColumnConfigs([]table.ColumnConfig{ + {Number: 1, AutoMerge: true}, + {Number: 2, Align: text.AlignCenter}, + }) + operators.SetStyle(table.StyleLight) + operators.Style().Options.SeparateRows = true + + txns.SetAutoIndex(true) + txns.SetColumnConfigs([]table.ColumnConfig{ + {Number: 1, AutoMerge: true}, + {Number: 2, AutoMerge: true}, + {Number: 3, AutoMerge: true}, + {Number: 4, Align: text.AlignCenter}, + }) + txns.SetStyle(table.StyleLight) + txns.Style().Options.SeparateRows = true + + txnQuorums.SetAutoIndex(true) + txnQuorums.SetColumnConfigs([]table.ColumnConfig{ + {Number: 1, AutoMerge: true}, + {Number: 2, AutoMerge: true, Align: text.AlignCenter}, + {Number: 3, Align: text.AlignCenter}, + {Number: 5, Align: text.AlignCenter}, + }) + txnQuorums.SetStyle(table.StyleLight) + txnQuorums.Style().Options.SeparateRows = true + + fmt.Println(operators.Render()) + fmt.Println(txns.Render()) + fmt.Println(txnQuorums.Render()) + return nil +} diff --git a/tools/ejections/config.go b/tools/ejections/config.go new file mode 100644 index 000000000..4ea7b3b6f --- /dev/null +++ b/tools/ejections/config.go @@ -0,0 +1,46 @@ +package ejections + +import ( + "github.com/Layr-Labs/eigenda/common" + "github.com/Layr-Labs/eigenda/common/geth" + "github.com/Layr-Labs/eigenda/tools/ejections/flags" + "github.com/urfave/cli" +) + +type Config struct { + LoggerConfig common.LoggerConfig + Days int + OperatorId string + SubgraphEndpoint string + First uint + Skip uint + + EthClientConfig geth.EthClientConfig + BLSOperatorStateRetrieverAddr string + EigenDAServiceManagerAddr string +} + +func ReadConfig(ctx *cli.Context) *Config { + return &Config{ + Days: ctx.Int(flags.DaysFlag.Name), + OperatorId: ctx.String(flags.OperatorIdFlag.Name), + SubgraphEndpoint: ctx.String(flags.SubgraphEndpointFlag.Name), + First: ctx.Uint(flags.FirstFlag.Name), + Skip: ctx.Uint(flags.SkipFlag.Name), + EthClientConfig: geth.ReadEthClientConfig(ctx), + BLSOperatorStateRetrieverAddr: ctx.GlobalString(flags.BlsOperatorStateRetrieverFlag.Name), + EigenDAServiceManagerAddr: ctx.GlobalString(flags.EigenDAServiceManagerFlag.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/ejections/flags/flags.go b/tools/ejections/flags/flags.go new file mode 100644 index 000000000..9cbbb37bd --- /dev/null +++ b/tools/ejections/flags/flags.go @@ -0,0 +1,84 @@ +package flags + +import ( + "github.com/Layr-Labs/eigenda/common" + "github.com/Layr-Labs/eigenda/common/geth" + "github.com/urfave/cli" +) + +const ( + FlagPrefix = "" + envPrefix = "" +) + +var ( + /* Required Flags*/ + SubgraphEndpointFlag = cli.StringFlag{ + Name: common.PrefixFlag(FlagPrefix, "subgraph"), + Usage: "Subgraph URL to query operator state", + Required: true, + EnvVar: common.PrefixEnvVar(envPrefix, "SUBGRAPH"), + } + OperatorIdFlag = cli.StringFlag{ + Name: common.PrefixFlag(FlagPrefix, "operator_id"), + Usage: "Query operator id", + Required: false, + EnvVar: common.PrefixEnvVar(envPrefix, "OPERATOR_ID"), + Value: "", + } + DaysFlag = cli.UintFlag{ + Name: common.PrefixFlag(FlagPrefix, "days"), + Usage: "Lookback days", + Required: false, + EnvVar: common.PrefixEnvVar(envPrefix, "DAYS"), + Value: 1, + } + FirstFlag = cli.UintFlag{ + Name: common.PrefixFlag(FlagPrefix, "first"), + Usage: "Return first n records (default 1000, max 10000)", + Required: false, + EnvVar: common.PrefixEnvVar(envPrefix, "FIRST"), + Value: 1000, + } + SkipFlag = cli.UintFlag{ + Name: common.PrefixFlag(FlagPrefix, "skip"), + Usage: "Skip first n records (default 0, max 1000000)", + Required: false, + EnvVar: common.PrefixEnvVar(envPrefix, "SKIP"), + Value: 0, + } + 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"), + } +) + +var requiredFlags = []cli.Flag{ + SubgraphEndpointFlag, + BlsOperatorStateRetrieverFlag, + EigenDAServiceManagerFlag, +} + +var optionalFlags = []cli.Flag{ + OperatorIdFlag, + DaysFlag, + FirstFlag, + SkipFlag, +} + +// 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)...) +}