From 9b1c071119bf0a07044a4d2b697729a19bba1104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gianguido=20Sor=C3=A0?= Date: Sat, 2 Mar 2024 18:47:16 -0700 Subject: [PATCH 01/15] cmd: add exit commands This PR adds exit-related commands under the `exit` subcommand: - `submit-partial-exit`, which signs and submit to an instance of Obol API a partial exit for a given DV in a given cluster lock - `broadcast`, which downloads a full exit from an instance of Obol API for a given validator if available, and broadcasts it to the configured beacon node - `active-validator-list`, which returns the list of validators which are `ACTIVE_ONGOING` contained in the specified cluster lock (useful for scripting). Moved `obolapi` mock implementation to `testutil/obolapimock` so other tests can use it. Added a few utility functions in `eth2util/keystore`, taken from `lido-dv-exit`: since it depends on Charon, we can migrate them. --- app/obolapi/exit_test.go | 5 +- cmd/cmd.go | 5 + cmd/exit.go | 114 ++++++++ cmd/exit_fullexit.go | 130 +++++++++ cmd/exit_fullexit_internal_test.go | 251 ++++++++++++++++ cmd/exit_list.go | 106 +++++++ cmd/exit_list_internal_test.go | 190 ++++++++++++ cmd/exit_submitpartial.go | 155 ++++++++++ cmd/exit_submitpartial_internal_test.go | 272 ++++++++++++++++++ eth2util/keystore/keystore.go | 98 +++++++ eth2util/keystore/keystore_test.go | 102 +++++++ testutil/beaconmock/options.go | 21 ++ .../obolapimock/obolapi_exit.go | 2 +- 13 files changed, 1448 insertions(+), 3 deletions(-) create mode 100644 cmd/exit.go create mode 100644 cmd/exit_fullexit.go create mode 100644 cmd/exit_fullexit_internal_test.go create mode 100644 cmd/exit_list.go create mode 100644 cmd/exit_list_internal_test.go create mode 100644 cmd/exit_submitpartial.go create mode 100644 cmd/exit_submitpartial_internal_test.go rename app/obolapi/exit_server_test.go => testutil/obolapimock/obolapi_exit.go (99%) diff --git a/app/obolapi/exit_test.go b/app/obolapi/exit_test.go index f9376565f..4358efb30 100644 --- a/app/obolapi/exit_test.go +++ b/app/obolapi/exit_test.go @@ -21,6 +21,7 @@ import ( "github.com/obolnetwork/charon/tbls" "github.com/obolnetwork/charon/tbls/tblsconv" "github.com/obolnetwork/charon/testutil/beaconmock" + "github.com/obolnetwork/charon/testutil/obolapimock" ) const exitEpoch = eth2p0.Epoch(162304) @@ -28,7 +29,7 @@ const exitEpoch = eth2p0.Epoch(162304) func TestAPIFlow(t *testing.T) { kn := 4 - handler, addLockFiles := MockServer(false) + handler, addLockFiles := obolapimock.MockServer(false) srv := httptest.NewServer(handler) defer srv.Close() @@ -119,7 +120,7 @@ func TestAPIFlow(t *testing.T) { func TestAPIFlowMissingSig(t *testing.T) { kn := 4 - handler, addLockFiles := MockServer(true) + handler, addLockFiles := obolapimock.MockServer(true) srv := httptest.NewServer(handler) defer srv.Close() diff --git a/cmd/cmd.go b/cmd/cmd.go index be35f60e3..27f291eef 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -48,6 +48,11 @@ func New() *cobra.Command { newAddValidatorsCmd(runAddValidatorsSolo), newViewClusterManifestCmd(runViewClusterManifest), ), + newExitCmd( + newListActiveValidatorsCmd(runListActiveValidatorsCmd), + newSubmitPartialExitCmd(runSubmitPartialExit), + newBcastFullExitCmd(runBcastFullExit), + ), newUnsafeCmd(newRunCmd(app.Run, true)), ) } diff --git a/cmd/exit.go b/cmd/exit.go new file mode 100644 index 000000000..ddc80e207 --- /dev/null +++ b/cmd/exit.go @@ -0,0 +1,114 @@ +// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "time" + + eth2http "github.com/attestantio/go-eth2-client/http" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/spf13/cobra" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/eth2wrap" + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/eth2util/signing" + "github.com/obolnetwork/charon/tbls" +) + +type exitConfig struct { + BeaconNodeURL string + ValidatorAddr string + DataDir string + ObolAPIEndpoint string + ExitEpoch uint64 + + PlaintextOutput bool + + Log log.Config +} + +func newExitCmd(cmds ...*cobra.Command) *cobra.Command { + root := &cobra.Command{ + Use: "exit", + Short: "Exit a distributed validator.", + Long: "Exit a distributed validator through the Obol API.", + } + + root.AddCommand(cmds...) + + return root +} + +func bindGenericExitFlags(cmd *cobra.Command, config *exitConfig) { + cmd.Flags().StringVar(&config.ObolAPIEndpoint, "obol-api-endpoint", "https://api.obol.tech", "Endpoint of the Obol API instance.") + cmd.Flags().StringVar(&config.BeaconNodeURL, "beacon-node-url", "", "Beacon node URL.") + cmd.Flags().StringVar(&config.DataDir, "data-dir", ".charon", "The directory where charon will read lock file and partial validator keys.") + + mustMarkFlagRequired(cmd, "beacon-node-url") +} + +func bindExitRelatedFlags(cmd *cobra.Command, config *exitConfig) { + cmd.Flags().StringVar(&config.ValidatorAddr, "validator-address", "", "Validator to exit, must be present in the cluster lock manifest.") + cmd.Flags().Uint64Var(&config.ExitEpoch, "exit-epoch", 162304, "Exit epoch at which the validator will exit, must be the same across all the partial exits.") + + mustMarkFlagRequired(cmd, "validator-address") +} + +func eth2Client(ctx context.Context, u string) (eth2wrap.Client, error) { + bnHTTPClient, err := eth2http.New(ctx, + eth2http.WithAddress(u), + eth2http.WithLogLevel(1), // zerolog.InfoLevel + ) + if err != nil { + return nil, errors.Wrap(err, "can't connect to beacon node") + } + + bnClient := bnHTTPClient.(*eth2http.Service) + + return eth2wrap.AdaptEth2HTTP(bnClient, 10*time.Second), nil +} + +// signExit signs a voluntary exit message for valIdx with the given keyShare. +func signExit(ctx context.Context, eth2Cl eth2wrap.Client, valIdx eth2p0.ValidatorIndex, keyShare tbls.PrivateKey, exitEpoch eth2p0.Epoch) (eth2p0.SignedVoluntaryExit, error) { + exit := ð2p0.VoluntaryExit{ + Epoch: exitEpoch, + ValidatorIndex: valIdx, + } + + sigData, err := sigDataForExit(ctx, *exit, eth2Cl, exitEpoch) + if err != nil { + return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "exit hash tree root") + } + + sig, err := tbls.Sign(keyShare, sigData[:]) + if err != nil { + return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "signing error") + } + + return eth2p0.SignedVoluntaryExit{ + Message: exit, + Signature: eth2p0.BLSSignature(sig), + }, nil +} + +// sigDataForExit returns the hash tree root for the given exit message, at the given exit epoch. +func sigDataForExit(ctx context.Context, exit eth2p0.VoluntaryExit, eth2Cl eth2wrap.Client, exitEpoch eth2p0.Epoch) ([32]byte, error) { + sigRoot, err := exit.HashTreeRoot() + if err != nil { + return [32]byte{}, errors.Wrap(err, "exit hash tree root") + } + + domain, err := signing.GetDomain(ctx, eth2Cl, signing.DomainExit, exitEpoch) + if err != nil { + return [32]byte{}, errors.Wrap(err, "get domain") + } + + sigData, err := (ð2p0.SigningData{ObjectRoot: sigRoot, Domain: domain}).HashTreeRoot() + if err != nil { + return [32]byte{}, errors.Wrap(err, "signing data hash tree root") + } + + return sigData, nil +} diff --git a/cmd/exit_fullexit.go b/cmd/exit_fullexit.go new file mode 100644 index 000000000..d55c5e397 --- /dev/null +++ b/cmd/exit_fullexit.go @@ -0,0 +1,130 @@ +// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "path/filepath" + + libp2plog "github.com/ipfs/go-log/v2" + "github.com/spf13/cobra" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/k1util" + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/obolapi" + "github.com/obolnetwork/charon/app/z" + "github.com/obolnetwork/charon/core" + "github.com/obolnetwork/charon/eth2util/keystore" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/tbls/tblsconv" +) + +func newBcastFullExitCmd(runFunc func(context.Context, exitConfig) error) *cobra.Command { + var config exitConfig + + cmd := &cobra.Command{ + Use: "broadcast", + Short: "Broadcast exit", + Long: `Broadcasts a full exit message, aggregated with the available partial signatures retrieved from Obol API.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := log.InitLogger(config.Log); err != nil { + return err + } + libp2plog.SetPrimaryCore(log.LoggerCore()) // Set libp2p logger to use charon logger + + printFlags(cmd.Context(), cmd.Flags()) + + return runFunc(cmd.Context(), config) + }, + } + + bindGenericExitFlags(cmd, &config) + bindExitRelatedFlags(cmd, &config) + bindLogFlags(cmd.Flags(), &config.Log) + + return cmd +} + +func runBcastFullExit(ctx context.Context, config exitConfig) error { + lockFilePath := filepath.Join(config.DataDir, "cluster-lock.json") + manifestFilePath := filepath.Join(config.DataDir, "cluster-manifest.pb") + identityKeyPath := filepath.Join(config.DataDir, "charon-enr-private-key") + + identityKey, err := k1util.Load(identityKeyPath) + if err != nil { + return errors.Wrap(err, "could not load identity key") + } + + cl, err := loadClusterManifest(manifestFilePath, lockFilePath) + if err != nil { + return errors.Wrap(err, "could not load cluster data") + } + + validator := core.PubKey(config.ValidatorAddr) + if _, err := validator.Bytes(); err != nil { + return errors.Wrap(err, "cannot convert validator pubkey to bytes") + } + + ctx = log.WithCtx(ctx, z.Str("validator", validator.String())) + + eth2Cl, err := eth2Client(ctx, config.BeaconNodeURL) + if err != nil { + return errors.Wrap(err, "cannot create eth2 client for specified beacon node") + } + + oAPI, err := obolapi.New(config.ObolAPIEndpoint) + if err != nil { + return errors.Wrap(err, "could not create obol api client") + } + + log.Info(ctx, "Retrieving full exit message") + + shareIdx, err := keystore.ShareIdxForCluster(cl, *identityKey.PubKey()) + if err != nil { + return errors.Wrap(err, "could not load share index from cluster lock") + } + + fullExit, err := oAPI.GetFullExit(ctx, config.ValidatorAddr, cl.GetInitialMutationHash(), shareIdx, identityKey) + if err != nil { + return errors.Wrap(err, "could not load full exit data from Obol API") + } + + // parse validator public key + rawPkBytes, err := validator.Bytes() + if err != nil { + return errors.Wrap(err, "could not serialize validator key bytes") + } + + pubkey, err := tblsconv.PubkeyFromBytes(rawPkBytes) + if err != nil { + return errors.Wrap(err, "could not convert validator key bytes to BLS public key") + } + + // parse signature + signature, err := tblsconv.SignatureFromBytes(fullExit.SignedExitMessage.Signature[:]) + if err != nil { + return errors.Wrap(err, "could not parse BLS signature from bytes") + } + + exitRoot, err := sigDataForExit( + ctx, + *fullExit.SignedExitMessage.Message, + eth2Cl, + fullExit.SignedExitMessage.Message.Epoch, + ) + if err != nil { + return errors.Wrap(err, "cannot calculate hash tree root for exit message for verification") + } + + if err := tbls.Verify(pubkey, exitRoot[:], signature); err != nil { + return errors.Wrap(err, "exit message signature not verified") + } + + if err := eth2Cl.SubmitVoluntaryExit(ctx, &fullExit.SignedExitMessage); err != nil { + return errors.Wrap(err, "could submit voluntary exit") + } + + return nil +} diff --git a/cmd/exit_fullexit_internal_test.go b/cmd/exit_fullexit_internal_test.go new file mode 100644 index 000000000..02cc8a4b6 --- /dev/null +++ b/cmd/exit_fullexit_internal_test.go @@ -0,0 +1,251 @@ +// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "fmt" + "math/rand" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/cluster/manifest" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/testutil" + "github.com/obolnetwork/charon/testutil/beaconmock" + "github.com/obolnetwork/charon/testutil/obolapimock" +) + +const badStr = "bad" + +func Test_runBcastFullExitCmd(t *testing.T) { + t.Parallel() + t.Run("main flow", Test_runBcastFullExitCmdFlow) + t.Run("config", Test_runBcastFullExitCmd_Config) +} + +func Test_runBcastFullExitCmdFlow(t *testing.T) { + t.Parallel() + ctx := context.Background() + + valAmt := 100 + operatorAmt := 4 + + random := rand.New(rand.NewSource(int64(0))) + + lock, enrs, keyShares := cluster.NewForT( + t, + valAmt, + operatorAmt, + operatorAmt, + 0, + random, + ) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + dag, err := manifest.NewDAGFromLockForT(t, lock) + require.NoError(t, err) + + mBytes, err := proto.Marshal(dag) + require.NoError(t, err) + + handler, addLockFiles := obolapimock.MockServer(false) + srv := httptest.NewServer(handler) + addLockFiles(lock) + defer srv.Close() + + validatorSet := beaconmock.ValidatorSet{} + + for idx, v := range lock.Validators { + validatorSet[eth2p0.ValidatorIndex(idx)] = ð2v1.Validator{ + Index: eth2p0.ValidatorIndex(idx), + Balance: 42, + Status: eth2v1.ValidatorStateActiveOngoing, + Validator: ð2p0.Validator{ + PublicKey: eth2p0.BLSPubKey(v.PubKey), + WithdrawalCredentials: testutil.RandomBytes32(), + }, + } + } + + beaconMock, err := beaconmock.New( + beaconmock.WithValidatorSet(validatorSet), + beaconmock.WithEndpoint("/eth/v1/beacon/pool/voluntary_exits", ""), + ) + require.NoError(t, err) + defer func() { + require.NoError(t, beaconMock.Close()) + }() + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) + + for idx := 0; idx < operatorAmt; idx++ { + config := exitConfig{ + BeaconNodeURL: beaconMock.Address(), + ValidatorAddr: lock.Validators[0].PublicKeyHex(), + DataDir: filepath.Join(root, fmt.Sprintf("op%d", idx)), + ObolAPIEndpoint: srv.URL, + ExitEpoch: 194048, + } + + require.NoError(t, runSubmitPartialExit(ctx, config), "operator index: %v", idx) + } + + config := exitConfig{ + BeaconNodeURL: beaconMock.Address(), + ValidatorAddr: lock.Validators[0].PublicKeyHex(), + DataDir: filepath.Join(root, fmt.Sprintf("op%d", 0)), + ObolAPIEndpoint: srv.URL, + ExitEpoch: 194048, + } + + require.NoError(t, runBcastFullExit(ctx, config)) +} + +func Test_runBcastFullExitCmd_Config(t *testing.T) { + t.Parallel() + type test struct { + name string + noIdentity bool + noManifest bool + badOAPIURL bool + badBeaconNodeURL bool + badValidatorAddr bool + errData string + } + + tests := []test{ + { + name: "No identity key", + noIdentity: true, + errData: "could not load identity key", + }, + { + name: "No manifest", + noManifest: true, + errData: "could not load cluster data", + }, + { + name: "Bad Obol API URL", + badOAPIURL: true, + errData: "could not create obol api client", + }, + { + name: "Bad beacon node URL", + badBeaconNodeURL: true, + errData: "cannot create eth2 client for specified beacon node", + }, + { + name: "Bad validator address", + badValidatorAddr: true, + errData: "cannot convert validator pubkey to bytes", + }, + } + + del := func(t *testing.T, tc test, root string, opIdx int) { + t.Helper() + + opID := fmt.Sprintf("op%d", opIdx) + oDir := filepath.Join(root, opID) + + switch { + case tc.noManifest: + require.NoError(t, os.RemoveAll(filepath.Join(oDir, "cluster-manifest.pb"))) + case tc.noIdentity: + require.NoError(t, os.RemoveAll(filepath.Join(oDir, "charon-enr-private-key"))) + } + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + valAmt := 100 + operatorAmt := 4 + + random := rand.New(rand.NewSource(int64(0))) + + lock, enrs, keyShares := cluster.NewForT( + t, + valAmt, + operatorAmt, + operatorAmt, + 0, + random, + ) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + dag, err := manifest.NewDAGFromLockForT(t, lock) + require.NoError(t, err) + + mBytes, err := proto.Marshal(dag) + require.NoError(t, err) + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + del(t, test, root, opIdx) + } + + bnURL := badStr + + if !test.badBeaconNodeURL { + beaconMock, err := beaconmock.New() + require.NoError(t, err) + defer func() { + require.NoError(t, beaconMock.Close()) + }() + bnURL = beaconMock.Address() + } + + oapiURL := badStr + if !test.badOAPIURL { + oapiURL = "https://api.obol.tech" + } + + valAddr := badStr + if !test.badValidatorAddr { + valAddr = lock.Validators[0].PublicKeyHex() + } + + config := exitConfig{ + BeaconNodeURL: bnURL, + ValidatorAddr: valAddr, + DataDir: filepath.Join(root, "op0"), // one operator is enough + ObolAPIEndpoint: oapiURL, + ExitEpoch: 0, + } + + require.ErrorContains(t, runBcastFullExit(ctx, config), test.errData) + }) + } +} diff --git a/cmd/exit_list.go b/cmd/exit_list.go new file mode 100644 index 000000000..23b25757f --- /dev/null +++ b/cmd/exit_list.go @@ -0,0 +1,106 @@ +// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "fmt" + "path/filepath" + + eth2api "github.com/attestantio/go-eth2-client/api" + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + libp2plog "github.com/ipfs/go-log/v2" + "github.com/spf13/cobra" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/z" +) + +func newListActiveValidatorsCmd(runFunc func(context.Context, exitConfig) error) *cobra.Command { + var config exitConfig + + cmd := &cobra.Command{ + Use: "active-validator-list", + Short: "List all active validators", + Long: `Returns a list of all the DV in the specified cluster whose status is ACTIVE_ONGOING, i.e. can be exited.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := log.InitLogger(config.Log); err != nil { + return err + } + libp2plog.SetPrimaryCore(log.LoggerCore()) // Set libp2p logger to use charon logger + + printFlags(cmd.Context(), cmd.Flags()) + + return runFunc(cmd.Context(), config) + }, + } + + cmd.Flags().BoolVar(&config.PlaintextOutput, "plaintext", false, "Prints each active validator on a line, without any debugging or logging artifact. Useful for scripting.") + + bindGenericExitFlags(cmd, &config) + bindLogFlags(cmd.Flags(), &config.Log) + + return cmd +} + +func runListActiveValidatorsCmd(ctx context.Context, config exitConfig) error { + valList, err := listActiveVals(ctx, config) + if err != nil { + return err + } + + for _, validator := range valList { + if config.PlaintextOutput { + //nolint:forbidigo // used for plaintext printing + fmt.Println(validator) + continue + } + + log.Info(ctx, "Validator", z.Str("pubkey", validator)) + } + + return nil +} + +func listActiveVals(ctx context.Context, config exitConfig) ([]string, error) { + lockFilePath := filepath.Join(config.DataDir, "cluster-lock.json") + manifestFilePath := filepath.Join(config.DataDir, "cluster-manifest.pb") + + cl, err := loadClusterManifest(manifestFilePath, lockFilePath) + if err != nil { + return nil, errors.Wrap(err, "could not load cluster data") + } + + eth2Cl, err := eth2Client(ctx, config.BeaconNodeURL) + if err != nil { + return nil, errors.Wrap(err, "cannot create eth2 client for specified beacon node") + } + + var allVals []eth2p0.BLSPubKey + + for _, v := range cl.Validators { + allVals = append(allVals, eth2p0.BLSPubKey(v.PublicKey)) + } + + valData, err := eth2Cl.Validators(ctx, ð2api.ValidatorsOpts{ + PubKeys: allVals, + State: "head", + }) + if err != nil { + return nil, errors.Wrap(err, "cannot fetch validator list") + } + + var ret []string + + for _, validator := range valData.Data { + if validator.Status == eth2v1.ValidatorStateActiveOngoing { + valStr := validator.Validator.PublicKey.String() + ret = append(ret, valStr) + } + } + + return ret, nil +} diff --git a/cmd/exit_list_internal_test.go b/cmd/exit_list_internal_test.go new file mode 100644 index 000000000..0363dd914 --- /dev/null +++ b/cmd/exit_list_internal_test.go @@ -0,0 +1,190 @@ +// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "fmt" + "math/rand" + "path/filepath" + "testing" + + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/cluster/manifest" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/testutil" + "github.com/obolnetwork/charon/testutil/beaconmock" +) + +func Test_runListActiveVals(t *testing.T) { + ctx := context.Background() + + valAmt := 100 + operatorAmt := 4 + + random := rand.New(rand.NewSource(int64(0))) + + lock, enrs, keyShares := cluster.NewForT( + t, + valAmt, + operatorAmt, + operatorAmt, + 0, + random, + ) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + dag, err := manifest.NewDAGFromLockForT(t, lock) + require.NoError(t, err) + + mBytes, err := proto.Marshal(dag) + require.NoError(t, err) + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) + + validatorSet := beaconmock.ValidatorSet{} + + for idx, v := range lock.Validators { + validatorSet[eth2p0.ValidatorIndex(idx)] = ð2v1.Validator{ + Index: eth2p0.ValidatorIndex(idx), + Balance: 42, + Status: eth2v1.ValidatorStateActiveOngoing, + Validator: ð2p0.Validator{ + PublicKey: eth2p0.BLSPubKey(v.PubKey), + WithdrawalCredentials: testutil.RandomBytes32(), + }, + } + } + + beaconMock, err := beaconmock.New(beaconmock.WithValidatorSet(validatorSet)) + require.NoError(t, err) + defer func() { + require.NoError(t, beaconMock.Close()) + }() + + config := exitConfig{ + BeaconNodeURL: beaconMock.Address(), + DataDir: filepath.Join(root, fmt.Sprintf("op%d", 0)), + PlaintextOutput: true, + } + + require.NoError(t, runListActiveValidatorsCmd(ctx, config)) +} + +func Test_listActiveVals(t *testing.T) { + ctx := context.Background() + + valAmt := 100 + operatorAmt := 4 + + random := rand.New(rand.NewSource(int64(0))) + + lock, enrs, keyShares := cluster.NewForT( + t, + valAmt, + operatorAmt, + operatorAmt, + 0, + random, + ) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + dag, err := manifest.NewDAGFromLockForT(t, lock) + require.NoError(t, err) + + mBytes, err := proto.Marshal(dag) + require.NoError(t, err) + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) + + t.Run("all validators in the cluster are active", func(t *testing.T) { + validatorSet := beaconmock.ValidatorSet{} + + for idx, v := range lock.Validators { + validatorSet[eth2p0.ValidatorIndex(idx)] = ð2v1.Validator{ + Index: eth2p0.ValidatorIndex(idx), + Balance: 42, + Status: eth2v1.ValidatorStateActiveOngoing, + Validator: ð2p0.Validator{ + PublicKey: eth2p0.BLSPubKey(v.PubKey), + WithdrawalCredentials: testutil.RandomBytes32(), + }, + } + } + + beaconMock, err := beaconmock.New(beaconmock.WithValidatorSet(validatorSet)) + require.NoError(t, err) + defer func() { + require.NoError(t, beaconMock.Close()) + }() + + config := exitConfig{ + BeaconNodeURL: beaconMock.Address(), + DataDir: filepath.Join(root, fmt.Sprintf("op%d", 0)), + PlaintextOutput: true, + } + + vals, err := listActiveVals(ctx, config) + require.NoError(t, err) + require.Len(t, vals, len(lock.Validators)) + }) + + t.Run("half validators in the cluster are active", func(t *testing.T) { + validatorSet := beaconmock.ValidatorSet{} + + for idx, v := range lock.Validators { + state := eth2v1.ValidatorStateActiveOngoing + if idx%2 == 0 { + state = eth2v1.ValidatorStateActiveExiting + } + validatorSet[eth2p0.ValidatorIndex(idx)] = ð2v1.Validator{ + Index: eth2p0.ValidatorIndex(idx), + Balance: 42, + Status: state, + Validator: ð2p0.Validator{ + PublicKey: eth2p0.BLSPubKey(v.PubKey), + WithdrawalCredentials: testutil.RandomBytes32(), + }, + } + } + + beaconMock, err := beaconmock.New(beaconmock.WithValidatorSet(validatorSet)) + require.NoError(t, err) + defer func() { + require.NoError(t, beaconMock.Close()) + }() + + config := exitConfig{ + BeaconNodeURL: beaconMock.Address(), + DataDir: filepath.Join(root, fmt.Sprintf("op%d", 0)), + PlaintextOutput: true, + } + + vals, err := listActiveVals(ctx, config) + require.NoError(t, err) + require.Len(t, vals, len(lock.Validators)/2) + }) +} diff --git a/cmd/exit_submitpartial.go b/cmd/exit_submitpartial.go new file mode 100644 index 000000000..e29278e02 --- /dev/null +++ b/cmd/exit_submitpartial.go @@ -0,0 +1,155 @@ +// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "path/filepath" + + eth2api "github.com/attestantio/go-eth2-client/api" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + libp2plog "github.com/ipfs/go-log/v2" + "github.com/spf13/cobra" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/k1util" + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/obolapi" + "github.com/obolnetwork/charon/app/z" + "github.com/obolnetwork/charon/core" + "github.com/obolnetwork/charon/eth2util/keystore" +) + +func newSubmitPartialExitCmd(runFunc func(context.Context, exitConfig) error) *cobra.Command { + var config exitConfig + + cmd := &cobra.Command{ + Use: "submit-partial-exit", + Short: "Submit partial exit message for a distributed validator.", + Long: `Submit a partial exit message for a given distributed validator.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := log.InitLogger(config.Log); err != nil { + return err + } + libp2plog.SetPrimaryCore(log.LoggerCore()) // Set libp2p logger to use charon logger + + printFlags(cmd.Context(), cmd.Flags()) + + return runFunc(cmd.Context(), config) + }, + } + + bindGenericExitFlags(cmd, &config) + bindExitRelatedFlags(cmd, &config) + bindLogFlags(cmd.Flags(), &config.Log) + + return cmd +} + +func runSubmitPartialExit(ctx context.Context, config exitConfig) error { + lockFilePath := filepath.Join(config.DataDir, "cluster-lock.json") + manifestFilePath := filepath.Join(config.DataDir, "cluster-manifest.pb") + identityKeyPath := filepath.Join(config.DataDir, "charon-enr-private-key") + keystoreDir := filepath.Join(config.DataDir, "validator_keys") + + identityKey, err := k1util.Load(identityKeyPath) + if err != nil { + return errors.Wrap(err, "could not load identity key") + } + + cl, err := loadClusterManifest(manifestFilePath, lockFilePath) + if err != nil { + return errors.Wrap(err, "could not load cluster data") + } + + rawValKeys, err := keystore.LoadFilesUnordered(keystoreDir) + if err != nil { + return errors.Wrap(err, "could not load keystore") + } + + valKeys, err := rawValKeys.SequencedKeys() + if err != nil { + return errors.Wrap(err, "could not load keystore") + } + + shares, err := keystore.KeysharesToValidatorPubkey(cl, valKeys) + if err != nil { + return errors.Wrap(err, "could not match keyshares with their counterparty in cluster manifest") + } + + validator := core.PubKey(config.ValidatorAddr) + + valEth2, err := validator.ToETH2() + if err != nil { + return errors.Wrap(err, "cannot convert validator pubkey to bytes") + } + + ctx = log.WithCtx(ctx, z.Str("validator", validator.String())) + + shareIdx, err := keystore.ShareIdxForCluster(cl, *identityKey.PubKey()) + if err != nil { + return errors.Wrap(err, "could not load share index from cluster lock") + } + + ourShare, ok := shares[validator] + if !ok { + return errors.New("validator not present in cluster manifest", z.Str("validator", validator.String())) + } + + eth2Cl, err := eth2Client(ctx, config.BeaconNodeURL) + if err != nil { + return errors.Wrap(err, "cannot create eth2 client for specified beacon node") + } + + oAPI, err := obolapi.New(config.ObolAPIEndpoint) + if err != nil { + return errors.Wrap(err, "could not create obol api client") + } + + log.Info(ctx, "Signing exit message for validator") + + rawValData, err := eth2Cl.Validators(ctx, ð2api.ValidatorsOpts{ + PubKeys: []eth2p0.BLSPubKey{ + valEth2, + }, + State: "head", + }) + if err != nil { + return errors.Wrap(err, "cannot fetch validator index") + } + + valData := rawValData.Data + + var valIndex eth2p0.ValidatorIndex + var valIndexFound bool + + for _, val := range valData { + if val.Validator.PublicKey == valEth2 { + valIndex = val.Index + valIndexFound = true + + break + } + } + + if !valIndexFound { + return errors.New("validator index not found in beacon node response") + } + + exitMsg, err := signExit(ctx, eth2Cl, valIndex, ourShare.Share, eth2p0.Epoch(config.ExitEpoch)) + if err != nil { + return errors.Wrap(err, "cannot sign partial exit message") + } + + exitBlob := obolapi.ExitBlob{ + PublicKey: config.ValidatorAddr, + SignedExitMessage: exitMsg, + } + + if err := oAPI.PostPartialExit(ctx, cl.GetInitialMutationHash(), shareIdx, identityKey, exitBlob); err != nil { + return errors.Wrap(err, "could not POST partial exit message to Obol API") + } + + return nil +} diff --git a/cmd/exit_submitpartial_internal_test.go b/cmd/exit_submitpartial_internal_test.go new file mode 100644 index 000000000..50b1ac2f3 --- /dev/null +++ b/cmd/exit_submitpartial_internal_test.go @@ -0,0 +1,272 @@ +// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "fmt" + "math/rand" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/obolnetwork/charon/app/k1util" + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/cluster/manifest" + "github.com/obolnetwork/charon/eth2util/keystore" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/testutil" + "github.com/obolnetwork/charon/testutil/beaconmock" + "github.com/obolnetwork/charon/testutil/obolapimock" +) + +//nolint:unparam // we mostly pass "4" for operatorAmt but we might change it later. +func writeAllLockData( + t *testing.T, + root string, + operatorAmt int, + enrs []*k1.PrivateKey, + operatorShares [][]tbls.PrivateKey, + manifestBytes []byte, +) { + t.Helper() + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + opID := fmt.Sprintf("op%d", opIdx) + oDir := filepath.Join(root, opID) + keysDir := filepath.Join(oDir, "validator_keys") + manifestFile := filepath.Join(oDir, "cluster-manifest.pb") + + require.NoError(t, os.MkdirAll(oDir, 0o755)) + require.NoError(t, k1util.Save(enrs[opIdx], filepath.Join(oDir, "charon-enr-private-key"))) + + require.NoError(t, os.MkdirAll(keysDir, 0o755)) + + require.NoError(t, keystore.StoreKeysInsecure(operatorShares[opIdx], keysDir, keystore.ConfirmInsecureKeys)) + require.NoError(t, os.WriteFile(manifestFile, manifestBytes, 0o755)) + } +} + +func Test_runSubmitPartialExit(t *testing.T) { + t.Parallel() + t.Run("main flow", Test_runSubmitPartialExitFlow) + t.Run("config", Test_runSubmitPartialExit_Config) +} + +func Test_runSubmitPartialExitFlow(t *testing.T) { + t.Parallel() + ctx := context.Background() + + valAmt := 100 + operatorAmt := 4 + + random := rand.New(rand.NewSource(int64(0))) + + lock, enrs, keyShares := cluster.NewForT( + t, + valAmt, + operatorAmt, + operatorAmt, + 0, + random, + ) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + dag, err := manifest.NewDAGFromLockForT(t, lock) + require.NoError(t, err) + + mBytes, err := proto.Marshal(dag) + require.NoError(t, err) + + handler, addLockFiles := obolapimock.MockServer(false) + srv := httptest.NewServer(handler) + addLockFiles(lock) + defer srv.Close() + + validatorSet := beaconmock.ValidatorSet{} + + for idx, v := range lock.Validators { + validatorSet[eth2p0.ValidatorIndex(idx)] = ð2v1.Validator{ + Index: eth2p0.ValidatorIndex(idx), + Balance: 42, + Status: eth2v1.ValidatorStateActiveOngoing, + Validator: ð2p0.Validator{ + PublicKey: eth2p0.BLSPubKey(v.PubKey), + WithdrawalCredentials: testutil.RandomBytes32(), + }, + } + } + + beaconMock, err := beaconmock.New(beaconmock.WithValidatorSet(validatorSet)) + require.NoError(t, err) + defer func() { + require.NoError(t, beaconMock.Close()) + }() + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) + + config := exitConfig{ + BeaconNodeURL: beaconMock.Address(), + ValidatorAddr: lock.Validators[0].PublicKeyHex(), + DataDir: filepath.Join(root, fmt.Sprintf("op%d", 0)), + ObolAPIEndpoint: srv.URL, + ExitEpoch: 194048, + } + + require.NoError(t, runSubmitPartialExit(ctx, config)) +} + +func Test_runSubmitPartialExit_Config(t *testing.T) { + t.Parallel() + type test struct { + name string + noIdentity bool + noManifest bool + noKeystore bool + badOAPIURL bool + badBeaconNodeURL bool + badValidatorAddr bool + errData string + } + + tests := []test{ + { + name: "No identity key", + noIdentity: true, + errData: "could not load identity key", + }, + { + name: "No manifest", + noManifest: true, + errData: "could not load cluster data", + }, + { + name: "No keystore", + noKeystore: true, + errData: "could not load keystore", + }, + { + name: "Bad Obol API URL", + badOAPIURL: true, + errData: "could not create obol api client", + }, + { + name: "Bad beacon node URL", + badBeaconNodeURL: true, + errData: "cannot create eth2 client for specified beacon node", + }, + { + name: "Bad validator address", + badValidatorAddr: true, + errData: "cannot convert validator pubkey to bytes", + }, + } + + del := func(t *testing.T, tc test, root string, opIdx int) { + t.Helper() + + opID := fmt.Sprintf("op%d", opIdx) + oDir := filepath.Join(root, opID) + + switch { + case tc.noManifest: + require.NoError(t, os.RemoveAll(filepath.Join(oDir, "cluster-manifest.pb"))) + case tc.noKeystore: + require.NoError(t, os.RemoveAll(filepath.Join(oDir, "validator_keys"))) + case tc.noIdentity: + require.NoError(t, os.RemoveAll(filepath.Join(oDir, "charon-enr-private-key"))) + } + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + valAmt := 100 + operatorAmt := 4 + + random := rand.New(rand.NewSource(int64(0))) + + lock, enrs, keyShares := cluster.NewForT( + t, + valAmt, + operatorAmt, + operatorAmt, + 0, + random, + ) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + dag, err := manifest.NewDAGFromLockForT(t, lock) + require.NoError(t, err) + + mBytes, err := proto.Marshal(dag) + require.NoError(t, err) + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + del(t, test, root, opIdx) + } + + bnURL := badStr + + if !test.badBeaconNodeURL { + beaconMock, err := beaconmock.New() + require.NoError(t, err) + defer func() { + require.NoError(t, beaconMock.Close()) + }() + bnURL = beaconMock.Address() + } + + oapiURL := badStr + if !test.badOAPIURL { + oapiURL = "https://api.obol.tech" + } + + valAddr := badStr + if !test.badValidatorAddr { + valAddr = lock.Validators[0].PublicKeyHex() + } + + config := exitConfig{ + BeaconNodeURL: bnURL, + ValidatorAddr: valAddr, + DataDir: filepath.Join(root, "op0"), // one operator is enough + ObolAPIEndpoint: oapiURL, + ExitEpoch: 0, + } + + require.ErrorContains(t, runSubmitPartialExit(ctx, config), test.errData) + }) + } +} diff --git a/eth2util/keystore/keystore.go b/eth2util/keystore/keystore.go index 2134597f7..e9b388ea7 100644 --- a/eth2util/keystore/keystore.go +++ b/eth2util/keystore/keystore.go @@ -17,11 +17,16 @@ import ( "strings" "testing" + k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/libp2p/go-libp2p/core/crypto" keystorev4 "github.com/wealdtech/go-eth2-wallet-encryptor-keystorev4" "github.com/obolnetwork/charon/app/errors" "github.com/obolnetwork/charon/app/forkjoin" "github.com/obolnetwork/charon/app/z" + "github.com/obolnetwork/charon/cluster/manifest" + manifestpb "github.com/obolnetwork/charon/cluster/manifestpb/v1" + "github.com/obolnetwork/charon/core" "github.com/obolnetwork/charon/tbls" "github.com/obolnetwork/charon/tbls/tblsconv" ) @@ -35,6 +40,16 @@ const ( loadStoreWorkers = 10 ) +// IndexedKeyShare represents a share in the context of a Charon cluster, +// alongside its index. +type IndexedKeyShare struct { + Share tbls.PrivateKey + Index int +} + +// ValidatorShares maps each ValidatorPubkey to the associated KeyShare. +type ValidatorShares map[core.PubKey]IndexedKeyShare + type confirmInsecure struct{} // ConfirmInsecureKeys is syntactic sugar to highlight the security implications of insecure keys. @@ -225,3 +240,86 @@ func checkDir(dir string) error { return nil } + +// KeysharesToValidatorPubkey maps each share in cl to the associated validator private key. +// It returns an error if a keyshare does not appear in cl, or if there's a validator public key associated to no +// keyshare. +func KeysharesToValidatorPubkey(cl *manifestpb.Cluster, shares []tbls.PrivateKey) (ValidatorShares, error) { + ret := make(map[core.PubKey]IndexedKeyShare) + + var pubShares []tbls.PublicKey + + for _, share := range shares { + ps, err := tbls.SecretToPublicKey(share) + if err != nil { + return nil, errors.Wrap(err, "private share to public share") + } + + pubShares = append(pubShares, ps) + } + + // this is sadly a O(n^2) search + for _, validator := range cl.Validators { + valHex := fmt.Sprintf("0x%x", validator.PublicKey) + + valPubShares := make(map[tbls.PublicKey]struct{}) + for _, valShare := range validator.PubShares { + valPubShares[tbls.PublicKey(valShare)] = struct{}{} + } + + found := false + for shareIdx, share := range pubShares { + if _, ok := valPubShares[share]; !ok { + continue + } + + ret[core.PubKey(valHex)] = IndexedKeyShare{ + Share: shares[shareIdx], + Index: shareIdx + 1, + } + found = true + + break + } + + if !found { + return nil, errors.New("share from provided private key shares slice not found in provided manifest") + } + } + + if len(ret) != len(cl.Validators) { + return nil, errors.New("amount of key shares don't match amount of validator public keys") + } + + return ret, nil +} + +// ShareIdxForCluster returns the share index for the Charon cluster's ENR identity key, given a *manifestpb.Cluster. +func ShareIdxForCluster(cl *manifestpb.Cluster, identityKey k1.PublicKey) (uint64, error) { + pids, err := manifest.ClusterPeerIDs(cl) + if err != nil { + return 0, errors.Wrap(err, "cluster peer ids") + } + + k := crypto.Secp256k1PublicKey(identityKey) + + shareIdx := -1 + for _, pid := range pids { + if !pid.MatchesPublicKey(&k) { + continue + } + + nIdx, err := manifest.ClusterNodeIdx(cl, pid) + if err != nil { + return 0, errors.Wrap(err, "cluster node idx") + } + + shareIdx = nIdx.ShareIdx + } + + if shareIdx == -1 { + return 0, errors.New("node index for loaded enr not found in cluster lock") + } + + return uint64(shareIdx), nil +} diff --git a/eth2util/keystore/keystore_test.go b/eth2util/keystore/keystore_test.go index 422a34f12..7905dc6e8 100644 --- a/eth2util/keystore/keystore_test.go +++ b/eth2util/keystore/keystore_test.go @@ -3,7 +3,9 @@ package keystore_test import ( + "bytes" "fmt" + "math/rand" "os" "path" "path/filepath" @@ -13,8 +15,13 @@ import ( "github.com/stretchr/testify/require" + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/cluster/manifest" + manifestpb "github.com/obolnetwork/charon/cluster/manifestpb/v1" "github.com/obolnetwork/charon/eth2util/keystore" "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/tbls/tblsconv" + "github.com/obolnetwork/charon/testutil" ) func TestStoreLoad(t *testing.T) { @@ -304,3 +311,98 @@ func TestCheckDir(t *testing.T) { err = keystore.StoreKeys(nil, "testdata/keystore-scrypt.json") require.ErrorContains(t, err, "not a directory") } + +func TestKeyshareToValidatorPubkey(t *testing.T) { + valAmt := 4 + sharesAmt := 10 + + privateShares := make([]tbls.PrivateKey, valAmt) + + cl := &manifestpb.Cluster{} + + for valIdx := 0; valIdx < valAmt; valIdx++ { + valPubk, err := tblsconv.PubkeyFromCore(testutil.RandomCorePubKey(t)) + require.NoError(t, err) + + validator := &manifestpb.Validator{ + PublicKey: valPubk[:], + } + + randomShareSelected := false + for shareIdx := 0; shareIdx < sharesAmt; shareIdx++ { + sharePriv, err := tbls.GenerateSecretKey() + require.NoError(t, err) + + sharePub, err := tbls.SecretToPublicKey(sharePriv) + require.NoError(t, err) + + if testutil.RandomBool() && !randomShareSelected { + privateShares[valIdx] = sharePriv + randomShareSelected = true + } + + validator.PubShares = append(validator.PubShares, sharePub[:]) + } + + rand.Shuffle(len(validator.PubShares), func(i, j int) { + validator.PubShares[i], validator.PubShares[j] = validator.PubShares[j], validator.PubShares[i] + }) + + cl.Validators = append(cl.Validators, validator) + } + + ret, err := keystore.KeysharesToValidatorPubkey(cl, privateShares) + require.NoError(t, err) + + require.Len(t, ret, 4) + + for valPubKey, sharePrivKey := range ret { + valFound := false + sharePrivKeyFound := false + + for _, val := range cl.Validators { + if string(valPubKey) == fmt.Sprintf("0x%x", val.PublicKey) { + valFound = true + break + } + } + + for _, share := range privateShares { + if bytes.Equal(share[:], sharePrivKey.Share[:]) { + sharePrivKeyFound = true + break + } + } + + require.True(t, valFound, "validator pubkey not found") + require.True(t, sharePrivKeyFound, "share priv key not found") + } +} + +func TestShareIdxForCluster(t *testing.T) { + valAmt := 100 + operatorAmt := 4 + + random := rand.New(rand.NewSource(int64(0))) + + lock, enrs, _ := cluster.NewForT( + t, + valAmt, + operatorAmt, + operatorAmt, + 0, + random, + ) + + dag, err := manifest.NewDAGFromLockForT(t, lock) + require.NoError(t, err) + + cl, err := manifest.Materialise(dag) + require.NoError(t, err) + + pubkey := enrs[0].PubKey() + + res, err := keystore.ShareIdxForCluster(cl, *pubkey) + require.NoError(t, err) + require.Equal(t, uint64(1), res) +} diff --git a/testutil/beaconmock/options.go b/testutil/beaconmock/options.go index 705e5d612..3e71d93d2 100644 --- a/testutil/beaconmock/options.go +++ b/testutil/beaconmock/options.go @@ -192,6 +192,27 @@ func WithValidatorSet(set ValidatorSet) Option { mock.ActiveValidatorsFunc = func(ctx context.Context) (eth2wrap.ActiveValidators, error) { return activeVals, nil } + + type getValidatorsResponse struct { + Data []*eth2v1.Validator `json:"data"` + } + + var resp getValidatorsResponse + for _, v := range set { + resp.Data = append(resp.Data, v) + } + + respJSON, err := json.Marshal(resp) + if err != nil { + //nolint:forbidigo // formatting an error in panic, it's okay + panic(fmt.Errorf("could not marshal pre-generated mock validator response, %w", err)) + } + + mock.overrides = append(mock.overrides, staticOverride{ + Endpoint: "/eth/v1/beacon/states/head/validators", + Key: "", + Value: string(respJSON), + }) } } diff --git a/app/obolapi/exit_server_test.go b/testutil/obolapimock/obolapi_exit.go similarity index 99% rename from app/obolapi/exit_server_test.go rename to testutil/obolapimock/obolapi_exit.go index 3afdbe5dc..3b248fe2e 100644 --- a/app/obolapi/exit_server_test.go +++ b/testutil/obolapimock/obolapi_exit.go @@ -1,6 +1,6 @@ // Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 -package obolapi_test +package obolapimock import ( "context" From ce6efde3edd1754e014abc2d9f1ccb9fad22376a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gianguido=20Sor=C3=A0?= Date: Mon, 11 Mar 2024 11:32:04 +0100 Subject: [PATCH 02/15] address comments from https://docs.google.com/document/d/140kknAtq2MWIWPUMIH4odoHtO0PhnHcP3EjMuAVhwU0/edit --- cmd/exit.go | 14 ++++++++------ cmd/{exit_fullexit.go => exit_broadcast.go} | 10 +++++----- ...nal_test.go => exit_broadcast_internal_test.go} | 12 ++++++------ cmd/{exit_submitpartial.go => exit_submit.go} | 8 ++++---- ...ternal_test.go => exit_submit_internal_test.go} | 8 ++++---- 5 files changed, 27 insertions(+), 25 deletions(-) rename cmd/{exit_fullexit.go => exit_broadcast.go} (88%) rename cmd/{exit_fullexit_internal_test.go => exit_broadcast_internal_test.go} (96%) rename cmd/{exit_submitpartial.go => exit_submit.go} (96%) rename cmd/{exit_submitpartial_internal_test.go => exit_submit_internal_test.go} (97%) diff --git a/cmd/exit.go b/cmd/exit.go index ddc80e207..f4b653882 100644 --- a/cmd/exit.go +++ b/cmd/exit.go @@ -19,9 +19,9 @@ import ( type exitConfig struct { BeaconNodeURL string - ValidatorAddr string + ValidatorPubkey string DataDir string - ObolAPIEndpoint string + PublishAddress string ExitEpoch uint64 PlaintextOutput bool @@ -33,7 +33,7 @@ func newExitCmd(cmds ...*cobra.Command) *cobra.Command { root := &cobra.Command{ Use: "exit", Short: "Exit a distributed validator.", - Long: "Exit a distributed validator through the Obol API.", + Long: "Sign and broadcast distributed validator exit messages using a remote API.", } root.AddCommand(cmds...) @@ -42,7 +42,7 @@ func newExitCmd(cmds ...*cobra.Command) *cobra.Command { } func bindGenericExitFlags(cmd *cobra.Command, config *exitConfig) { - cmd.Flags().StringVar(&config.ObolAPIEndpoint, "obol-api-endpoint", "https://api.obol.tech", "Endpoint of the Obol API instance.") + cmd.Flags().StringVar(&config.PublishAddress, "publish-address", "https://api.obol.tech", "Endpoint of the partial exits API instance.") cmd.Flags().StringVar(&config.BeaconNodeURL, "beacon-node-url", "", "Beacon node URL.") cmd.Flags().StringVar(&config.DataDir, "data-dir", ".charon", "The directory where charon will read lock file and partial validator keys.") @@ -50,10 +50,12 @@ func bindGenericExitFlags(cmd *cobra.Command, config *exitConfig) { } func bindExitRelatedFlags(cmd *cobra.Command, config *exitConfig) { - cmd.Flags().StringVar(&config.ValidatorAddr, "validator-address", "", "Validator to exit, must be present in the cluster lock manifest.") + const vpk string = "validator-public-key" + + cmd.Flags().StringVar(&config.ValidatorPubkey, vpk, "", "Public key of the validator to exit, must be present in the cluster lock manifest.") cmd.Flags().Uint64Var(&config.ExitEpoch, "exit-epoch", 162304, "Exit epoch at which the validator will exit, must be the same across all the partial exits.") - mustMarkFlagRequired(cmd, "validator-address") + mustMarkFlagRequired(cmd, vpk) } func eth2Client(ctx context.Context, u string) (eth2wrap.Client, error) { diff --git a/cmd/exit_fullexit.go b/cmd/exit_broadcast.go similarity index 88% rename from cmd/exit_fullexit.go rename to cmd/exit_broadcast.go index d55c5e397..3757cadd4 100644 --- a/cmd/exit_fullexit.go +++ b/cmd/exit_broadcast.go @@ -25,8 +25,8 @@ func newBcastFullExitCmd(runFunc func(context.Context, exitConfig) error) *cobra cmd := &cobra.Command{ Use: "broadcast", - Short: "Broadcast exit", - Long: `Broadcasts a full exit message, aggregated with the available partial signatures retrieved from Obol API.`, + Short: "Submit partial exit message for a distributed validator.", + Long: `Retrieves and broadcasts a fully signed validator exit message, aggregated with the available partial signatures retrieved from the publish-address.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { if err := log.InitLogger(config.Log); err != nil { @@ -62,7 +62,7 @@ func runBcastFullExit(ctx context.Context, config exitConfig) error { return errors.Wrap(err, "could not load cluster data") } - validator := core.PubKey(config.ValidatorAddr) + validator := core.PubKey(config.ValidatorPubkey) if _, err := validator.Bytes(); err != nil { return errors.Wrap(err, "cannot convert validator pubkey to bytes") } @@ -74,7 +74,7 @@ func runBcastFullExit(ctx context.Context, config exitConfig) error { return errors.Wrap(err, "cannot create eth2 client for specified beacon node") } - oAPI, err := obolapi.New(config.ObolAPIEndpoint) + oAPI, err := obolapi.New(config.PublishAddress) if err != nil { return errors.Wrap(err, "could not create obol api client") } @@ -86,7 +86,7 @@ func runBcastFullExit(ctx context.Context, config exitConfig) error { return errors.Wrap(err, "could not load share index from cluster lock") } - fullExit, err := oAPI.GetFullExit(ctx, config.ValidatorAddr, cl.GetInitialMutationHash(), shareIdx, identityKey) + fullExit, err := oAPI.GetFullExit(ctx, config.ValidatorPubkey, cl.GetInitialMutationHash(), shareIdx, identityKey) if err != nil { return errors.Wrap(err, "could not load full exit data from Obol API") } diff --git a/cmd/exit_fullexit_internal_test.go b/cmd/exit_broadcast_internal_test.go similarity index 96% rename from cmd/exit_fullexit_internal_test.go rename to cmd/exit_broadcast_internal_test.go index 02cc8a4b6..ef29fbf2d 100644 --- a/cmd/exit_fullexit_internal_test.go +++ b/cmd/exit_broadcast_internal_test.go @@ -99,9 +99,9 @@ func Test_runBcastFullExitCmdFlow(t *testing.T) { for idx := 0; idx < operatorAmt; idx++ { config := exitConfig{ BeaconNodeURL: beaconMock.Address(), - ValidatorAddr: lock.Validators[0].PublicKeyHex(), + ValidatorPubkey: lock.Validators[0].PublicKeyHex(), DataDir: filepath.Join(root, fmt.Sprintf("op%d", idx)), - ObolAPIEndpoint: srv.URL, + PublishAddress: srv.URL, ExitEpoch: 194048, } @@ -110,9 +110,9 @@ func Test_runBcastFullExitCmdFlow(t *testing.T) { config := exitConfig{ BeaconNodeURL: beaconMock.Address(), - ValidatorAddr: lock.Validators[0].PublicKeyHex(), + ValidatorPubkey: lock.Validators[0].PublicKeyHex(), DataDir: filepath.Join(root, fmt.Sprintf("op%d", 0)), - ObolAPIEndpoint: srv.URL, + PublishAddress: srv.URL, ExitEpoch: 194048, } @@ -239,9 +239,9 @@ func Test_runBcastFullExitCmd_Config(t *testing.T) { config := exitConfig{ BeaconNodeURL: bnURL, - ValidatorAddr: valAddr, + ValidatorPubkey: valAddr, DataDir: filepath.Join(root, "op0"), // one operator is enough - ObolAPIEndpoint: oapiURL, + PublishAddress: oapiURL, ExitEpoch: 0, } diff --git a/cmd/exit_submitpartial.go b/cmd/exit_submit.go similarity index 96% rename from cmd/exit_submitpartial.go rename to cmd/exit_submit.go index e29278e02..cb5305411 100644 --- a/cmd/exit_submitpartial.go +++ b/cmd/exit_submit.go @@ -24,7 +24,7 @@ func newSubmitPartialExitCmd(runFunc func(context.Context, exitConfig) error) *c var config exitConfig cmd := &cobra.Command{ - Use: "submit-partial-exit", + Use: "partial", Short: "Submit partial exit message for a distributed validator.", Long: `Submit a partial exit message for a given distributed validator.`, Args: cobra.NoArgs, @@ -78,7 +78,7 @@ func runSubmitPartialExit(ctx context.Context, config exitConfig) error { return errors.Wrap(err, "could not match keyshares with their counterparty in cluster manifest") } - validator := core.PubKey(config.ValidatorAddr) + validator := core.PubKey(config.ValidatorPubkey) valEth2, err := validator.ToETH2() if err != nil { @@ -102,7 +102,7 @@ func runSubmitPartialExit(ctx context.Context, config exitConfig) error { return errors.Wrap(err, "cannot create eth2 client for specified beacon node") } - oAPI, err := obolapi.New(config.ObolAPIEndpoint) + oAPI, err := obolapi.New(config.PublishAddress) if err != nil { return errors.Wrap(err, "could not create obol api client") } @@ -143,7 +143,7 @@ func runSubmitPartialExit(ctx context.Context, config exitConfig) error { } exitBlob := obolapi.ExitBlob{ - PublicKey: config.ValidatorAddr, + PublicKey: config.ValidatorPubkey, SignedExitMessage: exitMsg, } diff --git a/cmd/exit_submitpartial_internal_test.go b/cmd/exit_submit_internal_test.go similarity index 97% rename from cmd/exit_submitpartial_internal_test.go rename to cmd/exit_submit_internal_test.go index 50b1ac2f3..937916c05 100644 --- a/cmd/exit_submitpartial_internal_test.go +++ b/cmd/exit_submit_internal_test.go @@ -123,9 +123,9 @@ func Test_runSubmitPartialExitFlow(t *testing.T) { config := exitConfig{ BeaconNodeURL: beaconMock.Address(), - ValidatorAddr: lock.Validators[0].PublicKeyHex(), + ValidatorPubkey: lock.Validators[0].PublicKeyHex(), DataDir: filepath.Join(root, fmt.Sprintf("op%d", 0)), - ObolAPIEndpoint: srv.URL, + PublishAddress: srv.URL, ExitEpoch: 194048, } @@ -260,9 +260,9 @@ func Test_runSubmitPartialExit_Config(t *testing.T) { config := exitConfig{ BeaconNodeURL: bnURL, - ValidatorAddr: valAddr, + ValidatorPubkey: valAddr, DataDir: filepath.Join(root, "op0"), // one operator is enough - ObolAPIEndpoint: oapiURL, + PublishAddress: oapiURL, ExitEpoch: 0, } From 8b64f3a9d8d372514bb193e88dcb985412d053a6 Mon Sep 17 00:00:00 2001 From: Gianguido Sora Date: Mon, 25 Mar 2024 16:00:24 +0100 Subject: [PATCH 03/15] refactor to allow individually specifying cluster lock, validar_keys and enr privkey paths --- cmd/exit.go | 17 +++++--- cmd/exit_broadcast.go | 9 +--- cmd/exit_broadcast_internal_test.go | 66 +++++++++++++++-------------- cmd/exit_list.go | 6 +-- cmd/exit_list_internal_test.go | 43 ++++++++++--------- cmd/exit_submit.go | 12 ++---- cmd/exit_submit_internal_test.go | 55 ++++++++++++------------ 7 files changed, 103 insertions(+), 105 deletions(-) diff --git a/cmd/exit.go b/cmd/exit.go index f4b653882..32eeb05d8 100644 --- a/cmd/exit.go +++ b/cmd/exit.go @@ -18,11 +18,13 @@ import ( ) type exitConfig struct { - BeaconNodeURL string - ValidatorPubkey string - DataDir string - PublishAddress string - ExitEpoch uint64 + BeaconNodeURL string + ValidatorPubkey string + PrivateKeyPath string + ValidatorKeysDir string + LockFilePath string + PublishAddress string + ExitEpoch uint64 PlaintextOutput bool @@ -44,8 +46,9 @@ func newExitCmd(cmds ...*cobra.Command) *cobra.Command { func bindGenericExitFlags(cmd *cobra.Command, config *exitConfig) { cmd.Flags().StringVar(&config.PublishAddress, "publish-address", "https://api.obol.tech", "Endpoint of the partial exits API instance.") cmd.Flags().StringVar(&config.BeaconNodeURL, "beacon-node-url", "", "Beacon node URL.") - cmd.Flags().StringVar(&config.DataDir, "data-dir", ".charon", "The directory where charon will read lock file and partial validator keys.") - + cmd.Flags().StringVar(&config.PrivateKeyPath, "private-key-file ", ".charon/charon-enr-private-key", "The path to the charon enr private key file. ") + cmd.Flags().StringVar(&config.LockFilePath, "lock-file", ".charon/cluster-lock.json", "The path to the cluster lock file defining the distributed validator cluster.") + cmd.Flags().StringVar(&config.ValidatorKeysDir, "validator-keys-dir", ".charon/validator_keys", "Path to the directory containing the validator private key share files and passwords.") mustMarkFlagRequired(cmd, "beacon-node-url") } diff --git a/cmd/exit_broadcast.go b/cmd/exit_broadcast.go index 3757cadd4..f248a42e3 100644 --- a/cmd/exit_broadcast.go +++ b/cmd/exit_broadcast.go @@ -4,7 +4,6 @@ package cmd import ( "context" - "path/filepath" libp2plog "github.com/ipfs/go-log/v2" "github.com/spf13/cobra" @@ -48,16 +47,12 @@ func newBcastFullExitCmd(runFunc func(context.Context, exitConfig) error) *cobra } func runBcastFullExit(ctx context.Context, config exitConfig) error { - lockFilePath := filepath.Join(config.DataDir, "cluster-lock.json") - manifestFilePath := filepath.Join(config.DataDir, "cluster-manifest.pb") - identityKeyPath := filepath.Join(config.DataDir, "charon-enr-private-key") - - identityKey, err := k1util.Load(identityKeyPath) + identityKey, err := k1util.Load(config.PrivateKeyPath) if err != nil { return errors.Wrap(err, "could not load identity key") } - cl, err := loadClusterManifest(manifestFilePath, lockFilePath) + cl, err := loadClusterManifest("", config.LockFilePath) if err != nil { return errors.Wrap(err, "could not load cluster data") } diff --git a/cmd/exit_broadcast_internal_test.go b/cmd/exit_broadcast_internal_test.go index ef29fbf2d..65339e577 100644 --- a/cmd/exit_broadcast_internal_test.go +++ b/cmd/exit_broadcast_internal_test.go @@ -4,6 +4,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "math/rand" "net/http/httptest" @@ -14,10 +15,8 @@ import ( eth2v1 "github.com/attestantio/go-eth2-client/api/v1" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" - "google.golang.org/protobuf/proto" "github.com/obolnetwork/charon/cluster" - "github.com/obolnetwork/charon/cluster/manifest" "github.com/obolnetwork/charon/tbls" "github.com/obolnetwork/charon/testutil" "github.com/obolnetwork/charon/testutil/beaconmock" @@ -60,10 +59,7 @@ func Test_runBcastFullExitCmdFlow(t *testing.T) { } } - dag, err := manifest.NewDAGFromLockForT(t, lock) - require.NoError(t, err) - - mBytes, err := proto.Marshal(dag) + mBytes, err := json.Marshal(lock) require.NoError(t, err) handler, addLockFiles := obolapimock.MockServer(false) @@ -97,23 +93,30 @@ func Test_runBcastFullExitCmdFlow(t *testing.T) { writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) for idx := 0; idx < operatorAmt; idx++ { + baseDir := filepath.Join(root, fmt.Sprintf("op%d", idx)) + config := exitConfig{ - BeaconNodeURL: beaconMock.Address(), - ValidatorPubkey: lock.Validators[0].PublicKeyHex(), - DataDir: filepath.Join(root, fmt.Sprintf("op%d", idx)), - PublishAddress: srv.URL, - ExitEpoch: 194048, + BeaconNodeURL: beaconMock.Address(), + ValidatorPubkey: lock.Validators[0].PublicKeyHex(), + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + ExitEpoch: 194048, } require.NoError(t, runSubmitPartialExit(ctx, config), "operator index: %v", idx) } + baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) + config := exitConfig{ - BeaconNodeURL: beaconMock.Address(), - ValidatorPubkey: lock.Validators[0].PublicKeyHex(), - DataDir: filepath.Join(root, fmt.Sprintf("op%d", 0)), - PublishAddress: srv.URL, - ExitEpoch: 194048, + BeaconNodeURL: beaconMock.Address(), + ValidatorPubkey: lock.Validators[0].PublicKeyHex(), + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), PublishAddress: srv.URL, + ExitEpoch: 194048, } require.NoError(t, runBcastFullExit(ctx, config)) @@ -124,7 +127,7 @@ func Test_runBcastFullExitCmd_Config(t *testing.T) { type test struct { name string noIdentity bool - noManifest bool + noLock bool badOAPIURL bool badBeaconNodeURL bool badValidatorAddr bool @@ -138,9 +141,9 @@ func Test_runBcastFullExitCmd_Config(t *testing.T) { errData: "could not load identity key", }, { - name: "No manifest", - noManifest: true, - errData: "could not load cluster data", + name: "No lock", + noLock: true, + errData: "could not load cluster data", }, { name: "Bad Obol API URL", @@ -166,8 +169,8 @@ func Test_runBcastFullExitCmd_Config(t *testing.T) { oDir := filepath.Join(root, opID) switch { - case tc.noManifest: - require.NoError(t, os.RemoveAll(filepath.Join(oDir, "cluster-manifest.pb"))) + case tc.noLock: + require.NoError(t, os.RemoveAll(filepath.Join(oDir, "cluster-lock.json"))) case tc.noIdentity: require.NoError(t, os.RemoveAll(filepath.Join(oDir, "charon-enr-private-key"))) } @@ -204,10 +207,7 @@ func Test_runBcastFullExitCmd_Config(t *testing.T) { } } - dag, err := manifest.NewDAGFromLockForT(t, lock) - require.NoError(t, err) - - mBytes, err := proto.Marshal(dag) + mBytes, err := json.Marshal(lock) require.NoError(t, err) writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) @@ -237,12 +237,16 @@ func Test_runBcastFullExitCmd_Config(t *testing.T) { valAddr = lock.Validators[0].PublicKeyHex() } + baseDir := filepath.Join(root, "op0") // one operator is enough + config := exitConfig{ - BeaconNodeURL: bnURL, - ValidatorPubkey: valAddr, - DataDir: filepath.Join(root, "op0"), // one operator is enough - PublishAddress: oapiURL, - ExitEpoch: 0, + BeaconNodeURL: bnURL, + ValidatorPubkey: valAddr, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: oapiURL, + ExitEpoch: 0, } require.ErrorContains(t, runBcastFullExit(ctx, config), test.errData) diff --git a/cmd/exit_list.go b/cmd/exit_list.go index 23b25757f..f0932ae87 100644 --- a/cmd/exit_list.go +++ b/cmd/exit_list.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "path/filepath" eth2api "github.com/attestantio/go-eth2-client/api" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" @@ -66,10 +65,7 @@ func runListActiveValidatorsCmd(ctx context.Context, config exitConfig) error { } func listActiveVals(ctx context.Context, config exitConfig) ([]string, error) { - lockFilePath := filepath.Join(config.DataDir, "cluster-lock.json") - manifestFilePath := filepath.Join(config.DataDir, "cluster-manifest.pb") - - cl, err := loadClusterManifest(manifestFilePath, lockFilePath) + cl, err := loadClusterManifest("", config.LockFilePath) if err != nil { return nil, errors.Wrap(err, "could not load cluster data") } diff --git a/cmd/exit_list_internal_test.go b/cmd/exit_list_internal_test.go index 0363dd914..0e070b268 100644 --- a/cmd/exit_list_internal_test.go +++ b/cmd/exit_list_internal_test.go @@ -4,6 +4,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "math/rand" "path/filepath" @@ -12,10 +13,8 @@ import ( eth2v1 "github.com/attestantio/go-eth2-client/api/v1" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" - "google.golang.org/protobuf/proto" "github.com/obolnetwork/charon/cluster" - "github.com/obolnetwork/charon/cluster/manifest" "github.com/obolnetwork/charon/tbls" "github.com/obolnetwork/charon/testutil" "github.com/obolnetwork/charon/testutil/beaconmock" @@ -48,10 +47,7 @@ func Test_runListActiveVals(t *testing.T) { } } - dag, err := manifest.NewDAGFromLockForT(t, lock) - require.NoError(t, err) - - mBytes, err := proto.Marshal(dag) + mBytes, err := json.Marshal(lock) require.NoError(t, err) writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) @@ -76,10 +72,14 @@ func Test_runListActiveVals(t *testing.T) { require.NoError(t, beaconMock.Close()) }() + baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) + config := exitConfig{ - BeaconNodeURL: beaconMock.Address(), - DataDir: filepath.Join(root, fmt.Sprintf("op%d", 0)), - PlaintextOutput: true, + BeaconNodeURL: beaconMock.Address(), + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PlaintextOutput: true, } require.NoError(t, runListActiveValidatorsCmd(ctx, config)) @@ -112,10 +112,7 @@ func Test_listActiveVals(t *testing.T) { } } - dag, err := manifest.NewDAGFromLockForT(t, lock) - require.NoError(t, err) - - mBytes, err := proto.Marshal(dag) + mBytes, err := json.Marshal(lock) require.NoError(t, err) writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) @@ -141,10 +138,14 @@ func Test_listActiveVals(t *testing.T) { require.NoError(t, beaconMock.Close()) }() + baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) + config := exitConfig{ - BeaconNodeURL: beaconMock.Address(), - DataDir: filepath.Join(root, fmt.Sprintf("op%d", 0)), - PlaintextOutput: true, + BeaconNodeURL: beaconMock.Address(), + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PlaintextOutput: true, } vals, err := listActiveVals(ctx, config) @@ -177,10 +178,14 @@ func Test_listActiveVals(t *testing.T) { require.NoError(t, beaconMock.Close()) }() + baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) + config := exitConfig{ - BeaconNodeURL: beaconMock.Address(), - DataDir: filepath.Join(root, fmt.Sprintf("op%d", 0)), - PlaintextOutput: true, + BeaconNodeURL: beaconMock.Address(), + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PlaintextOutput: true, } vals, err := listActiveVals(ctx, config) diff --git a/cmd/exit_submit.go b/cmd/exit_submit.go index cb5305411..7c352b49a 100644 --- a/cmd/exit_submit.go +++ b/cmd/exit_submit.go @@ -4,7 +4,6 @@ package cmd import ( "context" - "path/filepath" eth2api "github.com/attestantio/go-eth2-client/api" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" @@ -48,22 +47,17 @@ func newSubmitPartialExitCmd(runFunc func(context.Context, exitConfig) error) *c } func runSubmitPartialExit(ctx context.Context, config exitConfig) error { - lockFilePath := filepath.Join(config.DataDir, "cluster-lock.json") - manifestFilePath := filepath.Join(config.DataDir, "cluster-manifest.pb") - identityKeyPath := filepath.Join(config.DataDir, "charon-enr-private-key") - keystoreDir := filepath.Join(config.DataDir, "validator_keys") - - identityKey, err := k1util.Load(identityKeyPath) + identityKey, err := k1util.Load(config.PrivateKeyPath) if err != nil { return errors.Wrap(err, "could not load identity key") } - cl, err := loadClusterManifest(manifestFilePath, lockFilePath) + cl, err := loadClusterManifest("", config.LockFilePath) if err != nil { return errors.Wrap(err, "could not load cluster data") } - rawValKeys, err := keystore.LoadFilesUnordered(keystoreDir) + rawValKeys, err := keystore.LoadFilesUnordered(config.ValidatorKeysDir) if err != nil { return errors.Wrap(err, "could not load keystore") } diff --git a/cmd/exit_submit_internal_test.go b/cmd/exit_submit_internal_test.go index 937916c05..181d448dd 100644 --- a/cmd/exit_submit_internal_test.go +++ b/cmd/exit_submit_internal_test.go @@ -4,6 +4,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "math/rand" "net/http/httptest" @@ -15,11 +16,9 @@ import ( eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/stretchr/testify/require" - "google.golang.org/protobuf/proto" "github.com/obolnetwork/charon/app/k1util" "github.com/obolnetwork/charon/cluster" - "github.com/obolnetwork/charon/cluster/manifest" "github.com/obolnetwork/charon/eth2util/keystore" "github.com/obolnetwork/charon/tbls" "github.com/obolnetwork/charon/testutil" @@ -42,7 +41,7 @@ func writeAllLockData( opID := fmt.Sprintf("op%d", opIdx) oDir := filepath.Join(root, opID) keysDir := filepath.Join(oDir, "validator_keys") - manifestFile := filepath.Join(oDir, "cluster-manifest.pb") + manifestFile := filepath.Join(oDir, "cluster-lock.json") require.NoError(t, os.MkdirAll(oDir, 0o755)) require.NoError(t, k1util.Save(enrs[opIdx], filepath.Join(oDir, "charon-enr-private-key"))) @@ -88,10 +87,7 @@ func Test_runSubmitPartialExitFlow(t *testing.T) { } } - dag, err := manifest.NewDAGFromLockForT(t, lock) - require.NoError(t, err) - - mBytes, err := proto.Marshal(dag) + mBytes, err := json.Marshal(lock) require.NoError(t, err) handler, addLockFiles := obolapimock.MockServer(false) @@ -121,12 +117,16 @@ func Test_runSubmitPartialExitFlow(t *testing.T) { writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) + baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) + config := exitConfig{ - BeaconNodeURL: beaconMock.Address(), - ValidatorPubkey: lock.Validators[0].PublicKeyHex(), - DataDir: filepath.Join(root, fmt.Sprintf("op%d", 0)), - PublishAddress: srv.URL, - ExitEpoch: 194048, + BeaconNodeURL: beaconMock.Address(), + ValidatorPubkey: lock.Validators[0].PublicKeyHex(), + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + ExitEpoch: 194048, } require.NoError(t, runSubmitPartialExit(ctx, config)) @@ -137,7 +137,7 @@ func Test_runSubmitPartialExit_Config(t *testing.T) { type test struct { name string noIdentity bool - noManifest bool + noLock bool noKeystore bool badOAPIURL bool badBeaconNodeURL bool @@ -152,9 +152,9 @@ func Test_runSubmitPartialExit_Config(t *testing.T) { errData: "could not load identity key", }, { - name: "No manifest", - noManifest: true, - errData: "could not load cluster data", + name: "No manifest", + noLock: true, + errData: "could not load cluster data", }, { name: "No keystore", @@ -185,8 +185,8 @@ func Test_runSubmitPartialExit_Config(t *testing.T) { oDir := filepath.Join(root, opID) switch { - case tc.noManifest: - require.NoError(t, os.RemoveAll(filepath.Join(oDir, "cluster-manifest.pb"))) + case tc.noLock: + require.NoError(t, os.RemoveAll(filepath.Join(oDir, "cluster-lock.json"))) case tc.noKeystore: require.NoError(t, os.RemoveAll(filepath.Join(oDir, "validator_keys"))) case tc.noIdentity: @@ -225,10 +225,7 @@ func Test_runSubmitPartialExit_Config(t *testing.T) { } } - dag, err := manifest.NewDAGFromLockForT(t, lock) - require.NoError(t, err) - - mBytes, err := proto.Marshal(dag) + mBytes, err := json.Marshal(lock) require.NoError(t, err) writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) @@ -258,12 +255,16 @@ func Test_runSubmitPartialExit_Config(t *testing.T) { valAddr = lock.Validators[0].PublicKeyHex() } + baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) + config := exitConfig{ - BeaconNodeURL: bnURL, - ValidatorPubkey: valAddr, - DataDir: filepath.Join(root, "op0"), // one operator is enough - PublishAddress: oapiURL, - ExitEpoch: 0, + BeaconNodeURL: bnURL, + ValidatorPubkey: valAddr, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: oapiURL, + ExitEpoch: 0, } require.ErrorContains(t, runSubmitPartialExit(ctx, config), test.errData) From b2368c7b9ce4e666b69cd37297b58811f66e611c Mon Sep 17 00:00:00 2001 From: Gianguido Sora Date: Mon, 25 Mar 2024 16:25:22 +0100 Subject: [PATCH 04/15] refactor exit submit cmd to "sign" --- cmd/cmd.go | 2 +- cmd/exit_broadcast_internal_test.go | 2 +- cmd/{exit_submit.go => exit_sign.go} | 8 ++++---- ...submit_internal_test.go => exit_sign_internal_test.go} | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) rename cmd/{exit_submit.go => exit_sign.go} (93%) rename cmd/{exit_submit_internal_test.go => exit_sign_internal_test.go} (98%) diff --git a/cmd/cmd.go b/cmd/cmd.go index eb3e650fb..c09d8130d 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -50,7 +50,7 @@ func New() *cobra.Command { ), newExitCmd( newListActiveValidatorsCmd(runListActiveValidatorsCmd), - newSubmitPartialExitCmd(runSubmitPartialExit), + newSubmitPartialExitCmd(runSignPartialExit), newBcastFullExitCmd(runBcastFullExit), ), newUnsafeCmd(newRunCmd(app.Run, true)), diff --git a/cmd/exit_broadcast_internal_test.go b/cmd/exit_broadcast_internal_test.go index 65339e577..c32a0c484 100644 --- a/cmd/exit_broadcast_internal_test.go +++ b/cmd/exit_broadcast_internal_test.go @@ -105,7 +105,7 @@ func Test_runBcastFullExitCmdFlow(t *testing.T) { ExitEpoch: 194048, } - require.NoError(t, runSubmitPartialExit(ctx, config), "operator index: %v", idx) + require.NoError(t, runSignPartialExit(ctx, config), "operator index: %v", idx) } baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) diff --git a/cmd/exit_submit.go b/cmd/exit_sign.go similarity index 93% rename from cmd/exit_submit.go rename to cmd/exit_sign.go index 7c352b49a..9af01eb84 100644 --- a/cmd/exit_submit.go +++ b/cmd/exit_sign.go @@ -23,9 +23,9 @@ func newSubmitPartialExitCmd(runFunc func(context.Context, exitConfig) error) *c var config exitConfig cmd := &cobra.Command{ - Use: "partial", - Short: "Submit partial exit message for a distributed validator.", - Long: `Submit a partial exit message for a given distributed validator.`, + Use: "sign", + Short: "Sign partial exit message for a distributed validator.", + Long: `Sign a partial exit message for a distributed validator and submit it to a remote API for aggregation.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { if err := log.InitLogger(config.Log); err != nil { @@ -46,7 +46,7 @@ func newSubmitPartialExitCmd(runFunc func(context.Context, exitConfig) error) *c return cmd } -func runSubmitPartialExit(ctx context.Context, config exitConfig) error { +func runSignPartialExit(ctx context.Context, config exitConfig) error { identityKey, err := k1util.Load(config.PrivateKeyPath) if err != nil { return errors.Wrap(err, "could not load identity key") diff --git a/cmd/exit_submit_internal_test.go b/cmd/exit_sign_internal_test.go similarity index 98% rename from cmd/exit_submit_internal_test.go rename to cmd/exit_sign_internal_test.go index 181d448dd..029f0dad5 100644 --- a/cmd/exit_submit_internal_test.go +++ b/cmd/exit_sign_internal_test.go @@ -129,7 +129,7 @@ func Test_runSubmitPartialExitFlow(t *testing.T) { ExitEpoch: 194048, } - require.NoError(t, runSubmitPartialExit(ctx, config)) + require.NoError(t, runSignPartialExit(ctx, config)) } func Test_runSubmitPartialExit_Config(t *testing.T) { @@ -267,7 +267,7 @@ func Test_runSubmitPartialExit_Config(t *testing.T) { ExitEpoch: 0, } - require.ErrorContains(t, runSubmitPartialExit(ctx, config), test.errData) + require.ErrorContains(t, runSignPartialExit(ctx, config), test.errData) }) } } From eae226b84f374def4bcd07037369f0db1fa0051d Mon Sep 17 00:00:00 2001 From: Gianguido Sora Date: Mon, 25 Mar 2024 17:36:34 +0100 Subject: [PATCH 05/15] add "exit fetch" command need to adapt `broadcast` to read from a static file --- cmd/cmd.go | 1 + cmd/exit.go | 80 +++++++++++++---- cmd/exit_broadcast.go | 12 ++- cmd/exit_fetch.go | 120 +++++++++++++++++++++++++ cmd/exit_fetch_internal_test.go | 155 ++++++++++++++++++++++++++++++++ cmd/exit_list.go | 6 +- cmd/exit_sign.go | 12 ++- 7 files changed, 364 insertions(+), 22 deletions(-) create mode 100644 cmd/exit_fetch.go create mode 100644 cmd/exit_fetch_internal_test.go diff --git a/cmd/cmd.go b/cmd/cmd.go index c09d8130d..7b49d6c95 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -52,6 +52,7 @@ func New() *cobra.Command { newListActiveValidatorsCmd(runListActiveValidatorsCmd), newSubmitPartialExitCmd(runSignPartialExit), newBcastFullExitCmd(runBcastFullExit), + newFetchExitCmd(runFetchExit), ), newUnsafeCmd(newRunCmd(app.Run, true)), ) diff --git a/cmd/exit.go b/cmd/exit.go index 32eeb05d8..2d503f2a2 100644 --- a/cmd/exit.go +++ b/cmd/exit.go @@ -25,10 +25,9 @@ type exitConfig struct { LockFilePath string PublishAddress string ExitEpoch uint64 - - PlaintextOutput bool - - Log log.Config + FetchedExitPath string + PlaintextOutput bool + Log log.Config } func newExitCmd(cmds ...*cobra.Command) *cobra.Command { @@ -43,22 +42,69 @@ func newExitCmd(cmds ...*cobra.Command) *cobra.Command { return root } -func bindGenericExitFlags(cmd *cobra.Command, config *exitConfig) { - cmd.Flags().StringVar(&config.PublishAddress, "publish-address", "https://api.obol.tech", "Endpoint of the partial exits API instance.") - cmd.Flags().StringVar(&config.BeaconNodeURL, "beacon-node-url", "", "Beacon node URL.") - cmd.Flags().StringVar(&config.PrivateKeyPath, "private-key-file ", ".charon/charon-enr-private-key", "The path to the charon enr private key file. ") - cmd.Flags().StringVar(&config.LockFilePath, "lock-file", ".charon/cluster-lock.json", "The path to the cluster lock file defining the distributed validator cluster.") - cmd.Flags().StringVar(&config.ValidatorKeysDir, "validator-keys-dir", ".charon/validator_keys", "Path to the directory containing the validator private key share files and passwords.") - mustMarkFlagRequired(cmd, "beacon-node-url") -} +type exitFlag int -func bindExitRelatedFlags(cmd *cobra.Command, config *exitConfig) { - const vpk string = "validator-public-key" +const ( + publishAddress exitFlag = iota + beaconNodeURL + privateKeyPath + lockFilePath + validatorKeysDir + validatorPubkey + exitEpoch +) - cmd.Flags().StringVar(&config.ValidatorPubkey, vpk, "", "Public key of the validator to exit, must be present in the cluster lock manifest.") - cmd.Flags().Uint64Var(&config.ExitEpoch, "exit-epoch", 162304, "Exit epoch at which the validator will exit, must be the same across all the partial exits.") +func (ef exitFlag) String() string { + switch ef { + case publishAddress: + return "publish-address" + case beaconNodeURL: + return "beacon-node-url" + case privateKeyPath: + return "private-key-file" + case lockFilePath: + return "lock-file" + case validatorKeysDir: + return "validator-keys-dir" + case validatorPubkey: + return "validator-public-key" + case exitEpoch: + return "exit-epoch" + default: + return "unknown" + } +} + +type exitCLIFlag struct { + flag exitFlag + required bool +} - mustMarkFlagRequired(cmd, vpk) +func bindExitFlags(cmd *cobra.Command, config *exitConfig, flags []exitCLIFlag) { + for _, f := range flags { + flag := f.flag + + switch flag { + case publishAddress: + cmd.Flags().StringVar(&config.PublishAddress, "publish-address", "https://api.obol.tech", "Endpoint of the partial exits API instance.") + case beaconNodeURL: + cmd.Flags().StringVar(&config.BeaconNodeURL, "beacon-node-url", "", "Beacon node URL.") + case privateKeyPath: + cmd.Flags().StringVar(&config.PrivateKeyPath, "private-key-file ", ".charon/charon-enr-private-key", "The path to the charon enr private key file. ") + case lockFilePath: + cmd.Flags().StringVar(&config.LockFilePath, "lock-file", ".charon/cluster-lock.json", "The path to the cluster lock file defining the distributed validator cluster.") + case validatorKeysDir: + cmd.Flags().StringVar(&config.ValidatorKeysDir, "validator-keys-dir", ".charon/validator_keys", "Path to the directory containing the validator private key share files and passwords.") + case validatorPubkey: + cmd.Flags().StringVar(&config.ValidatorPubkey, "validator-public-key", "", "Public key of the validator to exit, must be present in the cluster lock manifest.") + case exitEpoch: + cmd.Flags().Uint64Var(&config.ExitEpoch, "exit-epoch", 162304, "Exit epoch at which the validator will exit, must be the same across all the partial exits.") + } + + if f.required { + mustMarkFlagRequired(cmd, flag.String()) + } + } } func eth2Client(ctx context.Context, u string) (eth2wrap.Client, error) { diff --git a/cmd/exit_broadcast.go b/cmd/exit_broadcast.go index f248a42e3..f758c2cd9 100644 --- a/cmd/exit_broadcast.go +++ b/cmd/exit_broadcast.go @@ -39,8 +39,16 @@ func newBcastFullExitCmd(runFunc func(context.Context, exitConfig) error) *cobra }, } - bindGenericExitFlags(cmd, &config) - bindExitRelatedFlags(cmd, &config) + bindExitFlags(cmd, &config, []exitCLIFlag{ + {publishAddress, false}, + {privateKeyPath, false}, + {lockFilePath, false}, + {validatorKeysDir, false}, + {exitEpoch, false}, + {validatorPubkey, true}, + {beaconNodeURL, true}, + }) + bindLogFlags(cmd.Flags(), &config.Log) return cmd diff --git a/cmd/exit_fetch.go b/cmd/exit_fetch.go new file mode 100644 index 000000000..e0480a2fd --- /dev/null +++ b/cmd/exit_fetch.go @@ -0,0 +1,120 @@ +// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + libp2plog "github.com/ipfs/go-log/v2" + "github.com/spf13/cobra" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/k1util" + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/obolapi" + "github.com/obolnetwork/charon/app/z" + "github.com/obolnetwork/charon/core" + "github.com/obolnetwork/charon/eth2util/keystore" +) + +func newFetchExitCmd(runFunc func(context.Context, exitConfig) error) *cobra.Command { + var config exitConfig + + cmd := &cobra.Command{ + Use: "fetch", + Short: "Fetch full exit from partial exit API instance.", + Long: `Fetch a full exit message for a given validator from the partial exit API instance.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := log.InitLogger(config.Log); err != nil { + return err + } + libp2plog.SetPrimaryCore(log.LoggerCore()) // Set libp2p logger to use charon logger + + printFlags(cmd.Context(), cmd.Flags()) + + return runFunc(cmd.Context(), config) + }, + } + + bindExitFlags(cmd, &config, []exitCLIFlag{ + {publishAddress, false}, + {privateKeyPath, false}, + {lockFilePath, false}, + {validatorPubkey, true}, + }) + + bindLogFlags(cmd.Flags(), &config.Log) + + return cmd +} + +func runFetchExit(ctx context.Context, config exitConfig) error { + if _, err := os.Stat(config.FetchedExitPath); err != nil { + return errors.Wrap(err, "store exit path") + } + + writeTestFile := filepath.Join(config.FetchedExitPath, ".write-test") + if err := os.WriteFile(writeTestFile, []byte{}, 0o755); err != nil { //nolint:gosec // write test file + return errors.Wrap(err, "can't write to destination directory") + } + + if err := os.Remove(writeTestFile); err != nil { + return errors.Wrap(err, "can't delete write test file") + } + + identityKey, err := k1util.Load(config.PrivateKeyPath) + if err != nil { + return errors.Wrap(err, "could not load identity key") + } + + cl, err := loadClusterManifest("", config.LockFilePath) + if err != nil { + return errors.Wrap(err, "could not load cluster data") + } + + validator := core.PubKey(config.ValidatorPubkey) + if _, err := validator.Bytes(); err != nil { + return errors.Wrap(err, "cannot convert validator pubkey to bytes") + } + + ctx = log.WithCtx(ctx, z.Str("validator", validator.String())) + + oAPI, err := obolapi.New(config.PublishAddress) + if err != nil { + return errors.Wrap(err, "could not create obol api client") + } + + log.Info(ctx, "Retrieving full exit message") + + shareIdx, err := keystore.ShareIdxForCluster(cl, *identityKey.PubKey()) + if err != nil { + return errors.Wrap(err, "could not load share index from cluster lock") + } + + fullExit, err := oAPI.GetFullExit(ctx, config.ValidatorPubkey, cl.GetInitialMutationHash(), shareIdx, identityKey) + if err != nil { + return errors.Wrap(err, "could not load full exit data from Obol API") + } + + fetchedExitFname := fmt.Sprintf("exit-%s.json", config.ValidatorPubkey) + + fetchedExitPath := filepath.Join(config.FetchedExitPath, fetchedExitFname) + + exitData, err := json.Marshal(fullExit.SignedExitMessage) + if err != nil { + return errors.Wrap(err, "signed exit message marshal") + } + + if err := os.WriteFile(fetchedExitPath, exitData, 0o600); err != nil { + return errors.Wrap(err, "store signed exit message") + } + + log.Info(ctx, "Stored signed exit message", z.Str("path", fetchedExitPath)) + + return nil +} diff --git a/cmd/exit_fetch_internal_test.go b/cmd/exit_fetch_internal_test.go new file mode 100644 index 000000000..b794b1dae --- /dev/null +++ b/cmd/exit_fetch_internal_test.go @@ -0,0 +1,155 @@ +// Copyright © 2022-2024 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "math/rand" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/testutil" + "github.com/obolnetwork/charon/testutil/beaconmock" + "github.com/obolnetwork/charon/testutil/obolapimock" +) + +func Test_runFetchExit(t *testing.T) { + t.Parallel() + t.Run("full flow", Test_runFetchExitFullFlow) + t.Run("bad out dir", Test_runFetchExitBadOutDir) +} + +func Test_runFetchExitFullFlow(t *testing.T) { + t.Parallel() + ctx := context.Background() + + valAmt := 100 + operatorAmt := 4 + + random := rand.New(rand.NewSource(int64(0))) + + lock, enrs, keyShares := cluster.NewForT( + t, + valAmt, + operatorAmt, + operatorAmt, + 0, + random, + ) + + root := t.TempDir() + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + + for opIdx := 0; opIdx < operatorAmt; opIdx++ { + for _, share := range keyShares { + operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx]) + } + } + + mBytes, err := json.Marshal(lock) + require.NoError(t, err) + + handler, addLockFiles := obolapimock.MockServer(false) + srv := httptest.NewServer(handler) + addLockFiles(lock) + defer srv.Close() + + validatorSet := beaconmock.ValidatorSet{} + + for idx, v := range lock.Validators { + validatorSet[eth2p0.ValidatorIndex(idx)] = ð2v1.Validator{ + Index: eth2p0.ValidatorIndex(idx), + Balance: 42, + Status: eth2v1.ValidatorStateActiveOngoing, + Validator: ð2p0.Validator{ + PublicKey: eth2p0.BLSPubKey(v.PubKey), + WithdrawalCredentials: testutil.RandomBytes32(), + }, + } + } + + beaconMock, err := beaconmock.New( + beaconmock.WithValidatorSet(validatorSet), + ) + require.NoError(t, err) + defer func() { + require.NoError(t, beaconMock.Close()) + }() + + writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes) + + for idx := 0; idx < operatorAmt; idx++ { + baseDir := filepath.Join(root, fmt.Sprintf("op%d", idx)) + + config := exitConfig{ + BeaconNodeURL: beaconMock.Address(), + ValidatorPubkey: lock.Validators[0].PublicKeyHex(), + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + ExitEpoch: 194048, + } + + require.NoError(t, runSignPartialExit(ctx, config), "operator index: %v", idx) + } + + baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) + + config := exitConfig{ + ValidatorPubkey: lock.Validators[0].PublicKeyHex(), + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + FetchedExitPath: root, + } + + require.NoError(t, runFetchExit(ctx, config)) + + exitFilePath := filepath.Join(root, fmt.Sprintf("exit-%s.json", lock.Validators[0].PublicKeyHex())) + + require.FileExists(t, exitFilePath) + + f, err := os.Open(exitFilePath) + require.NoError(t, err) + + var finalExit eth2p0.SignedVoluntaryExit + require.NoError(t, json.NewDecoder(f).Decode(&finalExit)) + + require.NotEmpty(t, finalExit) +} + +func Test_runFetchExitBadOutDir(t *testing.T) { + t.Parallel() + config := exitConfig{ + FetchedExitPath: "bad", + } + + require.Error(t, runFetchExit(context.Background(), config)) + + config = exitConfig{ + FetchedExitPath: "", + } + + require.Error(t, runFetchExit(context.Background(), config)) + + cantWriteDir := filepath.Join(t.TempDir(), "cantwrite") + require.NoError(t, os.MkdirAll(cantWriteDir, 0o400)) + + config = exitConfig{ + FetchedExitPath: cantWriteDir, + } + + require.ErrorContains(t, runFetchExit(context.Background(), config), "permission denied") +} diff --git a/cmd/exit_list.go b/cmd/exit_list.go index f0932ae87..8c67605bd 100644 --- a/cmd/exit_list.go +++ b/cmd/exit_list.go @@ -39,7 +39,11 @@ func newListActiveValidatorsCmd(runFunc func(context.Context, exitConfig) error) cmd.Flags().BoolVar(&config.PlaintextOutput, "plaintext", false, "Prints each active validator on a line, without any debugging or logging artifact. Useful for scripting.") - bindGenericExitFlags(cmd, &config) + bindExitFlags(cmd, &config, []exitCLIFlag{ + {lockFilePath, false}, + {beaconNodeURL, true}, + }) + bindLogFlags(cmd.Flags(), &config.Log) return cmd diff --git a/cmd/exit_sign.go b/cmd/exit_sign.go index 9af01eb84..7c3ddc218 100644 --- a/cmd/exit_sign.go +++ b/cmd/exit_sign.go @@ -39,8 +39,16 @@ func newSubmitPartialExitCmd(runFunc func(context.Context, exitConfig) error) *c }, } - bindGenericExitFlags(cmd, &config) - bindExitRelatedFlags(cmd, &config) + bindExitFlags(cmd, &config, []exitCLIFlag{ + {publishAddress, false}, + {privateKeyPath, false}, + {lockFilePath, false}, + {validatorKeysDir, false}, + {exitEpoch, false}, + {validatorPubkey, true}, + {beaconNodeURL, true}, + }) + bindLogFlags(cmd.Flags(), &config.Log) return cmd From 8c0eb8edd89f965dfea6afa591a83f0bdbab9a7c Mon Sep 17 00:00:00 2001 From: Gianguido Sora Date: Tue, 26 Mar 2024 11:32:20 +0100 Subject: [PATCH 06/15] wire `broadcast` to read from file, if specified --- cmd/exit.go | 20 +++++--- cmd/exit_broadcast.go | 71 +++++++++++++++++++++++------ cmd/exit_broadcast_internal_test.go | 63 ++++++++++++++++++++----- 3 files changed, 120 insertions(+), 34 deletions(-) diff --git a/cmd/exit.go b/cmd/exit.go index 2d503f2a2..fc85527bb 100644 --- a/cmd/exit.go +++ b/cmd/exit.go @@ -27,6 +27,7 @@ type exitConfig struct { ExitEpoch uint64 FetchedExitPath string PlaintextOutput bool + ExitFromFilePath string Log log.Config } @@ -52,6 +53,7 @@ const ( validatorKeysDir validatorPubkey exitEpoch + exitFromFile ) func (ef exitFlag) String() string { @@ -70,6 +72,8 @@ func (ef exitFlag) String() string { return "validator-public-key" case exitEpoch: return "exit-epoch" + case exitFromFile: + return "exit-from-file" default: return "unknown" } @@ -86,19 +90,21 @@ func bindExitFlags(cmd *cobra.Command, config *exitConfig, flags []exitCLIFlag) switch flag { case publishAddress: - cmd.Flags().StringVar(&config.PublishAddress, "publish-address", "https://api.obol.tech", "Endpoint of the partial exits API instance.") + cmd.Flags().StringVar(&config.PublishAddress, publishAddress.String(), "https://api.obol.tech", "Endpoint of the partial exits API instance.") case beaconNodeURL: - cmd.Flags().StringVar(&config.BeaconNodeURL, "beacon-node-url", "", "Beacon node URL.") + cmd.Flags().StringVar(&config.BeaconNodeURL, beaconNodeURL.String(), "", "Beacon node URL.") case privateKeyPath: - cmd.Flags().StringVar(&config.PrivateKeyPath, "private-key-file ", ".charon/charon-enr-private-key", "The path to the charon enr private key file. ") + cmd.Flags().StringVar(&config.PrivateKeyPath, privateKeyPath.String(), ".charon/charon-enr-private-key", "The path to the charon enr private key file. ") case lockFilePath: - cmd.Flags().StringVar(&config.LockFilePath, "lock-file", ".charon/cluster-lock.json", "The path to the cluster lock file defining the distributed validator cluster.") + cmd.Flags().StringVar(&config.LockFilePath, lockFilePath.String(), ".charon/cluster-lock.json", "The path to the cluster lock file defining the distributed validator cluster.") case validatorKeysDir: - cmd.Flags().StringVar(&config.ValidatorKeysDir, "validator-keys-dir", ".charon/validator_keys", "Path to the directory containing the validator private key share files and passwords.") + cmd.Flags().StringVar(&config.ValidatorKeysDir, validatorKeysDir.String(), ".charon/validator_keys", "Path to the directory containing the validator private key share files and passwords.") case validatorPubkey: - cmd.Flags().StringVar(&config.ValidatorPubkey, "validator-public-key", "", "Public key of the validator to exit, must be present in the cluster lock manifest.") + cmd.Flags().StringVar(&config.ValidatorPubkey, validatorPubkey.String(), "", "Public key of the validator to exit, must be present in the cluster lock manifest.") case exitEpoch: - cmd.Flags().Uint64Var(&config.ExitEpoch, "exit-epoch", 162304, "Exit epoch at which the validator will exit, must be the same across all the partial exits.") + cmd.Flags().Uint64Var(&config.ExitEpoch, exitEpoch.String(), 162304, "Exit epoch at which the validator will exit, must be the same across all the partial exits.") + case exitFromFile: + cmd.Flags().StringVar(&config.ExitFromFilePath, exitFromFile.String(), "", "Retrieves signed exit message from a pre-prepared file instead of --publish-address.") } if f.required { diff --git a/cmd/exit_broadcast.go b/cmd/exit_broadcast.go index f758c2cd9..3ed6180c4 100644 --- a/cmd/exit_broadcast.go +++ b/cmd/exit_broadcast.go @@ -4,7 +4,12 @@ package cmd import ( "context" + "encoding/json" + "os" + "strings" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" libp2plog "github.com/ipfs/go-log/v2" "github.com/spf13/cobra" @@ -13,6 +18,7 @@ import ( "github.com/obolnetwork/charon/app/log" "github.com/obolnetwork/charon/app/obolapi" "github.com/obolnetwork/charon/app/z" + manifestpb "github.com/obolnetwork/charon/cluster/manifestpb/v1" "github.com/obolnetwork/charon/core" "github.com/obolnetwork/charon/eth2util/keystore" "github.com/obolnetwork/charon/tbls" @@ -47,6 +53,7 @@ func newBcastFullExitCmd(runFunc func(context.Context, exitConfig) error) *cobra {exitEpoch, false}, {validatorPubkey, true}, {beaconNodeURL, true}, + {exitFromFile, false}, }) bindLogFlags(cmd.Flags(), &config.Log) @@ -77,21 +84,19 @@ func runBcastFullExit(ctx context.Context, config exitConfig) error { return errors.Wrap(err, "cannot create eth2 client for specified beacon node") } - oAPI, err := obolapi.New(config.PublishAddress) - if err != nil { - return errors.Wrap(err, "could not create obol api client") - } + var fullExit eth2p0.SignedVoluntaryExit + maybeExitFilePath := strings.TrimSpace(config.ExitFromFilePath) - log.Info(ctx, "Retrieving full exit message") - - shareIdx, err := keystore.ShareIdxForCluster(cl, *identityKey.PubKey()) - if err != nil { - return errors.Wrap(err, "could not load share index from cluster lock") + if len(maybeExitFilePath) != 0 { + log.Info(ctx, "Retrieving full exit message from path", z.Str("path", maybeExitFilePath)) + fullExit, err = exitFromPath(maybeExitFilePath) + } else { + log.Info(ctx, "Retrieving full exit message from publish address") + fullExit, err = exitFromObolAPI(ctx, config.ValidatorPubkey, config.PublishAddress, cl, identityKey) } - fullExit, err := oAPI.GetFullExit(ctx, config.ValidatorPubkey, cl.GetInitialMutationHash(), shareIdx, identityKey) if err != nil { - return errors.Wrap(err, "could not load full exit data from Obol API") + return err } // parse validator public key @@ -106,16 +111,16 @@ func runBcastFullExit(ctx context.Context, config exitConfig) error { } // parse signature - signature, err := tblsconv.SignatureFromBytes(fullExit.SignedExitMessage.Signature[:]) + signature, err := tblsconv.SignatureFromBytes(fullExit.Signature[:]) if err != nil { return errors.Wrap(err, "could not parse BLS signature from bytes") } exitRoot, err := sigDataForExit( ctx, - *fullExit.SignedExitMessage.Message, + *fullExit.Message, eth2Cl, - fullExit.SignedExitMessage.Message.Epoch, + fullExit.Message.Epoch, ) if err != nil { return errors.Wrap(err, "cannot calculate hash tree root for exit message for verification") @@ -125,9 +130,45 @@ func runBcastFullExit(ctx context.Context, config exitConfig) error { return errors.Wrap(err, "exit message signature not verified") } - if err := eth2Cl.SubmitVoluntaryExit(ctx, &fullExit.SignedExitMessage); err != nil { + if err := eth2Cl.SubmitVoluntaryExit(ctx, &fullExit); err != nil { return errors.Wrap(err, "could submit voluntary exit") } return nil } + +// exitFromObolAPI fetches an eth2p0.SignedVoluntaryExit message from publishAddr for the given validatorPubkey. +func exitFromObolAPI(ctx context.Context, validatorPubkey, publishAddr string, cl *manifestpb.Cluster, identityKey *k1.PrivateKey) (eth2p0.SignedVoluntaryExit, error) { + oAPI, err := obolapi.New(publishAddr) + if err != nil { + return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "could not create obol api client") + } + + shareIdx, err := keystore.ShareIdxForCluster(cl, *identityKey.PubKey()) + if err != nil { + return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "could not load share index from cluster lock") + } + + fullExit, err := oAPI.GetFullExit(ctx, validatorPubkey, cl.GetInitialMutationHash(), shareIdx, identityKey) + if err != nil { + return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "could not load full exit data from Obol API") + } + + return fullExit.SignedExitMessage, nil +} + +// exitFromPath loads an eth2p0.SignedVoluntaryExit from path. +func exitFromPath(path string) (eth2p0.SignedVoluntaryExit, error) { + f, err := os.Open(path) + if err != nil { + return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "can't open signed exit message from path") + } + + var exit eth2p0.SignedVoluntaryExit + + if err := json.NewDecoder(f).Decode(&exit); err != nil { + return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "invalid signed exit message") + } + + return exit, nil +} diff --git a/cmd/exit_broadcast_internal_test.go b/cmd/exit_broadcast_internal_test.go index c32a0c484..29ccfdb28 100644 --- a/cmd/exit_broadcast_internal_test.go +++ b/cmd/exit_broadcast_internal_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/cluster/manifest" "github.com/obolnetwork/charon/tbls" "github.com/obolnetwork/charon/testutil" "github.com/obolnetwork/charon/testutil/beaconmock" @@ -27,12 +28,19 @@ const badStr = "bad" func Test_runBcastFullExitCmd(t *testing.T) { t.Parallel() - t.Run("main flow", Test_runBcastFullExitCmdFlow) + t.Run("main flow from api", func(t *testing.T) { + t.Parallel() + testRunBcastFullExitCmdFlow(t, false) + }) + t.Run("main flow from file", func(t *testing.T) { + t.Parallel() + testRunBcastFullExitCmdFlow(t, true) + }) t.Run("config", Test_runBcastFullExitCmd_Config) } -func Test_runBcastFullExitCmdFlow(t *testing.T) { - t.Parallel() +func testRunBcastFullExitCmdFlow(t *testing.T, fromFile bool) { + t.Helper() ctx := context.Background() valAmt := 100 @@ -59,6 +67,11 @@ func Test_runBcastFullExitCmdFlow(t *testing.T) { } } + dag, err := manifest.NewDAGFromLockForT(t, lock) + require.NoError(t, err) + cl, err := manifest.Materialise(dag) + require.NoError(t, err) + mBytes, err := json.Marshal(lock) require.NoError(t, err) @@ -115,8 +128,22 @@ func Test_runBcastFullExitCmdFlow(t *testing.T) { ValidatorPubkey: lock.Validators[0].PublicKeyHex(), PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), - LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), PublishAddress: srv.URL, - ExitEpoch: 194048, + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + ExitEpoch: 194048, + } + + if fromFile { + exit, err := exitFromObolAPI(ctx, lock.Validators[0].PublicKeyHex(), srv.URL, cl, enrs[0]) + require.NoError(t, err) + + exitBytes, err := json.Marshal(exit) + require.NoError(t, err) + + exitPath := filepath.Join(baseDir, "exit.json") + require.NoError(t, os.WriteFile(exitPath, exitBytes, 0o755)) + + config.ExitFromFilePath = exitPath } require.NoError(t, runBcastFullExit(ctx, config)) @@ -125,13 +152,14 @@ func Test_runBcastFullExitCmdFlow(t *testing.T) { func Test_runBcastFullExitCmd_Config(t *testing.T) { t.Parallel() type test struct { - name string - noIdentity bool - noLock bool - badOAPIURL bool - badBeaconNodeURL bool - badValidatorAddr bool - errData string + name string + noIdentity bool + noLock bool + badOAPIURL bool + badBeaconNodeURL bool + badValidatorAddr bool + badExistingExitPath bool + errData string } tests := []test{ @@ -160,6 +188,11 @@ func Test_runBcastFullExitCmd_Config(t *testing.T) { badValidatorAddr: true, errData: "cannot convert validator pubkey to bytes", }, + { + name: "Bad existing exit file", + badExistingExitPath: true, + errData: "invalid signed exit message", + }, } del := func(t *testing.T, tc test, root string, opIdx int) { @@ -249,6 +282,12 @@ func Test_runBcastFullExitCmd_Config(t *testing.T) { ExitEpoch: 0, } + if test.badExistingExitPath { + path := filepath.Join(baseDir, "exit.json") + require.NoError(t, os.WriteFile(path, []byte("bad"), 0o755)) + config.ExitFromFilePath = path + } + require.ErrorContains(t, runBcastFullExit(ctx, config), test.errData) }) } From a02c49c8305a5b146b24b7625db876e3dab890eb Mon Sep 17 00:00:00 2001 From: Gianguido Sora Date: Tue, 26 Mar 2024 15:07:23 +0100 Subject: [PATCH 07/15] address review comments --- cmd/exit_broadcast.go | 6 +++--- cmd/exit_broadcast_internal_test.go | 2 +- cmd/exit_fetch.go | 4 ++-- cmd/exit_list.go | 2 +- cmd/exit_sign.go | 6 +++--- cmd/exit_sign_internal_test.go | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cmd/exit_broadcast.go b/cmd/exit_broadcast.go index 3ed6180c4..71dbdb88c 100644 --- a/cmd/exit_broadcast.go +++ b/cmd/exit_broadcast.go @@ -69,7 +69,7 @@ func runBcastFullExit(ctx context.Context, config exitConfig) error { cl, err := loadClusterManifest("", config.LockFilePath) if err != nil { - return errors.Wrap(err, "could not load cluster data") + return errors.Wrap(err, "could not load cluster-lock.json") } validator := core.PubKey(config.ValidatorPubkey) @@ -131,7 +131,7 @@ func runBcastFullExit(ctx context.Context, config exitConfig) error { } if err := eth2Cl.SubmitVoluntaryExit(ctx, &fullExit); err != nil { - return errors.Wrap(err, "could submit voluntary exit") + return errors.Wrap(err, "could not submit voluntary exit") } return nil @@ -146,7 +146,7 @@ func exitFromObolAPI(ctx context.Context, validatorPubkey, publishAddr string, c shareIdx, err := keystore.ShareIdxForCluster(cl, *identityKey.PubKey()) if err != nil { - return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "could not load share index from cluster lock") + return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "could not determine operator index from cluster lock for supplied identity key") } fullExit, err := oAPI.GetFullExit(ctx, validatorPubkey, cl.GetInitialMutationHash(), shareIdx, identityKey) diff --git a/cmd/exit_broadcast_internal_test.go b/cmd/exit_broadcast_internal_test.go index 29ccfdb28..1c286575c 100644 --- a/cmd/exit_broadcast_internal_test.go +++ b/cmd/exit_broadcast_internal_test.go @@ -171,7 +171,7 @@ func Test_runBcastFullExitCmd_Config(t *testing.T) { { name: "No lock", noLock: true, - errData: "could not load cluster data", + errData: "could not load cluster-lock.json", }, { name: "Bad Obol API URL", diff --git a/cmd/exit_fetch.go b/cmd/exit_fetch.go index e0480a2fd..00aaed3c2 100644 --- a/cmd/exit_fetch.go +++ b/cmd/exit_fetch.go @@ -74,7 +74,7 @@ func runFetchExit(ctx context.Context, config exitConfig) error { cl, err := loadClusterManifest("", config.LockFilePath) if err != nil { - return errors.Wrap(err, "could not load cluster data") + return errors.Wrap(err, "could not load cluster-lock.json") } validator := core.PubKey(config.ValidatorPubkey) @@ -93,7 +93,7 @@ func runFetchExit(ctx context.Context, config exitConfig) error { shareIdx, err := keystore.ShareIdxForCluster(cl, *identityKey.PubKey()) if err != nil { - return errors.Wrap(err, "could not load share index from cluster lock") + return errors.Wrap(err, "could not determine operator index from cluster lock for supplied identity key") } fullExit, err := oAPI.GetFullExit(ctx, config.ValidatorPubkey, cl.GetInitialMutationHash(), shareIdx, identityKey) diff --git a/cmd/exit_list.go b/cmd/exit_list.go index 8c67605bd..e91256558 100644 --- a/cmd/exit_list.go +++ b/cmd/exit_list.go @@ -71,7 +71,7 @@ func runListActiveValidatorsCmd(ctx context.Context, config exitConfig) error { func listActiveVals(ctx context.Context, config exitConfig) ([]string, error) { cl, err := loadClusterManifest("", config.LockFilePath) if err != nil { - return nil, errors.Wrap(err, "could not load cluster data") + return nil, errors.Wrap(err, "could not load cluster-lock.json") } eth2Cl, err := eth2Client(ctx, config.BeaconNodeURL) diff --git a/cmd/exit_sign.go b/cmd/exit_sign.go index 7c3ddc218..994ded743 100644 --- a/cmd/exit_sign.go +++ b/cmd/exit_sign.go @@ -62,7 +62,7 @@ func runSignPartialExit(ctx context.Context, config exitConfig) error { cl, err := loadClusterManifest("", config.LockFilePath) if err != nil { - return errors.Wrap(err, "could not load cluster data") + return errors.Wrap(err, "could not load cluster-lock.json") } rawValKeys, err := keystore.LoadFilesUnordered(config.ValidatorKeysDir) @@ -91,12 +91,12 @@ func runSignPartialExit(ctx context.Context, config exitConfig) error { shareIdx, err := keystore.ShareIdxForCluster(cl, *identityKey.PubKey()) if err != nil { - return errors.Wrap(err, "could not load share index from cluster lock") + return errors.Wrap(err, "could not determine operator index from cluster lock for supplied identity key") } ourShare, ok := shares[validator] if !ok { - return errors.New("validator not present in cluster manifest", z.Str("validator", validator.String())) + return errors.New("validator not present in cluster lock", z.Str("validator", validator.String())) } eth2Cl, err := eth2Client(ctx, config.BeaconNodeURL) diff --git a/cmd/exit_sign_internal_test.go b/cmd/exit_sign_internal_test.go index 029f0dad5..1a0f18be4 100644 --- a/cmd/exit_sign_internal_test.go +++ b/cmd/exit_sign_internal_test.go @@ -152,9 +152,9 @@ func Test_runSubmitPartialExit_Config(t *testing.T) { errData: "could not load identity key", }, { - name: "No manifest", + name: "No cluster lock", noLock: true, - errData: "could not load cluster data", + errData: "could not load cluster-lock.json", }, { name: "No keystore", From b983026d17344db8944f479ebdfe66b45bddff82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gianguido=20Sor=C3=A0?= Date: Wed, 3 Apr 2024 10:57:11 +0200 Subject: [PATCH 08/15] Update exit_fetch.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Oisín Kyne <4981644+OisinKyne@users.noreply.github.com> --- cmd/exit_fetch.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/exit_fetch.go b/cmd/exit_fetch.go index 00aaed3c2..48759f760 100644 --- a/cmd/exit_fetch.go +++ b/cmd/exit_fetch.go @@ -26,7 +26,7 @@ func newFetchExitCmd(runFunc func(context.Context, exitConfig) error) *cobra.Com cmd := &cobra.Command{ Use: "fetch", - Short: "Fetch full exit from partial exit API instance.", + Short: "Fetch a signed exit message from the remote API", Long: `Fetch a full exit message for a given validator from the partial exit API instance.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { From 7388a71621190cd537188f87e194ae9ce14c146e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gianguido=20Sor=C3=A0?= Date: Wed, 3 Apr 2024 10:57:27 +0200 Subject: [PATCH 09/15] Update exit_fetch.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Oisín Kyne <4981644+OisinKyne@users.noreply.github.com> --- cmd/exit_fetch.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/exit_fetch.go b/cmd/exit_fetch.go index 48759f760..563e4b15d 100644 --- a/cmd/exit_fetch.go +++ b/cmd/exit_fetch.go @@ -27,7 +27,7 @@ func newFetchExitCmd(runFunc func(context.Context, exitConfig) error) *cobra.Com cmd := &cobra.Command{ Use: "fetch", Short: "Fetch a signed exit message from the remote API", - Long: `Fetch a full exit message for a given validator from the partial exit API instance.`, + Long: `Fetches a fully signed exit message for a given validator from the remote API.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { if err := log.InitLogger(config.Log); err != nil { From fac09a5b316f3a3084d181cb1af4edc686da011f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gianguido=20Sor=C3=A0?= Date: Wed, 3 Apr 2024 11:00:10 +0200 Subject: [PATCH 10/15] Update exit.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Oisín Kyne <4981644+OisinKyne@users.noreply.github.com> --- cmd/exit.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/exit.go b/cmd/exit.go index fc85527bb..20b17252f 100644 --- a/cmd/exit.go +++ b/cmd/exit.go @@ -90,7 +90,7 @@ func bindExitFlags(cmd *cobra.Command, config *exitConfig, flags []exitCLIFlag) switch flag { case publishAddress: - cmd.Flags().StringVar(&config.PublishAddress, publishAddress.String(), "https://api.obol.tech", "Endpoint of the partial exits API instance.") + cmd.Flags().StringVar(&config.PublishAddress, publishAddress.String(), "https://api.obol.tech", "The URL of the remote API.") case beaconNodeURL: cmd.Flags().StringVar(&config.BeaconNodeURL, beaconNodeURL.String(), "", "Beacon node URL.") case privateKeyPath: From 31e599de584070052850dba8e2496d0a54995529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gianguido=20Sor=C3=A0?= Date: Wed, 3 Apr 2024 11:00:22 +0200 Subject: [PATCH 11/15] Update exit.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Oisín Kyne <4981644+OisinKyne@users.noreply.github.com> --- cmd/exit.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/exit.go b/cmd/exit.go index 20b17252f..e3461c4a5 100644 --- a/cmd/exit.go +++ b/cmd/exit.go @@ -104,7 +104,7 @@ func bindExitFlags(cmd *cobra.Command, config *exitConfig, flags []exitCLIFlag) case exitEpoch: cmd.Flags().Uint64Var(&config.ExitEpoch, exitEpoch.String(), 162304, "Exit epoch at which the validator will exit, must be the same across all the partial exits.") case exitFromFile: - cmd.Flags().StringVar(&config.ExitFromFilePath, exitFromFile.String(), "", "Retrieves signed exit message from a pre-prepared file instead of --publish-address.") + cmd.Flags().StringVar(&config.ExitFromFilePath, exitFromFile.String(), "", "Retrieves a signed exit message from a pre-prepared file instead of --publish-address.") } if f.required { From 83422adbec9c0db142e55b18a4d0246b4338d8ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gianguido=20Sor=C3=A0?= Date: Wed, 3 Apr 2024 11:11:42 +0200 Subject: [PATCH 12/15] Update exit_sign.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Oisín Kyne <4981644+OisinKyne@users.noreply.github.com> --- cmd/exit_sign.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/exit_sign.go b/cmd/exit_sign.go index 994ded743..4dcddd488 100644 --- a/cmd/exit_sign.go +++ b/cmd/exit_sign.go @@ -24,7 +24,7 @@ func newSubmitPartialExitCmd(runFunc func(context.Context, exitConfig) error) *c cmd := &cobra.Command{ Use: "sign", - Short: "Sign partial exit message for a distributed validator.", + Short: "Sign partial exit message for a distributed validator", Long: `Sign a partial exit message for a distributed validator and submit it to a remote API for aggregation.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { From ddcf018e5db3339cc0d04d4a0e6c8c5ba26a7cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gianguido=20Sor=C3=A0?= Date: Wed, 3 Apr 2024 11:11:48 +0200 Subject: [PATCH 13/15] Update exit_broadcast.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Oisín Kyne <4981644+OisinKyne@users.noreply.github.com> --- cmd/exit_broadcast.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/exit_broadcast.go b/cmd/exit_broadcast.go index 71dbdb88c..f4033c0ad 100644 --- a/cmd/exit_broadcast.go +++ b/cmd/exit_broadcast.go @@ -30,7 +30,7 @@ func newBcastFullExitCmd(runFunc func(context.Context, exitConfig) error) *cobra cmd := &cobra.Command{ Use: "broadcast", - Short: "Submit partial exit message for a distributed validator.", + Short: "Submit partial exit message for a distributed validator", Long: `Retrieves and broadcasts a fully signed validator exit message, aggregated with the available partial signatures retrieved from the publish-address.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { From 05d252d4e46be5931bf19f0cd1a00ed4b4b02b0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gianguido=20Sor=C3=A0?= Date: Wed, 3 Apr 2024 11:11:58 +0200 Subject: [PATCH 14/15] Update exit_sign.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Oisín Kyne <4981644+OisinKyne@users.noreply.github.com> --- cmd/exit_sign.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/exit_sign.go b/cmd/exit_sign.go index 4dcddd488..3b13d6fc2 100644 --- a/cmd/exit_sign.go +++ b/cmd/exit_sign.go @@ -77,7 +77,7 @@ func runSignPartialExit(ctx context.Context, config exitConfig) error { shares, err := keystore.KeysharesToValidatorPubkey(cl, valKeys) if err != nil { - return errors.Wrap(err, "could not match keyshares with their counterparty in cluster manifest") + return errors.Wrap(err, "could not match local validator key shares with their counterparty in cluster lock") } validator := core.PubKey(config.ValidatorPubkey) From c101faacc89ce577bffb23781f031f67a6e8b086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gianguido=20Sor=C3=A0?= Date: Wed, 3 Apr 2024 11:13:34 +0200 Subject: [PATCH 15/15] Update keystore.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Oisín Kyne <4981644+OisinKyne@users.noreply.github.com> --- eth2util/keystore/keystore.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eth2util/keystore/keystore.go b/eth2util/keystore/keystore.go index e9b388ea7..cccbd76fb 100644 --- a/eth2util/keystore/keystore.go +++ b/eth2util/keystore/keystore.go @@ -283,7 +283,7 @@ func KeysharesToValidatorPubkey(cl *manifestpb.Cluster, shares []tbls.PrivateKey } if !found { - return nil, errors.New("share from provided private key shares slice not found in provided manifest") + return nil, errors.New("public key share from provided private key share not found in provided lock") } }