From d339653601f8ca7932687f32adb6c64c75cbde54 Mon Sep 17 00:00:00 2001 From: Kristof Csillag Date: Tue, 30 Apr 2024 23:34:37 +0200 Subject: [PATCH] Implement named accounts for Oasis consensus, Emerald and Sapphire --- src/app/components/Account/AccountLink.tsx | 6 +- src/app/data/named-accounts.ts | 18 ++++ src/app/data/oasis-account-names.ts | 120 +++++++++++++++++++++ src/app/data/pontusx-account-names.ts | 32 ++---- src/app/hooks/useAccountMetadata.ts | 36 +++++++ src/app/hooks/useAccountName.ts | 72 ------------- src/app/pages/SearchResultsPage/hooks.ts | 2 +- 7 files changed, 190 insertions(+), 96 deletions(-) create mode 100644 src/app/data/named-accounts.ts create mode 100644 src/app/data/oasis-account-names.ts create mode 100644 src/app/hooks/useAccountMetadata.ts delete mode 100644 src/app/hooks/useAccountName.ts diff --git a/src/app/components/Account/AccountLink.tsx b/src/app/components/Account/AccountLink.tsx index a81e3b65ac..8bac0a4062 100644 --- a/src/app/components/Account/AccountLink.tsx +++ b/src/app/components/Account/AccountLink.tsx @@ -6,7 +6,7 @@ import { RouteUtils } from '../../utils/route-utils' import InfoIcon from '@mui/icons-material/Info' import Typography from '@mui/material/Typography' import { SearchScope } from '../../../types/searchScope' -import { useAccountName } from '../../hooks/useAccountName' +import { useAccountMetadata } from '../../hooks/useAccountMetadata' import { trimLongString } from '../../utils/trimLongString' import { MaybeWithTooltip } from '../AdaptiveTrimmer/MaybeWithTooltip' import Box from '@mui/material/Box' @@ -69,7 +69,9 @@ export const AccountLink: FC = ({ extraTooltip, }) => { const { isTablet } = useScreenSize() - const { name: accountName } = useAccountName(scope, address) + const { metadata: accountMetadata } = useAccountMetadata(scope, address) + const accountName = accountMetadata?.name // TODO: we should also use the description + const to = RouteUtils.getAccountRoute(scope, address) const tooltipPostfix = extraTooltip ? ( diff --git a/src/app/data/named-accounts.ts b/src/app/data/named-accounts.ts new file mode 100644 index 0000000000..da5f12417e --- /dev/null +++ b/src/app/data/named-accounts.ts @@ -0,0 +1,18 @@ +export type AccountMetadata = { + name?: string + description?: string +} + +export type AccountMetadataInfo = { + metadata?: AccountMetadata + loading: boolean +} + +export type AccountMap = Map + +export type AccountEntry = { address: string } & AccountMetadata + +export type AccountData = { + map: AccountMap + list: AccountEntry[] +} diff --git a/src/app/data/oasis-account-names.ts b/src/app/data/oasis-account-names.ts new file mode 100644 index 0000000000..77c5feaaf6 --- /dev/null +++ b/src/app/data/oasis-account-names.ts @@ -0,0 +1,120 @@ +import axios from 'axios' +import { useQuery } from '@tanstack/react-query' +import { Layer } from '../../oasis-nexus/api' +import { Network } from '../../types/network' +import { findTextMatch } from '../components/HighlightedText/text-matching' +import * as process from 'process' +import { AccountData, AccountEntry, AccountMap, AccountMetadata, AccountMetadataInfo } from './named-accounts' + +const dataSources: Record>> = { + [Network.mainnet]: { + [Layer.consensus]: + 'https://raw.githubusercontent.com/oasisprotocol/nexus/main/account-names/mainnet_consensus.json', + [Layer.emerald]: + 'https://raw.githubusercontent.com/oasisprotocol/nexus/main/account-names/mainnet_paratime.json', + [Layer.sapphire]: + 'https://raw.githubusercontent.com/oasisprotocol/nexus/main/account-names/mainnet_paratime.json', + }, + [Network.testnet]: { + [Layer.consensus]: + 'https://raw.githubusercontent.com/oasisprotocol/nexus/main/account-names/testnet_consensus.json', + [Layer.emerald]: + 'https://raw.githubusercontent.com/oasisprotocol/nexus/main/account-names/testnet_paratime.json', + [Layer.sapphire]: + 'https://raw.githubusercontent.com/oasisprotocol/nexus/main/account-names/testnet_paratime.json', + }, +} + +const getOasisAccountsMetadata = (network: Network, layer: Layer) => + new Promise((resolve, reject) => { + const url = dataSources[network][layer] + if (!url) { + reject('No data available for this layer') + } else { + axios.get(url).then(response => { + if (response.status !== 200) reject("Couldn't load names") + if (!response.data) reject("Couldn't load names") + // console.log('Response data is', response.data) + const map: AccountMap = new Map() + const list: AccountEntry[] = [] + Array.from(response.data).forEach((entry: any) => { + const address = entry.Address + const metadata: AccountMetadata = { + name: entry.Name, + description: entry.Description, + } + // Register the metadata in its native form + list.push({ + address, + ...metadata, + }) + map.set(address, metadata) + }) + resolve({ + map, + list, + }) + }, reject) + } + }) + +export const useOasisAccountsMetadata = (network: Network, layer: Layer, enabled: boolean) => { + return useQuery(['oasisAccounts', network, layer], () => getOasisAccountsMetadata(network, layer), { + enabled, + staleTime: Infinity, + }) +} + +export const useOasisAccountMetadata = ( + network: Network, + layer: Layer, + address: string, + enabled: boolean, +): AccountMetadataInfo => { + // When running jest tests, we don't want to load from Pontus-X. + if (process.env.NODE_ENV === 'test') { + return { + metadata: { + name: undefined, + }, + loading: false, + } + } + // This is not a condition that can change while the app is running, so it's OK. + // eslint-disable-next-line react-hooks/rules-of-hooks + const { isLoading, error, data: allData } = useOasisAccountsMetadata(network, layer, enabled) + if (error) { + console.log('Failed to load Oasis account metadata', error) + } + return { + metadata: allData?.map.get(address), + loading: isLoading, + } +} + +export const useSearchForOasisAccountsByName = ( + network: Network, + layer: Layer, + nameFragment: string, + enabled: boolean, +) => { + const { isLoading, error, data: allData } = useOasisAccountsMetadata(network, layer, enabled) + if (error) { + console.log('Failed to load Oasis account metadata', error) + } + + const textMatcher = + nameFragment && enabled + ? (entry: AccountEntry): boolean => { + return !!findTextMatch(entry.name, [nameFragment]) + } + : () => false + return { + results: (allData?.list || []).filter(textMatcher).map(entry => ({ + network, + layer, + address: entry.address, + })), + isLoading, + } +} diff --git a/src/app/data/pontusx-account-names.ts b/src/app/data/pontusx-account-names.ts index b5474f0c96..b12d00dc85 100644 --- a/src/app/data/pontusx-account-names.ts +++ b/src/app/data/pontusx-account-names.ts @@ -1,32 +1,22 @@ import axios from 'axios' import { useQuery } from '@tanstack/react-query' -import type { AccountNameInfo } from '../hooks/useAccountName' import { Layer } from '../../oasis-nexus/api' import { Network } from '../../types/network' import { findTextMatch } from '../components/HighlightedText/text-matching' import * as process from 'process' +import { AccountData, AccountEntry, AccountMap, AccountMetadataInfo } from './named-accounts' const DATA_SOURCE_URL = 'https://raw.githubusercontent.com/deltaDAO/mvg-portal/main/pontusxAddresses.json' -type AccountMap = Map -type AccountEntry = { - name: string - address: string -} -type AccountData = { - map: AccountMap - list: AccountEntry[] -} - -const getPontusXAccountNames = () => +const getPontusXAccountsMetadata = () => new Promise((resolve, reject) => { axios.get(DATA_SOURCE_URL).then(response => { if (response.status !== 200) reject("Couldn't load names") if (!response.data) reject("Couldn't load names") - const map = new Map() + const map: AccountMap = new Map() const list: AccountEntry[] = [] Object.entries(response.data).forEach(([address, name]) => { - map.set(address, name) + map.set(address, { name: name as string }) const normalizedEntry: AccountEntry = { name: name as string, address, @@ -40,29 +30,29 @@ const getPontusXAccountNames = () => }, reject) }) -export const usePontusXAccountNames = (enabled: boolean) => { - return useQuery(['pontusXNames'], getPontusXAccountNames, { +export const usePontusXAccountsMetadata = (enabled: boolean) => { + return useQuery(['pontusXNames'], getPontusXAccountsMetadata, { enabled, staleTime: Infinity, }) } -export const usePontusXAccountName = (address: string, enabled: boolean): AccountNameInfo => { +export const usePontusXAccountMetadata = (address: string, enabled: boolean): AccountMetadataInfo => { // When running jest tests, we don't want to load from Pontus-X. if (process.env.NODE_ENV === 'test') { return { - name: undefined, + metadata: undefined, loading: false, } } // This is not a condition that can change while the app is running, so it's OK. // eslint-disable-next-line react-hooks/rules-of-hooks - const { isLoading, error, data: allNames } = usePontusXAccountNames(enabled) + const { isLoading, error, data: allData } = usePontusXAccountsMetadata(enabled) if (error) { console.log('Failed to load Pontus-X account names', error) } return { - name: allNames?.map.get(address), + metadata: allData?.map.get(address), loading: isLoading, } } @@ -72,7 +62,7 @@ export const useSearchForPontusXAccountsByName = ( nameFragment: string, enabled: boolean, ) => { - const { isLoading, error, data: allNames } = usePontusXAccountNames(enabled) + const { isLoading, error, data: allNames } = usePontusXAccountsMetadata(enabled) if (error) { console.log('Failed to load Pontus-X account names', error) } diff --git a/src/app/hooks/useAccountMetadata.ts b/src/app/hooks/useAccountMetadata.ts new file mode 100644 index 0000000000..bffc8713ae --- /dev/null +++ b/src/app/hooks/useAccountMetadata.ts @@ -0,0 +1,36 @@ +import { SearchScope } from '../../types/searchScope' +import { Layer } from '../../oasis-nexus/api' +import { usePontusXAccountMetadata, useSearchForPontusXAccountsByName } from '../data/pontusx-account-names' +import { AccountMetadataInfo } from '../data/named-accounts' +import { useOasisAccountMetadata, useSearchForOasisAccountsByName } from '../data/oasis-account-names' +import { getOasisAddress } from '../utils/helpers' + +/** + * Find out the metadata for an account + * + * This is the entry point that should be used by the application, + * since this function also includes caching. + */ +export const useAccountMetadata = (scope: SearchScope, address: string): AccountMetadataInfo => { + const isPontusX = scope.layer === Layer.pontusx + const pontusXData = usePontusXAccountMetadata(address, isPontusX) + const oasisData = useOasisAccountMetadata(scope.network, scope.layer, getOasisAddress(address), !isPontusX) + return isPontusX ? pontusXData : oasisData +} + +export const useSearchForAccountsByName = (scope: SearchScope, nameFragment = '') => { + const isValidPontusXSearch = scope.layer === Layer.pontusx && !!nameFragment + const pontusXResults = useSearchForPontusXAccountsByName(scope.network, nameFragment, isValidPontusXSearch) + const isValidOasisSearch = scope.layer !== Layer.pontusx && !!nameFragment + const oasisResults = useSearchForOasisAccountsByName( + scope.network, + scope.layer, + nameFragment, + isValidOasisSearch, + ) + return { + isLoading: + (isValidPontusXSearch && pontusXResults.isLoading) || (isValidOasisSearch && oasisResults.isLoading), + results: [...pontusXResults.results, ...oasisResults.results], + } +} diff --git a/src/app/hooks/useAccountName.ts b/src/app/hooks/useAccountName.ts deleted file mode 100644 index fe7a95ea77..0000000000 --- a/src/app/hooks/useAccountName.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { SearchScope } from '../../types/searchScope' -import Chance from 'chance' -import { Layer } from '../../oasis-nexus/api' -import { usePontusXAccountName, useSearchForPontusXAccountsByName } from '../data/pontusx-account-names' - -const NO_MATCH = '__no_match__' - -export type AccountNameInfo = { - name: string | undefined - loading: boolean -} - -/** - * Do we want to see some random names? - */ -const DEBUG_MODE = true - -/** - * Look up the name of an account. - */ -const lookupName = (scope: SearchScope, _address: string): string | undefined => { - switch (scope.layer) { - // TODO: look up the data - default: - // If debug mode is on, return mock names in ~50% of the cases, no nome otherwise - return DEBUG_MODE && Math.random() < 0.5 ? new Chance().name() : undefined - } -} - -const nameCache: Map = new Map() - -/** - * Find out the name of an account - * - * This is the entry point that should be used by the application, - * since this function also includes caching. - */ -export const useAccountName = (scope: SearchScope, address: string, dropCache = false): AccountNameInfo => { - const isPontusX = scope.layer === Layer.pontusx - - const pontusXName = usePontusXAccountName(address, isPontusX) - if (isPontusX) return pontusXName - - const key = `${scope.network}.${scope.layer}.${address}` - - if (dropCache) nameCache.delete(key) - const hasMatch = nameCache.has(key) - if (hasMatch) { - const cachedName = nameCache.get(key) - return { - name: cachedName === NO_MATCH ? undefined : cachedName, - loading: false, - } - } - const name = lookupName(scope, address) - nameCache.set(key, name ?? NO_MATCH) - return { - name, - loading: false, - } -} - -export const useSearchForAccountsByName = (scope: SearchScope, nameFragment = '') => { - const isValidPontusXSearch = scope.layer === Layer.pontusx && !!nameFragment - const pontusXResults = useSearchForPontusXAccountsByName(scope.network, nameFragment, isValidPontusXSearch) - return isValidPontusXSearch - ? pontusXResults - : { - isLoading: false, - results: [], - } -} diff --git a/src/app/pages/SearchResultsPage/hooks.ts b/src/app/pages/SearchResultsPage/hooks.ts index b5450202e5..5099614e24 100644 --- a/src/app/pages/SearchResultsPage/hooks.ts +++ b/src/app/pages/SearchResultsPage/hooks.ts @@ -19,7 +19,7 @@ import { import { RouteUtils } from '../../utils/route-utils' import { SearchParams } from '../../components/Search/search-utils' import { SearchScope } from '../../../types/searchScope' -import { useSearchForAccountsByName } from '../../hooks/useAccountName' +import { useSearchForAccountsByName } from '../../hooks/useAccountMetadata' function isDefined(item: T): item is NonNullable { return item != null