diff --git a/.changelog/2408.bugfix.md b/.changelog/2408.bugfix.md new file mode 100644 index 0000000000..5b04be679d --- /dev/null +++ b/.changelog/2408.bugfix.md @@ -0,0 +1 @@ +Support validators search by node or entity ids diff --git a/src/app/components/Search/search-utils.ts b/src/app/components/Search/search-utils.ts index 7881d02644..4b2f6d05e1 100644 --- a/src/app/components/Search/search-utils.ts +++ b/src/app/components/Search/search-utils.ts @@ -7,6 +7,7 @@ import { isValidEthAddress, isValidRoflAppId, uniq, + isValidPublicKey, } from '../../utils/helpers' import { RouteUtils, SpecifiedPerEnabledLayer } from '../../utils/route-utils' import { AppError, AppErrors } from '../../../types/errors' @@ -214,6 +215,14 @@ export const validateAndNormalize = { roflAppNameFragment: (searchTerm: string) => textSearch.roflAppName(searchTerm).result, + validatorEntityOrNodeId: (searchTerm: string) => { + const normalized = searchTerm.replace(/\s/g, '') + + if (isValidPublicKey(normalized)) { + return normalized + } + }, + evmAccount: (searchTerm: string): string | undefined => { if (isValidEthAddress(`0x${searchTerm}`)) { return `0x${searchTerm.toLowerCase()}` diff --git a/src/app/data/named-accounts.ts b/src/app/data/named-accounts.ts index 6f3698397c..5d31cbeabe 100644 --- a/src/app/data/named-accounts.ts +++ b/src/app/data/named-accounts.ts @@ -54,7 +54,7 @@ export type AccountNameSearchResults = { isError: boolean } -export type AccountNameSearchValidatorResults = { +export type ValidatorAccountSearchResults = { results: Account[] | undefined isLoading: boolean isError: boolean diff --git a/src/app/hooks/useSearchForValidatorsByEntityOrNodeId.ts b/src/app/hooks/useSearchForValidatorsByEntityOrNodeId.ts new file mode 100644 index 0000000000..1cc101cf72 --- /dev/null +++ b/src/app/hooks/useSearchForValidatorsByEntityOrNodeId.ts @@ -0,0 +1,40 @@ +import { useGetConsensusValidators, useGetConsensusAccountsAddresses } from 'oasis-nexus/api' +import { Network } from 'types/network' +import { ValidatorAccountSearchResults } from '../data/named-accounts' + +export const useSearchForValidatorsByEntityOrNodeId = ( + network: Network, + entityId?: string, +): ValidatorAccountSearchResults => { + const { isLoading, isError, data } = useGetConsensusValidators( + network, + { + id: entityId, + }, + { + query: { + enabled: !!entityId, + }, + }, + ) + + const matches = + data?.data?.validators.map(validator => ({ + address: validator.entity_address, + network, + })) || [] + + const { + isLoading: areConsensusAccountsLoading, + isError: areConsensusAccountsError, + data: consensusResults, + } = useGetConsensusAccountsAddresses(matches, { + enabled: !isLoading && !isError, + }) + + return { + isLoading: isLoading || areConsensusAccountsLoading, + isError: isError || areConsensusAccountsError, + results: consensusResults, + } +} diff --git a/src/app/hooks/useSearchForValidatorsByName.ts b/src/app/hooks/useSearchForValidatorsByName.ts index 3a9c85356c..116de21c0a 100644 --- a/src/app/hooks/useSearchForValidatorsByName.ts +++ b/src/app/hooks/useSearchForValidatorsByName.ts @@ -5,7 +5,7 @@ import { ValidatorAddressNameMap, } from 'oasis-nexus/api' import { Network } from 'types/network' -import { AccountNameSearchValidatorResults, AccountNameSearchConsensusMatch } from '../data/named-accounts' +import { ValidatorAccountSearchResults, AccountNameSearchConsensusMatch } from '../data/named-accounts' function findAddressesWithMatch( addressMap: ValidatorAddressNameMap, @@ -26,7 +26,7 @@ function findAddressesWithMatch( export const useSearchForValidatorsByName = ( network: Network, nameFragment: string[], -): AccountNameSearchValidatorResults => { +): ValidatorAccountSearchResults => { const { isLoading, isError, data } = useGetConsensusValidatorsAddressNameMap(network) const matches = data?.data && !!nameFragment.length ? findAddressesWithMatch(data?.data, nameFragment, network) : [] diff --git a/src/app/pages/SearchResultsPage/hooks.ts b/src/app/pages/SearchResultsPage/hooks.ts index d7fcf968d8..cf7d1f8ce9 100644 --- a/src/app/pages/SearchResultsPage/hooks.ts +++ b/src/app/pages/SearchResultsPage/hooks.ts @@ -30,6 +30,7 @@ import { SearchParams } from '../../components/Search/search-utils' import { SearchScope } from '../../../types/searchScope' import { useSearchForAccountsByName } from '../../hooks/useAccountMetadata' import { useSearchForValidatorsByName } from '../../hooks/useSearchForValidatorsByName' +import { useSearchForValidatorsByEntityOrNodeId } from '../../hooks/useSearchForValidatorsByEntityOrNodeId' function isDefined(item: T): item is NonNullable { return item != null @@ -343,6 +344,22 @@ export function useNamedAccountConditionally( } } +export function useValidatorEntityOrNodeIdConditionally(id: string | undefined): ConditionalResults { + const queries = RouteUtils.getEnabledNetworksForLayer('consensus').map(network => + // eslint-disable-next-line react-hooks/rules-of-hooks + useSearchForValidatorsByEntityOrNodeId(network, id), + ) + + return { + isLoading: queries.some(query => query.isLoading), + isError: queries.some(query => query.isError), + results: queries + .map(query => query.results) + .filter(isDefined) + .flat(), + } +} + export function useNamedValidatorConditionally(nameFragment: string[]) { const queries = RouteUtils.getEnabledNetworksForLayer('consensus').map(network => // eslint-disable-next-line react-hooks/rules-of-hooks @@ -415,6 +432,7 @@ export const useSearch = (currentScope: SearchScope | undefined, q: SearchParams roflAppId: useRoflAppIdConditionally(q.roflAppId), roflAppName: useRoflAppNameConditionally(q.roflAppNameFragment), accountsByName: useNamedAccountConditionally(currentScope, q.accountNameFragment), + validatorByEntityId: useValidatorEntityOrNodeIdConditionally(q.validatorEntityOrNodeId), validatorByName: useNamedValidatorConditionally(q.validatorNameFragment), tokens: useRuntimeTokenConditionally(currentScope, q.evmTokenNameFragment), proposals: useNetworkProposalsConditionally(q.networkProposalNameFragment), @@ -443,7 +461,11 @@ export const useSearch = (currentScope: SearchScope | undefined, q: SearchParams .filter(a => !alreadyAToken.has(a.network + a.layer + a.address)) // Deduplicate tokens const roflApps = [...queries.roflAppId.results, ...queries.roflAppName.results] const proposals = queries.proposals.results - const validators = [...queries.validatorByName.results, ...queries.oasisConsensusAccount.results] + const validators = [ + ...queries.validatorByName.results, + ...queries.validatorByEntityId.results, + ...queries.oasisConsensusAccount.results, + ] const results: SearchResultItem[] = isLoading ? [] : [ diff --git a/src/app/utils/helpers.ts b/src/app/utils/helpers.ts index 5f71457d23..f0cfa75282 100644 --- a/src/app/utils/helpers.ts +++ b/src/app/utils/helpers.ts @@ -68,6 +68,21 @@ export function getOasisAddressFromBase64PublicKey(key: string) { return oasis.staking.addressToBech32(oasis.staking.addressFromPublicKey(keyBytes)) } +export const isValidPublicKey = (key: string): boolean => { + try { + const keyBytes = new Uint8Array(Buffer.from(key, 'base64')) + + if (keyBytes.length !== 32) { + return false + } + + const address = oasis.staking.addressFromPublicKey(keyBytes) + return !!address + } catch (e) { + return false + } +} + export const isValidTxOasisHash = (hash: string): boolean => /^[0-9a-fA-F]{64}$/.test(hash) export const isValidTxEthHash = (hash: string): boolean => /^0x[0-9a-fA-F]{64}$/.test(hash) diff --git a/src/oasis-nexus/generated/api.ts b/src/oasis-nexus/generated/api.ts index 41a36d067f..1551b785ab 100644 --- a/src/oasis-nexus/generated/api.ts +++ b/src/oasis-nexus/generated/api.ts @@ -2239,6 +2239,12 @@ a name that is a superstring of the input param. */ name?: string; +/** + * A filter on the entity or node ID (base64-encoded ed25519 public key). +Returns the validator whose entity_id matches the provided value, or whose node's node_id matches the provided value. + + */ +id?: string; }; export type GetConsensusValidatorsAddressHistoryParams = {