diff --git a/cli/commands/findStalePods.go b/cli/commands/findStalePods.go new file mode 100644 index 0000000..17cb6e8 --- /dev/null +++ b/cli/commands/findStalePods.go @@ -0,0 +1,46 @@ +package commands + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Layr-Labs/eigenpod-proofs-generation/cli/core" + "github.com/fatih/color" +) + +type TFindStalePodsCommandArgs struct { + EthNode string + BeaconNode string + Verbose bool +} + +func FindStalePodsCommand(args TFindStalePodsCommandArgs) error { + ctx := context.Background() + eth, beacon, chainId, err := core.GetClients(ctx, args.EthNode, args.BeaconNode /* verbose */, args.Verbose) + core.PanicOnError("failed to dial clients", err) + + results, err := core.FindStaleEigenpods(ctx, eth, args.EthNode, beacon, chainId, args.Verbose) + core.PanicOnError("failed to find stale eigenpods", err) + + if !args.Verbose { + // just print json and be done + jsonStr, _ := json.MarshalIndent(results, "", " ") + fmt.Println(string(jsonStr)) + return nil + } + + for pod, res := range results { + color.Red("pod %s\n", pod) + for _, validator := range res { + fmt.Printf("\t[#%d] (%s) - %d\n", validator.Index, func() string { + if validator.Validator.Slashed { + return "slashed" + } else { + return "not slashed" + } + }(), validator.Validator.EffectiveBalance) + } + } + return nil +} diff --git a/cli/commands/staleBalance.go b/cli/commands/staleBalance.go new file mode 100644 index 0000000..d738213 --- /dev/null +++ b/cli/commands/staleBalance.go @@ -0,0 +1,106 @@ +package commands + +import ( + "context" + "fmt" + "math/big" + + eigenpodproofs "github.com/Layr-Labs/eigenpod-proofs-generation" + "github.com/Layr-Labs/eigenpod-proofs-generation/cli/core" + "github.com/Layr-Labs/eigenpod-proofs-generation/cli/core/onchain" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/fatih/color" +) + +type TFixStaleBalanceArgs struct { + EthNode string + BeaconNode string + Sender string + EigenpodAddress string + SlashedValidatorIndex int64 + Verbose bool + CheckpointBatchSize uint64 + NoPrompt bool +} + +// another fun cast brought to you by golang! +func proofCast(proof []eigenpodproofs.Bytes32) [][32]byte { + res := make([][32]byte, len(proof)) + for i, elt := range proof { + res[i] = elt + } + return res +} + +func FixStaleBalance(args TFixStaleBalanceArgs) error { + ctx := context.Background() + + eth, beacon, chainId, err := core.GetClients(ctx, args.EthNode, args.BeaconNode, args.Verbose) + core.PanicOnError("failed to get clients", err) + + validator, err := beacon.GetValidator(ctx, uint64(args.SlashedValidatorIndex)) + core.PanicOnError("failed to fetch validator state", err) + + if !validator.Validator.Slashed { + core.Panic("Provided validator was not slashed.") + return nil + } + + ownerAccount, err := core.PrepareAccount(&args.Sender, chainId, false /* noSend */) + core.PanicOnError("failed to parse sender PK", err) + + eigenpod, err := onchain.NewEigenPod(common.HexToAddress(args.EigenpodAddress), eth) + core.PanicOnError("failed to reach eigenpod", err) + + currentCheckpointTimestamp, err := eigenpod.CurrentCheckpointTimestamp(nil) + core.PanicOnError("failed to fetch any existing checkpoint info", err) + + if currentCheckpointTimestamp > 0 { + if args.Verbose { + color.Red("This eigenpod has an outstanding checkpoint (since %d). You must complete it before continuing.", currentCheckpointTimestamp) + } + + proofs, err := core.GenerateCheckpointProof(ctx, args.EigenpodAddress, eth, chainId, beacon) + core.PanicOnError("failed to generate checkpoint proofs", err) + + txns, err := core.SubmitCheckpointProof(ctx, args.Sender, args.EigenpodAddress, chainId, proofs, eth, args.CheckpointBatchSize, args.NoPrompt /* noSend */, false) + core.PanicOnError("failed to submit checkpoint proofs", err) + + for i, txn := range txns { + if args.Verbose { + fmt.Printf("sending txn[%d/%d]: %s (waiting)...", i, len(txns), txn.Hash()) + } + bind.WaitMined(ctx, eth, txn) + } + } + + proof, oracleBeaconTimesetamp, err := core.GenerateValidatorProof(ctx, args.EigenpodAddress, eth, chainId, beacon, new(big.Int).SetUint64(uint64(args.SlashedValidatorIndex)), args.Verbose) + core.PanicOnError("failed to generate credential proof for slashed validator", err) + + if !args.NoPrompt { + core.PanicIfNoConsent("This will invoke `EigenPod.verifyStaleBalance()` on the given eigenpod, which will start a checkpoint. Once started, this checkpoint must be completed.") + } + + if args.Verbose { + color.Black("Calling verifyStaleBalance() to update pod.") + } + + txn, err := eigenpod.VerifyStaleBalance( + ownerAccount.TransactionOptions, + oracleBeaconTimesetamp, + onchain.BeaconChainProofsStateRootProof{ + Proof: proof.StateRootProof.Proof.ToByteSlice(), + BeaconStateRoot: proof.StateRootProof.BeaconStateRoot, + }, + onchain.BeaconChainProofsValidatorProof{ + ValidatorFields: proofCast(proof.ValidatorFields[0]), + Proof: proof.ValidatorFieldsProofs[0].ToByteSlice(), + }, + ) + core.PanicOnError("failed to call verifyStaleBalance()", err) + + fmt.Printf("txn: %s\n", txn.Hash()) + + return nil +} diff --git a/cli/core/beaconClient.go b/cli/core/beaconClient.go index f47dce9..9499d1a 100644 --- a/cli/core/beaconClient.go +++ b/cli/core/beaconClient.go @@ -9,6 +9,7 @@ import ( v1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/http" "github.com/attestantio/go-eth2-client/spec" + "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/pkg/errors" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -23,6 +24,7 @@ var ( type BeaconClient interface { GetBeaconHeader(ctx context.Context, blockId string) (*v1.BeaconBlockHeader, error) GetBeaconState(ctx context.Context, stateId string) (*spec.VersionedBeaconState, error) + GetValidator(ctx context.Context, index uint64) (*v1.Validator, error) } type beaconClient struct { @@ -65,6 +67,30 @@ func (b *beaconClient) GetBeaconHeader(ctx context.Context, blockId string) (*v1 return nil, ErrBeaconClientNotSupported } +func (b *beaconClient) GetValidator(ctx context.Context, index uint64) (*v1.Validator, error) { + if provider, ok := b.eth2client.(eth2client.ValidatorsProvider); ok { + opts := api.ValidatorsOpts{ + State: "head", + Indices: []phase0.ValidatorIndex{phase0.ValidatorIndex(index)}, + } + singleValidorInfoResponse, err := provider.Validators(ctx, &opts) + if err != nil { + return nil, err + } + + if singleValidorInfoResponse == nil { + return nil, errors.New("beacon state is nil") + } + + if b.verbose { + log.Info().Msg("finished download") + } + return singleValidorInfoResponse.Data[phase0.ValidatorIndex(index)], nil + } + + return nil, ErrBeaconClientNotSupported +} + func (b *beaconClient) GetBeaconState(ctx context.Context, stateId string) (*spec.VersionedBeaconState, error) { timeout, _ := time.ParseDuration("200s") if provider, ok := b.eth2client.(eth2client.BeaconStateProvider); ok { diff --git a/cli/core/findStalePods.go b/cli/core/findStalePods.go new file mode 100644 index 0000000..c1519fa --- /dev/null +++ b/cli/core/findStalePods.go @@ -0,0 +1,256 @@ +package core + +import ( + "context" + "fmt" + "log" + "math/big" + + "github.com/Layr-Labs/eigenpod-proofs-generation/cli/core/onchain" + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/pkg/errors" +) + +func PodManagerContracts() map[uint64]string { + return map[uint64]string{ + 0: "0x91E677b07F7AF907ec9a428aafA9fc14a0d3A338", + 17000: "0x30770d7E3e71112d7A6b7259542D1f680a70e315", //testnet holesky + } +} + +type Cache struct { + PodOwnerShares map[string]PodOwnerShare +} + +type PodOwnerShare struct { + Shares uint64 + IsEigenpod bool +} + +const ACCEPTABLE_BALANCE_DEVIATION = float64(0.95) + +var cache Cache + +func isEigenpod(eth *ethclient.Client, chainId uint64, eigenpodAddress string) (bool, error) { + if cache.PodOwnerShares == nil { + cache.PodOwnerShares = make(map[string]PodOwnerShare) + } + + if val, ok := cache.PodOwnerShares[eigenpodAddress]; ok { + return val.IsEigenpod, nil + } + + // default to false + cache.PodOwnerShares[eigenpodAddress] = PodOwnerShare{ + Shares: 0, + IsEigenpod: false, + } + + podManAddress, ok := PodManagerContracts()[chainId] + if !ok { + return false, fmt.Errorf("chain %d not supported", chainId) + } + podMan, err := onchain.NewEigenPodManager(common.HexToAddress(podManAddress), eth) + if err != nil { + return false, err + } + + if podMan == nil { + return false, errors.New("failed to find eigenpod manager") + } + + pod, err := onchain.NewEigenPod(common.HexToAddress(eigenpodAddress), eth) + if err != nil { + return false, err + } + + owner, err := pod.PodOwner(nil) + if err != nil { + return false, err + } + + expectedPod, err := podMan.OwnerToPod(&bind.CallOpts{}, owner) + if err != nil { + return false, fmt.Errorf("ownerToPod() failed: %s", err.Error()) + } + if expectedPod.Cmp(common.HexToAddress(eigenpodAddress)) != 0 { + return false, nil + } + + podOwnerShares, err := podMan.PodOwnerShares(nil, owner) + if err != nil { + return false, fmt.Errorf("PodOwnerShares() failed: %s", err.Error()) + } + + // Simulate fetching from contracts + // Implement contract fetching logic here + cache.PodOwnerShares[eigenpodAddress] = PodOwnerShare{ + Shares: podOwnerShares.Uint64(), + IsEigenpod: true, + } + + return true, nil +} + +func executionWithdrawalAddress(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 +} + +func FindStaleEigenpods(ctx context.Context, eth *ethclient.Client, nodeUrl string, beacon BeaconClient, chainId *big.Int, verbose bool) (map[string][]ValidatorWithIndex, error) { + beaconState, err := beacon.GetBeaconState(ctx, "head") + if err != nil { + return nil, fmt.Errorf("error downloading beacon state: %s", err.Error()) + } + + // Simulate fetching validators + _allValidators, err := beaconState.Validators() + if err != nil { + return nil, 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 := executionWithdrawalAddress(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] = *executionWithdrawalAddress(validator.Validator.WithdrawalCredentials) + } + + if len(withdrawalAddressesToCheck) == 0 { + log.Println("No EigenValidators were slashed.") + return map[string][]ValidatorWithIndex{}, nil + } + + validatorToPod := map[uint64]string{} + + allSlashedValidatorsBelongingToEigenpods := aFilter(allSlashedValidators, func(validator ValidatorWithIndex) bool { + isPod, err := isEigenpod(eth, chainId.Uint64(), *executionWithdrawalAddress(validator.Validator.WithdrawalCredentials)) + if err != nil { + return false + } + return isPod + }) + + allValidatorInfo := make(map[uint64]onchain.IEigenPodValidatorInfo) + + for _, validator := range allSlashedValidatorsBelongingToEigenpods { + eigenpodAddress := *executionWithdrawalAddress(validator.Validator.WithdrawalCredentials) + pod, err := onchain.NewEigenPod(common.HexToAddress(eigenpodAddress), eth) + PanicOnError("failed to dial eigenpod", err) + + info, err := pod.ValidatorPubkeyToInfo(nil, validator.Validator.PublicKey[:]) + if err != nil { + // failed to load validator info. + return map[string][]ValidatorWithIndex{}, err + } + allValidatorInfo[validator.Index] = info + } + + allActiveSlashedValidatorsBelongingToEigenpods := aFilter(allSlashedValidatorsBelongingToEigenpods, func(validator ValidatorWithIndex) bool { + validatorInfo := allValidatorInfo[validator.Index] + return validatorInfo.Status == 1 + }) + + if verbose { + log.Printf("%d EigenValidators were slashed\n", len(allActiveSlashedValidatorsBelongingToEigenpods)) + } + + slashedEigenpods := make(map[string][]ValidatorWithIndex) + for _, validator := range allActiveSlashedValidatorsBelongingToEigenpods { + podAddress := executionWithdrawalAddress(validator.Validator.WithdrawalCredentials) + if podAddress != nil { + slashedEigenpods[*podAddress] = append(slashedEigenpods[*podAddress], validator) + validatorToPod[validator.Index] = *podAddress + } + } + + if verbose { + log.Printf("%d EigenPods were slashed\n", len(slashedEigenpods)) + } + + allValidatorBalances, err := beaconState.ValidatorBalances() + if err != nil { + return nil, err + } + + var unhealthyEigenpods map[string]bool = make(map[string]bool) + for _, validator := range allActiveSlashedValidatorsBelongingToEigenpods { + balance := allValidatorBalances[validator.Index] + pod := validatorToPod[validator.Index] + executionBalance := cache.PodOwnerShares[pod].Shares + if executionBalance == 0 { + continue + } + if balance <= phase0.Gwei(float64(executionBalance)*ACCEPTABLE_BALANCE_DEVIATION) { + unhealthyEigenpods[pod] = true + if verbose { + log.Printf("[%s] %.2f%% deviation (beacon: %d -> execution: %d)\n", pod, 100*(float64(executionBalance)-float64(balance))/float64(executionBalance), balance, executionBalance) + } + } + } + + if len(unhealthyEigenpods) == 0 { + if verbose { + log.Println("All slashed eigenpods are within 5% of their expected balance.") + } + return map[string][]ValidatorWithIndex{}, nil + } + + if verbose { + log.Printf("%d EigenPods were unhealthy\n", len(unhealthyEigenpods)) + } + + var entries map[string][]ValidatorWithIndex = make(map[string][]ValidatorWithIndex) + for val := range unhealthyEigenpods { + entries[val] = slashedEigenpods[val] + } + + return entries, nil +} diff --git a/cli/core/status.go b/cli/core/status.go index a667610..b1bbde7 100644 --- a/cli/core/status.go +++ b/cli/core/status.go @@ -114,7 +114,7 @@ func GetStatus(ctx context.Context, eigenpodAddress string, eth *ethclient.Clien Slashed: validator.Slashed, PublicKey: validator.PublicKey.String(), IsAwaitingActivationQueue: validator.ActivationEpoch == FAR_FUTURE_EPOCH, - IsAwaitingWithdrawalCredentialProof: (validatorInfo.Status == ValidatorStatusInactive) && validator.ExitEpoch == FAR_FUTURE_EPOCH && validator.ActivationEpoch != FAR_FUTURE_EPOCH, + IsAwaitingWithdrawalCredentialProof: IsAwaitingWithdrawalCredentialProof(validatorInfo, validator), EffectiveBalance: uint64(validator.EffectiveBalance), CurrentBalance: uint64(allBalances[validatorIndex]), } diff --git a/cli/core/utils.go b/cli/core/utils.go index d0969ac..cceed26 100644 --- a/cli/core/utils.go +++ b/cli/core/utils.go @@ -268,6 +268,10 @@ func GetCurrentCheckpointBlockRoot(eigenpodAddress string, eth *ethclient.Client return &checkpoint.BeaconBlockRoot, nil } +func IsAwaitingWithdrawalCredentialProof(validatorInfo onchain.IEigenPodValidatorInfo, validator *phase0.Validator) bool { + return (validatorInfo.Status == ValidatorStatusInactive) && validator.ExitEpoch == FAR_FUTURE_EPOCH && validator.ActivationEpoch != FAR_FUTURE_EPOCH +} + func GetClients(ctx context.Context, node, beaconNodeUri string, enableLogs bool) (*ethclient.Client, BeaconClient, *big.Int, error) { eth, err := ethclient.Dial(node) if err != nil { diff --git a/cli/main.go b/cli/main.go index b197e0d..7650449 100644 --- a/cli/main.go +++ b/cli/main.go @@ -13,7 +13,8 @@ import ( var eigenpodAddress, beacon, node, sender string var useJSON = false var specificValidator uint64 = math.MaxUint64 -var estimateGas bool = false +var estimateGas = false +var slashedValidatorIndex uint64 func main() { var batchSize uint64 @@ -29,6 +30,55 @@ func main() { EnableBashCompletion: true, UseShortOptionHandling: true, Commands: []*cli.Command{ + { + Name: "find-stale-pods", + Args: true, + Usage: "Locate stale pods, whose balances have deviated by more than 5%% due to beacon slashing.", + UsageText: "./cli find-stale-pods ", + Flags: []cli.Flag{ + ExecNodeFlag, + BeaconNodeFlag, + }, + Action: func(cctx *cli.Context) error { + commands.FindStalePodsCommand(commands.TFindStalePodsCommandArgs{ + EthNode: node, + BeaconNode: beacon, + Verbose: verbose, + }) + return nil + }, + }, + { + Name: "correct-stale-pod", + Args: true, + Usage: "Correct a stale balance on an eigenpod, which has been slashed on the beacon chain.", + UsageText: "./cli correct-stale-pod [FLAGS] ", + Flags: []cli.Flag{ + PodAddressFlag, + ExecNodeFlag, + BeaconNodeFlag, + BatchBySize(&batchSize, utils.DEFAULT_BATCH_CHECKPOINT), + Require(SenderPkFlag), + &cli.Uint64Flag{ + Name: "validatorIndex", + Usage: "The index of a validator slashed that belongs to the pod.", + Required: true, + Destination: &slashedValidatorIndex, + }, + }, + Action: func(cctx *cli.Context) error { + return commands.FixStaleBalance(commands.TFixStaleBalanceArgs{ + EthNode: node, + BeaconNode: beacon, + Sender: sender, + EigenpodAddress: eigenpodAddress, + SlashedValidatorIndex: int64(slashedValidatorIndex), + Verbose: verbose, + CheckpointBatchSize: batchSize, + NoPrompt: noPrompt, + }) + }, + }, { Name: "assign-submitter", Args: true, diff --git a/go.mod b/go.mod index 6880404..5cab89e 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 955c3d1..61c24f2 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=