diff --git a/CHANGELOG.md b/CHANGELOG.md index b833d3923e1..b57a7e8de65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased (develop) +- added: Reverse-resolve recipient addresses to ENS / Unstoppable Domains / ZNS names in the send flow, address modal, and transaction history. + ## 4.49.0 (staging) - added: Honor `af` affiliate parameter on `deep.edge.app` deep links, activating the promotion alongside any inner payload (e.g. private-key import). diff --git a/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap b/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap index 5713ea73221..b42537c0ea4 100644 --- a/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/SendScene2.ui.test.tsx.snap @@ -1520,16 +1520,18 @@ exports[`SendScene2 1 spendTarget 1`] = ` diff --git a/src/__tests__/scenes/__snapshots__/TransactionDetailsScene.test.tsx.snap b/src/__tests__/scenes/__snapshots__/TransactionDetailsScene.test.tsx.snap index d9968d453a2..a2b1c44ad20 100644 --- a/src/__tests__/scenes/__snapshots__/TransactionDetailsScene.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/TransactionDetailsScene.test.tsx.snap @@ -402,26 +402,35 @@ exports[`TransactionDetailsScene should render 1`] = ` > Sender Name - - timmy - + + timmy + + Recipient Name - - timmy - + + timmy + + { fioCheckQueue: number = 0 + // Bumped on each text change; the reverse-lookup callback only acts when its + // captured value still matches `reverseLookupSeq`. Prevents stale results + // from earlier inputs from clobbering the label after the user has moved on. + reverseLookupSeq: number = 0 constructor(props: Props) { super(props) this.fioCheckQueue = 0 + this.reverseLookupSeq = 0 this.state = { uri: '', validLabel: undefined, @@ -158,10 +164,20 @@ export class AddressModalComponent extends React.Component { onChangeTextDelayed = async (domain: string): Promise => { this.setState({ errorLabel: undefined, validLabel: undefined }) this.updateUri(domain) + // Invalidate any in-flight reverse lookup so a stale resolved name from a + // previous raw-address input can't briefly land in `validLabel` after the + // user switches to a domain (which doesn't otherwise touch the counter). + ++this.reverseLookupSeq try { const { currencyCode } = this.props if (this.checkIfDomain(domain)) { await this.resolveName(domain, currencyCode) + } else { + // Looks like a raw address: try a reverse lookup so the resolved + // name surfaces in the input's `validLabel` slot before the user + // taps Next. Symmetric to the forward-resolution flow above (which + // shows the resolved address as the green label). + this.tryReverseLookup(domain.trim()) } await this.checkIfFioAddress(domain) } catch (error: unknown) { @@ -169,6 +185,35 @@ export class AddressModalComponent extends React.Component { } } + tryReverseLookup = (input: string): void => { + if (input === '') return + const { coreWallet, currencyCode } = this.props + const seq = ++this.reverseLookupSeq + // Gate on `parseUri` succeeding so we only hit the network when the input + // is a valid address (or URI containing one) for this wallet's chain. + // This avoids per-keystroke reverse lookups while the user is mid-typing. + coreWallet + .parseUri(input, currencyCode) + .then(async parsed => { + if (seq !== this.reverseLookupSeq) return + const publicAddress = parsed.publicAddress + if (publicAddress == null || publicAddress === '') return + const result = await reverseLookupName( + coreWallet.currencyInfo.pluginId, + publicAddress + ) + // Drop stale results: the user has typed past this input. + if (seq !== this.reverseLookupSeq) return + if (result == null) return + // Don't clobber a forward-resolution validLabel or an active error. + if (this.state.validLabel != null || this.state.errorLabel != null) { + return + } + this.setState({ validLabel: result.name }) + }) + .catch((_err: unknown) => undefined) + } + // Non-async wrapper to satisfy handler-name and no-misused-promises rules handleChangeText = (domain: string): void => { this.onChangeTextDelayed(domain).catch((e: unknown) => { @@ -533,7 +578,7 @@ const getStyles = cacheStyles((theme: Theme) => ({ } })) -export function AddressModal(props: OwnProps): React.ReactElement { +export const AddressModal: React.FC = props => { const theme = useTheme() const dispatch = useDispatch() diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 7d3fac929f2..58aa9578ff5 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -79,7 +79,6 @@ import { ErrorCard, I18nError } from '../cards/ErrorCard' import type { AccentColors } from '../common/DotsBackground' import { EdgeAnim } from '../common/EdgeAnim' import { SceneWrapper } from '../common/SceneWrapper' -import { styled } from '../hoc/styled' import { ButtonsModal } from '../modals/ButtonsModal' import { FlipInputModal2, @@ -193,7 +192,7 @@ const isEvmWallet = (wallet: EdgeCurrencyWallet): boolean => { return specialInfo.walletConnectV2ChainId?.namespace === 'eip155' } -const SendComponent = (props: Props): React.ReactElement => { +const SendComponent: React.FC = props => { const { route, navigation } = props const dispatch = useDispatch() const theme = useTheme() @@ -428,7 +427,7 @@ const SendComponent = (props: Props): React.ReactElement => { const handleChangeAddress = (spendTarget: EdgeSpendTarget) => async (changeAddressResult: ChangeAddressResult): Promise => { - const { addressEntryMethod, parsedUri, fioAddress, alias, znsName } = + const { addressEntryMethod, parsedUri, fioAddress, alias, resolvedName } = changeAddressResult if (parsedUri != null) { @@ -469,7 +468,7 @@ const SendComponent = (props: Props): React.ReactElement => { spendTarget.otherParams = { fioAddress, zanoAlias: alias, - znsName + resolvedName } // We can assume the spendTarget object came from the Component spendInfo so simply resetting the spendInfo @@ -496,12 +495,12 @@ const SendComponent = (props: Props): React.ReactElement => { spendTarget: EdgeSpendTarget ): React.ReactElement => { const { publicAddress, nativeAmount, otherParams = {} } = spendTarget - const { fioAddress, znsName } = otherParams + const { fioAddress, resolvedName } = otherParams let title = '' if (fioAddress != null) { title = `Send To (${fioAddress}) ${publicAddress}` - } else if (znsName != null) { - title = `Send To (${znsName}) ${publicAddress}` + } else if (resolvedName != null) { + title = `Send To (${resolvedName.name}) ${publicAddress}` } else { title = `Send To ${publicAddress}` } @@ -543,8 +542,14 @@ const SendComponent = (props: Props): React.ReactElement => { if (coreWallet != null && hiddenFeaturesMap.address !== true) { // TODO: Change API of AddressTile to access undefined recipientAddress const { publicAddress = '', otherParams = {} } = spendTarget - const { fioAddress, zanoAlias, znsName } = otherParams - const recipientName = fioAddress ?? znsName ?? zanoAlias + const { fioAddress, zanoAlias, resolvedName } = otherParams + const recipientName = fioAddress ?? resolvedName?.name ?? zanoAlias + // Only the name-service path carries an inline service badge — FIO and + // Zano handles render plain. + const recipientNameService = + recipientName != null && recipientName === resolvedName?.name + ? resolvedName.service + : null const title = lstrings.send_scene_send_to_address + (spendInfo.spendTargets.length > 1 ? ` ${(index + 1).toString()}` : '') @@ -564,6 +569,7 @@ const SendComponent = (props: Props): React.ReactElement => { lockInputs={lockTilesMap.address} isCameraOpen={doOpenCamera} recipientName={recipientName} + recipientNameService={recipientNameService} navigation={navigation as NavigationBase} /> ) @@ -1324,13 +1330,16 @@ const SendComponent = (props: Props): React.ReactElement => { payeeName = zanoAliases[0] } } - // Same idea for ZNS (.zec) names on Zcash - if (coreWallet.currencyInfo.pluginId === 'zcash') { - const znsNames = spendInfo.spendTargets - .map(t => t.otherParams?.znsName) - .filter((a): a is string => a != null && a.length > 0) - if (znsNames.length === 1) { - payeeName = znsNames[0] + // Same idea for any name-service result (ENS / UD / ZNS) captured by + // AddressTile2's forward or reverse lookup. The chain-specific Zcash + // branch above is now subsumed by this generic check; ZNS results + // flow through `resolvedName` like any other service. + if (payeeName == null) { + const resolvedNames = spendInfo.spendTargets + .map(t => t.otherParams?.resolvedName?.name) + .filter((n): n is string => n != null && n.length > 0) + if (resolvedNames.length === 1) { + payeeName = resolvedNames[0] } } for (const target of spendInfo.spendTargets) { @@ -1794,106 +1803,95 @@ const SendComponent = (props: Props): React.ReactElement => { backgroundGradientStart={theme.assetBackgroundGradientStart} overrideDots={theme.backgroundDots.assetOverrideDots} > - {({ insetStyle }) => ( - <> - { - const kbRef: KeyboardAwareScrollView | null = ref as any - scrollViewRef.current = kbRef - }} - contentContainerStyle={{ - ...insetStyle, - paddingTop: 0, - paddingBottom: theme.rem(5) - }} - extraScrollHeight={theme.rem(2.75)} - enableOnAndroid - scrollIndicatorInsets={SCROLL_INDICATOR_INSET_FIX} - > - - - {renderSelectedWallet()} - {renderSelectFioAddress()} - - - - - {renderAddressAmountPairs()} - {renderTimeout()} - - - - {renderAddAddress()} - - - - {renderFees()} - {renderMetadataNotes()} - {renderMemoOptions()} - {renderInfoTiles()} - {renderAuthentication()} - - - - {renderScamWarning()} - - {renderPendingTransactionWarning()} - {renderNymWarning()} - {renderError()} - {sliderTopNode} - - - {showSlider && ( - - + {({ insetStyle }) => { + // We only need a bit more room under the slider when it's against + // the bottom edge of the screen to improve usability — things + // close to the edges of the screen are hard to access. When + // notifications push the slider up away from the bottom edge, + // reduce the bottom margin. + const sliderBottom = + insetStyle.paddingBottom + + (hasNotifications ? theme.rem(1) : theme.rem(2)) + return ( + <> + { + const kbRef: KeyboardAwareScrollView | null = ref as any + scrollViewRef.current = kbRef + }} + contentContainerStyle={{ + ...insetStyle, + paddingTop: 0, + paddingBottom: theme.rem(5) + }} + extraScrollHeight={theme.rem(2.75)} + enableOnAndroid + scrollIndicatorInsets={SCROLL_INDICATOR_INSET_FIX} + > + + + {renderSelectedWallet()} + {renderSelectFioAddress()} + - )} - - - )} + + + {renderAddressAmountPairs()} + {renderTimeout()} + + + + {renderAddAddress()} + + + + {renderFees()} + {renderMetadataNotes()} + {renderMemoOptions()} + {renderInfoTiles()} + {renderAuthentication()} + + + + {renderScamWarning()} + + {renderPendingTransactionWarning()} + {renderNymWarning()} + {renderError()} + {sliderTopNode} + + + {showSlider && ( + + + + )} + + + ) + }} ) } -const StyledKeyboardAwareScrollView = styled(KeyboardAwareScrollView)( - theme => ({ +export const SendScene2 = React.memo(SendComponent) + +const getStyles = cacheStyles((theme: Theme) => ({ + keyboardAwareScrollView: { margin: theme.rem(0.5), marginBottom: 0 - }) -) - -const StyledSliderView = styled(View)<{ - insetBottom: number - hasNotifications: boolean -}>(theme => props => { - const { insetBottom, hasNotifications } = props - - // We only need a bit more room under the slider when it's against the bottom - // edge of the screen to improve usability - things close to the edges of the - // screen are hard to access. - // We don't need this extra space when notifications push the slider up away - // from the bottom edge, so reduce the bottom margins in this case. - const bottom = insetBottom + (hasNotifications ? theme.rem(1) : theme.rem(2)) - - return { + }, + sliderView: { width: '100%', justifyContent: 'center', alignItems: 'center', - position: 'absolute', - bottom - } -}) - -export const SendScene2 = React.memo(SendComponent) - -const getStyles = cacheStyles((theme: Theme) => ({ + position: 'absolute' + }, calcFeeView: { flexDirection: 'row' }, diff --git a/src/components/scenes/TransactionDetailsScene.tsx b/src/components/scenes/TransactionDetailsScene.tsx index dc36fb94a08..1d17b7a482c 100644 --- a/src/components/scenes/TransactionDetailsScene.tsx +++ b/src/components/scenes/TransactionDetailsScene.tsx @@ -26,8 +26,8 @@ import { displayFiatAmount } from '../../hooks/useFiatText' import { useHandler } from '../../hooks/useHandler' import { useHistoricalRate } from '../../hooks/useHistoricalRate' import { useIconColor } from '../../hooks/useIconColor' +import { useReverseName } from '../../hooks/useReverseName' import { useWatch } from '../../hooks/useWatch' -import { useZnsName } from '../../hooks/useZnsName' import { toPercentString } from '../../locales/intl' import { lstrings } from '../../locales/strings' import { getExchangeDenom } from '../../selectors/DenominationSelectors' @@ -65,6 +65,7 @@ import { TxCryptoAmountRow } from '../rows/TxCryptoAmountRow' import { Airship, showError, showToast } from '../services/AirshipInstance' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { EdgeText } from '../themed/EdgeText' +import { NameServicePrefix } from '../themed/NameServicePrefix' interface Props extends EdgeAppSceneProps<'transactionDetails'> { wallet: EdgeCurrencyWallet @@ -451,11 +452,19 @@ export const TransactionDetailsComponent: React.FC = props => { direction === 'send' ? transaction.spendTargets?.[0]?.publicAddress : undefined - const znsName = useZnsName(wallet.currencyInfo.pluginId, recipientAddress) - const personName = + const reverseName = useReverseName( + wallet.currencyInfo.pluginId, + recipientAddress + ) + const customName = localMetadata.name != null && localMetadata.name !== '' ? localMetadata.name - : znsName ?? personLabel + : null + const personName = customName ?? reverseName?.name ?? personLabel + // Show the name-service logo only when the displayed name actually came + // from a reverse lookup (not a user-set contact name or a default label). + const personService = + customName == null && reverseName != null ? reverseName.service : null const personHeader = sprintf( lstrings.transaction_details_person_name, personLabel @@ -522,7 +531,12 @@ export const TransactionDetailsComponent: React.FC = props => { title={personHeader} onPress={openPersonInput} > - {personName} + + {personService != null ? ( + + ) : null} + {personName} + @@ -684,6 +698,10 @@ const getStyles = cacheStyles((theme: Theme) => ({ flexDirection: 'row', alignItems: 'center' }, + personRow: { + flexDirection: 'row', + alignItems: 'center' + }, tileAvatarIcon: { color: theme.primaryText, marginRight: theme.rem(0.5) diff --git a/src/components/themed/NameServicePrefix.tsx b/src/components/themed/NameServicePrefix.tsx new file mode 100644 index 00000000000..54da405a373 --- /dev/null +++ b/src/components/themed/NameServicePrefix.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' +import FastImage, { type ImageStyle } from 'react-native-fast-image' + +import ENS_LOGO from '../../assets/images/ens_logo.png' +import type { NameService } from '../../util/nameServices' +import { useTheme } from '../services/ThemeContext' + +// Map of name-service identifier to its logo asset. Services without a bundled +// asset render no prefix at all (no placeholder, no reserved space) so the +// caller's text appears unchanged. +const LOGO_MAP: Record = { + ens: ENS_LOGO, + unstoppable: null, + zns: null +} + +interface Props { + service: NameService + // Edge-to-edge size of the logo. Defaults to `theme.rem(1)` so the prefix + // matches surrounding default-sized text. Override for contexts using a + // larger or smaller text size. + size?: number +} + +// Small inline logo to prefix a resolved name string (e.g. "[ENS] alice.eth"). +// Caller is responsible for the row layout — wrap this and the text in a +// `flexDirection: 'row'` view, or use it inside a `` block on platforms +// that support inline images in text. +export const NameServicePrefix: React.FC = ({ service, size }) => { + const theme = useTheme() + const source = LOGO_MAP[service] + if (source == null) return null + const dim = size ?? theme.rem(1) + const style: ImageStyle = { + width: dim, + height: dim, + marginRight: theme.rem(0.25) + } + return ( + + ) +} diff --git a/src/components/themed/TransactionListRow.tsx b/src/components/themed/TransactionListRow.tsx index b74ed61eeb0..9b4a9ca7678 100644 --- a/src/components/themed/TransactionListRow.tsx +++ b/src/components/themed/TransactionListRow.tsx @@ -24,7 +24,7 @@ import { useDisplayDenom } from '../../hooks/useDisplayDenom' import { displayFiatAmount } from '../../hooks/useFiatText' import { useHandler } from '../../hooks/useHandler' import { useHistoricalRate } from '../../hooks/useHistoricalRate' -import { useZnsName } from '../../hooks/useZnsName' +import { useReverseName } from '../../hooks/useReverseName' import { formatNumber } from '../../locales/intl' import { lstrings } from '../../locales/strings' import { getExchangeDenom } from '../../selectors/DenominationSelectors' @@ -109,11 +109,11 @@ const TransactionViewInner: React.FC = props => { direction === 'send' ? transaction.spendTargets?.[0]?.publicAddress : undefined - const znsName = useZnsName(currencyInfo.pluginId, recipientAddress) + const reverseName = useReverseName(currencyInfo.pluginId, recipientAddress) const name = metadataName != null && metadataName !== '' ? metadataName - : znsName ?? metadataName + : reverseName?.name ?? metadataName const isSentTransaction = direction === 'send' const cryptoAmount = div( diff --git a/src/components/tiles/AddressTile2.tsx b/src/components/tiles/AddressTile2.tsx index a738786a748..7d36a570e6a 100644 --- a/src/components/tiles/AddressTile2.tsx +++ b/src/components/tiles/AddressTile2.tsx @@ -7,6 +7,7 @@ import type { } from 'edge-core-js' import { ethers } from 'ethers' import * as React from 'react' +import { View } from 'react-native' import AntDesign from 'react-native-vector-icons/AntDesign' import FontAwesome from 'react-native-vector-icons/FontAwesome' import FontAwesome5 from 'react-native-vector-icons/FontAwesome5' @@ -23,6 +24,7 @@ import type { NavigationBase } from '../../types/routerTypes' import { getCurrencyCode } from '../../util/CurrencyInfoHelpers' import { parseDeepLink } from '../../util/DeepLinkParser' import { checkPubAddress } from '../../util/FioAddressUtils' +import { type NameService, reverseLookupName } from '../../util/nameServices' import { resolveName } from '../../util/resolveName' import { isEmail } from '../../util/utils' import { isZnsName, resolveZnsName } from '../../util/zns' @@ -40,6 +42,7 @@ import { EdgeRow } from '../rows/EdgeRow' import { Airship, showError, showToast } from '../services/AirshipInstance' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { EdgeText } from '../themed/EdgeText' +import { NameServicePrefix } from '../themed/NameServicePrefix' export type AddressEntryMethod = 'scan' | 'other' @@ -48,7 +51,13 @@ export interface ChangeAddressResult { parsedUri?: EdgeParsedUri addressEntryMethod: AddressEntryMethod alias?: string - znsName?: string + /** + * Name resolved for the recipient via either forward resolution (user typed + * a name like "alice.eth") or reverse lookup of the entered address. Carries + * the source service so consumers can render a service-specific badge and + * persist the name into transaction metadata. + */ + resolvedName?: { name: string; service: NameService } } export interface AddressTileRef { @@ -66,9 +75,17 @@ interface Props { isCameraOpen: boolean /** * Friendly recipient name to render above the public address — e.g. a FIO - * handle, Zano alias, or ZNS (.zcash) name. Display-only. + * handle, Zano alias, or a name from a name-service reverse/forward lookup. + * Display-only. */ recipientName?: string + /** + * Source service for `recipientName`, when applicable. When set and the + * service has a logo asset, an inline 1rem prefix renders before the name. + * Pass `null` (or omit) to suppress the prefix — used for FIO/Zano handles + * which carry no name-service identity. + */ + recipientNameService?: NameService | null navigation: NavigationBase } @@ -78,6 +95,7 @@ export const AddressTile2 = React.forwardRef( coreWallet, tokenId, recipientName, + recipientNameService, isCameraOpen, lockInputs, navigation, @@ -156,7 +174,7 @@ export const AddressTile2 = React.forwardRef( const enteredInput = address.trim() address = enteredInput let zanoAlias: string | undefined - let znsName: string | undefined + let resolvedName: { name: string; service: NameService } | undefined let fioAddress if (fioPlugin != null) { try { @@ -215,7 +233,10 @@ export const AddressTile2 = React.forwardRef( try { const ethersProvider = ethers.getDefaultProvider(network) const resolvedAddress = await ethersProvider.resolveName(address) - if (resolvedAddress != null) address = resolvedAddress + if (resolvedAddress != null) { + resolvedName = { name: enteredInput, service: 'ens' } + address = resolvedAddress + } } catch (_) {} } } @@ -233,7 +254,7 @@ export const AddressTile2 = React.forwardRef( } catch (_) {} } - // Preserve and resolve ZcashNames like "alice.zcash" + // Preserve and resolve ZcashNames like "alice.zcash" / "alice.zec" if ( coreWallet.currencyInfo.pluginId === 'zcash' && isZnsName(enteredInput) @@ -241,7 +262,10 @@ export const AddressTile2 = React.forwardRef( try { const resolved = await resolveZnsName(enteredInput) if (resolved != null) { - znsName = enteredInput.toLowerCase() + resolvedName = { + name: enteredInput.toLowerCase(), + service: 'zns' + } address = resolved } } catch (_) {} @@ -286,13 +310,25 @@ export const AddressTile2 = React.forwardRef( return } + // If we don't already have a resolved name from a forward-typed + // domain, attempt a reverse lookup against the parsed public + // address. The dispatcher caches per (pluginId, address) so this + // is a no-op on subsequent paste of the same address. + if (resolvedName == null) { + const reverse = await reverseLookupName( + coreWallet.currencyInfo.pluginId, + parsedUri.publicAddress + ) + if (reverse != null) resolvedName = reverse + } + // set address await onChangeAddress({ fioAddress, parsedUri, addressEntryMethod, alias: zanoAlias, - znsName + resolvedName }) } catch (e: unknown) { const currencyInfo = coreWallet.currencyInfo @@ -529,7 +565,12 @@ export const AddressTile2 = React.forwardRef( exit={{ type: 'stretchOutY' }} > {recipientName == null ? null : ( - {recipientName + '\n'} + + {recipientNameService != null ? ( + + ) : null} + {recipientName} + )} ({ + recipientNameRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: theme.rem(0.5) + }, buttonsContainer: { paddingTop: theme.rem(0.75), flexDirection: 'row', diff --git a/src/hooks/useReverseName.ts b/src/hooks/useReverseName.ts new file mode 100644 index 00000000000..d8a562efe7e --- /dev/null +++ b/src/hooks/useReverseName.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react' + +import { + hasReverseLookupSupport, + peekReverseLookupCache, + reverseLookupName, + type ReverseLookupResult +} from '../util/nameServices' + +// Reverse-resolves an address to a human-readable name using whichever name +// services apply to the wallet's pluginId (ENS / UD / ZNS — see +// `nameServices.getReverseLookupServices`). Returns `null` until a name is +// found; never throws. +// +// Recycled-component safety: when the hook's inputs change (different row in +// a transaction list, different address typed into AddressTile2), we +// immediately reset to whatever the cache currently holds for the new key. +// Without this reset a prior row's resolved name could briefly leak onto the +// new row while the async lookup is in flight. +export const useReverseName = ( + pluginId: string, + address: string | undefined +): ReverseLookupResult | null => { + const enabled = + address != null && address !== '' && hasReverseLookupSupport(pluginId) + + const [result, setResult] = useState( + enabled ? peekReverseLookupCache(pluginId, address) : null + ) + + useEffect(() => { + if (!enabled || address == null) { + setResult(null) + return + } + setResult(peekReverseLookupCache(pluginId, address)) + let cancelled = false + reverseLookupName(pluginId, address) + .then(next => { + if (!cancelled) setResult(next) + }) + .catch((_err: unknown) => undefined) + return () => { + cancelled = true + } + }, [enabled, pluginId, address]) + + return result +} diff --git a/src/hooks/useZnsName.ts b/src/hooks/useZnsName.ts deleted file mode 100644 index 0ef059420de..00000000000 --- a/src/hooks/useZnsName.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useEffect, useState } from 'react' - -import { reverseResolveZnsAddress } from '../util/zns' - -const cache = new Map() -const inflight = new Map>() - -export const clearZnsLookupCache = (): void => { - cache.clear() - inflight.clear() -} - -const lookupZnsName = async (address: string): Promise => { - if (cache.has(address)) return cache.get(address) ?? null - let promise = inflight.get(address) - if (promise == null) { - promise = reverseResolveZnsAddress(address).catch((_err: unknown) => null) - inflight.set(address, promise) - } - const result = await promise - cache.set(address, result) - inflight.delete(address) - return result -} - -export const useZnsName = ( - pluginId: string, - address: string | undefined -): string | null => { - const enabled = pluginId === 'zcash' && address != null && address !== '' - const [name, setName] = useState( - enabled ? cache.get(address) ?? null : null - ) - - useEffect(() => { - if (!enabled) { - // Clear stale name when the hook is disabled (e.g. component recycled - // onto a non-zcash row, or address became undefined). - setName(null) - return - } - // Reset to the current cache value (or null) immediately on address - // change so a recycled component doesn't briefly show the prior row's - // resolved name while the async lookup is in flight. - setName(cache.get(address) ?? null) - let cancelled = false - lookupZnsName(address) - .then(result => { - if (!cancelled) setName(result) - }) - .catch((_err: unknown) => null) - return () => { - cancelled = true - } - }, [enabled, address]) - - return name -} diff --git a/src/util/nameServices.ts b/src/util/nameServices.ts new file mode 100644 index 00000000000..29cd01f3937 --- /dev/null +++ b/src/util/nameServices.ts @@ -0,0 +1,167 @@ +import Resolver from '@unstoppabledomains/resolution' +import { ethers } from 'ethers' + +import { getSpecialCurrencyInfo } from '../constants/WalletAndCurrencyConstants' +import { ENV } from '../env' +import { reverseResolveZnsAddress } from './zns' + +export type NameService = 'ens' | 'unstoppable' | 'zns' + +export interface ReverseLookupResult { + name: string + service: NameService +} + +// Per-pluginId service dispatch. +// +// Reverse-lookup support is much narrower than forward resolution because each +// service requires either an explicit reverse-record index (ZNS), an EVM-only +// reverse resolver (ENS L1, UD), or both. Services are tried in the listed +// order and the first non-null result wins. +// +// - ZNS: Zcash only. Backed by the ZcashNames indexer; Orchard outputs only. +// - ENS: Ethereum L1 mainnet only. ENSIP-3 reverse records. ENSIP-19 (multi- +// chain reverse) requires ethers v6 — out of scope until that upgrade. +// - Unstoppable Domains: any EVM chain (per UD docs `Resolution.reverse` only +// resolves EVM addresses). Requires `UNSTOPPABLE_DOMAINS_API_KEY` in env. +const getReverseLookupServices = (pluginId: string): NameService[] => { + if (pluginId === 'zcash') return ['zns'] + if (pluginId === 'ethereum') return ['ens', 'unstoppable'] + const info = getSpecialCurrencyInfo(pluginId) + if (info.walletConnectV2ChainId?.namespace === 'eip155') + return ['unstoppable'] + return [] +} + +// Convenience: does any service apply to this pluginId? Used by callers (hook, +// UI) to short-circuit before issuing an async lookup. +export const hasReverseLookupSupport = (pluginId: string): boolean => + getReverseLookupServices(pluginId).length > 0 + +// --- ENS --- +// `getDefaultProvider('mainnet')` returns a FallbackProvider that requires no +// API key but rate-limits modestly. Same default pattern used by the forward +// resolver in AddressTile2. ethers v5's `lookupAddress` performs the full +// ENSIP-3 round-trip (reverse record → forward verify), so the returned name +// is guaranteed to resolve back to `address`. +const reverseLookupEns = async (address: string): Promise => { + const provider = ethers.getDefaultProvider('mainnet') + return await provider.lookupAddress(address) +} + +// --- Unstoppable Domains --- +let udResolver: Resolver | null = null +const getUdResolver = (): Resolver | null => { + if (ENV.UNSTOPPABLE_DOMAINS_API_KEY == null) return null + udResolver ??= new Resolver({ apiKey: ENV.UNSTOPPABLE_DOMAINS_API_KEY }) + return udResolver +} + +const reverseLookupUnstoppable = async ( + address: string +): Promise => { + const resolver = getUdResolver() + if (resolver == null) return null + return await resolver.reverse(address) +} + +// --- ZNS --- +const reverseLookupZns = async (address: string): Promise => { + return await reverseResolveZnsAddress(address) +} + +// --- Cache --- +// +// Process-lifetime cache keyed on pluginId+address. Two important invariants: +// +// 1. Guard on success: only cache outcomes from a fully-successful traversal +// of the dispatch chain. If ANY service throws (network/rate-limit/etc.) +// and no positive result was found, we leave the cache empty so the next +// lookup retries. Caching a `null` from a transient failure would poison +// the address for the lifetime of the process. +// +// 2. Positive results cache eagerly: as soon as any service returns a name, +// we cache it and stop traversing further services. The verified ENS +// name (lookupAddress) takes priority over UD on Ethereum mainnet. +// +// The inflight map dedupes concurrent calls for the same key — useful when +// the same address renders in many transaction-list rows on first paint. +const cache = new Map() +const inflight = new Map>() + +const cacheKey = (pluginId: string, address: string): string => + `${pluginId}:${address}` + +export const clearReverseLookupCache = (): void => { + cache.clear() + inflight.clear() +} + +const performReverseLookup = async ( + pluginId: string, + address: string +): Promise => { + const services = getReverseLookupServices(pluginId) + let allServicesSucceeded = true + + for (const service of services) { + let name: string | null = null + try { + switch (service) { + case 'ens': + name = await reverseLookupEns(address) + break + case 'unstoppable': + name = await reverseLookupUnstoppable(address) + break + case 'zns': + name = await reverseLookupZns(address) + break + } + } catch (_err: unknown) { + allServicesSucceeded = false + continue + } + + if (name != null) { + const result: ReverseLookupResult = { name, service } + cache.set(cacheKey(pluginId, address), result) + return result + } + } + + if (allServicesSucceeded) { + cache.set(cacheKey(pluginId, address), null) + } + return null +} + +export const reverseLookupName = async ( + pluginId: string, + address: string +): Promise => { + if (address === '') return null + if (!hasReverseLookupSupport(pluginId)) return null + + const key = cacheKey(pluginId, address) + if (cache.has(key)) return cache.get(key) ?? null + + let promise = inflight.get(key) + if (promise == null) { + promise = performReverseLookup(pluginId, address) + inflight.set(key, promise) + } + try { + return await promise + } finally { + inflight.delete(key) + } +} + +export const peekReverseLookupCache = ( + pluginId: string, + address: string | undefined +): ReverseLookupResult | null => { + if (address == null || address === '') return null + return cache.get(cacheKey(pluginId, address)) ?? null +}