From fdea60492ecef29b7cccd5e7ce56b7650a8afd52 Mon Sep 17 00:00:00 2001 From: j0ntz Date: Fri, 1 May 2026 15:50:18 -0700 Subject: [PATCH 1/5] Fix lint warnings before reverse-lookup feature Convert AddressModal and SendComponent to React.FC, and replace the two `styled()` wrappers in SendScene2 with regular React Native components driven by `useTheme()` + `cacheStyles()`. No behavior change. The slider-view snapshot updates reflect two equivalent shifts: the old `styled(View)` HOC was incidentally forwarding `hasNotifications` and `insetBottom` as DOM props (which the underlying View ignored), and the inlined style is now an array `[base, { bottom }]` rather than a merged object. Both render to the same pixels. Touched here in preparation for reverse-lookup edits in subsequent commits, per the lint-warnings.sh workflow contract for files entering the working set. --- .../__snapshots__/SendScene2.ui.test.tsx.snap | 200 ++++++++++-------- src/components/modals/AddressModal.tsx | 2 +- src/components/scenes/SendScene2.tsx | 172 +++++++-------- 3 files changed, 191 insertions(+), 183 deletions(-) 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/components/modals/AddressModal.tsx b/src/components/modals/AddressModal.tsx index b197212b80d..05f5e8d6d3e 100644 --- a/src/components/modals/AddressModal.tsx +++ b/src/components/modals/AddressModal.tsx @@ -533,7 +533,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..3d5a67141dd 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() @@ -1794,106 +1793,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' }, From 25c94d772e1f8ba6876c1654c2c94cf666a58f95 Mon Sep 17 00:00:00 2001 From: j0ntz Date: Fri, 1 May 2026 15:54:18 -0700 Subject: [PATCH 2/5] Add reverse-lookup dispatcher for ENS, UD, and ZNS Introduces `src/util/nameServices.ts` with a `reverseLookupName(pluginId, address)` function that maps each chain to its supported reverse-lookup services and tries them in order (positive result wins, transient failures don't poison the cache). Service eligibility: - ZNS: zcash only - ENS: ethereum only (ENSIP-3 L1 mainnet via ethers v5 lookupAddress) - Unstoppable Domains: any EVM chain when UNSTOPPABLE_DOMAINS_API_KEY is set (UD reverse only resolves EVM addresses per their docs) The cache uses guard-on-success semantics: results are cached when the dispatch chain completes cleanly, but transient errors leave the entry empty so the next call retries. Inflight dedup prevents duplicate network hits when the same address renders in many list rows. This replaces the ad-hoc cache in `useZnsName` with a service-aware equivalent that subsequent commits will wire into the send and transaction-history scenes. --- src/util/nameServices.ts | 167 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 src/util/nameServices.ts 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 +} From 2bf93540d97e3d9c695aa2a4b0a07ad97c01b9c7 Mon Sep 17 00:00:00 2001 From: j0ntz Date: Fri, 1 May 2026 16:00:55 -0700 Subject: [PATCH 3/5] Use reverse-lookup dispatcher in transaction history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `useReverseName` (a multi-service replacement for `useZnsName`) and the `NameServicePrefix` visual element, then swaps both TransactionListRow and TransactionDetailsScene over to the new hook. UX surfaces: - TransactionListRow: text-only render. The list is dense enough that an inline logo would crowd the row; the resolved name still appears, just without the badge. - TransactionDetailsScene: prefix the resolved name with a 1rem-tall logo. The prefix only renders when the displayed name actually came from a reverse lookup — user-set contact names and the default "To"/"From" label render plain. Missing logo assets (UD, ZNS today) fall back to no-prefix without reserving space. LoginActions swaps `clearZnsLookupCache` for `clearReverseLookupCache` on the same logout boundary so per-login cache state stays isolated. The legacy `useZnsName` hook has no remaining consumers and is removed. The TransactionDetailsScene snapshot picks up the new row-layout `` wrapper that hosts the optional prefix; the wrapper is benign when no prefix is present. --- .../TransactionDetailsScene.test.tsx.snap | 86 +++++++++++-------- src/actions/LoginActions.tsx | 4 +- .../scenes/TransactionDetailsScene.tsx | 28 ++++-- src/components/themed/NameServicePrefix.tsx | 46 ++++++++++ src/components/themed/TransactionListRow.tsx | 6 +- src/hooks/useReverseName.ts | 49 +++++++++++ src/hooks/useZnsName.ts | 58 ------------- 7 files changed, 175 insertions(+), 102 deletions(-) create mode 100644 src/components/themed/NameServicePrefix.tsx create mode 100644 src/hooks/useReverseName.ts delete mode 100644 src/hooks/useZnsName.ts 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 + + { 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/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 -} From a565f1686258410634602c46f12425b63a13512c Mon Sep 17 00:00:00 2001 From: j0ntz Date: Fri, 1 May 2026 16:08:50 -0700 Subject: [PATCH 4/5] Reverse-lookup names in send flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the reverse-lookup dispatcher into AddressTile2's address-entry path and threads the result through SendScene2 so resolved names appear in the send tile and persist into transaction metadata. AddressTile2 changes: - Replace `ChangeAddressResult.znsName` with `resolvedName`, a `{ name, service }` pair that captures both forward-typed names (alice.eth, alice.zcash, alice.zec) and reverse-resolved names from raw addresses. - After parseUri succeeds and we have a public address, attempt a reverse lookup if no forward-typed name was captured. The dispatcher caches per (pluginId, address) so repeated entry of the same address is a no-op. - New `recipientNameService` prop drives an inline `NameServicePrefix` badge above the address. FIO and Zano handles continue to render plain (no badge), preserving the prior look for those flows. SendScene2 changes: - Carry `resolvedName` through `spendTarget.otherParams` and the `EditableAmountTile` title (still text-only per the design — the tile is a confirmation snapshot, not the live entry surface). - Generalize the post-broadcast `payeeName` derivation so any single-resolved-name spendInfo (ENS / UD / ZNS) produces a payeeName, replacing the chain-specific Zcash branch. Outcome: pasting a 0x address to an Ethereum send shows the resolved ENS / UD name above the hex address as soon as the lookup completes, and that name persists as `payeeName` in the broadcasted transaction's metadata so it surfaces in the transaction history. --- src/components/scenes/SendScene2.tsx | 38 ++++++++++------ src/components/tiles/AddressTile2.tsx | 62 +++++++++++++++++++++++---- 2 files changed, 78 insertions(+), 22 deletions(-) diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index 3d5a67141dd..58aa9578ff5 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -427,7 +427,7 @@ const SendComponent: React.FC = props => { const handleChangeAddress = (spendTarget: EdgeSpendTarget) => async (changeAddressResult: ChangeAddressResult): Promise => { - const { addressEntryMethod, parsedUri, fioAddress, alias, znsName } = + const { addressEntryMethod, parsedUri, fioAddress, alias, resolvedName } = changeAddressResult if (parsedUri != null) { @@ -468,7 +468,7 @@ const SendComponent: React.FC = props => { spendTarget.otherParams = { fioAddress, zanoAlias: alias, - znsName + resolvedName } // We can assume the spendTarget object came from the Component spendInfo so simply resetting the spendInfo @@ -495,12 +495,12 @@ const SendComponent: React.FC = props => { 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}` } @@ -542,8 +542,14 @@ const SendComponent: React.FC = props => { 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()}` : '') @@ -563,6 +569,7 @@ const SendComponent: React.FC = props => { lockInputs={lockTilesMap.address} isCameraOpen={doOpenCamera} recipientName={recipientName} + recipientNameService={recipientNameService} navigation={navigation as NavigationBase} /> ) @@ -1323,13 +1330,16 @@ const SendComponent: React.FC = props => { 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) { 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', From 1c1a20a83602032d1e7582be0813d9157f5a7608 Mon Sep 17 00:00:00 2001 From: j0ntz Date: Fri, 1 May 2026 16:14:07 -0700 Subject: [PATCH 5/5] Live reverse-lookup feedback in AddressModal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user pastes or types a string that doesn't match any forward- domain pattern, attempt a reverse lookup against the wallet's pluginId and surface the resolved name in the input's `validLabel` slot. The existing forward-resolution path (which puts the resolved address in the same slot) is unchanged, so the green helper text is symmetric: - type alice.eth → see 0x… resolve below the input - paste 0x… → see alice.eth resolve below the input A monotonically increasing sequence counter scopes each lookup to the input it was issued for, so a slow late-arriving result can't clobber the label after the user has moved on. Adds the user-visible CHANGELOG entry covering the full reverse-lookup feature (this commit plus the prior three on this branch). --- CHANGELOG.md | 2 ++ src/components/modals/AddressModal.tsx | 45 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) 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/components/modals/AddressModal.tsx b/src/components/modals/AddressModal.tsx index 05f5e8d6d3e..2eb818fe68b 100644 --- a/src/components/modals/AddressModal.tsx +++ b/src/components/modals/AddressModal.tsx @@ -28,6 +28,7 @@ import { type FioAddresses, getFioAddressCache } from '../../util/FioAddressUtils' +import { reverseLookupName } from '../../util/nameServices' import { resolveName } from '../../util/resolveName' import { isZnsName, resolveZnsName } from '../../util/zns' import { EdgeButton } from '../buttons/EdgeButton' @@ -80,10 +81,15 @@ type Props = StateProps & OwnProps & DispatchProps & ThemeProps export class AddressModalComponent extends React.Component { 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) => {