diff --git a/cli/core/scan.go b/cli/core/scan.go new file mode 100644 index 00000000..beba79db --- /dev/null +++ b/cli/core/scan.go @@ -0,0 +1,238 @@ +package core + +import ( + "context" + "fmt" + "log" + "math/big" + "time" + + "github.com/Layr-Labs/eigenpod-proofs-generation/cli/core/onchain" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + multicall "github.com/forta-network/go-multicall" +) + +type Cache struct { + PodOwnerShares map[string]PodOwnerShare +} + +type PodOwnerShare struct { + AsOfBlockNumber uint64 + Shares uint64 + IsEigenpod bool +} + +var cache Cache + +func sleep(ms int) { + time.Sleep(time.Duration(ms) * time.Millisecond) +} + +func isEigenpod(eigenpodAddress []byte) (bool, error) { + eigenpod := common.Bytes2Hex(eigenpodAddress) + + if val, ok := cache.PodOwnerShares[eigenpod]; ok { + return val.IsEigenpod, nil + } + + // Simulate fetching from contracts + // Implement contract fetching logic here + + cache.PodOwnerShares[eigenpod] = PodOwnerShare{ + AsOfBlockNumber: 123, // replace with actual block number + Shares: 0, + IsEigenpod: false, + } + + return false, nil +} + +func withdrawalCredentialsBelongsToEigenpod(withdrawalCredentials []byte) (bool, error) { + if withdrawalCredentials[0] != 1 { + return false, nil + } + + withdrawalAddress := withdrawalCredentials[12:] + return isEigenpod(withdrawalAddress) +} + +func executionWithdrawlAddress(withdrawalCredentials []byte) *string { + if withdrawalCredentials[0] != '1' { + return nil + } + addr := common.Bytes2Hex(withdrawalCredentials[12:]) + return &addr +} + +func aFilter[T any](coll []T, criteria func(T) bool) []T { + var result []T + for _, item := range coll { + if criteria(item) { + result = append(result, item) + } + } + return result +} + +func aMap[T any, A any](coll []T, mapper func(T, uint64) A) []A { + var result []A + for idx, item := range coll { + result = append(result, mapper(item, uint64(idx))) + } + return result +} + +// https://www.multicall3.com/deployments +func DeployedAddresses() map[int]string { + return map[int]string{ + 0: "0xcA11bde05977b3631167028862bE2a173976CA11", + 17000: "0xcA11bde05977b3631167028862bE2a173976CA11", + } +} + +func MulticallEigenpod(eigenpodAddress string) multicall.Contract { + eigenpodAbi, err := onchain.EigenPodMetaData.GetAbi() + if err != nil { + panic(err) + } + + return multicall.Contract{ + ABI: eigenpodAbi, + Address: common.HexToAddress(eigenpodAddress), + } +} + +func scanForUnhealthyEigenpods(ctx context.Context, eth *ethclient.Client, nodeUrl string, beacon BeaconClient, chainId *big.Int) error { + addr, ok := DeployedAddresses()[int(chainId.Int64())] + if !ok { + return fmt.Errorf("no known multicall deployment for chain: %d", chainId.Int64()) + } + + mc, err := multicall.Dial(context.Background(), nodeUrl, addr) + if err != nil { + panic(err) + } + + beaconState, err := beacon.GetBeaconState(ctx, "head") + if err != nil { + return fmt.Errorf("error downloading beacon state: %s", err.Error()) + } + + // Simulate fetching validators + _allValidators, err := beaconState.Validators() + if err != nil { + return err + } + + allValidatorBalances, err := beaconState.ValidatorBalances() + if err != nil { + return err + } + + allValidatorsWithIndices := aMap(_allValidators, func(validator *phase0.Validator, index uint64) ValidatorWithIndex { + return ValidatorWithIndex{ + Validator: validator, + Index: index, + } + }) + + allWithdrawalAddresses := make(map[string]struct{}) + for _, v := range allValidatorsWithIndices { + address := executionWithdrawlAddress(v.Validator.WithdrawalCredentials) + if address != nil { + allWithdrawalAddresses[*address] = struct{}{} + } + } + + allSlashedValidators := aFilter(allValidatorsWithIndices, func(v ValidatorWithIndex) bool { + if !v.Validator.Slashed { + return false // we only care about slashed validators. + } + if v.Validator.WithdrawalCredentials[0] != 1 { + return false // not an execution withdrawal address + } + return true + }) + + withdrawalAddressesToCheck := make(map[uint64]string) + for _, validator := range allSlashedValidators { + withdrawalAddressesToCheck[validator.Index] = *executionWithdrawlAddress(validator.Validator.WithdrawalCredentials) + } + + if len(withdrawalAddressesToCheck) == 0 { + log.Println("No EigenValidators were slashed.") + return nil + } + + // now, check across $withdrawalAddressesToCheck + potentialEigenpods := make([]multicall.Contract, len(withdrawalAddressesToCheck)) + numPotentialPods := 0 + for _, withdrawalAddress := range withdrawalAddressesToCheck { + potentialEigenpods[numPotentialPods] = MulticallEigenpod(withdrawalAddress) + numPotentialPods++ + } + + loadPodOwners := aMap(potentialEigenpods, func(eigenpod multicall.Contract) *multicall.Call { + return nil + }) + + // load all of the podOwners + mc.Call(nil, + loadPodOwners..., + ) + + log.Printf("%d EigenValidators were slashed\n", len(allSlashedValidatorsBelongingToEigenpods)) + + slashedEigenpods := make(map[string][]*phase0.Validator) + for _, validator := range allSlashedValidatorsBelongingToEigenpods { + podAddress := executionWithdrawlAddress(validator.WithdrawalCredentials) + if podAddress != nil { + slashedEigenpods[*podAddress] = append(slashedEigenpods[*podAddress], validator) + } + } + + log.Printf("%d EigenPods were slashed\n", len(slashedEigenpods)) + + slashedEigenpodBeaconBalances := make(map[string]phase0.Gwei) + for idx, validator := range allValidators { + eigenpod := executionWithdrawlAddress(validator.WithdrawalCredentials) + if eigenpod != nil { + isEigenpod := cache.PodOwnerShares[*eigenpod].IsEigenpod + if isEigenpod { + slashedEigenpodBeaconBalances[*eigenpod] += allValidatorBalances[idx] + } + } + } + + var unhealthyEigenpods []string + for pod, balance := range slashedEigenpodBeaconBalances { + executionBalance := cache.PodOwnerShares[pod].Shares + if executionBalance == 0 { + continue + } + if balance <= phase0.Gwei(float64(executionBalance)*0.95) { + unhealthyEigenpods = append(unhealthyEigenpods, pod) + log.Printf("[%s] %.2f%% deviation (beacon: %d -> execution: %d)\n", pod, 100*(float64(executionBalance)-float64(balance))/float64(executionBalance), balance, executionBalance) + } + } + + if len(unhealthyEigenpods) == 0 { + log.Println("All slashed eigenpods are within 5% of their expected balance.") + return nil + } + + log.Printf("%d EigenPods were unhealthy\n", len(unhealthyEigenpods)) + + var entries []map[string]interface{} + for _, val := range unhealthyEigenpods { + entries = append(entries, map[string]interface{}{ + "eigenpod": val, + "slashedValidators": slashedEigenpods[val], + }) + } + + fmt.Println(entries) + return nil +} diff --git a/go.mod b/go.mod index 6880404a..5cab89e7 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/deckarep/golang-set/v2 v2.1.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect github.com/ethereum/c-kzg-4844 v0.4.0 // indirect + github.com/forta-network/go-multicall v0.0.0-20230701154355-9467c4ddaa83 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/go-logr/logr v1.2.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect diff --git a/go.sum b/go.sum index 955c3d18..61c24f26 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,8 @@ github.com/ferranbt/fastssz v0.1.3 h1:ZI+z3JH05h4kgmFXdHuR1aWYsgrg7o+Fw7/NCzM16M github.com/ferranbt/fastssz v0.1.3/go.mod h1:0Y9TEd/9XuFlh7mskMPfXiI2Dkw4Ddg9EyXt1W7MRvE= github.com/fjl/memsize v0.0.2 h1:27txuSD9or+NZlnOWdKUxeBzTAUkWCVh+4Gf2dWFOzA= github.com/fjl/memsize v0.0.2/go.mod h1:VvhXpOYNQvB+uIk2RvXzuaQtkQJzzIx6lSBe1xv7hi0= +github.com/forta-network/go-multicall v0.0.0-20230701154355-9467c4ddaa83 h1:aVJgFjILhAM3q1h2PVVRJkUAVBPteDNo2cjhQLzCvp0= +github.com/forta-network/go-multicall v0.0.0-20230701154355-9467c4ddaa83/go.mod h1:nqTUF1REklpWLZ/M5HfzqhSHNz4dPVKzJvbLziqTZpw= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqGNY4FhTFhk+o9oFHGINQ/+vhlm8HFzi6znCI=