diff --git a/app/obolapi/api.go b/app/obolapi/api.go index 4ce60a1a8..3224a3e9a 100644 --- a/app/obolapi/api.go +++ b/app/obolapi/api.go @@ -28,7 +28,7 @@ const ( func New(urlStr string, options ...func(*Client)) (Client, error) { _, err := url.ParseRequestURI(urlStr) // check that urlStr is valid if err != nil { - return Client{}, errors.Wrap(err, "could not parse Obol API URL") + return Client{}, errors.Wrap(err, "parse Obol API URL") } // always set a default timeout, even if no options are provided @@ -63,7 +63,7 @@ func WithTimeout(timeout time.Duration) func(*Client) { func (c Client) url() *url.URL { baseURL, err := url.ParseRequestURI(c.baseURL) if err != nil { - panic(errors.Wrap(err, "could not parse Obol API URL, this should never happen")) + panic(errors.Wrap(err, "parse Obol API URL, this should never happen")) } return baseURL @@ -83,7 +83,7 @@ func (c Client) PublishLock(ctx context.Context, lock cluster.Lock) error { ctx, cancel := context.WithTimeout(ctx, c.reqTimeout) defer cancel() - err = httpPost(ctx, addr, b) + err = httpPost(ctx, addr, b, nil) if err != nil { return err } @@ -105,27 +105,58 @@ func launchpadURLPath(lock cluster.Lock) string { return fmt.Sprintf(launchpadReturnPathFmt, lock.LockHash) } -func httpPost(ctx context.Context, url *url.URL, b []byte) error { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url.String(), bytes.NewReader(b)) +func httpPost(ctx context.Context, url *url.URL, body []byte, headers map[string]string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url.String(), bytes.NewReader(body)) if err != nil { return errors.Wrap(err, "new POST request with ctx") } req.Header.Add("Content-Type", "application/json") + for key, val := range headers { + req.Header.Set(key, val) + } res, err := new(http.Client).Do(req) if err != nil { - return errors.Wrap(err, "failed to call POST endpoint") + return errors.Wrap(err, "call POST endpoint") } defer res.Body.Close() - data, err := io.ReadAll(res.Body) + if res.StatusCode/100 != 2 { + data, err := io.ReadAll(res.Body) + if err != nil { + return errors.Wrap(err, "read POST response", z.Int("status", res.StatusCode)) + } + + return errors.New("http POST failed", z.Int("status", res.StatusCode), z.Str("body", string(data))) + } + + return nil +} + +func httpGet(ctx context.Context, url *url.URL, headers map[string]string) (io.ReadCloser, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil) if err != nil { - return errors.Wrap(err, "failed to read POST response") + return nil, errors.Wrap(err, "new GET request with ctx") + } + req.Header.Add("Content-Type", "application/json") + + for key, val := range headers { + req.Header.Set(key, val) + } + + res, err := new(http.Client).Do(req) + if err != nil { + return nil, errors.Wrap(err, "call GET endpoint") } if res.StatusCode/100 != 2 { - return errors.New("post failed", z.Int("status", res.StatusCode), z.Str("body", string(data))) + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, errors.Wrap(err, "read POST response", z.Int("status", res.StatusCode)) + } + + return nil, errors.New("http GET failed", z.Int("status", res.StatusCode), z.Str("body", string(data))) } - return nil + return res.Body, nil } diff --git a/app/obolapi/api_internal_test.go b/app/obolapi/api_internal_test.go index 32bf7800a..babcc9a84 100644 --- a/app/obolapi/api_internal_test.go +++ b/app/obolapi/api_internal_test.go @@ -3,6 +3,11 @@ package obolapi import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" "testing" "time" @@ -21,3 +26,153 @@ func TestWithTimeout(t *testing.T) { require.NoError(t, err) require.Equal(t, timeout, oapi.reqTimeout) } + +func TestHttpPost(t *testing.T) { + tests := []struct { + name string + body []byte + headers map[string]string + server *httptest.Server + endpoint string + expectedError string + }{ + { + name: "default scenario", + body: nil, + headers: nil, + endpoint: "/post-request", + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, r.URL.Path, "/post-request") + require.Equal(t, r.Method, http.MethodPost) + require.Equal(t, r.Header.Get("Content-Type"), "application/json") + w.WriteHeader(http.StatusOK) + })), + expectedError: "", + }, + { + name: "default scenario with body and headers", + body: []byte(`{"test_body_key": "test_body_value"}`), + headers: map[string]string{"test_header_key": "test_header_value"}, + endpoint: "/post-request", + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, r.URL.Path, "/post-request") + require.Equal(t, r.Method, http.MethodPost) + require.Equal(t, r.Header.Get("Content-Type"), "application/json") + require.Equal(t, r.Header.Get("test_header_key"), "test_header_value") //nolint:canonicalheader + + data, err := io.ReadAll(r.Body) + require.NoError(t, err) + defer r.Body.Close() + require.Equal(t, string(data), `{"test_body_key": "test_body_value"}`) + + w.WriteHeader(http.StatusOK) + _, err = w.Write([]byte(`"OK"`)) + require.NoError(t, err) + })), + expectedError: "", + }, + { + name: "status code not 2XX", + body: nil, + headers: nil, + endpoint: "/post-request", + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, r.URL.Path, "/post-request") + require.Equal(t, r.Method, http.MethodPost) + require.Equal(t, r.Header.Get("Content-Type"), "application/json") + + w.WriteHeader(http.StatusBadRequest) + _, err := w.Write([]byte(`"Bad Request response"`)) + require.NoError(t, err) + })), + expectedError: "POST failed", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + testServerURL, err := url.ParseRequestURI(test.server.URL) + require.NoError(t, err) + err = httpPost(context.Background(), testServerURL.JoinPath(test.endpoint), test.body, test.headers) + if test.expectedError != "" { + require.Error(t, err) + require.ErrorContains(t, err, test.expectedError) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestHttpGet(t *testing.T) { + tests := []struct { + name string + headers map[string]string + server *httptest.Server + endpoint string + expectedResp []byte + expectedError string + }{ + { + name: "default scenario", + headers: nil, + endpoint: "/get-request", + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, r.URL.Path, "/get-request") + require.Equal(t, r.Method, http.MethodGet) + require.Equal(t, r.Header.Get("Content-Type"), "application/json") + w.WriteHeader(http.StatusOK) + })), + expectedError: "", + }, + { + name: "default scenario with headers", + headers: map[string]string{"test_header_key": "test_header_value"}, + endpoint: "/get-request", + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, r.URL.Path, "/get-request") + require.Equal(t, r.Method, http.MethodGet) + require.Equal(t, r.Header.Get("Content-Type"), "application/json") + require.Equal(t, r.Header.Get("test_header_key"), "test_header_value") //nolint:canonicalheader + + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`"OK"`)) + require.NoError(t, err) + })), + expectedResp: []byte(`"OK"`), + expectedError: "", + }, + { + name: "status code not 2XX", + headers: nil, + endpoint: "/get-request", + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, r.URL.Path, "/get-request") + require.Equal(t, r.Method, http.MethodGet) + require.Equal(t, r.Header.Get("Content-Type"), "application/json") + + w.WriteHeader(http.StatusBadRequest) + _, err := w.Write([]byte(`"Bad Request response"`)) + require.NoError(t, err) + })), + expectedResp: []byte(`"Bad Request response"`), + expectedError: "GET failed", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + testServerURL, err := url.ParseRequestURI(test.server.URL) + require.NoError(t, err) + respBody, err := httpGet(context.Background(), testServerURL.JoinPath(test.endpoint), test.headers) + if test.expectedError != "" { + require.Error(t, err) + require.ErrorContains(t, err, test.expectedError) + } else { + require.NoError(t, err) + defer respBody.Close() + resp, err := io.ReadAll(respBody) + require.NoError(t, err) + require.Equal(t, string(resp), string(test.expectedResp)) + } + }) + } +} diff --git a/app/obolapi/exit.go b/app/obolapi/exit.go index 6a41aa60d..dae3653d9 100644 --- a/app/obolapi/exit.go +++ b/app/obolapi/exit.go @@ -3,12 +3,10 @@ package obolapi import ( - "bytes" "context" "encoding/hex" "encoding/json" "fmt" - "net/http" "net/url" "sort" "strconv" @@ -71,7 +69,7 @@ func (c Client) PostPartialExits(ctx context.Context, lockHash []byte, shareInde u, err := url.ParseRequestURI(c.baseURL) if err != nil { - return errors.Wrap(err, "bad obol api url") + return errors.Wrap(err, "bad Obol API url") } u.Path = path @@ -107,24 +105,9 @@ func (c Client) PostPartialExits(ctx context.Context, lockHash []byte, shareInde ctx, cancel := context.WithTimeout(ctx, c.reqTimeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(data)) + err = httpPost(ctx, u, data, nil) if err != nil { - return errors.Wrap(err, "http new post request") - } - - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return errors.Wrap(err, "http post error") - } - - defer func() { - _ = resp.Body.Close() - }() - - if resp.StatusCode != http.StatusCreated { - return errors.New("http error", z.Int("status_code", resp.StatusCode)) + return errors.Wrap(err, "http Obol API POST request") } return nil @@ -142,7 +125,7 @@ func (c Client) GetFullExit(ctx context.Context, valPubkey string, lockHash []by u, err := url.ParseRequestURI(c.baseURL) if err != nil { - return ExitBlob{}, errors.Wrap(err, "bad obol api url") + return ExitBlob{}, errors.Wrap(err, "bad Obol API url") } u.Path = path @@ -150,11 +133,6 @@ func (c Client) GetFullExit(ctx context.Context, valPubkey string, lockHash []by ctx, cancel := context.WithTimeout(ctx, c.reqTimeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - if err != nil { - return ExitBlob{}, errors.Wrap(err, "http new get request") - } - exitAuthData := FullExitAuthBlob{ LockHash: lockHash, ValidatorPubkey: valPubkeyBytes, @@ -172,28 +150,15 @@ func (c Client) GetFullExit(ctx context.Context, valPubkey string, lockHash []by return ExitBlob{}, errors.Wrap(err, "k1 sign") } - req.Header.Set("Authorization", bearerString(lockHashSignature)) - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) + respBody, err := httpGet(ctx, u, map[string]string{"Authorization": bearerString(lockHashSignature)}) if err != nil { - return ExitBlob{}, errors.Wrap(err, "http get error") - } - - if resp.StatusCode != http.StatusOK { - if resp.StatusCode == http.StatusNotFound { - return ExitBlob{}, ErrNoExit - } - - return ExitBlob{}, errors.New("http error", z.Int("status_code", resp.StatusCode)) + return ExitBlob{}, errors.Wrap(err, "http Obol API GET request") } - defer func() { - _ = resp.Body.Close() - }() + defer respBody.Close() var er FullExitResponse - if err := json.NewDecoder(resp.Body).Decode(&er); err != nil { + if err := json.NewDecoder(respBody).Decode(&er); err != nil { return ExitBlob{}, errors.Wrap(err, "json unmarshal error") } diff --git a/cmd/cmd.go b/cmd/cmd.go index 2b2d15ab5..1eda3c9cd 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -59,7 +59,7 @@ func New() *cobra.Command { ), newExitCmd( newListActiveValidatorsCmd(runListActiveValidatorsCmd), - newSubmitPartialExitCmd(runSignPartialExit), + newSignPartialExitCmd(runSignPartialExit), newBcastFullExitCmd(runBcastFullExit), newFetchExitCmd(runFetchExit), ), diff --git a/cmd/exit.go b/cmd/exit.go index 2f933aa4c..d14d19d4c 100644 --- a/cmd/exit.go +++ b/cmd/exit.go @@ -192,7 +192,7 @@ func eth2Client(ctx context.Context, u []string, timeout time.Duration, forkVers } if _, err = cl.NodeVersion(ctx, ð2api.NodeVersionOpts{}); err != nil { - return nil, errors.Wrap(err, "can't connect to beacon node") + return nil, errors.Wrap(err, "connect to beacon node") } return cl, nil diff --git a/cmd/exit_broadcast.go b/cmd/exit_broadcast.go index b32079c00..101a81a5b 100644 --- a/cmd/exit_broadcast.go +++ b/cmd/exit_broadcast.go @@ -113,17 +113,17 @@ func runBcastFullExit(ctx context.Context, config exitConfig) error { identityKey, err := k1util.Load(config.PrivateKeyPath) if err != nil { - return errors.Wrap(err, "could not load identity key") + return errors.Wrap(err, "load identity key") } cl, err := loadClusterManifest("", config.LockFilePath) if err != nil { - return errors.Wrap(err, "could not load cluster-lock.json") + return errors.Wrap(err, "load cluster lock", z.Str("lock_file_path", config.LockFilePath)) } eth2Cl, err := eth2Client(ctx, config.BeaconNodeEndpoints, config.BeaconNodeTimeout, [4]byte(cl.GetForkVersion())) if err != nil { - return errors.Wrap(err, "cannot create eth2 client for specified beacon node") + return errors.Wrap(err, "create eth2 client for specified beacon node(s)", z.Any("beacon_nodes_endpoints", config.BeaconNodeEndpoints)) } fullExits := make(map[core.PubKey]eth2p0.SignedVoluntaryExit) @@ -131,15 +131,16 @@ func runBcastFullExit(ctx context.Context, config exitConfig) error { if config.ExitFromFileDir != "" { entries, err := os.ReadDir(config.ExitFromFileDir) if err != nil { - return errors.Wrap(err, "could not read exits directory") + return errors.Wrap(err, "read exits directory", z.Str("exit_file_dir", config.ExitFromFileDir)) } for _, entry := range entries { if !strings.HasPrefix(entry.Name(), "exit-") { continue } - exit, err := fetchFullExit(ctx, filepath.Join(config.ExitFromFileDir, entry.Name()), config, cl, identityKey, "") + valCtx := log.WithCtx(ctx, z.Str("validator_exit_file", entry.Name())) + exit, err := fetchFullExit(valCtx, filepath.Join(config.ExitFromFileDir, entry.Name()), config, cl, identityKey, "") if err != nil { - return errors.Wrap(err, "fetch full exit for all from dir") + return err } validatorPubKey, err := validatorPubKeyFromFileName(entry.Name()) @@ -153,11 +154,10 @@ func runBcastFullExit(ctx context.Context, config exitConfig) error { for _, validator := range cl.GetValidators() { validatorPubKeyHex := fmt.Sprintf("0x%x", validator.GetPublicKey()) - valCtx := log.WithCtx(ctx, z.Str("validator", validatorPubKeyHex)) - + valCtx := log.WithCtx(ctx, z.Str("validator_public_key", validatorPubKeyHex)) exit, err := fetchFullExit(valCtx, "", config, cl, identityKey, validatorPubKeyHex) if err != nil { - return errors.Wrap(err, "fetch full exit for all from public key") + return errors.Wrap(err, "fetch full exit for all validators from public key") } validatorPubKey, err := core.PubKeyFromBytes(validator.GetPublicKey()) if err != nil { @@ -167,9 +167,10 @@ func runBcastFullExit(ctx context.Context, config exitConfig) error { } } } else { - exit, err := fetchFullExit(ctx, strings.TrimSpace(config.ExitFromFilePath), config, cl, identityKey, config.ValidatorPubkey) + valCtx := log.WithCtx(ctx, z.Str("validator_public_key", config.ValidatorPubkey), z.Str("validator_exit_file", config.ExitFromFilePath)) + exit, err := fetchFullExit(valCtx, strings.TrimSpace(config.ExitFromFilePath), config, cl, identityKey, config.ValidatorPubkey) if err != nil { - return errors.Wrap(err, "fetch full exit for public key") + return errors.Wrap(err, "fetch full exit for validator", z.Str("validator_public_key", config.ValidatorPubkey), z.Str("validator_exit_file", config.ExitFromFilePath)) } var validatorPubKey core.PubKey if len(strings.TrimSpace(config.ExitFromFilePath)) != 0 { @@ -192,11 +193,11 @@ func validatorPubKeyFromFileName(fileName string) (core.PubKey, error) { validatorPubKeyHex := strings.TrimPrefix(strings.TrimSuffix(fileNameChecked, fileExtension), "exit-0x") validatorPubKeyBytes, err := hex.DecodeString(validatorPubKeyHex) if err != nil { - return "", errors.Wrap(err, "cannot decode public key hex from file name") + return "", errors.Wrap(err, "decode public key hex from file name", z.Str("public_key", validatorPubKeyHex)) } validatorPubKey, err := core.PubKeyFromBytes(validatorPubKeyBytes) if err != nil { - return "", errors.Wrap(err, "cannot decode core public key from hex") + return "", errors.Wrap(err, "decode core public key from hex") } return validatorPubKey, nil @@ -207,7 +208,7 @@ func fetchFullExit(ctx context.Context, exitFilePath string, config exitConfig, var err error if len(exitFilePath) != 0 { - log.Info(ctx, "Retrieving full exit message from path", z.Str("path", exitFilePath)) + log.Info(ctx, "Retrieving full exit message from path") fullExit, err = exitFromPath(exitFilePath) } else { log.Info(ctx, "Retrieving full exit message from publish address") @@ -223,18 +224,18 @@ func broadcastExitsToBeacon(ctx context.Context, eth2Cl eth2wrap.Client, exits m rawPkBytes, err := validator.Bytes() if err != nil { - return errors.Wrap(err, "could not serialize validator key bytes") + return errors.Wrap(err, "serialize validator key bytes", z.Str("validator", validator.String())) } pubkey, err := tblsconv.PubkeyFromBytes(rawPkBytes) if err != nil { - return errors.Wrap(err, "could not convert validator key bytes to BLS public key") + return errors.Wrap(err, "convert validator key bytes to BLS public key") } // parse signature signature, err := tblsconv.SignatureFromBytes(fullExit.Signature[:]) if err != nil { - return errors.Wrap(err, "could not parse BLS signature from bytes") + return errors.Wrap(err, "parse BLS signature from bytes", z.Str("exit_signature", fullExit.Signature.String())) } exitRoot, err := sigDataForExit( @@ -244,7 +245,7 @@ func broadcastExitsToBeacon(ctx context.Context, eth2Cl eth2wrap.Client, exits m fullExit.Message.Epoch, ) if err != nil { - return errors.Wrap(err, "cannot calculate hash tree root for exit message for verification") + return errors.Wrap(err, "calculate hash tree root for exit message for verification") } if err := tbls.Verify(pubkey, exitRoot[:], signature); err != nil { @@ -255,8 +256,9 @@ func broadcastExitsToBeacon(ctx context.Context, eth2Cl eth2wrap.Client, exits m for validator, fullExit := range exits { valCtx := log.WithCtx(ctx, z.Str("validator", validator.String())) if err := eth2Cl.SubmitVoluntaryExit(valCtx, &fullExit); err != nil { - return errors.Wrap(err, "could not submit voluntary exit") + return errors.Wrap(err, "submit voluntary exit") } + log.Info(valCtx, "Successfully submitted voluntary exit for validator") } return nil @@ -266,17 +268,17 @@ func broadcastExitsToBeacon(ctx context.Context, eth2Cl eth2wrap.Client, exits m func exitFromObolAPI(ctx context.Context, validatorPubkey, publishAddr string, publishTimeout time.Duration, cl *manifestpb.Cluster, identityKey *k1.PrivateKey) (eth2p0.SignedVoluntaryExit, error) { oAPI, err := obolapi.New(publishAddr, obolapi.WithTimeout(publishTimeout)) if err != nil { - return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "could not create obol api client") + return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "create Obol API client", z.Str("publish_address", publishAddr)) } shareIdx, err := keystore.ShareIdxForCluster(cl, *identityKey.PubKey()) if err != nil { - return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "could not determine operator index from cluster lock for supplied identity key") + return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "determine operator index from cluster lock for supplied identity key") } 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 eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "load full exit data from Obol API", z.Str("publish_address", publishAddr)) } return fullExit.SignedExitMessage, nil @@ -286,7 +288,7 @@ func exitFromObolAPI(ctx context.Context, validatorPubkey, publishAddr string, p 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") + return eth2p0.SignedVoluntaryExit{}, errors.Wrap(err, "open signed exit message from path") } var exit eth2p0.SignedVoluntaryExit diff --git a/cmd/exit_broadcast_internal_test.go b/cmd/exit_broadcast_internal_test.go index 88bf2ba5b..83990d5d2 100644 --- a/cmd/exit_broadcast_internal_test.go +++ b/cmd/exit_broadcast_internal_test.go @@ -209,22 +209,22 @@ func Test_runBcastFullExitCmd_Config(t *testing.T) { { name: "No identity key", noIdentity: true, - errData: "could not load identity key", + errData: "load identity key", }, { name: "No lock", noLock: true, - errData: "could not load cluster-lock.json", + errData: "load cluster lock", }, { name: "Bad Obol API URL", badOAPIURL: true, - errData: "could not create obol api client", + errData: "create Obol API client", }, { name: "Bad beacon node URLs", badBeaconNodeEndpoints: true, - errData: "cannot create eth2 client for specified beacon node", + errData: "create eth2 client for specified beacon node", }, { name: "Bad validator address", @@ -337,3 +337,92 @@ func Test_runBcastFullExitCmd_Config(t *testing.T) { }) } } + +func TestExitBroadcastCLI(t *testing.T) { + tests := []struct { + name string + expectedErr string + + flags []string + }{ + { + name: "check flags", + expectedErr: "load identity key: read private key from disk: open test: no such file or directory", + flags: []string{ + "--publish-address=test", + "--private-key-file=test", + "--lock-file=test", + "--validator-keys-dir=test", + "--exit-epoch=1", + "--validator-public-key=test", // single exit + "--beacon-node-endpoints=test1,test2", + "--exit-from-file=test", // single exit + "--beacon-node-timeout=1ms", + "--publish-timeout=1ms", + "--all=false", // single exit + "--testnet-name=test", + "--testnet-fork-version=test", + "--testnet-chain-id=1", + "--testnet-genesis-timestamp=1", + "--testnet-capella-hard-fork=test", + }, + }, + { + name: "check flags all", + expectedErr: "load identity key: read private key from disk: open test: no such file or directory", + flags: []string{ + "--publish-address=test", + "--private-key-file=test", + "--lock-file=test", + "--validator-keys-dir=test", // exit all + "--exit-epoch=1", + "--beacon-node-endpoints=test1,test2", + "--exit-from-dir=test", + "--beacon-node-timeout=1ms", + "--publish-timeout=1ms", + "--all", // exit all + "--testnet-name=test", + "--testnet-fork-version=test", + "--testnet-chain-id=1", + "--testnet-genesis-timestamp=1", + "--testnet-capella-hard-fork=test", + }, + }, + { + name: "check flags all", + expectedErr: "load identity key: read private key from disk: open test: no such file or directory", + flags: []string{ + "--publish-address=test", + "--private-key-file=test", + "--lock-file=test", + "--validator-keys-dir=test", // exit all + "--exit-epoch=1", + "--beacon-node-endpoints=test1,test2", + "--exit-from-dir=test", + "--beacon-node-timeout=1ms", + "--publish-timeout=1ms", + "--all", // exit all + "--testnet-name=test", + "--testnet-fork-version=test", + "--testnet-chain-id=1", + "--testnet-genesis-timestamp=1", + "--testnet-capella-hard-fork=test", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := newExitCmd(newBcastFullExitCmd(runBcastFullExit)) + cmd.SetArgs(append([]string{"broadcast"}, test.flags...)) + + err := cmd.Execute() + if test.expectedErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, test.expectedErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cmd/exit_fetch.go b/cmd/exit_fetch.go index 7dd7296ec..9a27a3974 100644 --- a/cmd/exit_fetch.go +++ b/cmd/exit_fetch.go @@ -86,36 +86,36 @@ func runFetchExit(ctx context.Context, config exitConfig) error { } if _, err := os.Stat(config.FetchedExitPath); err != nil { - return errors.Wrap(err, "store exit path") + return errors.Wrap(err, "store exit path", z.Str("fetched_exit_path", config.FetchedExitPath)) } 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") + return errors.Wrap(err, "write to destination directory", z.Str("fetched_exit_path", config.FetchedExitPath)) } if err := os.Remove(writeTestFile); err != nil { - return errors.Wrap(err, "can't delete write test file") + return errors.Wrap(err, "delete write test file", z.Str("test_file_path", writeTestFile)) } identityKey, err := k1util.Load(config.PrivateKeyPath) if err != nil { - return errors.Wrap(err, "could not load identity key") + return errors.Wrap(err, "load identity key", z.Str("private_key_path", config.PrivateKeyPath)) } cl, err := loadClusterManifest("", config.LockFilePath) if err != nil { - return errors.Wrap(err, "could not load cluster-lock.json") + return errors.Wrap(err, "load cluster lock", z.Str("lock_file_path", config.LockFilePath)) } oAPI, err := obolapi.New(config.PublishAddress, obolapi.WithTimeout(config.PublishTimeout)) if err != nil { - return errors.Wrap(err, "could not create obol api client") + return errors.Wrap(err, "create Obol API client", z.Str("publish_address", config.PublishAddress)) } shareIdx, err := keystore.ShareIdxForCluster(cl, *identityKey.PubKey()) if err != nil { - return errors.Wrap(err, "could not determine operator index from cluster lock for supplied identity key") + return errors.Wrap(err, "determine operator index from cluster lock for supplied identity key") } if config.All { @@ -128,7 +128,7 @@ func runFetchExit(ctx context.Context, config exitConfig) error { fullExit, err := oAPI.GetFullExit(valCtx, validatorPubKeyHex, cl.GetInitialMutationHash(), shareIdx, identityKey) if err != nil { - return errors.Wrap(err, "could not load full exit data from Obol API") + return errors.Wrap(err, "load full exit data from Obol API", z.Str("validator_public_key", validatorPubKeyHex)) } err = writeExitToFile(valCtx, validatorPubKeyHex, config.FetchedExitPath, fullExit) @@ -139,7 +139,7 @@ func runFetchExit(ctx context.Context, config exitConfig) error { } else { validator := core.PubKey(config.ValidatorPubkey) if _, err := validator.Bytes(); err != nil { - return errors.Wrap(err, "cannot convert validator pubkey to bytes") + return errors.Wrap(err, "convert validator pubkey to bytes", z.Str("validator_public_key", config.ValidatorPubkey)) } ctx = log.WithCtx(ctx, z.Str("validator", validator.String())) @@ -148,7 +148,7 @@ func runFetchExit(ctx context.Context, config exitConfig) error { 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 errors.Wrap(err, "load full exit data from Obol API", z.Str("validator_public_key", config.ValidatorPubkey)) } err = writeExitToFile(ctx, config.ValidatorPubkey, config.FetchedExitPath, fullExit) diff --git a/cmd/exit_fetch_internal_test.go b/cmd/exit_fetch_internal_test.go index 8c690adea..0496eb1da 100644 --- a/cmd/exit_fetch_internal_test.go +++ b/cmd/exit_fetch_internal_test.go @@ -169,3 +169,80 @@ func Test_runFetchExitBadOutDir(t *testing.T) { require.ErrorContains(t, runFetchExit(context.Background(), config), "permission denied") } + +func TestExitFetchCLI(t *testing.T) { + tests := []struct { + name string + expectedErr string + flags []string + }{ + { + name: "check flags", + expectedErr: "store exit path: stat 1: no such file or directory", + flags: []string{ + "--publish-address=test", + "--private-key-file=test", + "--lock-file=test", + "--validator-public-key=test", + "--fetched-exit-path=1", + "--publish-timeout=1ms", + "--all=false", + "--testnet-name=test", + "--testnet-fork-version=test", + "--testnet-chain-id=1", + "--testnet-genesis-timestamp=1", + "--testnet-capella-hard-fork=test", + }, + }, + { + name: "no validator public key and not all", + expectedErr: "validator-public-key must be specified when exiting single validator.", + flags: []string{ + "--publish-address=test", + "--private-key-file=test", + "--lock-file=test", + "--fetched-exit-path=1", + "--publish-timeout=1ms", + "--all=false", + "--testnet-name=test", + "--testnet-fork-version=test", + "--testnet-chain-id=1", + "--testnet-genesis-timestamp=1", + "--testnet-capella-hard-fork=test", + }, + }, + { + name: "validator public key and all", + expectedErr: "validator-public-key should not be specified when all is, as it is obsolete and misleading.", + flags: []string{ + "--publish-address=test", + "--private-key-file=test", + "--lock-file=test", + "--validator-public-key=test", + "--fetched-exit-path=1", + "--publish-timeout=1ms", + "--all=true", + "--testnet-name=test", + "--testnet-fork-version=test", + "--testnet-chain-id=1", + "--testnet-genesis-timestamp=1", + "--testnet-capella-hard-fork=test", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := newExitCmd(newFetchExitCmd(runFetchExit)) + cmd.SetArgs(append([]string{"fetch"}, test.flags...)) + + err := cmd.Execute() + if test.expectedErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, test.expectedErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cmd/exit_list.go b/cmd/exit_list.go index ef263f7ec..e60959b0d 100644 --- a/cmd/exit_list.go +++ b/cmd/exit_list.go @@ -75,7 +75,7 @@ func runListActiveValidatorsCmd(ctx context.Context, config exitConfig) error { continue } - log.Info(ctx, "Validator", z.Str("pubkey", validator)) + log.Info(ctx, "Validator", z.Str("validator_public_key", validator)) } return nil @@ -84,12 +84,12 @@ 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-lock.json") + return nil, errors.Wrap(err, "load cluster lock", z.Str("lock_file_path", config.LockFilePath)) } eth2Cl, err := eth2Client(ctx, config.BeaconNodeEndpoints, config.BeaconNodeTimeout, [4]byte{}) // fine to avoid initializing a fork version, we're just querying the BN if err != nil { - return nil, errors.Wrap(err, "cannot create eth2 client for specified beacon node") + return nil, errors.Wrap(err, "create eth2 client for specified beacon node(s)", z.Any("beacon_nodes_endpoints", config.BeaconNodeEndpoints)) } var allVals []eth2p0.BLSPubKey @@ -103,7 +103,7 @@ func listActiveVals(ctx context.Context, config exitConfig) ([]string, error) { State: "head", }) if err != nil { - return nil, errors.Wrap(err, "cannot fetch validator list") + return nil, errors.Wrap(err, "fetch validator list from beacon", z.Str("beacon_address", eth2Cl.Address()), z.Any("validators", allVals)) } var ret []string diff --git a/cmd/exit_list_internal_test.go b/cmd/exit_list_internal_test.go index 05450b9b6..974e1f2c2 100644 --- a/cmd/exit_list_internal_test.go +++ b/cmd/exit_list_internal_test.go @@ -197,3 +197,41 @@ func Test_listActiveVals(t *testing.T) { require.Len(t, vals, len(lock.Validators)/2) }) } + +func TestExitListCLI(t *testing.T) { + tests := []struct { + name string + expectedErr string + flags []string + }{ + { + name: "check flags", + expectedErr: "load cluster lock: load cluster manifest from disk: load dag from disk: no file found", + flags: []string{ + "--lock-file=test", + "--beacon-node-endpoints=test1,test2", + "--beacon-node-timeout=1ms", + "--testnet-name=test", + "--testnet-fork-version=test", + "--testnet-chain-id=1", + "--testnet-genesis-timestamp=1", + "--testnet-capella-hard-fork=test", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := newExitCmd(newListActiveValidatorsCmd(runListActiveValidatorsCmd)) + cmd.SetArgs(append([]string{"active-validator-list"}, test.flags...)) + + err := cmd.Execute() + if test.expectedErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, test.expectedErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cmd/exit_sign.go b/cmd/exit_sign.go index 9aac498f5..342d6d7cc 100644 --- a/cmd/exit_sign.go +++ b/cmd/exit_sign.go @@ -23,7 +23,7 @@ import ( "github.com/obolnetwork/charon/eth2util/keystore" ) -func newSubmitPartialExitCmd(runFunc func(context.Context, exitConfig) error) *cobra.Command { +func newSignPartialExitCmd(runFunc func(context.Context, exitConfig) error) *cobra.Command { var config exitConfig cmd := &cobra.Command{ @@ -70,7 +70,7 @@ func newSubmitPartialExitCmd(runFunc func(context.Context, exitConfig) error) *c if !valPubkPresent && !valIdxPresent && !config.All { //nolint:revive // we use our own version of the errors package. - return errors.New(fmt.Sprintf("either %s or %s must be specified at least.", validatorIndex.String(), validatorPubkey.String())) + return errors.New(fmt.Sprintf("either %s or %s must be specified at least when exiting single validator.", validatorIndex.String(), validatorPubkey.String())) } if config.All && (valIdxPresent || valPubkPresent) { @@ -96,42 +96,42 @@ 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") + return errors.Wrap(err, "load identity key", z.Str("private_key_path", config.PrivateKeyPath)) } cl, err := loadClusterManifest("", config.LockFilePath) if err != nil { - return errors.Wrap(err, "could not load cluster-lock.json") + return errors.Wrap(err, "load cluster lock", z.Str("lock_file_path", config.LockFilePath)) } rawValKeys, err := keystore.LoadFilesUnordered(config.ValidatorKeysDir) if err != nil { - return errors.Wrap(err, "could not load keystore, check if path exists", z.Str("path", config.ValidatorKeysDir)) + return errors.Wrap(err, "load keystore, check if path exists", z.Str("validator_keys_dir", config.ValidatorKeysDir)) } valKeys, err := rawValKeys.SequencedKeys() if err != nil { - return errors.Wrap(err, "could not load keystore") + return errors.Wrap(err, "load keystore") } shares, err := keystore.KeysharesToValidatorPubkey(cl, valKeys) if err != nil { - return errors.Wrap(err, "could not match local validator key shares with their counterparty in cluster lock") + return errors.Wrap(err, "match local validator key shares with their counterparty in cluster lock") } shareIdx, err := keystore.ShareIdxForCluster(cl, *identityKey.PubKey()) if err != nil { - return errors.Wrap(err, "could not determine operator index from cluster lock for supplied identity key") + return errors.Wrap(err, "determine operator index from cluster lock for supplied identity key") } oAPI, err := obolapi.New(config.PublishAddress, obolapi.WithTimeout(config.PublishTimeout)) if err != nil { - return errors.Wrap(err, "could not create obol api client") + return errors.Wrap(err, "create Obol API client", z.Str("publish_address", config.PublishAddress)) } eth2Cl, err := eth2Client(ctx, config.BeaconNodeEndpoints, config.BeaconNodeTimeout, [4]byte(cl.GetForkVersion())) if err != nil { - return errors.Wrap(err, "cannot create eth2 client for specified beacon node") + return errors.Wrap(err, "create eth2 client for specified beacon node(s)", z.Any("beacon_nodes_endpoints", config.BeaconNodeEndpoints)) } if config.ValidatorIndexPresent { @@ -149,17 +149,17 @@ func runSignPartialExit(ctx context.Context, config exitConfig) error { if config.All { exitBlobs, err = signAllValidatorsExits(ctx, config, eth2Cl, shares) if err != nil { - return errors.Wrap(err, "could not sign exits for all validators") + return errors.Wrap(err, "sign exits for all validators") } } else { exitBlobs, err = signSingleValidatorExit(ctx, config, eth2Cl, shares) if err != nil { - return errors.Wrap(err, "could not sign exit for validator") + return errors.Wrap(err, "sign exit for validator") } } if err := oAPI.PostPartialExits(ctx, cl.GetInitialMutationHash(), shareIdx, identityKey, exitBlobs...); err != nil { - return errors.Wrap(err, "could not POST partial exit message to Obol API") + return errors.Wrap(err, "http POST partial exit message to Obol API") } return nil @@ -168,7 +168,7 @@ func runSignPartialExit(ctx context.Context, config exitConfig) error { func signSingleValidatorExit(ctx context.Context, config exitConfig, eth2Cl eth2wrap.Client, shares keystore.ValidatorShares) ([]obolapi.ExitBlob, error) { valEth2, err := fetchValidatorBLSPubKey(ctx, config, eth2Cl) if err != nil { - return nil, errors.Wrap(err, "cannot fetch validator public key") + return nil, errors.Wrap(err, "fetch validator public key") } validator := core.PubKeyFrom48Bytes(valEth2) @@ -180,14 +180,14 @@ func signSingleValidatorExit(ctx context.Context, config exitConfig, eth2Cl eth2 valIndex, err := fetchValidatorIndex(ctx, config, eth2Cl) if err != nil { - return nil, errors.Wrap(err, "cannot fetch validator index") + return nil, errors.Wrap(err, "fetch validator index") } - log.Info(ctx, "Signing exit message for validator") + log.Info(ctx, "Signing partial exit message for validator", z.Str("validator_public_key", valEth2.String()), z.U64("validator_index", uint64(valIndex))) exitMsg, err := signExit(ctx, eth2Cl, valIndex, ourShare.Share, eth2p0.Epoch(config.ExitEpoch)) if err != nil { - return nil, errors.Wrap(err, "cannot sign partial exit message") + return nil, errors.Wrap(err, "sign partial exit message", z.Str("validator_public_key", valEth2.String()), z.U64("validator_index", uint64(valIndex)), z.Int("exit_epoch", int(config.ExitEpoch))) } return []obolapi.ExitBlob{ @@ -203,43 +203,43 @@ func signAllValidatorsExits(ctx context.Context, config exitConfig, eth2Cl eth2w for pk := range shares { eth2PK, err := pk.ToETH2() if err != nil { - return nil, errors.Wrap(err, "cannot convert core pubkey to eth2 pubkey") + return nil, errors.Wrap(err, "convert core pubkey to eth2 pubkey", z.Str("pub_key", eth2PK.String())) } valsEth2 = append(valsEth2, eth2PK) } rawValData, err := queryBeaconForValidator(ctx, eth2Cl, valsEth2, nil) if err != nil { - return nil, errors.Wrap(err, "fetch validator indices from beacon") + return nil, errors.Wrap(err, "fetch all validators indices from beacon") } for _, val := range rawValData.Data { share, ok := shares[core.PubKeyFrom48Bytes(val.Validator.PublicKey)] if !ok { - //nolint:revive // we use our own version of the errors package. - return nil, errors.New(fmt.Sprintf("validator public key %s not found in cluster lock", val.Validator.PublicKey)) + return nil, errors.New("validator public key not found in cluster lock", z.Str("validator_public_key", val.Validator.PublicKey.String())) } share.Index = int(val.Index) shares[core.PubKeyFrom48Bytes(val.Validator.PublicKey)] = share } - log.Info(ctx, "Signing exit message for all validators") + log.Info(ctx, "Signing partial exit message for all active validators") var exitBlobs []obolapi.ExitBlob for pk, share := range shares { exitMsg, err := signExit(ctx, eth2Cl, eth2p0.ValidatorIndex(share.Index), share.Share, eth2p0.Epoch(config.ExitEpoch)) if err != nil { - return nil, errors.Wrap(err, "cannot sign partial exit message") + return nil, errors.Wrap(err, "sign partial exit message", z.Str("validator_public_key", pk.String()), z.Int("validator_index", share.Index), z.Int("exit_epoch", int(config.ExitEpoch))) } eth2PK, err := pk.ToETH2() if err != nil { - return nil, errors.Wrap(err, "cannot convert core pubkey to eth2 pubkey") + return nil, errors.Wrap(err, "convert core pubkey to eth2 pubkey", z.Str("core_pubkey", pk.String())) } exitBlob := obolapi.ExitBlob{ PublicKey: eth2PK.String(), SignedExitMessage: exitMsg, } exitBlobs = append(exitBlobs, exitBlob) + log.Info(ctx, "Successfully signed exit message", z.Str("validator_public_key", pk.String()), z.Int("validator_index", share.Index)) } return exitBlobs, nil @@ -249,7 +249,7 @@ func fetchValidatorBLSPubKey(ctx context.Context, config exitConfig, eth2Cl eth2 if config.ValidatorPubkey != "" { valEth2, err := core.PubKey(config.ValidatorPubkey).ToETH2() if err != nil { - return eth2p0.BLSPubKey{}, errors.Wrap(err, "cannot convert core pubkey to eth2 pubkey") + return eth2p0.BLSPubKey{}, errors.Wrap(err, "convert core pubkey to eth2 pubkey", z.Str("core_pubkey", config.ValidatorPubkey)) } return valEth2, nil @@ -257,7 +257,7 @@ func fetchValidatorBLSPubKey(ctx context.Context, config exitConfig, eth2Cl eth2 rawValData, err := queryBeaconForValidator(ctx, eth2Cl, nil, []eth2p0.ValidatorIndex{eth2p0.ValidatorIndex(config.ValidatorIndex)}) if err != nil { - return eth2p0.BLSPubKey{}, errors.Wrap(err, "fetch validator pubkey from beacon") + return eth2p0.BLSPubKey{}, errors.Wrap(err, "fetch validator pubkey from beacon", z.Str("beacon_address", eth2Cl.Address()), z.U64("validator_index", config.ValidatorIndex)) } for _, val := range rawValData.Data { @@ -266,7 +266,7 @@ func fetchValidatorBLSPubKey(ctx context.Context, config exitConfig, eth2Cl eth2 } } - return eth2p0.BLSPubKey{}, errors.New("validator index not found in beacon node response") + return eth2p0.BLSPubKey{}, errors.New("validator index not found in beacon node response", z.Str("beacon_address", eth2Cl.Address()), z.U64("validator_index", config.ValidatorIndex), z.Any("raw_response", rawValData)) } func fetchValidatorIndex(ctx context.Context, config exitConfig, eth2Cl eth2wrap.Client) (eth2p0.ValidatorIndex, error) { @@ -276,12 +276,12 @@ func fetchValidatorIndex(ctx context.Context, config exitConfig, eth2Cl eth2wrap valEth2, err := core.PubKey(config.ValidatorPubkey).ToETH2() if err != nil { - return 0, errors.Wrap(err, "cannot convert core pubkey to eth2 pubkey") + return 0, errors.Wrap(err, "convert core pubkey to eth2 pubkey", z.Str("core_pubkey", config.ValidatorPubkey)) } rawValData, err := queryBeaconForValidator(ctx, eth2Cl, []eth2p0.BLSPubKey{valEth2}, nil) if err != nil { - return 0, errors.Wrap(err, "cannot fetch validator index from beacon") + return 0, errors.Wrap(err, "fetch validator index from beacon", z.Str("beacon_address", eth2Cl.Address()), z.Str("validator_pubkey", valEth2.String())) } for _, val := range rawValData.Data { @@ -290,7 +290,7 @@ func fetchValidatorIndex(ctx context.Context, config exitConfig, eth2Cl eth2wrap } } - return 0, errors.New("validator public key not found in beacon node response") + return 0, errors.New("validator public key not found in beacon node response", z.Str("beacon_address", eth2Cl.Address()), z.Str("validator_pubkey", valEth2.String()), z.Any("raw_response", rawValData)) } func queryBeaconForValidator(ctx context.Context, eth2Cl eth2wrap.Client, pubKeys []eth2p0.BLSPubKey, indices []eth2p0.ValidatorIndex) (*eth2api.Response[map[eth2p0.ValidatorIndex]*eth2v1.Validator], error) { @@ -302,7 +302,7 @@ func queryBeaconForValidator(ctx context.Context, eth2Cl eth2wrap.Client, pubKey rawValData, err := eth2Cl.Validators(ctx, valAPICallOpts) if err != nil { - return nil, errors.Wrap(err, "fetch validators from beacon") + return nil, errors.Wrap(err, "fetch validators from beacon", z.Str("beacon_address", eth2Cl.Address()), z.Any("options", valAPICallOpts)) } return rawValData, nil diff --git a/cmd/exit_sign_internal_test.go b/cmd/exit_sign_internal_test.go index 7adf39f81..7ec08ad95 100644 --- a/cmd/exit_sign_internal_test.go +++ b/cmd/exit_sign_internal_test.go @@ -66,7 +66,7 @@ func Test_runSubmitPartialExit(t *testing.T) { false, "test", 0, - "cannot convert core pubkey to eth2 pubkey", + "convert core pubkey to eth2 pubkey", false, ) }) @@ -102,7 +102,7 @@ func Test_runSubmitPartialExit(t *testing.T) { true, "test", 9999, - "cannot convert core pubkey to eth2 pubkey", + "convert core pubkey to eth2 pubkey", false, ) }) @@ -263,32 +263,32 @@ func Test_runSubmitPartialExit_Config(t *testing.T) { { name: "No identity key", noIdentity: true, - errData: "could not load identity key", + errData: "load identity key", }, { name: "No cluster lock", noLock: true, - errData: "could not load cluster-lock.json", + errData: "load cluster lock", }, { name: "No keystore", noKeystore: true, - errData: "could not load keystore", + errData: "load keystore", }, { name: "Bad Obol API URL", badOAPIURL: true, - errData: "could not create obol api client", + errData: "create Obol API client", }, { name: "Bad beacon node URL", badBeaconNodeEndpoints: true, - errData: "cannot create eth2 client for specified beacon node", + errData: "create eth2 client for specified beacon node", }, { name: "Bad validator address", badValidatorAddr: true, - errData: "cannot convert core pubkey to eth2 pubkey", + errData: "convert core pubkey to eth2 pubkey", }, } @@ -386,3 +386,111 @@ func Test_runSubmitPartialExit_Config(t *testing.T) { }) } } + +func TestExitSignCLI(t *testing.T) { + tests := []struct { + name string + expectedErr string + flags []string + }{ + { + name: "check flags", + expectedErr: "load identity key: read private key from disk: open test: no such file or directory", + flags: []string{ + "--publish-address=test", + "--private-key-file=test", + "--lock-file=test", + "--validator-keys-dir=test", + "--exit-epoch=1", + "--validator-public-key=test", + "--validator-index=1", + "--beacon-node-endpoints=test1,test2", + "--beacon-node-timeout=1ms", + "--publish-timeout=1ms", + "--all=false", + "--testnet-name=test", + "--testnet-fork-version=test", + "--testnet-chain-id=1", + "--testnet-genesis-timestamp=1", + "--testnet-capella-hard-fork=test", + }, + }, + { + name: "no pubkey, no index, single validator", + expectedErr: "either validator-index or validator-public-key must be specified at least when exiting single validator.", + flags: []string{ + "--publish-address=test", + "--private-key-file=test", + "--lock-file=test", + "--validator-keys-dir=test", + "--exit-epoch=1", + "--beacon-node-endpoints=test1,test2", + "--beacon-node-timeout=1ms", + "--publish-timeout=1ms", + "--all=false", + "--testnet-name=test", + "--testnet-fork-version=test", + "--testnet-chain-id=1", + "--testnet-genesis-timestamp=1", + "--testnet-capella-hard-fork=test", + }, + }, + { + name: "pubkey present, all validators", + expectedErr: "validator-index or validator-public-key should not be specified when all is, as they are obsolete and misleading.", + flags: []string{ + "--publish-address=test", + "--private-key-file=test", + "--lock-file=test", + "--validator-keys-dir=test", + "--exit-epoch=1", + "--validator-public-key=test", + "--beacon-node-endpoints=test1,test2", + "--beacon-node-timeout=1ms", + "--publish-timeout=1ms", + "--all=true", + "--testnet-name=test", + "--testnet-fork-version=test", + "--testnet-chain-id=1", + "--testnet-genesis-timestamp=1", + "--testnet-capella-hard-fork=test", + }, + }, + { + name: "index present, all validators", + expectedErr: "validator-index or validator-public-key should not be specified when all is, as they are obsolete and misleading.", + flags: []string{ + "--publish-address=test", + "--private-key-file=test", + "--lock-file=test", + "--validator-keys-dir=test", + "--exit-epoch=1", + "--validator-index=1", + "--beacon-node-endpoints=test1,test2", + "--beacon-node-timeout=1ms", + "--publish-timeout=1ms", + "--all=true", + "--testnet-name=test", + "--testnet-fork-version=test", + "--testnet-chain-id=1", + "--testnet-genesis-timestamp=1", + "--testnet-capella-hard-fork=test", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := newExitCmd(newSignPartialExitCmd(runSignPartialExit)) + cmd.SetArgs(append([]string{"sign"}, test.flags...)) + + err := cmd.Execute() + if test.expectedErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, test.expectedErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/testutil/obolapimock/obolapi_exit.go b/testutil/obolapimock/obolapi_exit.go index 4d321c03c..86f2de156 100644 --- a/testutil/obolapimock/obolapi_exit.go +++ b/testutil/obolapimock/obolapi_exit.go @@ -314,7 +314,7 @@ func cleanTmpl(tmpl string) string { "").Replace(tmpl) } -// MockServer returns a obol API mock test server. +// MockServer returns a Obol API mock test server. // It returns a http.Handler to be served over HTTP, and a function to add cluster lock files to its database. func MockServer(dropOnePsig bool, beacon eth2wrap.Client) (http.Handler, func(lock cluster.Lock)) { ts := testServer{