From 78ba9992ae3c1324d7864870d3aaee15ac47b160 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Fri, 22 Nov 2024 15:12:42 -0500 Subject: [PATCH 1/2] fix token address searches on discover screen (#6272) --- src/handlers/tokenSearch.ts | 79 ++++++++++++++++++++++++------------- 1 file changed, 52 insertions(+), 27 deletions(-) diff --git a/src/handlers/tokenSearch.ts b/src/handlers/tokenSearch.ts index 6626776c55a..e03d7452837 100644 --- a/src/handlers/tokenSearch.ts +++ b/src/handlers/tokenSearch.ts @@ -1,17 +1,15 @@ import { isAddress } from '@ethersproject/address'; import { qs } from 'url-parse'; import { RainbowFetchClient } from '../rainbow-fetch'; -import { TokenSearchThreshold, TokenSearchTokenListId, TokenSearchUniswapAssetKey } from '@/entities'; +import { TokenSearchThreshold, TokenSearchTokenListId } from '@/entities'; import { logger, RainbowError } from '@/logger'; -import { EthereumAddress } from '@rainbow-me/swaps'; import { RainbowToken, TokenSearchToken } from '@/entities/tokens'; import { chainsName } from '@/chains'; +import { ChainId } from '@/chains/types'; -type TokenSearchApiResponse = { - data: TokenSearchToken[]; -}; +const ALL_VERIFIED_TOKENS_PARAM = '/?list=verifiedAssets'; -const tokenSearchApi = new RainbowFetchClient({ +const tokenSearchHttp = new RainbowFetchClient({ baseURL: 'https://token-search.rainbow.me/v2', headers: { 'Accept': 'application/json', @@ -20,50 +18,77 @@ const tokenSearchApi = new RainbowFetchClient({ timeout: 30000, }); +function parseTokenSearch(assets: TokenSearchToken[]): RainbowToken[] { + return assets.map(token => { + const networkKeys = Object.keys(token.networks); + const chainId = Number(networkKeys[0]); + const network = chainsName[chainId]; + return { + ...token, + chainId, + address: token.networks['1']?.address || token.networks[chainId]?.address, + network, + mainnet_address: token.networks['1']?.address, + }; + }); +} + export const tokenSearch = async (searchParams: { - chainId: number; + chainId: ChainId; fromChainId?: number | ''; - keys: TokenSearchUniswapAssetKey[]; + keys: (keyof RainbowToken)[]; list: TokenSearchTokenListId; threshold: TokenSearchThreshold; query: string; }): Promise => { const queryParams: { - keys: TokenSearchUniswapAssetKey[]; + keys: string; list: TokenSearchTokenListId; threshold: TokenSearchThreshold; query?: string; fromChainId?: number; } = { - keys: searchParams.keys, + keys: searchParams.keys.join(','), list: searchParams.list, threshold: searchParams.threshold, query: searchParams.query, }; + const { chainId, query } = searchParams; + + const isAddressSearch = query && isAddress(query); + + if (isAddressSearch) { + queryParams.keys = `networks.${chainId}.address`; + } + + const url = `/?${qs.stringify(queryParams)}`; + const isSearchingVerifiedAssets = queryParams.list === 'verifiedAssets'; + try { - if (isAddress(searchParams.query)) { - // @ts-ignore - params.keys = `networks.${searchParams.chainId}.address`; + const tokenSearch = await tokenSearchHttp.get<{ data: TokenSearchToken[] }>(url); + + if (isAddressSearch && isSearchingVerifiedAssets) { + if (tokenSearch && tokenSearch.data.data.length > 0) { + return parseTokenSearch(tokenSearch.data.data); + } + + const allVerifiedTokens = await tokenSearchHttp.get<{ data: TokenSearchToken[] }>(ALL_VERIFIED_TOKENS_PARAM); + + const addressQuery = query.trim().toLowerCase(); + + const addressMatchesOnOtherChains = allVerifiedTokens.data.data.filter(a => + Object.values(a.networks).some(n => n?.address === addressQuery) + ); + + return parseTokenSearch(addressMatchesOnOtherChains); } - const url = `/?${qs.stringify(queryParams)}`; - const tokenSearch = await tokenSearchApi.get(url); + if (!tokenSearch.data?.data) { return []; } - return tokenSearch.data.data.map(token => { - const networkKeys = Object.keys(token.networks); - const chainId = Number(networkKeys[0]); - const network = chainsName[chainId]; - return { - ...token, - chainId, - address: token.networks['1']?.address || token.networks[chainId]?.address, - network, - mainnet_address: token.networks['1']?.address, - }; - }); + return parseTokenSearch(tokenSearch.data.data); } catch (e: any) { logger.error(new RainbowError(`[tokenSearch]: An error occurred while searching for query`), { query: searchParams.query, From f8808d83c0994d8a6b0c0584bd04782da7c29c76 Mon Sep 17 00:00:00 2001 From: Daniel Sinclair <4412473+DanielSinclair@users.noreply.github.com> Date: Fri, 22 Nov 2024 16:07:28 -0500 Subject: [PATCH 2/2] fix: wallet telemetry identify (#6258) * sentry hash alignment, amend interface to match bx * assign telemetry wallet context in useInitializeWallet fix: lint * fix: walletType could be undefined during onboarding, import * fix: redux strore mocking in utils tests --- src/App.tsx | 29 ++++-------------- src/analytics/__mocks__/index.ts | 2 +- src/analytics/__tests__/index.test.ts | 6 ++-- src/analytics/__tests__/utils.test.ts | 2 ++ src/analytics/index.ts | 43 ++++++++++++++++----------- src/analytics/utils.ts | 36 +++++++++++++++++++++- src/hooks/useInitializeWallet.ts | 20 +++++++++++++ src/redux/__mocks__/store.ts | 8 ++--- 8 files changed, 96 insertions(+), 50 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d59b7ed7296..157ff68b606 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,18 +18,16 @@ import { designSystemPlaygroundEnabled, reactNativeDisableYellowBox, showNetwork import monitorNetwork from '@/debugging/network'; import { Playground } from '@/design-system/playground/Playground'; import RainbowContextWrapper from '@/helpers/RainbowContext'; -import * as keychain from '@/model/keychain'; import { Navigation } from '@/navigation'; import { PersistQueryClientProvider, persistOptions, queryClient } from '@/react-query'; import store, { AppDispatch, type AppState } from '@/redux/store'; -import { MainThemeProvider, useTheme } from '@/theme/ThemeContext'; -import { addressKey } from '@/utils/keychainConstants'; +import { MainThemeProvider } from '@/theme/ThemeContext'; import { SharedValuesProvider } from '@/helpers/SharedValuesContext'; import { InitialRouteContext } from '@/navigation/initialRoute'; import { Portal } from '@/react-native-cool-modals/Portal'; import { NotificationsHandler } from '@/notifications/NotificationsHandler'; import { analyticsV2 } from '@/analytics'; -import { getOrCreateDeviceId, securelyHashWalletAddress } from '@/analytics/utils'; +import { getOrCreateDeviceId } from '@/analytics/utils'; import { logger, RainbowError } from '@/logger'; import * as ls from '@/storage'; import { migrate } from '@/migrations'; @@ -38,7 +36,6 @@ import { ReviewPromptAction } from '@/storage/schema'; import { initializeRemoteConfig } from '@/model/remoteConfig'; import { NavigationContainerRef } from '@react-navigation/native'; import { RootStackParamList } from '@/navigation/types'; -import { Address } from 'viem'; import { IS_ANDROID, IS_DEV } from '@/env'; import { prefetchDefaultFavorites } from '@/resources/favorites'; import Routes from '@/navigation/Routes'; @@ -102,27 +99,11 @@ function Root() { const isReturningUser = ls.device.get(['isReturningUser']); const [deviceId, deviceIdWasJustCreated] = await getOrCreateDeviceId(); - const currentWalletAddress = await keychain.loadString(addressKey); - const currentWalletAddressHash = - typeof currentWalletAddress === 'string' ? securelyHashWalletAddress(currentWalletAddress as Address) : undefined; - Sentry.setUser({ - id: deviceId, - currentWalletAddress: currentWalletAddressHash, - }); - - /** - * Add helpful values to `analyticsV2` instance - */ + // Initial telemetry; amended with wallet context later in `useInitializeWallet` + Sentry.setUser({ id: deviceId }); analyticsV2.setDeviceId(deviceId); - if (currentWalletAddressHash) { - analyticsV2.setCurrentWalletAddressHash(currentWalletAddressHash); - } - - /** - * `analyticsv2` has all it needs to function. - */ - analyticsV2.identify({}); + analyticsV2.identify(); const isReviewInitialized = ls.review.get(['initialized']); if (!isReviewInitialized) { diff --git a/src/analytics/__mocks__/index.ts b/src/analytics/__mocks__/index.ts index a53a6fd8fa6..71f987845ab 100644 --- a/src/analytics/__mocks__/index.ts +++ b/src/analytics/__mocks__/index.ts @@ -9,7 +9,7 @@ export const analyticsV2 = { screen: jest.fn(), track: jest.fn(), setDeviceId: jest.fn(), - setCurrentWalletAddressHash: jest.fn(), + setWalletContext: jest.fn(), enable: jest.fn(), disable: jest.fn(), event, diff --git a/src/analytics/__tests__/index.test.ts b/src/analytics/__tests__/index.test.ts index 248f5b86da5..94a2624510a 100644 --- a/src/analytics/__tests__/index.test.ts +++ b/src/analytics/__tests__/index.test.ts @@ -18,7 +18,7 @@ describe.skip('@/analytics', () => { test('track', () => { const analytics = new Analytics(); - analytics.setCurrentWalletAddressHash('hash'); + analytics.setWalletContext({ walletAddressHash: 'hash', walletType: 'owned' }); analytics.track(analytics.event.pressedButton); expect(analytics.client.track).toHaveBeenCalledWith(analytics.event.pressedButton, { @@ -29,7 +29,7 @@ describe.skip('@/analytics', () => { test('identify', () => { const analytics = new Analytics(); - analytics.setCurrentWalletAddressHash('hash'); + analytics.setWalletContext({ walletAddressHash: 'hash', walletType: 'owned' }); analytics.setDeviceId('id'); analytics.identify({ currency: 'USD' }); @@ -42,7 +42,7 @@ describe.skip('@/analytics', () => { test('screen', () => { const analytics = new Analytics(); - analytics.setCurrentWalletAddressHash('hash'); + analytics.setWalletContext({ walletAddressHash: 'hash', walletType: 'owned' }); analytics.screen(Routes.BACKUP_SHEET); expect(analytics.client.screen).toHaveBeenCalledWith(Routes.BACKUP_SHEET, { diff --git a/src/analytics/__tests__/utils.test.ts b/src/analytics/__tests__/utils.test.ts index 7d507f61b54..2f01fb4dd26 100644 --- a/src/analytics/__tests__/utils.test.ts +++ b/src/analytics/__tests__/utils.test.ts @@ -8,6 +8,8 @@ jest.mock('@/model/keychain', () => ({ loadString: jest.fn(), })); +jest.mock('@/redux/store'); + jest.mock('@sentry/react-native', () => ({ setUser: jest.fn(), })); diff --git a/src/analytics/index.ts b/src/analytics/index.ts index 6bbd722edb8..222cf513b34 100644 --- a/src/analytics/index.ts +++ b/src/analytics/index.ts @@ -5,13 +5,15 @@ import { EventProperties, event } from '@/analytics/event'; import { UserProperties } from '@/analytics/userProperties'; import { logger, RainbowError } from '@/logger'; import { device } from '@/storage'; +import { WalletContext } from './utils'; const isTesting = IS_TESTING === 'true'; export class Analytics { - client: any; - currentWalletAddressHash?: string; + client: typeof rudderClient; deviceId?: string; + walletAddressHash?: WalletContext['walletAddressHash']; + walletType?: WalletContext['walletType']; event = event; disabled: boolean; @@ -30,22 +32,27 @@ export class Analytics { * here. This uses the `deviceId` as the identifier, and attaches the hashed * wallet address as a property, if available. */ - identify(userProperties: UserProperties) { + identify(userProperties?: UserProperties) { if (this.disabled) return; const metadata = this.getDefaultMetadata(); - this.client.identify(this.deviceId, { - ...userProperties, - ...metadata, - }); + this.client.identify( + this.deviceId as string, + { + ...metadata, + ...userProperties, + }, + {} + ); } /** * Sends a `screen` event. */ - screen(routeName: string, params: Record = {}): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + screen(routeName: string, params: Record = {}, walletContext?: WalletContext): void { if (this.disabled) return; const metadata = this.getDefaultMetadata(); - this.client.screen(routeName, { ...params, ...metadata }); + this.client.screen(routeName, { ...metadata, ...walletContext, ...params }); } /** @@ -53,15 +60,16 @@ export class Analytics { * `@/analytics/event`, and if properties are associated with it, they must * be defined as part of `EventProperties` in the same file */ - track(event: T, params?: EventProperties[T]) { + track(event: T, params?: EventProperties[T], walletContext?: WalletContext) { if (this.disabled) return; const metadata = this.getDefaultMetadata(); - this.client.track(event, { ...params, ...metadata }); + this.client.track(event, { ...metadata, ...walletContext, ...params }); } private getDefaultMetadata() { return { - walletAddressHash: this.currentWalletAddressHash, + walletAddressHash: this.walletAddressHash, + walletType: this.walletType, }; } @@ -80,17 +88,18 @@ export class Analytics { * `identify()`, you must do that on your own. */ setDeviceId(deviceId: string) { - logger.debug(`[Analytics]: Set deviceId on analytics instance`); this.deviceId = deviceId; + logger.debug(`[Analytics]: Set deviceId on analytics instance`); } /** - * Set `currentWalletAddressHash` for use in events. This DOES NOT call + * Set `walletAddressHash` and `walletType` for use in events. This DOES NOT call * `identify()`, you must do that on your own. */ - setCurrentWalletAddressHash(currentWalletAddressHash: string) { - logger.debug(`[Analytics]: Set currentWalletAddressHash on analytics instance`); - this.currentWalletAddressHash = currentWalletAddressHash; + setWalletContext(walletContext: WalletContext) { + this.walletAddressHash = walletContext.walletAddressHash; + this.walletType = walletContext.walletType; + logger.debug(`[Analytics]: Set walletAddressHash on analytics instance`); } /** diff --git a/src/analytics/utils.ts b/src/analytics/utils.ts index 450ed5666ff..042edf95da2 100644 --- a/src/analytics/utils.ts +++ b/src/analytics/utils.ts @@ -1,11 +1,15 @@ import { nanoid } from 'nanoid/non-secure'; import { SECURE_WALLET_HASH_KEY } from 'react-native-dotenv'; +import type { Address } from 'viem'; import * as ls from '@/storage'; import * as keychain from '@/model/keychain'; import { analyticsUserIdentifier } from '@/utils/keychainConstants'; import { logger, RainbowError } from '@/logger'; import { computeHmac, SupportedAlgorithm } from '@ethersproject/sha2'; +import { findWalletWithAccount } from '@/helpers/findWalletWithAccount'; +import store from '@/redux/store'; +import { EthereumWalletType } from '@/helpers/walletTypes'; /** * Returns the device id in a type-safe manner. It will throw if no device ID @@ -58,7 +62,7 @@ export async function getOrCreateDeviceId(): Promise<[string, boolean]> { } } -export function securelyHashWalletAddress(walletAddress: `0x${string}`): string | undefined { +function securelyHashWalletAddress(walletAddress: Address): string | undefined { if (!SECURE_WALLET_HASH_KEY) { logger.error(new RainbowError(`[securelyHashWalletAddress]: Required .env variable SECURE_WALLET_HASH_KEY does not exist`)); } @@ -80,3 +84,33 @@ export function securelyHashWalletAddress(walletAddress: `0x${string}`): string logger.error(new RainbowError(`[securelyHashWalletAddress]: Wallet address hashing failed`)); } } + +export type WalletContext = { + walletType?: 'owned' | 'hardware' | 'watched'; + walletAddressHash?: string; +}; + +export async function getWalletContext(address: Address): Promise { + // currentAddressStore address is initialized to '' + if (!address || address === ('' as Address)) return {}; + + // walletType maybe undefined after initial wallet creation + const { wallets } = store.getState(); + const wallet = findWalletWithAccount(wallets.wallets || {}, address); + + const walletType = ( + { + [EthereumWalletType.mnemonic]: 'owned', + [EthereumWalletType.privateKey]: 'owned', + [EthereumWalletType.seed]: 'owned', + [EthereumWalletType.readOnly]: 'watched', + [EthereumWalletType.bluetooth]: 'hardware', + } as const + )[wallet?.type!]; + const walletAddressHash = securelyHashWalletAddress(address); + + return { + walletType, + walletAddressHash, + }; +} diff --git a/src/hooks/useInitializeWallet.ts b/src/hooks/useInitializeWallet.ts index b46134e68a8..5f934050e9d 100644 --- a/src/hooks/useInitializeWallet.ts +++ b/src/hooks/useInitializeWallet.ts @@ -19,6 +19,10 @@ import { WrappedAlert as Alert } from '@/helpers/alert'; import { PROFILES, useExperimentalFlag } from '@/config'; import { runKeychainIntegrityChecks } from '@/handlers/walletReadyEvents'; import { RainbowError, logger } from '@/logger'; +import { getOrCreateDeviceId, getWalletContext } from '@/analytics/utils'; +import * as Sentry from '@sentry/react-native'; +import { analyticsV2 } from '@/analytics'; +import { Address } from 'viem'; export default function useInitializeWallet() { const dispatch = useDispatch(); @@ -82,6 +86,22 @@ export default function useInitializeWallet() { walletAddress, }); + // Capture wallet context in telemetry + // walletType maybe undefied after initial wallet creation + const { walletType, walletAddressHash } = await getWalletContext(walletAddress as Address); + const [deviceId] = await getOrCreateDeviceId(); + + Sentry.setUser({ + id: deviceId, + walletAddressHash, + walletType, + }); + + // Allows calling telemetry before currentAddress is available (i.e. onboarding) + if (walletType || walletAddressHash) analyticsV2.setWalletContext({ walletAddressHash, walletType }); + analyticsV2.setDeviceId(deviceId); + analyticsV2.identify(); + if (!switching) { // Run keychain integrity checks right after walletInit // Except when switching wallets! diff --git a/src/redux/__mocks__/store.ts b/src/redux/__mocks__/store.ts index ec0224288a3..e0f04cc705d 100644 --- a/src/redux/__mocks__/store.ts +++ b/src/redux/__mocks__/store.ts @@ -1,4 +1,4 @@ -export default { - getState: jest.fn(), - dispatch: jest.fn(), -}; +import { jest } from '@jest/globals'; + +export const getState = jest.fn(); +export const dispatch = jest.fn();