From eb9a79bc82a310a29408ac631d36a45890d752c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gianguido=20Sor=C3=A0?= Date: Wed, 29 May 2024 13:55:36 +0200 Subject: [PATCH 1/3] cmd: allow signing exits with validator index, beacon node URLs rework Allow specifying more than one beacon node to use with the `--beacon-node-endpoints` flag. Users can now sign exits directly with a validator index rather than specifying its public key. If both are specified, validator index takes precedence. `sign` checks that the specified validator index exists in the cluster lock before proceeding. --- cmd/exit.go | 63 ++++++++++++----------- cmd/exit_broadcast.go | 4 +- cmd/exit_broadcast_internal_test.go | 80 ++++++++++++++--------------- cmd/exit_fetch_internal_test.go | 20 ++++---- cmd/exit_list.go | 4 +- cmd/exit_list_internal_test.go | 36 ++++++------- cmd/exit_sign.go | 71 +++++++++++++++++++------ cmd/exit_sign_internal_test.go | 78 ++++++++++++++++------------ 8 files changed, 205 insertions(+), 151 deletions(-) diff --git a/cmd/exit.go b/cmd/exit.go index af9656fe9..73c3de9f3 100644 --- a/cmd/exit.go +++ b/cmd/exit.go @@ -6,7 +6,7 @@ import ( "context" "time" - eth2http "github.com/attestantio/go-eth2-client/http" + eth2api "github.com/attestantio/go-eth2-client/api" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/spf13/cobra" @@ -18,19 +18,21 @@ import ( ) type exitConfig struct { - BeaconNodeURL string - ValidatorPubkey string - PrivateKeyPath string - ValidatorKeysDir string - LockFilePath string - PublishAddress string - PublishTimeout time.Duration - ExitEpoch uint64 - FetchedExitPath string - PlaintextOutput bool - BeaconNodeTimeout time.Duration - ExitFromFilePath string - Log log.Config + BeaconNodeEndpoints []string + ValidatorPubkey string + ValidatorIndex uint64 + ValidatorIndexPresent bool + PrivateKeyPath string + ValidatorKeysDir string + LockFilePath string + PublishAddress string + PublishTimeout time.Duration + ExitEpoch uint64 + FetchedExitPath string + PlaintextOutput bool + BeaconNodeTimeout time.Duration + ExitFromFilePath string + Log log.Config } func newExitCmd(cmds ...*cobra.Command) *cobra.Command { @@ -49,7 +51,7 @@ type exitFlag int const ( publishAddress exitFlag = iota - beaconNodeURL + beaconNodeEndpoints privateKeyPath lockFilePath validatorKeysDir @@ -59,14 +61,15 @@ const ( beaconNodeTimeout fetchedExitPath publishTimeout + validatorIndex ) func (ef exitFlag) String() string { switch ef { case publishAddress: return "publish-address" - case beaconNodeURL: - return "beacon-node-url" + case beaconNodeEndpoints: + return "beacon-node-endpoints" case privateKeyPath: return "private-key-file" case lockFilePath: @@ -85,6 +88,8 @@ func (ef exitFlag) String() string { return "fetched-exit-path" case publishTimeout: return "publish-timeout" + case validatorIndex: + return "validator-index" default: return "unknown" } @@ -110,8 +115,8 @@ func bindExitFlags(cmd *cobra.Command, config *exitConfig, flags []exitCLIFlag) switch flag { case publishAddress: cmd.Flags().StringVar(&config.PublishAddress, publishAddress.String(), "https://api.obol.tech", maybeRequired("The URL of the remote API.")) - case beaconNodeURL: - cmd.Flags().StringVar(&config.BeaconNodeURL, beaconNodeURL.String(), "", maybeRequired("Beacon node URL.")) + case beaconNodeEndpoints: + cmd.Flags().StringSliceVar(&config.BeaconNodeEndpoints, beaconNodeEndpoints.String(), nil, maybeRequired("Comma separated list of one or more beacon node endpoint URLs.")) case privateKeyPath: cmd.Flags().StringVar(&config.PrivateKeyPath, privateKeyPath.String(), ".charon/charon-enr-private-key", maybeRequired("The path to the charon enr private key file. ")) case lockFilePath: @@ -119,7 +124,7 @@ func bindExitFlags(cmd *cobra.Command, config *exitConfig, flags []exitCLIFlag) case validatorKeysDir: cmd.Flags().StringVar(&config.ValidatorKeysDir, validatorKeysDir.String(), ".charon/validator_keys", maybeRequired("Path to the directory containing the validator private key share files and passwords.")) case validatorPubkey: - cmd.Flags().StringVar(&config.ValidatorPubkey, validatorPubkey.String(), "", maybeRequired("Public key of the validator to exit, must be present in the cluster lock manifest.")) + cmd.Flags().StringVar(&config.ValidatorPubkey, validatorPubkey.String(), "", maybeRequired("Public key of the validator to exit, must be present in the cluster lock manifest. If --validator-index is also provided, that flag takes precedence.")) case exitEpoch: cmd.Flags().Uint64Var(&config.ExitEpoch, exitEpoch.String(), 162304, maybeRequired("Exit epoch at which the validator will exit, must be the same across all the partial exits.")) case exitFromFile: @@ -130,6 +135,8 @@ func bindExitFlags(cmd *cobra.Command, config *exitConfig, flags []exitCLIFlag) cmd.Flags().StringVar(&config.FetchedExitPath, fetchedExitPath.String(), "./", maybeRequired("Path to store fetched signed exit messages.")) case publishTimeout: cmd.Flags().DurationVar(&config.PublishTimeout, publishTimeout.String(), 30*time.Second, "Timeout for publishing a signed exit to the publish-address API.") + case validatorIndex: + cmd.Flags().Uint64Var(&config.ValidatorIndex, validatorIndex.String(), 0, "Validator index of the validator to exit, the associated public key must be present in the cluster lock manifest. If --validator-pubkey is also provided, this flag takes precedence.") } if f.required { @@ -138,19 +145,17 @@ func bindExitFlags(cmd *cobra.Command, config *exitConfig, flags []exitCLIFlag) } } -func eth2Client(ctx context.Context, u string, timeout time.Duration) (eth2wrap.Client, error) { - bnHTTPClient, err := eth2http.New(ctx, - eth2http.WithAddress(u), - eth2http.WithTimeout(timeout), - eth2http.WithLogLevel(1), // zerolog.InfoLevel - ) +func eth2Client(ctx context.Context, u []string, timeout time.Duration) (eth2wrap.Client, error) { + cl, err := eth2wrap.NewMultiHTTP(timeout, u...) if err != nil { - return nil, errors.Wrap(err, "can't connect to beacon node") + return nil, err } - bnClient := bnHTTPClient.(*eth2http.Service) + if _, err = cl.NodeVersion(ctx, ð2api.NodeVersionOpts{}); err != nil { + return nil, errors.Wrap(err, "can't connect to beacon node") + } - return eth2wrap.AdaptEth2HTTP(bnClient, timeout), nil + return cl, nil } // signExit signs a voluntary exit message for valIdx with the given keyShare. diff --git a/cmd/exit_broadcast.go b/cmd/exit_broadcast.go index 14929ed3a..74655c942 100644 --- a/cmd/exit_broadcast.go +++ b/cmd/exit_broadcast.go @@ -53,7 +53,7 @@ func newBcastFullExitCmd(runFunc func(context.Context, exitConfig) error) *cobra {validatorKeysDir, false}, {exitEpoch, false}, {validatorPubkey, true}, - {beaconNodeURL, true}, + {beaconNodeEndpoints, true}, {exitFromFile, false}, {beaconNodeTimeout, false}, }) @@ -81,7 +81,7 @@ func runBcastFullExit(ctx context.Context, config exitConfig) error { ctx = log.WithCtx(ctx, z.Str("validator", validator.String())) - eth2Cl, err := eth2Client(ctx, config.BeaconNodeURL, config.BeaconNodeTimeout) + eth2Cl, err := eth2Client(ctx, config.BeaconNodeEndpoints, config.BeaconNodeTimeout) if err != nil { return errors.Wrap(err, "cannot create eth2 client for specified beacon node") } diff --git a/cmd/exit_broadcast_internal_test.go b/cmd/exit_broadcast_internal_test.go index 5640e6f66..aa03508dd 100644 --- a/cmd/exit_broadcast_internal_test.go +++ b/cmd/exit_broadcast_internal_test.go @@ -99,7 +99,7 @@ func testRunBcastFullExitCmdFlow(t *testing.T, fromFile bool) { require.NoError(t, beaconMock.Close()) }() - eth2Cl, err := eth2Client(ctx, beaconMock.Address(), 10*time.Second) + eth2Cl, err := eth2Client(ctx, []string{beaconMock.Address()}, 10*time.Second) require.NoError(t, err) eth2Cl.SetForkVersion([4]byte(lock.ForkVersion)) @@ -115,15 +115,15 @@ func testRunBcastFullExitCmdFlow(t *testing.T, fromFile bool) { 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, - BeaconNodeTimeout: 30 * time.Second, - PublishTimeout: 10 * time.Second, + BeaconNodeEndpoints: []string{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, + BeaconNodeTimeout: 30 * time.Second, + PublishTimeout: 10 * time.Second, } require.NoError(t, runSignPartialExit(ctx, config), "operator index: %v", idx) @@ -132,15 +132,15 @@ func testRunBcastFullExitCmdFlow(t *testing.T, fromFile bool) { baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) 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, - BeaconNodeTimeout: 30 * time.Second, - PublishTimeout: 10 * time.Second, + BeaconNodeEndpoints: []string{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, + BeaconNodeTimeout: 30 * time.Second, + PublishTimeout: 10 * time.Second, } if fromFile { @@ -162,14 +162,14 @@ func testRunBcastFullExitCmdFlow(t *testing.T, fromFile bool) { func Test_runBcastFullExitCmd_Config(t *testing.T) { t.Parallel() type test struct { - name string - noIdentity bool - noLock bool - badOAPIURL bool - badBeaconNodeURL bool - badValidatorAddr bool - badExistingExitPath bool - errData string + name string + noIdentity bool + noLock bool + badOAPIURL bool + badBeaconNodeEndpoints bool + badValidatorAddr bool + badExistingExitPath bool + errData string } tests := []test{ @@ -189,9 +189,9 @@ func Test_runBcastFullExitCmd_Config(t *testing.T) { 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 beacon node URLs", + badBeaconNodeEndpoints: true, + errData: "cannot create eth2 client for specified beacon node", }, { name: "Bad validator address", @@ -261,7 +261,7 @@ func Test_runBcastFullExitCmd_Config(t *testing.T) { bnURL := badStr - if !test.badBeaconNodeURL { + if !test.badBeaconNodeEndpoints { beaconMock, err := beaconmock.New() require.NoError(t, err) defer func() { @@ -283,15 +283,15 @@ func Test_runBcastFullExitCmd_Config(t *testing.T) { baseDir := filepath.Join(root, "op0") // one operator is enough config := exitConfig{ - 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, - BeaconNodeTimeout: 30 * time.Second, - PublishTimeout: 10 * time.Second, + BeaconNodeEndpoints: []string{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, + BeaconNodeTimeout: 30 * time.Second, + PublishTimeout: 10 * time.Second, } if test.badExistingExitPath { diff --git a/cmd/exit_fetch_internal_test.go b/cmd/exit_fetch_internal_test.go index 6777f070f..0e39784d6 100644 --- a/cmd/exit_fetch_internal_test.go +++ b/cmd/exit_fetch_internal_test.go @@ -83,7 +83,7 @@ func Test_runFetchExitFullFlow(t *testing.T) { require.NoError(t, beaconMock.Close()) }() - eth2Cl, err := eth2Client(ctx, beaconMock.Address(), 10*time.Second) + eth2Cl, err := eth2Client(ctx, []string{beaconMock.Address()}, 10*time.Second) require.NoError(t, err) eth2Cl.SetForkVersion([4]byte(lock.ForkVersion)) @@ -99,15 +99,15 @@ func Test_runFetchExitFullFlow(t *testing.T) { 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, - BeaconNodeTimeout: 30 * time.Second, - PublishTimeout: 10 * time.Second, + BeaconNodeEndpoints: []string{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, + BeaconNodeTimeout: 30 * time.Second, + PublishTimeout: 10 * time.Second, } require.NoError(t, runSignPartialExit(ctx, config), "operator index: %v", idx) diff --git a/cmd/exit_list.go b/cmd/exit_list.go index bea49c488..a5ec19555 100644 --- a/cmd/exit_list.go +++ b/cmd/exit_list.go @@ -41,7 +41,7 @@ func newListActiveValidatorsCmd(runFunc func(context.Context, exitConfig) error) bindExitFlags(cmd, &config, []exitCLIFlag{ {lockFilePath, false}, - {beaconNodeURL, true}, + {beaconNodeEndpoints, true}, {beaconNodeTimeout, false}, }) @@ -75,7 +75,7 @@ func listActiveVals(ctx context.Context, config exitConfig) ([]string, error) { return nil, errors.Wrap(err, "could not load cluster-lock.json") } - eth2Cl, err := eth2Client(ctx, config.BeaconNodeURL, config.BeaconNodeTimeout) + eth2Cl, err := eth2Client(ctx, config.BeaconNodeEndpoints, config.BeaconNodeTimeout) if err != nil { return nil, errors.Wrap(err, "cannot create eth2 client for specified beacon node") } diff --git a/cmd/exit_list_internal_test.go b/cmd/exit_list_internal_test.go index 95aaa195f..c9fde4562 100644 --- a/cmd/exit_list_internal_test.go +++ b/cmd/exit_list_internal_test.go @@ -76,12 +76,12 @@ func Test_runListActiveVals(t *testing.T) { baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) config := exitConfig{ - 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, - BeaconNodeTimeout: 30 * time.Second, + BeaconNodeEndpoints: []string{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, + BeaconNodeTimeout: 30 * time.Second, } require.NoError(t, runListActiveValidatorsCmd(ctx, config)) @@ -143,12 +143,12 @@ func Test_listActiveVals(t *testing.T) { baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) config := exitConfig{ - 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, - BeaconNodeTimeout: 30 * time.Second, + BeaconNodeEndpoints: []string{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, + BeaconNodeTimeout: 30 * time.Second, } vals, err := listActiveVals(ctx, config) @@ -184,12 +184,12 @@ func Test_listActiveVals(t *testing.T) { baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) config := exitConfig{ - 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, - BeaconNodeTimeout: 30 * time.Second, + BeaconNodeEndpoints: []string{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, + BeaconNodeTimeout: 30 * time.Second, } vals, err := listActiveVals(ctx, config) diff --git a/cmd/exit_sign.go b/cmd/exit_sign.go index 7874db330..b7e5cf3fa 100644 --- a/cmd/exit_sign.go +++ b/cmd/exit_sign.go @@ -4,6 +4,8 @@ package cmd import ( "context" + "fmt" + "strings" eth2api "github.com/attestantio/go-eth2-client/api" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" @@ -45,13 +47,26 @@ func newSubmitPartialExitCmd(runFunc func(context.Context, exitConfig) error) *c {lockFilePath, false}, {validatorKeysDir, false}, {exitEpoch, false}, - {validatorPubkey, true}, - {beaconNodeURL, true}, + {validatorPubkey, false}, + {validatorIndex, false}, + {beaconNodeEndpoints, true}, {beaconNodeTimeout, false}, }) bindLogFlags(cmd.Flags(), &config.Log) + wrapPreRunE(cmd, func(cmd *cobra.Command, _ []string) error { + valIdxPresent := cmd.Flags().Lookup(validatorIndex.String()).Changed + if strings.TrimSpace(config.ValidatorPubkey) == "" && !valIdxPresent { + //nolint:revive // we use our own version of the errors package. + return errors.New(fmt.Sprintf("%s or %s must be specified.", validatorIndex.String(), validatorPubkey.String())) + } + + config.ValidatorIndexPresent = valIdxPresent + + return nil + }) + return cmd } @@ -84,11 +99,15 @@ func runSignPartialExit(ctx context.Context, config exitConfig) error { validator := core.PubKey(config.ValidatorPubkey) valEth2, err := validator.ToETH2() - if err != nil { + if err != nil && !config.ValidatorIndexPresent { return errors.Wrap(err, "cannot convert validator pubkey to bytes") } - ctx = log.WithCtx(ctx, z.Str("validator", validator.String())) + if config.ValidatorIndexPresent { + ctx = log.WithCtx(ctx, z.U64("validator_index", config.ValidatorIndex)) + } else { + ctx = log.WithCtx(ctx, z.Str("validator", validator.String())) + } shareIdx, err := keystore.ShareIdxForCluster(cl, *identityKey.PubKey()) if err != nil { @@ -96,11 +115,11 @@ func runSignPartialExit(ctx context.Context, config exitConfig) error { } ourShare, ok := shares[validator] - if !ok { + if !ok && !config.ValidatorIndexPresent { return errors.New("validator not present in cluster lock", z.Str("validator", validator.String())) } - eth2Cl, err := eth2Client(ctx, config.BeaconNodeURL, config.BeaconNodeTimeout) + eth2Cl, err := eth2Client(ctx, config.BeaconNodeEndpoints, config.BeaconNodeTimeout) if err != nil { return errors.Wrap(err, "cannot create eth2 client for specified beacon node") } @@ -114,26 +133,44 @@ func runSignPartialExit(ctx context.Context, config exitConfig) error { log.Info(ctx, "Signing exit message for validator") - rawValData, err := eth2Cl.Validators(ctx, ð2api.ValidatorsOpts{ - PubKeys: []eth2p0.BLSPubKey{ - valEth2, - }, + var valIndex eth2p0.ValidatorIndex + var valIndexFound bool + + valAPICallOpts := ð2api.ValidatorsOpts{ State: "head", - }) + } + + if config.ValidatorIndexPresent { + valAPICallOpts.Indices = []eth2p0.ValidatorIndex{ + eth2p0.ValidatorIndex(config.ValidatorIndex), + } + } else { + valAPICallOpts.PubKeys = []eth2p0.BLSPubKey{ + valEth2, + } + } + + rawValData, err := eth2Cl.Validators(ctx, valAPICallOpts) if err != nil { - return errors.Wrap(err, "cannot fetch validator index") + return errors.Wrap(err, "cannot fetch validator") } valData := rawValData.Data - var valIndex eth2p0.ValidatorIndex - var valIndexFound bool - for _, val := range valData { - if val.Validator.PublicKey == valEth2 { + if val.Validator.PublicKey == valEth2 || val.Index == eth2p0.ValidatorIndex(config.ValidatorIndex) { valIndex = val.Index valIndexFound = true + // re-initialize state variable after looking up all the necessary details, since user only provided a validator index + if config.ValidatorIndexPresent { + valEth2 = val.Validator.PublicKey + ourShare, ok = shares[core.PubKeyFrom48Bytes(valEth2)] + if !ok && !config.ValidatorIndexPresent { + return errors.New("validator not present in cluster lock", z.U64("validator_index", config.ValidatorIndex), z.Str("validator", validator.String())) + } + } + break } } @@ -148,7 +185,7 @@ func runSignPartialExit(ctx context.Context, config exitConfig) error { } exitBlob := obolapi.ExitBlob{ - PublicKey: config.ValidatorPubkey, + PublicKey: valEth2.String(), SignedExitMessage: exitMsg, } diff --git a/cmd/exit_sign_internal_test.go b/cmd/exit_sign_internal_test.go index bdecaf3dc..cce098317 100644 --- a/cmd/exit_sign_internal_test.go +++ b/cmd/exit_sign_internal_test.go @@ -56,11 +56,17 @@ func writeAllLockData( func Test_runSubmitPartialExit(t *testing.T) { t.Parallel() - t.Run("main flow", Test_runSubmitPartialExitFlow) + t.Run("main flow with pubkey", func(t *testing.T) { + runSubmitPartialExitFlowTest(t, false) + }) + t.Run("main flow with validator index", func(t *testing.T) { + runSubmitPartialExitFlowTest(t, true) + }) t.Run("config", Test_runSubmitPartialExit_Config) } -func Test_runSubmitPartialExitFlow(t *testing.T) { +func runSubmitPartialExitFlowTest(t *testing.T, useValIdx bool) { + t.Helper() t.Parallel() ctx := context.Background() @@ -111,7 +117,7 @@ func Test_runSubmitPartialExitFlow(t *testing.T) { require.NoError(t, beaconMock.Close()) }() - eth2Cl, err := eth2Client(ctx, beaconMock.Address(), 10*time.Second) + eth2Cl, err := eth2Client(ctx, []string{beaconMock.Address()}, 10*time.Second) require.NoError(t, err) eth2Cl.SetForkVersion([4]byte(lock.ForkVersion)) @@ -126,15 +132,21 @@ func Test_runSubmitPartialExitFlow(t *testing.T) { baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) 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, - BeaconNodeTimeout: 30 * time.Second, - PublishTimeout: 10 * time.Second, + BeaconNodeEndpoints: []string{beaconMock.Address()}, + 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, + BeaconNodeTimeout: 30 * time.Second, + PublishTimeout: 10 * time.Second, + } + + if useValIdx { + config.ValidatorIndex = 0 + config.ValidatorIndexPresent = true + } else { + config.ValidatorPubkey = lock.Validators[0].PublicKeyHex() } require.NoError(t, runSignPartialExit(ctx, config)) @@ -143,14 +155,14 @@ func Test_runSubmitPartialExitFlow(t *testing.T) { func Test_runSubmitPartialExit_Config(t *testing.T) { t.Parallel() type test struct { - name string - noIdentity bool - noLock bool - noKeystore bool - badOAPIURL bool - badBeaconNodeURL bool - badValidatorAddr bool - errData string + name string + noIdentity bool + noLock bool + noKeystore bool + badOAPIURL bool + badBeaconNodeEndpoints bool + badValidatorAddr bool + errData string } tests := []test{ @@ -175,9 +187,9 @@ func Test_runSubmitPartialExit_Config(t *testing.T) { 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 beacon node URL", + badBeaconNodeEndpoints: true, + errData: "cannot create eth2 client for specified beacon node", }, { name: "Bad validator address", @@ -244,7 +256,7 @@ func Test_runSubmitPartialExit_Config(t *testing.T) { bnURL := badStr - if !test.badBeaconNodeURL { + if !test.badBeaconNodeEndpoints { beaconMock, err := beaconmock.New() require.NoError(t, err) defer func() { @@ -266,15 +278,15 @@ func Test_runSubmitPartialExit_Config(t *testing.T) { baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0)) config := exitConfig{ - 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, - BeaconNodeTimeout: 30 * time.Second, - PublishTimeout: 10 * time.Second, + BeaconNodeEndpoints: []string{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, + BeaconNodeTimeout: 30 * time.Second, + PublishTimeout: 10 * time.Second, } require.ErrorContains(t, runSignPartialExit(ctx, config), test.errData) From e0a2f79b9296037ddc6ad195650ca0845dcf01cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gianguido=20Sor=C3=A0?= Date: Thu, 30 May 2024 11:12:36 +0200 Subject: [PATCH 2/3] cmd: add expert mode on `exit sign` If both validator public key and index are provided, don't query the beacon node for validator liveliness (i.e. the validator has been created on the beacon chain). Allows pre-signing exits for validator that are yet to be created, some third-parties need this functionality. --- cmd/exit.go | 5 +- cmd/exit_sign.go | 74 ++++++++++++++----------- cmd/exit_sign_internal_test.go | 99 +++++++++++++++++++++++++++++++--- 3 files changed, 139 insertions(+), 39 deletions(-) diff --git a/cmd/exit.go b/cmd/exit.go index 73c3de9f3..95fe72983 100644 --- a/cmd/exit.go +++ b/cmd/exit.go @@ -22,6 +22,7 @@ type exitConfig struct { ValidatorPubkey string ValidatorIndex uint64 ValidatorIndexPresent bool + ExpertMode bool PrivateKeyPath string ValidatorKeysDir string LockFilePath string @@ -124,7 +125,7 @@ func bindExitFlags(cmd *cobra.Command, config *exitConfig, flags []exitCLIFlag) case validatorKeysDir: cmd.Flags().StringVar(&config.ValidatorKeysDir, validatorKeysDir.String(), ".charon/validator_keys", maybeRequired("Path to the directory containing the validator private key share files and passwords.")) case validatorPubkey: - cmd.Flags().StringVar(&config.ValidatorPubkey, validatorPubkey.String(), "", maybeRequired("Public key of the validator to exit, must be present in the cluster lock manifest. If --validator-index is also provided, that flag takes precedence.")) + cmd.Flags().StringVar(&config.ValidatorPubkey, validatorPubkey.String(), "", maybeRequired("Public key of the validator to exit, must be present in the cluster lock manifest. If --validator-index is also provided, validator liveliness won't be checked on the beacon chain.")) case exitEpoch: cmd.Flags().Uint64Var(&config.ExitEpoch, exitEpoch.String(), 162304, maybeRequired("Exit epoch at which the validator will exit, must be the same across all the partial exits.")) case exitFromFile: @@ -136,7 +137,7 @@ func bindExitFlags(cmd *cobra.Command, config *exitConfig, flags []exitCLIFlag) case publishTimeout: cmd.Flags().DurationVar(&config.PublishTimeout, publishTimeout.String(), 30*time.Second, "Timeout for publishing a signed exit to the publish-address API.") case validatorIndex: - cmd.Flags().Uint64Var(&config.ValidatorIndex, validatorIndex.String(), 0, "Validator index of the validator to exit, the associated public key must be present in the cluster lock manifest. If --validator-pubkey is also provided, this flag takes precedence.") + cmd.Flags().Uint64Var(&config.ValidatorIndex, validatorIndex.String(), 0, "Validator index of the validator to exit, the associated public key must be present in the cluster lock manifest. If --validator-pubkey is also provided, validator liveliness won't be checked on the beacon chain.") } if f.required { diff --git a/cmd/exit_sign.go b/cmd/exit_sign.go index b7e5cf3fa..e57db461d 100644 --- a/cmd/exit_sign.go +++ b/cmd/exit_sign.go @@ -57,12 +57,15 @@ func newSubmitPartialExitCmd(runFunc func(context.Context, exitConfig) error) *c wrapPreRunE(cmd, func(cmd *cobra.Command, _ []string) error { valIdxPresent := cmd.Flags().Lookup(validatorIndex.String()).Changed + valPubkPresent := cmd.Flags().Lookup(validatorPubkey.String()).Changed + if strings.TrimSpace(config.ValidatorPubkey) == "" && !valIdxPresent { //nolint:revive // we use our own version of the errors package. return errors.New(fmt.Sprintf("%s or %s must be specified.", validatorIndex.String(), validatorPubkey.String())) } config.ValidatorIndexPresent = valIdxPresent + config.ExpertMode = valIdxPresent && valPubkPresent return nil }) @@ -99,13 +102,18 @@ func runSignPartialExit(ctx context.Context, config exitConfig) error { validator := core.PubKey(config.ValidatorPubkey) valEth2, err := validator.ToETH2() - if err != nil && !config.ValidatorIndexPresent { - return errors.Wrap(err, "cannot convert validator pubkey to bytes") + if err != nil { + if (strings.TrimSpace(config.ValidatorPubkey) != "" && !config.ValidatorIndexPresent) || config.ExpertMode { + return errors.Wrap(err, "cannot convert validator pubkey to bytes") + } } - if config.ValidatorIndexPresent { + switch { + case config.ExpertMode: + ctx = log.WithCtx(ctx, z.U64("validator_index", config.ValidatorIndex), z.Str("validator", validator.String())) + case config.ValidatorIndexPresent && !config.ExpertMode: ctx = log.WithCtx(ctx, z.U64("validator_index", config.ValidatorIndex)) - } else { + default: ctx = log.WithCtx(ctx, z.Str("validator", validator.String())) } @@ -115,17 +123,12 @@ func runSignPartialExit(ctx context.Context, config exitConfig) error { } ourShare, ok := shares[validator] - if !ok && !config.ValidatorIndexPresent { - return errors.New("validator not present in cluster lock", z.Str("validator", validator.String())) - } - - eth2Cl, err := eth2Client(ctx, config.BeaconNodeEndpoints, config.BeaconNodeTimeout) - if err != nil { - return errors.Wrap(err, "cannot create eth2 client for specified beacon node") + if !ok { + if (strings.TrimSpace(config.ValidatorPubkey) != "" && !config.ValidatorIndexPresent) || config.ExpertMode { + return errors.New("validator not present in cluster lock", z.Str("validator", validator.String())) + } } - eth2Cl.SetForkVersion([4]byte(cl.GetForkVersion())) - oAPI, err := obolapi.New(config.PublishAddress, obolapi.WithTimeout(config.PublishTimeout)) if err != nil { return errors.Wrap(err, "could not create obol api client") @@ -150,33 +153,42 @@ func runSignPartialExit(ctx context.Context, config exitConfig) error { } } - rawValData, err := eth2Cl.Validators(ctx, valAPICallOpts) + eth2Cl, err := eth2Client(ctx, config.BeaconNodeEndpoints, config.BeaconNodeTimeout) if err != nil { - return errors.Wrap(err, "cannot fetch validator") + return errors.Wrap(err, "cannot create eth2 client for specified beacon node") } - valData := rawValData.Data + eth2Cl.SetForkVersion([4]byte(cl.GetForkVersion())) + + if !config.ExpertMode { + rawValData, err := eth2Cl.Validators(ctx, valAPICallOpts) + if err != nil { + return errors.Wrap(err, "cannot fetch validator") + } - for _, val := range valData { - if val.Validator.PublicKey == valEth2 || val.Index == eth2p0.ValidatorIndex(config.ValidatorIndex) { - valIndex = val.Index - valIndexFound = true + valData := rawValData.Data - // re-initialize state variable after looking up all the necessary details, since user only provided a validator index - if config.ValidatorIndexPresent { - valEth2 = val.Validator.PublicKey - ourShare, ok = shares[core.PubKeyFrom48Bytes(valEth2)] - if !ok && !config.ValidatorIndexPresent { - return errors.New("validator not present in cluster lock", z.U64("validator_index", config.ValidatorIndex), z.Str("validator", validator.String())) + for _, val := range valData { + if val.Validator.PublicKey == valEth2 || val.Index == eth2p0.ValidatorIndex(config.ValidatorIndex) { + valIndex = val.Index + valIndexFound = true + + // re-initialize state variable after looking up all the necessary details, since user only provided a validator index + if config.ValidatorIndexPresent { + valEth2 = val.Validator.PublicKey + ourShare, ok = shares[core.PubKeyFrom48Bytes(valEth2)] + if !ok && !config.ValidatorIndexPresent { + return errors.New("validator not present in cluster lock", z.U64("validator_index", config.ValidatorIndex), z.Str("validator", validator.String())) + } } - } - break + break + } } - } - if !valIndexFound { - return errors.New("validator index not found in beacon node response") + 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)) diff --git a/cmd/exit_sign_internal_test.go b/cmd/exit_sign_internal_test.go index cce098317..dc925edb8 100644 --- a/cmd/exit_sign_internal_test.go +++ b/cmd/exit_sign_internal_test.go @@ -19,6 +19,8 @@ import ( "github.com/stretchr/testify/require" "github.com/obolnetwork/charon/app/k1util" + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/z" "github.com/obolnetwork/charon/cluster" "github.com/obolnetwork/charon/eth2util/keystore" "github.com/obolnetwork/charon/tbls" @@ -56,20 +58,82 @@ func writeAllLockData( func Test_runSubmitPartialExit(t *testing.T) { t.Parallel() + + t.Run("main flow with bad pubkey", func(t *testing.T) { + runSubmitPartialExitFlowTest( + t, + false, + false, + "test", + 0, + "cannot convert validator pubkey to bytes", + ) + }) + + t.Run("main flow with pubkey not found in cluster lock", func(t *testing.T) { + runSubmitPartialExitFlowTest( + t, + false, + false, + testutil.RandomEth2PubKey(t).String(), + 0, + "validator not present in cluster lock", + ) + }) + + t.Run("main flow with validator index set not found in cluster lock", func(t *testing.T) { + runSubmitPartialExitFlowTest( + t, + true, + false, + "", + 9999, + "validator index not found in beacon node response", + ) + }) + + t.Run("main flow with expert mode with bad pubkey", func(t *testing.T) { + runSubmitPartialExitFlowTest( + t, + true, + true, + "test", + 9999, + "cannot convert validator pubkey to bytes", + ) + }) + + t.Run("main flow with expert mode with pubkey not found in cluster lock", func(t *testing.T) { + runSubmitPartialExitFlowTest( + t, + true, + true, + testutil.RandomEth2PubKey(t).String(), + 9999, + "validator not present in cluster lock", + ) + }) + t.Run("main flow with pubkey", func(t *testing.T) { - runSubmitPartialExitFlowTest(t, false) + runSubmitPartialExitFlowTest(t, false, false, "", 0, "") }) t.Run("main flow with validator index", func(t *testing.T) { - runSubmitPartialExitFlowTest(t, true) + runSubmitPartialExitFlowTest(t, true, false, "", 0, "") }) + t.Run("main flow with expert mode", func(t *testing.T) { + runSubmitPartialExitFlowTest(t, true, true, "", 0, "") + }) + t.Run("config", Test_runSubmitPartialExit_Config) } -func runSubmitPartialExitFlowTest(t *testing.T, useValIdx bool) { +func runSubmitPartialExitFlowTest(t *testing.T, useValIdx bool, expertMode bool, valPubkey string, valIndex uint64, errString string) { t.Helper() t.Parallel() ctx := context.Background() + ctx = log.WithCtx(ctx, z.Str("test_case", t.Name())) + valAmt := 100 operatorAmt := 4 @@ -142,11 +206,34 @@ func runSubmitPartialExitFlowTest(t *testing.T, useValIdx bool) { PublishTimeout: 10 * time.Second, } - if useValIdx { - config.ValidatorIndex = 0 + index := uint64(0) + pubkey := lock.Validators[0].PublicKeyHex() + + if valIndex != 0 { + index = valIndex + } + + if valPubkey != "" { + pubkey = valPubkey + } + + if expertMode { + config.ValidatorIndex = index config.ValidatorIndexPresent = true + config.ValidatorPubkey = pubkey + config.ExpertMode = true } else { - config.ValidatorPubkey = lock.Validators[0].PublicKeyHex() + if useValIdx { + config.ValidatorIndex = index + config.ValidatorIndexPresent = true + } else { + config.ValidatorPubkey = pubkey + } + } + + if errString != "" { + require.ErrorContains(t, runSignPartialExit(ctx, config), errString) + return } require.NoError(t, runSignPartialExit(ctx, config)) From 0f3c88f46073ce6ae96016ec2d03605464ed2e5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gianguido=20Sor=C3=A0?= Date: Fri, 31 May 2024 13:32:46 +0200 Subject: [PATCH 3/3] cmd: review comment --- 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 e57db461d..c8ca8b6bc 100644 --- a/cmd/exit_sign.go +++ b/cmd/exit_sign.go @@ -61,7 +61,7 @@ func newSubmitPartialExitCmd(runFunc func(context.Context, exitConfig) error) *c if strings.TrimSpace(config.ValidatorPubkey) == "" && !valIdxPresent { //nolint:revive // we use our own version of the errors package. - return errors.New(fmt.Sprintf("%s or %s must be specified.", validatorIndex.String(), validatorPubkey.String())) + return errors.New(fmt.Sprintf("either %s or %s must be specified at least.", validatorIndex.String(), validatorPubkey.String())) } config.ValidatorIndexPresent = valIdxPresent