diff --git a/.env.d.ts b/.env.d.ts index f675f3ec11..9497e64758 100644 --- a/.env.d.ts +++ b/.env.d.ts @@ -48,6 +48,7 @@ declare global { RUDDERSTACK_DATA_PLANE: string; RUDDERSTACK_WRITE_KEY: string; SENTRY_DSN: string; + SECURE_WALLET_HASH_KEY: string; // Development IS_DEV: 'true' | 'false'; IS_TESTING: 'true' | 'false'; diff --git a/package.json b/package.json index c0dceaa5ab..283ee42685 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "@ethersproject/units": "5.7.0", "@ethersproject/wallet": "5.7.0", "@ledgerhq/hw-app-eth": "6.38.1", + "@ethersproject/sha2": "5.7.0", "@ledgerhq/hw-transport-webhid": "6.29.3", "@metamask/browser-passworder": "4.1.0", "@metamask/eth-sig-util": "7.0.1", diff --git a/src/analytics/index.ts b/src/analytics/index.ts index 4902c4154d..4499bd7c48 100644 --- a/src/analytics/index.ts +++ b/src/analytics/index.ts @@ -2,6 +2,7 @@ import { Analytics as RudderAnalytics } from '@rudderstack/analytics-js-service- import { EventProperties, event } from '~/analytics/event'; import { UserProperties } from '~/analytics/userProperties'; +import { type WalletContext } from '~/analytics/util'; import { analyticsDisabledStore } from '~/core/state/currentSettings/analyticsDisabled'; import { logger } from '~/logger'; @@ -33,6 +34,8 @@ const context = { export class Analytics { client?: RudderAnalytics; deviceId?: string; + walletAddressHash?: WalletContext['walletAddressHash']; + walletType?: WalletContext['walletType']; event = event; disabled = true; // to do: check user setting here @@ -73,7 +76,7 @@ export class Analytics { identify(userProperties?: UserProperties) { if (this.disabled || IS_DEV || IS_TESTING || !this.deviceId) return; const metadata = this.getDefaultMetadata(); - const traits = { ...userProperties, ...metadata }; + const traits = { ...metadata, ...userProperties }; this.client?.identify({ userId: this.deviceId, traits, context }); logger.info('analytics.identify()', { userId: this.deviceId, @@ -84,10 +87,14 @@ export class Analytics { /** * Sends a `screen` event to RudderStack. */ - screen(name: string, params: Record = {}): void { + screen( + name: string, + params?: Record, + walletContext?: WalletContext, + ) { if (this.disabled || IS_DEV || IS_TESTING || !this.deviceId) return; const metadata = this.getDefaultMetadata(); - const properties = { ...params, ...metadata }; + const properties = { ...metadata, ...walletContext, ...params }; this.client?.screen({ userId: this.deviceId, name, properties, context }); logger.info('analytics.screen()', { userId: this.deviceId, @@ -104,10 +111,11 @@ export class Analytics { track( event: T, params?: EventProperties[T], + walletContext?: WalletContext, ) { if (this.disabled || IS_DEV || IS_TESTING || !this.deviceId) return; const metadata = this.getDefaultMetadata(); - const properties = Object.assign(metadata, params); + const properties = { ...metadata, ...walletContext, ...params }; this.client?.track({ userId: this.deviceId, event, properties, context }); logger.info('analytics.track()', { userId: this.deviceId, @@ -118,10 +126,13 @@ export class Analytics { /** * Scaffolding for Default Metadata params - * This is used in the App for `walletAddressHash` + * This is used in the App for `walletAddressHash` and `walletType` */ private getDefaultMetadata() { - return {}; + return { + walletAddressHash: this.walletAddressHash, + walletType: this.walletType, + }; } /** @@ -133,6 +144,19 @@ export class Analytics { logger.debug(`Set deviceId on analytics instance`, { deviceId }); } + /** + * Set `walletAddressHash` and `walletType` for use in events. + * This DOES NOT call `identify()`, you must do that on your own. + */ + setWalletContext(walletContext: WalletContext) { + this.walletAddressHash = walletContext.walletAddressHash; + this.walletType = walletContext.walletType; + logger.debug( + `Set walletAddressHash and walletType on analytics instance`, + walletContext, + ); + } + /** * Enable RudderStack tracking. Defaults to enabled. */ diff --git a/src/analytics/util.ts b/src/analytics/util.ts new file mode 100644 index 0000000000..0ced4b36e4 --- /dev/null +++ b/src/analytics/util.ts @@ -0,0 +1,76 @@ +import { SupportedAlgorithm, computeHmac } from '@ethersproject/sha2'; +import { Address } from 'viem'; + +import { getWallet } from '~/entries/popup/handlers/wallet'; +import { KeychainType } from '~/core/types/keychainTypes'; +import { RainbowError, logger } from '~/logger'; + +const SECURE_WALLET_HASH_KEY = process.env.SECURE_WALLET_HASH_KEY; + +function securelyHashWalletAddress( + walletAddress: Address | undefined, +): string | undefined { + if (!SECURE_WALLET_HASH_KEY) { + logger.error( + new RainbowError( + `[securelyHashWalletAddress]: Required .env variable SECURE_WALLET_HASH_KEY does not exist`, + ), + ); + return; + } + + if (!walletAddress) return; + + try { + const hmac = computeHmac( + SupportedAlgorithm.sha256, + // must be hex `0x` string + SECURE_WALLET_HASH_KEY, + // must be hex `0x` string + walletAddress, + ); + + logger.debug(`[securelyHashWalletAddress]: Wallet address securely hashed`); + + return hmac; + } catch (e) { + // could be an invalid hashing key, or trying to hash an ENS + 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 {}; + + const walletAddressHash = securelyHashWalletAddress(address); + + // walletType is unavailable when keychain is locked + let walletType; + try { + // expect getWallet error when keychain is locked + const wallet = await getWallet(address); + walletType = ({ + [KeychainType.HdKeychain]: 'owned', + [KeychainType.KeyPairKeychain]: 'owned', + [KeychainType.ReadOnlyKeychain]: 'watched', + [KeychainType.HardwareWalletKeychain]: 'hardware', + } as const)[wallet?.type]; + } catch (e) {} + + return { + walletType, + walletAddressHash, + }; +} diff --git a/src/core/sentry/index.ts b/src/core/sentry/index.ts index 65ede68b38..10cb322336 100644 --- a/src/core/sentry/index.ts +++ b/src/core/sentry/index.ts @@ -38,8 +38,18 @@ export function initializeSentry(context: 'popup' | 'background') { } } -export function setSentryUser(deviceId: string) { +export function setSentryUser({ + deviceId, + walletAddressHash, + walletType, +}: { + deviceId: string; + walletAddressHash?: string; + walletType?: 'owned' | 'hardware' | 'watched'; +}) { Sentry.setUser({ id: deviceId, + walletAddressHash, + walletType, }); } diff --git a/src/core/state/currentSettings/index.ts b/src/core/state/currentSettings/index.ts index ea77d2b89d..70c69307fc 100644 --- a/src/core/state/currentSettings/index.ts +++ b/src/core/state/currentSettings/index.ts @@ -8,6 +8,10 @@ export { currentLanguageStore, useCurrentLanguageStore, } from './currentLanguage'; +export { + currentThemeStore, + useCurrentThemeStore, +} from './currentTheme'; export { flashbotsEnabledStore, useFlashbotsEnabledStore, diff --git a/src/core/state/index.ts b/src/core/state/index.ts index f74864825b..79344667e1 100644 --- a/src/core/state/index.ts +++ b/src/core/state/index.ts @@ -4,12 +4,14 @@ export { currentChainIdStore, currentCurrencyStore, currentLanguageStore, + currentThemeStore, flashbotsEnabledStore, isDefaultWalletStore, useCurrentAddressStore, useCurrentChainIdStore, useCurrentCurrencyStore, useCurrentLanguageStore, + useCurrentThemeStore, useFlashbotsEnabledStore, useIsDefaultWalletStore, } from './currentSettings'; diff --git a/src/core/telemetry/index.ts b/src/core/telemetry/index.ts new file mode 100644 index 0000000000..1c773fe922 --- /dev/null +++ b/src/core/telemetry/index.ts @@ -0,0 +1,37 @@ +import { useEffect } from 'react'; + +import { analytics } from '~/analytics'; +import { getWalletContext } from '~/analytics/util'; +import { useAuth } from '~/entries/popup/hooks/useAuth'; + +import { setSentryUser } from '../sentry'; +import { useCurrentAddressStore, useDeviceIdStore } from '../state'; + +export const TelemetryIdentifier = () => { + const { status: authStatus } = useAuth(); + const { deviceId } = useDeviceIdStore(); + const { currentAddress } = useCurrentAddressStore(); + + // update telemetry wallet each time selected wallet changes + useEffect(() => { + // update wallet context and trigger identify + const identify = async () => { + const { walletType, walletAddressHash } = + await getWalletContext(currentAddress); + setSentryUser({ deviceId, walletAddressHash, walletType }); + // allows calling telemetry before currentAddress is available (i.e. onboarding) + if (walletType || walletAddressHash) + analytics.setWalletContext({ walletAddressHash, walletType }); + analytics.setDeviceId(deviceId); + analytics.identify(); + }; + // Disable analytics & sentry for e2e and dev mode + if (process.env.IS_TESTING !== 'true' && process.env.IS_DEV !== 'true') { + if (authStatus === '') return; // wait for auth state to settle + else if (authStatus === 'READY') identify(); // assign full wallet context + else identify(); // assign partial wallet context immediately if available + } + }, [deviceId, currentAddress, authStatus]); + + return null; +}; diff --git a/src/entries/popup/App.tsx b/src/entries/popup/App.tsx index 2ae5bfcd5f..c12fb626b5 100644 --- a/src/entries/popup/App.tsx +++ b/src/entries/popup/App.tsx @@ -13,9 +13,9 @@ import { flushQueuedEvents } from '~/analytics/flushQueuedEvents'; import config from '~/core/firebase/remoteConfig'; import { initializeMessenger } from '~/core/messengers'; import { persistOptions, queryClient } from '~/core/react-query'; -import { initializeSentry, setSentryUser } from '~/core/sentry'; -import { useCurrentLanguageStore, useDeviceIdStore } from '~/core/state'; -import { useCurrentThemeStore } from '~/core/state/currentSettings/currentTheme'; +import { initializeSentry } from '~/core/sentry'; +import { useCurrentLanguageStore, useCurrentThemeStore } from '~/core/state'; +import { TelemetryIdentifier } from '~/core/telemetry'; import { POPUP_DIMENSIONS } from '~/core/utils/dimensions'; import { WagmiConfigUpdater, wagmiConfig } from '~/core/wagmi'; import { Box, ThemeProvider } from '~/design-system'; @@ -34,7 +34,6 @@ const backgroundMessenger = initializeMessenger({ connect: 'background' }); export function App() { const { currentLanguage, setCurrentLanguage } = useCurrentLanguageStore(); - const { deviceId } = useDeviceIdStore(); const { rainbowChains } = useRainbowChains(); const prevChains = usePrevious(rainbowChains); @@ -60,9 +59,6 @@ export function App() { // Disable analytics & sentry for e2e and dev mode if (process.env.IS_TESTING !== 'true' && process.env.IS_DEV !== 'true') { initializeSentry('popup'); - setSentryUser(deviceId); - analytics.setDeviceId(deviceId); - analytics.identify(); analytics.track(event.popupOpened); setTimeout(() => flushQueuedEvents(), 1000); } @@ -122,6 +118,7 @@ export function App() { + diff --git a/src/entries/popup/pages/messages/RequestAccounts/index.tsx b/src/entries/popup/pages/messages/RequestAccounts/index.tsx index 40fee6fbf5..d33f5668d0 100644 --- a/src/entries/popup/pages/messages/RequestAccounts/index.tsx +++ b/src/entries/popup/pages/messages/RequestAccounts/index.tsx @@ -3,6 +3,7 @@ import { Address } from 'viem'; import { analytics } from '~/analytics'; import { event } from '~/analytics/event'; +import { getWalletContext } from '~/analytics/util'; import { initializeMessenger } from '~/core/messengers'; import { useDappMetadata } from '~/core/resources/metadata/dapp'; import { useAppSessionsStore, useCurrentAddressStore } from '~/core/state'; @@ -46,7 +47,7 @@ export const RequestAccounts = ({ ); const [selectedWallet, setSelectedWallet] = useState
(currentAddress); - const onAcceptRequest = useCallback(() => { + const onAcceptRequest = useCallback(async () => { try { setLoading(true); approveRequest({ @@ -63,12 +64,16 @@ export const RequestAccounts = ({ address: selectedWallet, chainId: selectedChainId, }); - analytics.track(event.dappPromptConnectApproved, { - chainId: selectedChainId, - dappURL: dappMetadata?.url || '', - dappDomain: dappMetadata?.appHost || '', - dappName: dappMetadata?.appName, - }); + analytics.track( + event.dappPromptConnectApproved, + { + chainId: selectedChainId, + dappURL: dappMetadata?.url || '', + dappDomain: dappMetadata?.appHost || '', + dappName: dappMetadata?.appName, + }, + await getWalletContext(selectedWallet), + ); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { logger.info('error connecting to dapp'); @@ -88,20 +93,25 @@ export const RequestAccounts = ({ dappUrl, ]); - const onRejectRequest = useCallback(() => { + const onRejectRequest = useCallback(async () => { rejectRequest(); - analytics.track(event.dappPromptConnectRejected, { - chainId: selectedChainId, - dappURL: dappMetadata?.url || '', - dappDomain: dappMetadata?.appHost || '', - dappName: dappMetadata?.appName, - }); + analytics.track( + event.dappPromptConnectRejected, + { + chainId: selectedChainId, + dappURL: dappMetadata?.url || '', + dappDomain: dappMetadata?.appHost || '', + dappName: dappMetadata?.appName, + }, + await getWalletContext(selectedWallet), + ); }, [ dappMetadata?.url, dappMetadata?.appHost, dappMetadata?.appName, rejectRequest, selectedChainId, + selectedWallet, ]); return ( diff --git a/src/entries/popup/pages/messages/SendTransaction/index.tsx b/src/entries/popup/pages/messages/SendTransaction/index.tsx index 8eadba199c..8ee67859d2 100644 --- a/src/entries/popup/pages/messages/SendTransaction/index.tsx +++ b/src/entries/popup/pages/messages/SendTransaction/index.tsx @@ -5,6 +5,7 @@ import { Address } from 'viem'; import { analytics } from '~/analytics'; import { event } from '~/analytics/event'; +import { getWalletContext } from '~/analytics/util'; import config from '~/core/firebase/remoteConfig'; import { i18n } from '~/core/languages'; import { chainsNativeAsset } from '~/core/references/chains'; @@ -120,12 +121,16 @@ export function SendTransaction({ approveRequest(result.hash); setWaitingForDevice(false); - analytics.track(event.dappPromptSendTransactionApproved, { - chainId: txData.chainId, - dappURL: dappMetadata?.url || '', - dappDomain: dappMetadata?.appHost || '', - dappName: dappMetadata?.appName, - }); + analytics.track( + event.dappPromptSendTransactionApproved, + { + chainId: txData.chainId, + dappURL: dappMetadata?.url || '', + dappDomain: dappMetadata?.appHost || '', + dappName: dappMetadata?.appName, + }, + await getWalletContext(activeSession?.address), + ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { @@ -160,15 +165,19 @@ export function SendTransaction({ dappMetadata?.appName, ]); - const onRejectRequest = useCallback(() => { + const onRejectRequest = useCallback(async () => { rejectRequest(); if (activeSession) { - analytics.track(event.dappPromptSendTransactionRejected, { - chainId: activeSession?.chainId, - dappURL: dappMetadata?.url || '', - dappDomain: dappMetadata?.appHost || '', - dappName: dappMetadata?.appName, - }); + analytics.track( + event.dappPromptSendTransactionRejected, + { + chainId: activeSession?.chainId, + dappURL: dappMetadata?.url || '', + dappDomain: dappMetadata?.appHost || '', + dappName: dappMetadata?.appName, + }, + await getWalletContext(activeSession?.address), + ); } }, [ rejectRequest, diff --git a/src/entries/popup/pages/messages/SignMessage/index.tsx b/src/entries/popup/pages/messages/SignMessage/index.tsx index c13b2b88a9..c9d41b39d8 100644 --- a/src/entries/popup/pages/messages/SignMessage/index.tsx +++ b/src/entries/popup/pages/messages/SignMessage/index.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { analytics } from '~/analytics'; import { event } from '~/analytics/event'; +import { getWalletContext } from '~/analytics/util'; import { i18n } from '~/core/languages'; import { useDappMetadata } from '~/core/resources/metadata/dapp'; import { useFeatureFlagsStore } from '~/core/state/currentSettings/featureFlags'; @@ -78,23 +79,31 @@ export function SignMessage({ requestPayload.msgData, requestPayload.address, ); - analytics.track(event.dappPromptSignMessageApproved, { - chainId: activeSession?.chainId, - dappURL: dappMetadata?.url || '', - dappDomain: dappMetadata?.appHost || '', - dappName: dappMetadata?.appName, - }); + analytics.track( + event.dappPromptSignMessageApproved, + { + chainId: activeSession?.chainId, + dappURL: dappMetadata?.url || '', + dappDomain: dappMetadata?.appHost || '', + dappName: dappMetadata?.appName, + }, + await getWalletContext(activeSession?.address), + ); } else if (walletAction === 'sign_typed_data') { result = await wallet.signTypedData( requestPayload.msgData, requestPayload.address, ); - analytics.track(event.dappPromptSignTypedDataApproved, { - chainId: activeSession?.chainId, - dappURL: dappMetadata?.url || '', - dappDomain: dappMetadata?.appHost || '', - dappName: dappMetadata?.appName, - }); + analytics.track( + event.dappPromptSignTypedDataApproved, + { + chainId: activeSession?.chainId, + dappURL: dappMetadata?.url || '', + dappDomain: dappMetadata?.appHost || '', + dappName: dappMetadata?.appName, + }, + await getWalletContext(activeSession?.address), + ); } approveRequest(result); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -112,33 +121,44 @@ export function SignMessage({ dappMetadata?.appHost, dappMetadata?.appName, activeSession?.chainId, + activeSession?.address, request, selectedWallet, ]); - const onRejectRequest = useCallback(() => { + const onRejectRequest = useCallback(async () => { rejectRequest(); + if (!activeSession?.address) return; const walletAction = getWalletActionMethod(request?.method); if (walletAction === 'personal_sign') { - analytics.track(event.dappPromptSignMessageRejected, { - chainId: activeSession?.chainId || 0, - dappURL: dappMetadata?.url || '', - dappDomain: dappMetadata?.appHost || '', - dappName: dappMetadata?.appName, - }); + analytics.track( + event.dappPromptSignMessageRejected, + { + chainId: activeSession?.chainId || 0, + dappURL: dappMetadata?.url || '', + dappDomain: dappMetadata?.appHost || '', + dappName: dappMetadata?.appName, + }, + await getWalletContext(activeSession?.address), + ); } else if (walletAction === 'sign_typed_data') { - analytics.track(event.dappPromptSignTypedDataRejected, { - chainId: activeSession?.chainId || 0, - dappURL: dappMetadata?.url || '', - dappDomain: dappMetadata?.appHost || '', - dappName: dappMetadata?.appName, - }); + analytics.track( + event.dappPromptSignTypedDataRejected, + { + chainId: activeSession?.chainId || 0, + dappURL: dappMetadata?.url || '', + dappDomain: dappMetadata?.appHost || '', + dappName: dappMetadata?.appName, + }, + await getWalletContext(activeSession?.address), + ); } }, [ dappMetadata?.url, dappMetadata?.appHost, dappMetadata?.appName, activeSession?.chainId, + activeSession?.address, rejectRequest, request?.method, ]);