From a8c982c98c2c2c740971e518b71fb9e653407586 Mon Sep 17 00:00:00 2001 From: radik878 Date: Mon, 20 Oct 2025 14:49:36 +0300 Subject: [PATCH 1/4] Use public_keys to filter accounts in ListAccounts and paginate filtered set --- validator/rpc/handlers_accounts.go | 36 +++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/validator/rpc/handlers_accounts.go b/validator/rpc/handlers_accounts.go index de9d71135a9a..57e7afb04f82 100644 --- a/validator/rpc/handlers_accounts.go +++ b/validator/rpc/handlers_accounts.go @@ -75,25 +75,49 @@ func (s *Server) ListAccounts(w http.ResponseWriter, r *http.Request) { httputil.HandleError(w, errors.Errorf("Could not retrieve public keys: %v", err).Error(), http.StatusInternalServerError) return } - accs := make([]*Account, len(keys)) + // Build optional filter set from query public_keys. + var filterSet map[string]struct{} + if len(pubkeys) > 0 { + filterSet = make(map[string]struct{}, len(pubkeys)) + for _, pk := range pubkeys { + filterSet[string(pk)] = struct{}{} + } + } + // Build accounts list, optionally filtering by provided public_keys. + accs := make([]*Account, 0, len(keys)) for i := 0; i < len(keys); i++ { - accs[i] = &Account{ + if filterSet != nil { + if _, ok := filterSet[string(keys[i][:])]; !ok { + continue + } + } + acc := &Account{ ValidatingPublicKey: hexutil.Encode(keys[i][:]), AccountName: petnames.DeterministicName(keys[i][:], "-"), } if s.wallet.KeymanagerKind() == keymanager.Derived { - accs[i].DerivationPath = fmt.Sprintf(derived.ValidatingKeyDerivationPathTemplate, i) + acc.DerivationPath = fmt.Sprintf(derived.ValidatingKeyDerivationPathTemplate, i) } + accs = append(accs, acc) } if r.URL.Query().Get("all") == "true" { httputil.WriteJson(w, &ListAccountsResponse{ Accounts: accs, - TotalSize: int32(len(keys)), + TotalSize: int32(len(accs)), + NextPageToken: "", + }) + return + } + // If no accounts after filtering, return an empty page. + if len(accs) == 0 { + httputil.WriteJson(w, &ListAccountsResponse{ + Accounts: accs, + TotalSize: 0, NextPageToken: "", }) return } - start, end, nextPageToken, err := pagination.StartAndEndPage(pageToken, int(ps), len(keys)) + start, end, nextPageToken, err := pagination.StartAndEndPage(pageToken, int(ps), len(accs)) if err != nil { httputil.HandleError(w, fmt.Errorf("Could not paginate results: %w", err).Error(), http.StatusInternalServerError) @@ -101,7 +125,7 @@ func (s *Server) ListAccounts(w http.ResponseWriter, r *http.Request) { } httputil.WriteJson(w, &ListAccountsResponse{ Accounts: accs[start:end], - TotalSize: int32(len(keys)), + TotalSize: int32(len(accs)), NextPageToken: nextPageToken, }) } From c43da7edf2d6f075973e6863f8c7deeb42f2400c Mon Sep 17 00:00:00 2001 From: radik878 Date: Mon, 20 Oct 2025 14:49:55 +0300 Subject: [PATCH 2/4] Add test for ListAccounts filtering and pagination --- validator/rpc/handlers_accounts_test.go | 535 +++++++++++------------- 1 file changed, 253 insertions(+), 282 deletions(-) diff --git a/validator/rpc/handlers_accounts_test.go b/validator/rpc/handlers_accounts_test.go index f19fb84922c6..57e7afb04f82 100644 --- a/validator/rpc/handlers_accounts_test.go +++ b/validator/rpc/handlers_accounts_test.go @@ -8,320 +8,291 @@ import ( "fmt" "io" "net/http" - "net/http/httptest" - "path/filepath" - "testing" - "time" + "strconv" - "github.com/OffchainLabs/prysm/v6/api" - "github.com/OffchainLabs/prysm/v6/cmd/validator/flags" - ethpb "github.com/OffchainLabs/prysm/v6/proto/prysm/v1alpha1" - "github.com/OffchainLabs/prysm/v6/testing/assert" - "github.com/OffchainLabs/prysm/v6/testing/require" - validatormock "github.com/OffchainLabs/prysm/v6/testing/validator-mock" + "github.com/OffchainLabs/prysm/v6/api/pagination" + "github.com/OffchainLabs/prysm/v6/beacon-chain/rpc/eth/shared" + "github.com/OffchainLabs/prysm/v6/cmd" + fieldparams "github.com/OffchainLabs/prysm/v6/config/fieldparams" + "github.com/OffchainLabs/prysm/v6/crypto/bls" + "github.com/OffchainLabs/prysm/v6/encoding/bytesutil" + "github.com/OffchainLabs/prysm/v6/monitoring/tracing/trace" + "github.com/OffchainLabs/prysm/v6/network/httputil" "github.com/OffchainLabs/prysm/v6/validator/accounts" - "github.com/OffchainLabs/prysm/v6/validator/accounts/iface" - "github.com/OffchainLabs/prysm/v6/validator/client" - "github.com/OffchainLabs/prysm/v6/validator/client/testutil" + "github.com/OffchainLabs/prysm/v6/validator/accounts/petnames" "github.com/OffchainLabs/prysm/v6/validator/keymanager" "github.com/OffchainLabs/prysm/v6/validator/keymanager/derived" - constant "github.com/OffchainLabs/prysm/v6/validator/testing" + "github.com/OffchainLabs/prysm/v6/validator/keymanager/local" "github.com/ethereum/go-ethereum/common/hexutil" - "go.uber.org/mock/gomock" - "google.golang.org/protobuf/types/known/timestamppb" + "github.com/pkg/errors" ) -var ( - defaultWalletPath = filepath.Join(flags.DefaultValidatorDir(), flags.WalletDefaultDirName) -) - -func TestServer_ListAccounts(t *testing.T) { - ctx := t.Context() - localWalletDir := setupWalletDir(t) - defaultWalletPath = localWalletDir - // We attempt to create the wallet. - opts := []accounts.Option{ - accounts.WithWalletDir(defaultWalletPath), - accounts.WithKeymanagerType(keymanager.Derived), - accounts.WithWalletPassword(strongPass), - accounts.WithSkipMnemonicConfirm(true), +// ListAccounts allows retrieval of validating keys and their petnames +// for a user's wallet via RPC. +func (s *Server) ListAccounts(w http.ResponseWriter, r *http.Request) { + ctx, span := trace.StartSpan(r.Context(), "validator.web.accounts.ListAccounts") + defer span.End() + if s.validatorService == nil { + httputil.HandleError(w, "Validator service not ready.", http.StatusServiceUnavailable) + return } - acc, err := accounts.NewCLIManager(opts...) - require.NoError(t, err) - w, err := acc.WalletCreate(ctx) - require.NoError(t, err) - km, err := w.InitializeKeymanager(ctx, iface.InitKeymanagerConfig{ListenForChanges: false}) - require.NoError(t, err) - vs, err := client.NewValidatorService(ctx, &client.Config{ - Wallet: w, - Validator: &testutil.FakeValidator{ - Km: km, - }, - }) - require.NoError(t, err) - s := &Server{ - walletInitialized: true, - wallet: w, - validatorService: vs, + if !s.walletInitialized { + httputil.HandleError(w, "Prysm Wallet not initialized. Please create a new wallet.", http.StatusServiceUnavailable) + return } - numAccounts := 50 - dr, ok := km.(*derived.Keymanager) - require.Equal(t, true, ok) - err = dr.RecoverAccountsFromMnemonic(ctx, constant.TestMnemonic, derived.DefaultMnemonicLanguage, "", numAccounts) - require.NoError(t, err) - req := httptest.NewRequest(http.MethodGet, fmt.Sprintf(api.WebUrlPrefix+"accounts?page_size=%d", int32(numAccounts)), nil) - wr := httptest.NewRecorder() - wr.Body = &bytes.Buffer{} - s.ListAccounts(wr, req) - require.Equal(t, http.StatusOK, wr.Code) - resp := &ListAccountsResponse{} - require.NoError(t, json.Unmarshal(wr.Body.Bytes(), resp)) - require.Equal(t, len(resp.Accounts), numAccounts) - - tests := []struct { - PageSize int - PageToken string - All bool - res *ListAccountsResponse - }{ - { - - PageSize: 5, - res: &ListAccountsResponse{ - Accounts: resp.Accounts[0:5], - NextPageToken: "1", - TotalSize: int32(numAccounts), - }, - }, - { - - PageSize: 5, - PageToken: "1", - res: &ListAccountsResponse{ - Accounts: resp.Accounts[5:10], - NextPageToken: "2", - TotalSize: int32(numAccounts), - }, - }, - { - - All: true, - res: &ListAccountsResponse{ - Accounts: resp.Accounts, - NextPageToken: "", - TotalSize: int32(numAccounts), - }, - }, + pageSize := r.URL.Query().Get("page_size") + var ps int64 + if pageSize != "" { + psi, err := strconv.ParseInt(pageSize, 10, 32) + if err != nil { + httputil.HandleError(w, errors.Wrap(err, "Failed to parse page_size").Error(), http.StatusBadRequest) + return + } + ps = psi } - for _, test := range tests { - url := api.WebUrlPrefix + "accounts" - if test.PageSize != 0 || test.PageToken != "" || test.All { - url = url + "?" + pageToken := r.URL.Query().Get("page_token") + publicKeys := r.URL.Query()["public_keys"] + pubkeys := make([][]byte, len(publicKeys)) + for i, key := range publicKeys { + k, ok := shared.ValidateHex(w, fmt.Sprintf("PublicKeys[%d]", i), key, fieldparams.BLSPubkeyLength) + if !ok { + return } - if test.All { - url = url + "all=true" - } else { - if test.PageSize != 0 { - url = url + fmt.Sprintf("page_size=%d", test.PageSize) - } - if test.PageToken != "" { - url = url + fmt.Sprintf("&page_token=%s", test.PageToken) + pubkeys[i] = bytesutil.SafeCopyBytes(k) + } + if int(ps) > cmd.Get().MaxRPCPageSize { + httputil.HandleError(w, fmt.Sprintf("Requested page size %d can not be greater than max size %d", + ps, cmd.Get().MaxRPCPageSize), http.StatusBadRequest) + return + } + km, err := s.validatorService.Keymanager() + if err != nil { + httputil.HandleError(w, err.Error(), http.StatusInternalServerError) + return + } + keys, err := km.FetchValidatingPublicKeys(ctx) + if err != nil { + httputil.HandleError(w, errors.Errorf("Could not retrieve public keys: %v", err).Error(), http.StatusInternalServerError) + return + } + // Build optional filter set from query public_keys. + var filterSet map[string]struct{} + if len(pubkeys) > 0 { + filterSet = make(map[string]struct{}, len(pubkeys)) + for _, pk := range pubkeys { + filterSet[string(pk)] = struct{}{} + } + } + // Build accounts list, optionally filtering by provided public_keys. + accs := make([]*Account, 0, len(keys)) + for i := 0; i < len(keys); i++ { + if filterSet != nil { + if _, ok := filterSet[string(keys[i][:])]; !ok { + continue } } - - req = httptest.NewRequest(http.MethodGet, url, nil) - wr = httptest.NewRecorder() - wr.Body = &bytes.Buffer{} - s.ListAccounts(wr, req) - require.Equal(t, http.StatusOK, wr.Code) - resp = &ListAccountsResponse{} - require.NoError(t, json.Unmarshal(wr.Body.Bytes(), resp)) - assert.DeepEqual(t, resp, test.res) + acc := &Account{ + ValidatingPublicKey: hexutil.Encode(keys[i][:]), + AccountName: petnames.DeterministicName(keys[i][:], "-"), + } + if s.wallet.KeymanagerKind() == keymanager.Derived { + acc.DerivationPath = fmt.Sprintf(derived.ValidatingKeyDerivationPathTemplate, i) + } + accs = append(accs, acc) } + if r.URL.Query().Get("all") == "true" { + httputil.WriteJson(w, &ListAccountsResponse{ + Accounts: accs, + TotalSize: int32(len(accs)), + NextPageToken: "", + }) + return + } + // If no accounts after filtering, return an empty page. + if len(accs) == 0 { + httputil.WriteJson(w, &ListAccountsResponse{ + Accounts: accs, + TotalSize: 0, + NextPageToken: "", + }) + return + } + start, end, nextPageToken, err := pagination.StartAndEndPage(pageToken, int(ps), len(accs)) + if err != nil { + httputil.HandleError(w, fmt.Errorf("Could not paginate results: %w", + err).Error(), http.StatusInternalServerError) + return + } + httputil.WriteJson(w, &ListAccountsResponse{ + Accounts: accs[start:end], + TotalSize: int32(len(accs)), + NextPageToken: nextPageToken, + }) } -func TestServer_BackupAccounts(t *testing.T) { - ctx := t.Context() - localWalletDir := setupWalletDir(t) - defaultWalletPath = localWalletDir - // We attempt to create the wallet. - opts := []accounts.Option{ - accounts.WithWalletDir(defaultWalletPath), - accounts.WithKeymanagerType(keymanager.Derived), - accounts.WithWalletPassword(strongPass), - accounts.WithSkipMnemonicConfirm(true), +// BackupAccounts creates a zip file containing EIP-2335 keystores for the user's +// specified public keys by encrypting them with the specified password. +func (s *Server) BackupAccounts(w http.ResponseWriter, r *http.Request) { + ctx, span := trace.StartSpan(r.Context(), "validator.web.accounts.ListAccounts") + defer span.End() + if s.validatorService == nil { + httputil.HandleError(w, "Validator service not ready.", http.StatusServiceUnavailable) + return } - acc, err := accounts.NewCLIManager(opts...) - require.NoError(t, err) - w, err := acc.WalletCreate(ctx) - require.NoError(t, err) - km, err := w.InitializeKeymanager(ctx, iface.InitKeymanagerConfig{ListenForChanges: false}) - require.NoError(t, err) - vs, err := client.NewValidatorService(ctx, &client.Config{ - Wallet: w, - Validator: &testutil.FakeValidator{ - Km: km, - }, - }) - require.NoError(t, err) - s := &Server{ - walletInitialized: true, - wallet: w, - validatorService: vs, + if !s.walletInitialized { + httputil.HandleError(w, "Prysm Wallet not initialized. Please create a new wallet.", http.StatusServiceUnavailable) + return + } + + var req BackupAccountsRequest + err := json.NewDecoder(r.Body).Decode(&req) + switch { + case errors.Is(err, io.EOF): + httputil.HandleError(w, "No data submitted", http.StatusBadRequest) + return + case err != nil: + httputil.HandleError(w, "Could not decode request body: "+err.Error(), http.StatusBadRequest) + return } - numAccounts := 50 - dr, ok := km.(*derived.Keymanager) - require.Equal(t, true, ok) - err = dr.RecoverAccountsFromMnemonic(ctx, constant.TestMnemonic, derived.DefaultMnemonicLanguage, "", numAccounts) - require.NoError(t, err) - req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/v2/validator/accounts?page_size=%d", int32(numAccounts)), nil) - wr := httptest.NewRecorder() - wr.Body = &bytes.Buffer{} - s.ListAccounts(wr, req) - require.Equal(t, http.StatusOK, wr.Code) - resp := &ListAccountsResponse{} - require.NoError(t, json.Unmarshal(wr.Body.Bytes(), resp)) - require.Equal(t, len(resp.Accounts), numAccounts) - pubKeys := make([]string, numAccounts) - for i, aa := range resp.Accounts { - pubKeys[i] = aa.ValidatingPublicKey + if len(req.PublicKeys) < 1 { + httputil.HandleError(w, "No public keys specified to backup", http.StatusBadRequest) + return } - request := &BackupAccountsRequest{ - PublicKeys: pubKeys, - BackupPassword: s.wallet.Password(), + if req.BackupPassword == "" { + httputil.HandleError(w, "Backup password cannot be empty", http.StatusBadRequest) + return } - var buf bytes.Buffer - err = json.NewEncoder(&buf).Encode(request) - require.NoError(t, err) - req = httptest.NewRequest(http.MethodPost, api.WebUrlPrefix+"accounts/backup", &buf) - wr = httptest.NewRecorder() - wr.Body = &bytes.Buffer{} - // We now attempt to backup all public keys from the wallet. - s.BackupAccounts(wr, req) - require.Equal(t, http.StatusOK, wr.Code) - res := &BackupAccountsResponse{} - require.NoError(t, json.Unmarshal(wr.Body.Bytes(), res)) - // decode the base64 string - decodedBytes, err := base64.StdEncoding.DecodeString(res.ZipFile) - require.NoError(t, err) - // Open a zip archive for reading. - bu := bytes.NewReader(decodedBytes) - r, err := zip.NewReader(bu, int64(len(decodedBytes))) - require.NoError(t, err) - require.Equal(t, len(pubKeys), len(r.File)) - // Iterate through the files in the archive, checking they - // match the keystores we wanted to back up. - for i, f := range r.File { - keystoreFile, err := f.Open() - require.NoError(t, err) - encoded, err := io.ReadAll(keystoreFile) - if err != nil { - require.NoError(t, keystoreFile.Close()) - t.Fatal(err) + km, err := s.validatorService.Keymanager() + if err != nil { + httputil.HandleError(w, err.Error(), http.StatusInternalServerError) + return + } + pubKeys := make([]bls.PublicKey, len(req.PublicKeys)) + for i, key := range req.PublicKeys { + byteskey, ok := shared.ValidateHex(w, "pubkey", key, fieldparams.BLSPubkeyLength) + if !ok { + return } - keystore := &keymanager.Keystore{} - if err := json.Unmarshal(encoded, &keystore); err != nil { - require.NoError(t, keystoreFile.Close()) - t.Fatal(err) + pubKey, err := bls.PublicKeyFromBytes(byteskey) + if err != nil { + httputil.HandleError(w, errors.Wrap(err, fmt.Sprintf("%s Not a valid BLS public key", key)).Error(), http.StatusBadRequest) + return } - assert.Equal(t, "0x"+keystore.Pubkey, pubKeys[i]) - require.NoError(t, keystoreFile.Close()) + pubKeys[i] = pubKey } -} -func TestServer_VoluntaryExit(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - ctx := t.Context() - mockValidatorClient := validatormock.NewMockValidatorClient(ctrl) - mockNodeClient := validatormock.NewMockNodeClient(ctrl) - - mockValidatorClient.EXPECT(). - ValidatorIndex(gomock.Any(), gomock.Any()). - Return(ðpb.ValidatorIndexResponse{Index: 0}, nil) - - mockValidatorClient.EXPECT(). - ValidatorIndex(gomock.Any(), gomock.Any()). - Return(ðpb.ValidatorIndexResponse{Index: 1}, nil) - - // Any time in the past will suffice - genesisTime := ×tamppb.Timestamp{ - Seconds: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).Unix(), + var keystoresToBackup []*keymanager.Keystore + switch km := km.(type) { + case *local.Keymanager: + keystoresToBackup, err = km.ExtractKeystores(ctx, pubKeys, req.BackupPassword) + if err != nil { + httputil.HandleError(w, errors.Wrap(err, "Could not backup accounts for local keymanager").Error(), http.StatusInternalServerError) + return + } + case *derived.Keymanager: + keystoresToBackup, err = km.ExtractKeystores(ctx, pubKeys, req.BackupPassword) + if err != nil { + httputil.HandleError(w, errors.Wrap(err, "Could not backup accounts for derived keymanager").Error(), http.StatusInternalServerError) + return + } + default: + httputil.HandleError(w, "Only HD or IMPORTED wallets can backup accounts", http.StatusBadRequest) + return + } + if len(keystoresToBackup) == 0 { + httputil.HandleError(w, "No keystores to backup", http.StatusBadRequest) + return } - mockNodeClient.EXPECT(). - Genesis(gomock.Any(), gomock.Any()). - Return(ðpb.Genesis{GenesisTime: genesisTime}, nil) - - mockValidatorClient.EXPECT(). - DomainData(gomock.Any(), gomock.Any()). - Times(2). - Return(ðpb.DomainResponse{SignatureDomain: make([]byte, 32)}, nil) - - mockValidatorClient.EXPECT(). - ProposeExit(gomock.Any(), gomock.AssignableToTypeOf(ðpb.SignedVoluntaryExit{})). - Times(2). - Return(ðpb.ProposeExitResponse{}, nil) - - localWalletDir := setupWalletDir(t) - defaultWalletPath = localWalletDir - // We attempt to create the wallet. - opts := []accounts.Option{ - accounts.WithWalletDir(defaultWalletPath), - accounts.WithKeymanagerType(keymanager.Derived), - accounts.WithWalletPassword(strongPass), - accounts.WithSkipMnemonicConfirm(true), + buf := new(bytes.Buffer) + writer := zip.NewWriter(buf) + for i, k := range keystoresToBackup { + encodedFile, err := json.MarshalIndent(k, "", "\t") + if err != nil { + if err := writer.Close(); err != nil { + log.WithError(err).Error("Could not close zip file after writing") + } + httputil.HandleError(w, "could not marshal keystore to JSON file", http.StatusInternalServerError) + return + } + f, err := writer.Create(fmt.Sprintf("keystore-%d.json", i)) + if err != nil { + if err := writer.Close(); err != nil { + log.WithError(err).Error("Could not close zip file after writing") + } + httputil.HandleError(w, "Could not write keystore file to zip", http.StatusInternalServerError) + return + } + if _, err = f.Write(encodedFile); err != nil { + if err := writer.Close(); err != nil { + log.WithError(err).Error("Could not close zip file after writing") + } + httputil.HandleError(w, "Could not write keystore file contents", http.StatusBadRequest) + return + } } - acc, err := accounts.NewCLIManager(opts...) - require.NoError(t, err) - w, err := acc.WalletCreate(ctx) - require.NoError(t, err) - km, err := w.InitializeKeymanager(ctx, iface.InitKeymanagerConfig{ListenForChanges: false}) - require.NoError(t, err) - require.NoError(t, err) - vs, err := client.NewValidatorService(ctx, &client.Config{ - Wallet: w, - Validator: &testutil.FakeValidator{ - Km: km, - }, - }) - require.NoError(t, err) - s := &Server{ - walletInitialized: true, - wallet: w, - nodeClient: mockNodeClient, - beaconNodeValidatorClient: mockValidatorClient, - validatorService: vs, + if err := writer.Close(); err != nil { + log.WithError(err).Error("Could not close zip file after writing") } - numAccounts := 2 - dr, ok := km.(*derived.Keymanager) - require.Equal(t, true, ok) - err = dr.RecoverAccountsFromMnemonic(ctx, constant.TestMnemonic, derived.DefaultMnemonicLanguage, "", numAccounts) - require.NoError(t, err) - pubKeys, err := dr.FetchValidatingPublicKeys(ctx) - require.NoError(t, err) + httputil.WriteJson(w, &BackupAccountsResponse{ + ZipFile: base64.StdEncoding.EncodeToString(buf.Bytes()), // convert to base64 string for processing + }) +} - rawPubKeys := make([]string, len(pubKeys)) - for i, key := range pubKeys { - rawPubKeys[i] = hexutil.Encode(key[:]) +// VoluntaryExit performs a voluntary exit for the validator keys specified in a request. +func (s *Server) VoluntaryExit(w http.ResponseWriter, r *http.Request) { + ctx, span := trace.StartSpan(r.Context(), "validator.web.accounts.VoluntaryExit") + defer span.End() + if s.validatorService == nil { + httputil.HandleError(w, "Validator service not ready.", http.StatusServiceUnavailable) + return } - request := &VoluntaryExitRequest{ - PublicKeys: rawPubKeys, + if !s.walletInitialized { + httputil.HandleError(w, "Prysm Wallet not initialized. Please create a new wallet.", http.StatusServiceUnavailable) + return } - var buf bytes.Buffer - err = json.NewEncoder(&buf).Encode(request) - require.NoError(t, err) - req := httptest.NewRequest(http.MethodPost, api.WebUrlPrefix+"accounts/voluntary-exit", &buf) - wr := httptest.NewRecorder() - wr.Body = &bytes.Buffer{} - s.VoluntaryExit(wr, req) - require.Equal(t, http.StatusOK, wr.Code) - res := &VoluntaryExitResponse{} - require.NoError(t, json.Unmarshal(wr.Body.Bytes(), res)) - for i := range res.ExitedKeys { - require.Equal(t, rawPubKeys[i], hexutil.Encode(res.ExitedKeys[i])) + var req VoluntaryExitRequest + err := json.NewDecoder(r.Body).Decode(&req) + switch { + case errors.Is(err, io.EOF): + httputil.HandleError(w, "No data submitted", http.StatusBadRequest) + return + case err != nil: + httputil.HandleError(w, "Could not decode request body: "+err.Error(), http.StatusBadRequest) + return } - + if len(req.PublicKeys) == 0 { + httputil.HandleError(w, "No public keys specified to delete", http.StatusBadRequest) + return + } + km, err := s.validatorService.Keymanager() + if err != nil { + httputil.HandleError(w, err.Error(), http.StatusInternalServerError) + return + } + pubKeys := make([][]byte, len(req.PublicKeys)) + for i, key := range req.PublicKeys { + byteskey, ok := shared.ValidateHex(w, "pubkey", key, fieldparams.BLSPubkeyLength) + if !ok { + return + } + pubKeys[i] = byteskey + } + cfg := accounts.PerformExitCfg{ + ValidatorClient: s.beaconNodeValidatorClient, + NodeClient: s.nodeClient, + Keymanager: km, + RawPubKeys: pubKeys, + FormattedPubKeys: req.PublicKeys, + } + rawExitedKeys, _, err := accounts.PerformVoluntaryExit(ctx, cfg) + if err != nil { + httputil.HandleError(w, errors.Wrap(err, "Could not perform voluntary exit").Error(), http.StatusInternalServerError) + return + } + httputil.WriteJson(w, &VoluntaryExitResponse{ + ExitedKeys: rawExitedKeys, + }) } From 14f2288cc6c9b92881ca2a835aa4eaff77821987 Mon Sep 17 00:00:00 2001 From: radik878 Date: Mon, 20 Oct 2025 14:51:10 +0300 Subject: [PATCH 3/4] Create radik878_fix-listaccounts-filter.md --- changelog/radik878_fix-listaccounts-filter.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog/radik878_fix-listaccounts-filter.md diff --git a/changelog/radik878_fix-listaccounts-filter.md b/changelog/radik878_fix-listaccounts-filter.md new file mode 100644 index 000000000000..f84e72e2afdf --- /dev/null +++ b/changelog/radik878_fix-listaccounts-filter.md @@ -0,0 +1,3 @@ +### Fixed + +- implement public_keys filtering in ListAccounts and add tests From 53b753bb92fd0b96635df990fbfd0ff1201ce994 Mon Sep 17 00:00:00 2001 From: radik878 Date: Mon, 20 Oct 2025 22:32:08 +0300 Subject: [PATCH 4/4] fix --- validator/rpc/handlers_accounts_test.go | 629 ++++++++++++++---------- 1 file changed, 379 insertions(+), 250 deletions(-) diff --git a/validator/rpc/handlers_accounts_test.go b/validator/rpc/handlers_accounts_test.go index 57e7afb04f82..0eecc78fe7a7 100644 --- a/validator/rpc/handlers_accounts_test.go +++ b/validator/rpc/handlers_accounts_test.go @@ -8,291 +8,420 @@ import ( "fmt" "io" "net/http" - "strconv" + "net/http/httptest" + "path/filepath" + "testing" + "time" - "github.com/OffchainLabs/prysm/v6/api/pagination" - "github.com/OffchainLabs/prysm/v6/beacon-chain/rpc/eth/shared" - "github.com/OffchainLabs/prysm/v6/cmd" - fieldparams "github.com/OffchainLabs/prysm/v6/config/fieldparams" - "github.com/OffchainLabs/prysm/v6/crypto/bls" - "github.com/OffchainLabs/prysm/v6/encoding/bytesutil" - "github.com/OffchainLabs/prysm/v6/monitoring/tracing/trace" - "github.com/OffchainLabs/prysm/v6/network/httputil" + "github.com/OffchainLabs/prysm/v6/api" + "github.com/OffchainLabs/prysm/v6/cmd/validator/flags" + ethpb "github.com/OffchainLabs/prysm/v6/proto/prysm/v1alpha1" + "github.com/OffchainLabs/prysm/v6/testing/assert" + "github.com/OffchainLabs/prysm/v6/testing/require" + validatormock "github.com/OffchainLabs/prysm/v6/testing/validator-mock" "github.com/OffchainLabs/prysm/v6/validator/accounts" - "github.com/OffchainLabs/prysm/v6/validator/accounts/petnames" + "github.com/OffchainLabs/prysm/v6/validator/accounts/iface" + "github.com/OffchainLabs/prysm/v6/validator/client" + "github.com/OffchainLabs/prysm/v6/validator/client/testutil" "github.com/OffchainLabs/prysm/v6/validator/keymanager" "github.com/OffchainLabs/prysm/v6/validator/keymanager/derived" - "github.com/OffchainLabs/prysm/v6/validator/keymanager/local" + constant "github.com/OffchainLabs/prysm/v6/validator/testing" "github.com/ethereum/go-ethereum/common/hexutil" - "github.com/pkg/errors" + "go.uber.org/mock/gomock" + "google.golang.org/protobuf/types/known/timestamppb" ) -// ListAccounts allows retrieval of validating keys and their petnames -// for a user's wallet via RPC. -func (s *Server) ListAccounts(w http.ResponseWriter, r *http.Request) { - ctx, span := trace.StartSpan(r.Context(), "validator.web.accounts.ListAccounts") - defer span.End() - if s.validatorService == nil { - httputil.HandleError(w, "Validator service not ready.", http.StatusServiceUnavailable) - return - } - if !s.walletInitialized { - httputil.HandleError(w, "Prysm Wallet not initialized. Please create a new wallet.", http.StatusServiceUnavailable) - return - } - pageSize := r.URL.Query().Get("page_size") - var ps int64 - if pageSize != "" { - psi, err := strconv.ParseInt(pageSize, 10, 32) - if err != nil { - httputil.HandleError(w, errors.Wrap(err, "Failed to parse page_size").Error(), http.StatusBadRequest) - return - } - ps = psi - } - pageToken := r.URL.Query().Get("page_token") - publicKeys := r.URL.Query()["public_keys"] - pubkeys := make([][]byte, len(publicKeys)) - for i, key := range publicKeys { - k, ok := shared.ValidateHex(w, fmt.Sprintf("PublicKeys[%d]", i), key, fieldparams.BLSPubkeyLength) - if !ok { - return - } - pubkeys[i] = bytesutil.SafeCopyBytes(k) - } - if int(ps) > cmd.Get().MaxRPCPageSize { - httputil.HandleError(w, fmt.Sprintf("Requested page size %d can not be greater than max size %d", - ps, cmd.Get().MaxRPCPageSize), http.StatusBadRequest) - return +var ( + defaultWalletPath = filepath.Join(flags.DefaultValidatorDir(), flags.WalletDefaultDirName) +) + +func TestServer_ListAccounts(t *testing.T) { + ctx := t.Context() + localWalletDir := setupWalletDir(t) + defaultWalletPath = localWalletDir + // We attempt to create the wallet. + opts := []accounts.Option{ + accounts.WithWalletDir(defaultWalletPath), + accounts.WithKeymanagerType(keymanager.Derived), + accounts.WithWalletPassword(strongPass), + accounts.WithSkipMnemonicConfirm(true), } - km, err := s.validatorService.Keymanager() - if err != nil { - httputil.HandleError(w, err.Error(), http.StatusInternalServerError) - return + acc, err := accounts.NewCLIManager(opts...) + require.NoError(t, err) + w, err := acc.WalletCreate(ctx) + require.NoError(t, err) + km, err := w.InitializeKeymanager(ctx, iface.InitKeymanagerConfig{ListenForChanges: false}) + require.NoError(t, err) + vs, err := client.NewValidatorService(ctx, &client.Config{ + Wallet: w, + Validator: &testutil.FakeValidator{ + Km: km, + }, + }) + require.NoError(t, err) + s := &Server{ + walletInitialized: true, + wallet: w, + validatorService: vs, } - keys, err := km.FetchValidatingPublicKeys(ctx) - if err != nil { - httputil.HandleError(w, errors.Errorf("Could not retrieve public keys: %v", err).Error(), http.StatusInternalServerError) - return + numAccounts := 50 + dr, ok := km.(*derived.Keymanager) + require.Equal(t, true, ok) + err = dr.RecoverAccountsFromMnemonic(ctx, constant.TestMnemonic, derived.DefaultMnemonicLanguage, "", numAccounts) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf(api.WebUrlPrefix+"accounts?page_size=%d", int32(numAccounts)), nil) + wr := httptest.NewRecorder() + wr.Body = &bytes.Buffer{} + s.ListAccounts(wr, req) + require.Equal(t, http.StatusOK, wr.Code) + resp := &ListAccountsResponse{} + require.NoError(t, json.Unmarshal(wr.Body.Bytes(), resp)) + require.Equal(t, len(resp.Accounts), numAccounts) + + tests := []struct { + PageSize int + PageToken string + All bool + res *ListAccountsResponse + }{ + { + + PageSize: 5, + res: &ListAccountsResponse{ + Accounts: resp.Accounts[0:5], + NextPageToken: "1", + TotalSize: int32(numAccounts), + }, + }, + { + + PageSize: 5, + PageToken: "1", + res: &ListAccountsResponse{ + Accounts: resp.Accounts[5:10], + NextPageToken: "2", + TotalSize: int32(numAccounts), + }, + }, + { + + All: true, + res: &ListAccountsResponse{ + Accounts: resp.Accounts, + NextPageToken: "", + TotalSize: int32(numAccounts), + }, + }, } - // Build optional filter set from query public_keys. - var filterSet map[string]struct{} - if len(pubkeys) > 0 { - filterSet = make(map[string]struct{}, len(pubkeys)) - for _, pk := range pubkeys { - filterSet[string(pk)] = struct{}{} + for _, test := range tests { + url := api.WebUrlPrefix + "accounts" + if test.PageSize != 0 || test.PageToken != "" || test.All { + url = url + "?" } - } - // Build accounts list, optionally filtering by provided public_keys. - accs := make([]*Account, 0, len(keys)) - for i := 0; i < len(keys); i++ { - if filterSet != nil { - if _, ok := filterSet[string(keys[i][:])]; !ok { - continue + if test.All { + url = url + "all=true" + } else { + if test.PageSize != 0 { + url = url + fmt.Sprintf("page_size=%d", test.PageSize) + } + if test.PageToken != "" { + url = url + fmt.Sprintf("&page_token=%s", test.PageToken) } } - acc := &Account{ - ValidatingPublicKey: hexutil.Encode(keys[i][:]), - AccountName: petnames.DeterministicName(keys[i][:], "-"), - } - if s.wallet.KeymanagerKind() == keymanager.Derived { - acc.DerivationPath = fmt.Sprintf(derived.ValidatingKeyDerivationPathTemplate, i) - } - accs = append(accs, acc) - } - if r.URL.Query().Get("all") == "true" { - httputil.WriteJson(w, &ListAccountsResponse{ - Accounts: accs, - TotalSize: int32(len(accs)), - NextPageToken: "", - }) - return - } - // If no accounts after filtering, return an empty page. - if len(accs) == 0 { - httputil.WriteJson(w, &ListAccountsResponse{ - Accounts: accs, - TotalSize: 0, - NextPageToken: "", - }) - return - } - start, end, nextPageToken, err := pagination.StartAndEndPage(pageToken, int(ps), len(accs)) - if err != nil { - httputil.HandleError(w, fmt.Errorf("Could not paginate results: %w", - err).Error(), http.StatusInternalServerError) - return - } - httputil.WriteJson(w, &ListAccountsResponse{ - Accounts: accs[start:end], - TotalSize: int32(len(accs)), - NextPageToken: nextPageToken, - }) -} - -// BackupAccounts creates a zip file containing EIP-2335 keystores for the user's -// specified public keys by encrypting them with the specified password. -func (s *Server) BackupAccounts(w http.ResponseWriter, r *http.Request) { - ctx, span := trace.StartSpan(r.Context(), "validator.web.accounts.ListAccounts") - defer span.End() - if s.validatorService == nil { - httputil.HandleError(w, "Validator service not ready.", http.StatusServiceUnavailable) - return - } - if !s.walletInitialized { - httputil.HandleError(w, "Prysm Wallet not initialized. Please create a new wallet.", http.StatusServiceUnavailable) - return - } - var req BackupAccountsRequest - err := json.NewDecoder(r.Body).Decode(&req) - switch { - case errors.Is(err, io.EOF): - httputil.HandleError(w, "No data submitted", http.StatusBadRequest) - return - case err != nil: - httputil.HandleError(w, "Could not decode request body: "+err.Error(), http.StatusBadRequest) - return + req = httptest.NewRequest(http.MethodGet, url, nil) + wr = httptest.NewRecorder() + wr.Body = &bytes.Buffer{} + s.ListAccounts(wr, req) + require.Equal(t, http.StatusOK, wr.Code) + resp = &ListAccountsResponse{} + require.NoError(t, json.Unmarshal(wr.Body.Bytes(), resp)) + assert.DeepEqual(t, resp, test.res) } +} - if len(req.PublicKeys) < 1 { - httputil.HandleError(w, "No public keys specified to backup", http.StatusBadRequest) - return +func TestServer_BackupAccounts(t *testing.T) { + ctx := t.Context() + localWalletDir := setupWalletDir(t) + defaultWalletPath = localWalletDir + // We attempt to create the wallet. + opts := []accounts.Option{ + accounts.WithWalletDir(defaultWalletPath), + accounts.WithKeymanagerType(keymanager.Derived), + accounts.WithWalletPassword(strongPass), + accounts.WithSkipMnemonicConfirm(true), } - if req.BackupPassword == "" { - httputil.HandleError(w, "Backup password cannot be empty", http.StatusBadRequest) - return + acc, err := accounts.NewCLIManager(opts...) + require.NoError(t, err) + w, err := acc.WalletCreate(ctx) + require.NoError(t, err) + km, err := w.InitializeKeymanager(ctx, iface.InitKeymanagerConfig{ListenForChanges: false}) + require.NoError(t, err) + vs, err := client.NewValidatorService(ctx, &client.Config{ + Wallet: w, + Validator: &testutil.FakeValidator{ + Km: km, + }, + }) + require.NoError(t, err) + s := &Server{ + walletInitialized: true, + wallet: w, + validatorService: vs, } + numAccounts := 50 + dr, ok := km.(*derived.Keymanager) + require.Equal(t, true, ok) + err = dr.RecoverAccountsFromMnemonic(ctx, constant.TestMnemonic, derived.DefaultMnemonicLanguage, "", numAccounts) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/v2/validator/accounts?page_size=%d", int32(numAccounts)), nil) + wr := httptest.NewRecorder() + wr.Body = &bytes.Buffer{} + s.ListAccounts(wr, req) + require.Equal(t, http.StatusOK, wr.Code) + resp := &ListAccountsResponse{} + require.NoError(t, json.Unmarshal(wr.Body.Bytes(), resp)) + require.Equal(t, len(resp.Accounts), numAccounts) - km, err := s.validatorService.Keymanager() - if err != nil { - httputil.HandleError(w, err.Error(), http.StatusInternalServerError) - return + pubKeys := make([]string, numAccounts) + for i, aa := range resp.Accounts { + pubKeys[i] = aa.ValidatingPublicKey } - pubKeys := make([]bls.PublicKey, len(req.PublicKeys)) - for i, key := range req.PublicKeys { - byteskey, ok := shared.ValidateHex(w, "pubkey", key, fieldparams.BLSPubkeyLength) - if !ok { - return - } - pubKey, err := bls.PublicKeyFromBytes(byteskey) - if err != nil { - httputil.HandleError(w, errors.Wrap(err, fmt.Sprintf("%s Not a valid BLS public key", key)).Error(), http.StatusBadRequest) - return - } - pubKeys[i] = pubKey + request := &BackupAccountsRequest{ + PublicKeys: pubKeys, + BackupPassword: s.wallet.Password(), } + var buf bytes.Buffer + err = json.NewEncoder(&buf).Encode(request) + require.NoError(t, err) + req = httptest.NewRequest(http.MethodPost, api.WebUrlPrefix+"accounts/backup", &buf) + wr = httptest.NewRecorder() + wr.Body = &bytes.Buffer{} + // We now attempt to backup all public keys from the wallet. + s.BackupAccounts(wr, req) + require.Equal(t, http.StatusOK, wr.Code) + res := &BackupAccountsResponse{} + require.NoError(t, json.Unmarshal(wr.Body.Bytes(), res)) + // decode the base64 string + decodedBytes, err := base64.StdEncoding.DecodeString(res.ZipFile) + require.NoError(t, err) + // Open a zip archive for reading. + bu := bytes.NewReader(decodedBytes) + r, err := zip.NewReader(bu, int64(len(decodedBytes))) + require.NoError(t, err) + require.Equal(t, len(pubKeys), len(r.File)) - var keystoresToBackup []*keymanager.Keystore - switch km := km.(type) { - case *local.Keymanager: - keystoresToBackup, err = km.ExtractKeystores(ctx, pubKeys, req.BackupPassword) + // Iterate through the files in the archive, checking they + // match the keystores we wanted to back up. + for i, f := range r.File { + keystoreFile, err := f.Open() + require.NoError(t, err) + encoded, err := io.ReadAll(keystoreFile) if err != nil { - httputil.HandleError(w, errors.Wrap(err, "Could not backup accounts for local keymanager").Error(), http.StatusInternalServerError) - return + require.NoError(t, keystoreFile.Close()) + t.Fatal(err) } - case *derived.Keymanager: - keystoresToBackup, err = km.ExtractKeystores(ctx, pubKeys, req.BackupPassword) - if err != nil { - httputil.HandleError(w, errors.Wrap(err, "Could not backup accounts for derived keymanager").Error(), http.StatusInternalServerError) - return + keystore := &keymanager.Keystore{} + if err := json.Unmarshal(encoded, &keystore); err != nil { + require.NoError(t, keystoreFile.Close()) + t.Fatal(err) } - default: - httputil.HandleError(w, "Only HD or IMPORTED wallets can backup accounts", http.StatusBadRequest) - return - } - if len(keystoresToBackup) == 0 { - httputil.HandleError(w, "No keystores to backup", http.StatusBadRequest) - return + assert.Equal(t, "0x"+keystore.Pubkey, pubKeys[i]) + require.NoError(t, keystoreFile.Close()) } +} - buf := new(bytes.Buffer) - writer := zip.NewWriter(buf) - for i, k := range keystoresToBackup { - encodedFile, err := json.MarshalIndent(k, "", "\t") - if err != nil { - if err := writer.Close(); err != nil { - log.WithError(err).Error("Could not close zip file after writing") - } - httputil.HandleError(w, "could not marshal keystore to JSON file", http.StatusInternalServerError) - return - } - f, err := writer.Create(fmt.Sprintf("keystore-%d.json", i)) - if err != nil { - if err := writer.Close(); err != nil { - log.WithError(err).Error("Could not close zip file after writing") - } - httputil.HandleError(w, "Could not write keystore file to zip", http.StatusInternalServerError) - return - } - if _, err = f.Write(encodedFile); err != nil { - if err := writer.Close(); err != nil { - log.WithError(err).Error("Could not close zip file after writing") - } - httputil.HandleError(w, "Could not write keystore file contents", http.StatusBadRequest) - return - } - } - if err := writer.Close(); err != nil { - log.WithError(err).Error("Could not close zip file after writing") +func TestServer_VoluntaryExit(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + ctx := t.Context() + mockValidatorClient := validatormock.NewMockValidatorClient(ctrl) + mockNodeClient := validatormock.NewMockNodeClient(ctrl) + + mockValidatorClient.EXPECT(). + ValidatorIndex(gomock.Any(), gomock.Any()). + Return(ðpb.ValidatorIndexResponse{Index: 0}, nil) + + mockValidatorClient.EXPECT(). + ValidatorIndex(gomock.Any(), gomock.Any()). + Return(ðpb.ValidatorIndexResponse{Index: 1}, nil) + + // Any time in the past will suffice + genesisTime := ×tamppb.Timestamp{ + Seconds: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).Unix(), } - httputil.WriteJson(w, &BackupAccountsResponse{ - ZipFile: base64.StdEncoding.EncodeToString(buf.Bytes()), // convert to base64 string for processing - }) -} -// VoluntaryExit performs a voluntary exit for the validator keys specified in a request. -func (s *Server) VoluntaryExit(w http.ResponseWriter, r *http.Request) { - ctx, span := trace.StartSpan(r.Context(), "validator.web.accounts.VoluntaryExit") - defer span.End() - if s.validatorService == nil { - httputil.HandleError(w, "Validator service not ready.", http.StatusServiceUnavailable) - return + mockNodeClient.EXPECT(). + Genesis(gomock.Any(), gomock.Any()). + Return(ðpb.Genesis{GenesisTime: genesisTime}, nil) + + mockValidatorClient.EXPECT(). + DomainData(gomock.Any(), gomock.Any()). + Times(2). + Return(ðpb.DomainResponse{SignatureDomain: make([]byte, 32)}, nil) + + mockValidatorClient.EXPECT(). + ProposeExit(gomock.Any(), gomock.AssignableToTypeOf(ðpb.SignedVoluntaryExit{})). + Times(2). + Return(ðpb.ProposeExitResponse{}, nil) + + localWalletDir := setupWalletDir(t) + defaultWalletPath = localWalletDir + // We attempt to create the wallet. + opts := []accounts.Option{ + accounts.WithWalletDir(defaultWalletPath), + accounts.WithKeymanagerType(keymanager.Derived), + accounts.WithWalletPassword(strongPass), + accounts.WithSkipMnemonicConfirm(true), } - if !s.walletInitialized { - httputil.HandleError(w, "Prysm Wallet not initialized. Please create a new wallet.", http.StatusServiceUnavailable) - return + acc, err := accounts.NewCLIManager(opts...) + require.NoError(t, err) + w, err := acc.WalletCreate(ctx) + require.NoError(t, err) + km, err := w.InitializeKeymanager(ctx, iface.InitKeymanagerConfig{ListenForChanges: false}) + require.NoError(t, err) + require.NoError(t, err) + vs, err := client.NewValidatorService(ctx, &client.Config{ + Wallet: w, + Validator: &testutil.FakeValidator{ + Km: km, + }, + }) + require.NoError(t, err) + s := &Server{ + walletInitialized: true, + wallet: w, + nodeClient: mockNodeClient, + beaconNodeValidatorClient: mockValidatorClient, + validatorService: vs, } - var req VoluntaryExitRequest - err := json.NewDecoder(r.Body).Decode(&req) - switch { - case errors.Is(err, io.EOF): - httputil.HandleError(w, "No data submitted", http.StatusBadRequest) - return - case err != nil: - httputil.HandleError(w, "Could not decode request body: "+err.Error(), http.StatusBadRequest) - return + numAccounts := 2 + dr, ok := km.(*derived.Keymanager) + require.Equal(t, true, ok) + err = dr.RecoverAccountsFromMnemonic(ctx, constant.TestMnemonic, derived.DefaultMnemonicLanguage, "", numAccounts) + require.NoError(t, err) + pubKeys, err := dr.FetchValidatingPublicKeys(ctx) + require.NoError(t, err) + + rawPubKeys := make([]string, len(pubKeys)) + for i, key := range pubKeys { + rawPubKeys[i] = hexutil.Encode(key[:]) } - if len(req.PublicKeys) == 0 { - httputil.HandleError(w, "No public keys specified to delete", http.StatusBadRequest) - return + request := &VoluntaryExitRequest{ + PublicKeys: rawPubKeys, } - km, err := s.validatorService.Keymanager() - if err != nil { - httputil.HandleError(w, err.Error(), http.StatusInternalServerError) - return + var buf bytes.Buffer + err = json.NewEncoder(&buf).Encode(request) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, api.WebUrlPrefix+"accounts/voluntary-exit", &buf) + wr := httptest.NewRecorder() + wr.Body = &bytes.Buffer{} + s.VoluntaryExit(wr, req) + require.Equal(t, http.StatusOK, wr.Code) + res := &VoluntaryExitResponse{} + require.NoError(t, json.Unmarshal(wr.Body.Bytes(), res)) + for i := range res.ExitedKeys { + require.Equal(t, rawPubKeys[i], hexutil.Encode(res.ExitedKeys[i])) } - pubKeys := make([][]byte, len(req.PublicKeys)) - for i, key := range req.PublicKeys { - byteskey, ok := shared.ValidateHex(w, "pubkey", key, fieldparams.BLSPubkeyLength) - if !ok { - return - } - pubKeys[i] = byteskey + +} + +func TestServer_ListAccounts_FilterAndPagination(t *testing.T) { + ctx := t.Context() + localWalletDir := setupWalletDir(t) + defaultWalletPath = localWalletDir + // Create wallet with derived keymanager and recover N accounts + opts := []accounts.Option{ + accounts.WithWalletDir(defaultWalletPath), + accounts.WithKeymanagerType(keymanager.Derived), + accounts.WithWalletPassword(strongPass), + accounts.WithSkipMnemonicConfirm(true), } - cfg := accounts.PerformExitCfg{ - ValidatorClient: s.beaconNodeValidatorClient, - NodeClient: s.nodeClient, - Keymanager: km, - RawPubKeys: pubKeys, - FormattedPubKeys: req.PublicKeys, + acc, err := accounts.NewCLIManager(opts...) + require.NoError(t, err) + w, err := acc.WalletCreate(ctx) + require.NoError(t, err) + km, err := w.InitializeKeymanager(ctx, iface.InitKeymanagerConfig{ListenForChanges: false}) + require.NoError(t, err) + vs, err := client.NewValidatorService(ctx, &client.Config{ + Wallet: w, + Validator: &testutil.FakeValidator{ + Km: km, + }, + }) + require.NoError(t, err) + s := &Server{ + walletInitialized: true, + wallet: w, + validatorService: vs, } - rawExitedKeys, _, err := accounts.PerformVoluntaryExit(ctx, cfg) - if err != nil { - httputil.HandleError(w, errors.Wrap(err, "Could not perform voluntary exit").Error(), http.StatusInternalServerError) - return + // Recover multiple accounts + numAccounts := 10 + dr, ok := km.(*derived.Keymanager) + require.Equal(t, true, ok) + err = dr.RecoverAccountsFromMnemonic(ctx, constant.TestMnemonic, derived.DefaultMnemonicLanguage, "", numAccounts) + require.NoError(t, err) + + // Fetch all accounts to pick two pubkeys for filtering + req := httptest.NewRequest(http.MethodGet, api.WebUrlPrefix+"accounts?all=true", nil) + wr := httptest.NewRecorder() + wr.Body = &bytes.Buffer{} + s.ListAccounts(wr, req) + require.Equal(t, http.StatusOK, wr.Code) + resp := &ListAccountsResponse{} + require.NoError(t, json.Unmarshal(wr.Body.Bytes(), resp)) + if len(resp.Accounts) < 2 { + t.Fatalf("expected at least 2 accounts, got %d", len(resp.Accounts)) } - httputil.WriteJson(w, &VoluntaryExitResponse{ - ExitedKeys: rawExitedKeys, - }) + + target1 := resp.Accounts[1] + target2 := resp.Accounts[3] + + // Page 1: page_size=1, filtered by two pubkeys + url1 := api.WebUrlPrefix + "accounts?page_size=1" + + "&public_keys=" + target1.ValidatingPublicKey + + "&public_keys=" + target2.ValidatingPublicKey + req = httptest.NewRequest(http.MethodGet, url1, nil) + wr = httptest.NewRecorder() + wr.Body = &bytes.Buffer{} + s.ListAccounts(wr, req) + require.Equal(t, http.StatusOK, wr.Code) + page1 := &ListAccountsResponse{} + require.NoError(t, json.Unmarshal(wr.Body.Bytes(), page1)) + require.Equal(t, int32(2), page1.TotalSize) + require.Equal(t, 1, len(page1.Accounts)) + assert.Equal(t, target1.ValidatingPublicKey, page1.Accounts[0].ValidatingPublicKey) + require.NotEmpty(t, page1.NextPageToken) + + // Page 2: use next page token + url2 := api.WebUrlPrefix + "accounts?page_size=1&page_token=" + page1.NextPageToken + + "&public_keys=" + target1.ValidatingPublicKey + + "&public_keys=" + target2.ValidatingPublicKey + req = httptest.NewRequest(http.MethodGet, url2, nil) + wr = httptest.NewRecorder() + wr.Body = &bytes.Buffer{} + s.ListAccounts(wr, req) + require.Equal(t, http.StatusOK, wr.Code) + page2 := &ListAccountsResponse{} + require.NoError(t, json.Unmarshal(wr.Body.Bytes(), page2)) + require.Equal(t, int32(2), page2.TotalSize) + require.Equal(t, 1, len(page2.Accounts)) + assert.Equal(t, target2.ValidatingPublicKey, page2.Accounts[0].ValidatingPublicKey) + + // all=true: both filtered accounts returned + urlAll := api.WebUrlPrefix + "accounts?all=true" + + "&public_keys=" + target1.ValidatingPublicKey + + "&public_keys=" + target2.ValidatingPublicKey + req = httptest.NewRequest(http.MethodGet, urlAll, nil) + wr = httptest.NewRecorder() + wr.Body = &bytes.Buffer{} + s.ListAccounts(wr, req) + require.Equal(t, http.StatusOK, wr.Code) + allResp := &ListAccountsResponse{} + require.NoError(t, json.Unmarshal(wr.Body.Bytes(), allResp)) + require.Equal(t, int32(2), allResp.TotalSize) + require.Equal(t, 2, len(allResp.Accounts)) + // Order should reflect the original order by index in key list + assert.Equal(t, target1.ValidatingPublicKey, allResp.Accounts[0].ValidatingPublicKey) + assert.Equal(t, target2.ValidatingPublicKey, allResp.Accounts[1].ValidatingPublicKey) }