From 750f8b7aef8590525c25638216f93fa6fe6e8013 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 17:02:33 +0000 Subject: [PATCH 01/13] refactor(analytics): consolidate domain events to shared catalog (#2883) Rename the remaining action/outcome analytics events to the shared cross-platform `domain.action_past` grammar and consolidate events that describe the same action behind a call-site discriminator. - Rewrite the AnalyticsEvent wire strings (payment/swap/signing/asset/ account/onboarding/discovery/history/onramp/platform events). - Collapse the four Blockaid scan events into `blockaid.scan_completed` (scan_target + result) and add `blockaid.scan_failed` at scan-failure paths; skip the expected non-mainnet short-circuit and cancellations. - Collapse the swap pickers into `swap.picker_opened` (side), the add/remove prompt responses into `asset_add.responded` / `asset_remove.responded` (decision), the store-open events into `app_update.store_opened` (source), the payment-type selections, the unsafe-asset add, and the trustline-removal failures. - Route path/routed payment outcomes to swap events and collectible send failures to `collectible_send.failed`. - Align touched property keys to the grammar (origin, reason_code, from_asset_code/to_asset_code, operation) and add asset_code. - Remove the redundant, unreferenced "account screen: copied public key". schema_version/surface/network/account_id_hash still come from buildCommonContext; screen.viewed and the property-model foundation are untouched. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XFcohvcYZtftSU5Kcypg5P --- .../ForceUpdateScreen/ForceUpdateScreen.tsx | 4 +- .../screens/HomeScreen/HomeScreenHeader.tsx | 4 +- .../SwapScreen/hooks/useSwapNavigation.ts | 13 +- src/config/analyticsConfig.ts | 276 ++++++++++-------- src/hooks/useManageTokens.ts | 10 +- src/services/analytics/transactions.ts | 105 +++++-- src/services/blockaid/api.ts | 81 ++++- 7 files changed, 322 insertions(+), 171 deletions(-) diff --git a/src/components/screens/ForceUpdateScreen/ForceUpdateScreen.tsx b/src/components/screens/ForceUpdateScreen/ForceUpdateScreen.tsx index 72ad00316..9554e1314 100644 --- a/src/components/screens/ForceUpdateScreen/ForceUpdateScreen.tsx +++ b/src/components/screens/ForceUpdateScreen/ForceUpdateScreen.tsx @@ -34,7 +34,9 @@ export const ForceUpdateScreen: React.FC = ({ const handleGoToAppStore = () => { openAppStore().then(() => { - analytics.track(AnalyticsEvent.APP_UPDATE_OPEN_STORE_FROM_SCREEN); + analytics.track(AnalyticsEvent.APP_UPDATE_STORE_OPENED, { + source: "screen", + }); onDismiss?.(); }); }; diff --git a/src/components/screens/HomeScreen/HomeScreenHeader.tsx b/src/components/screens/HomeScreen/HomeScreenHeader.tsx index 0a4bfd3df..eb50ba22c 100644 --- a/src/components/screens/HomeScreen/HomeScreenHeader.tsx +++ b/src/components/screens/HomeScreen/HomeScreenHeader.tsx @@ -42,7 +42,9 @@ const HomeScreenHeader = ( const handleBannerPress = useCallback(() => { openAppStore().then(() => { - analytics.track(AnalyticsEvent.APP_UPDATE_OPEN_STORE_FROM_BANNER); + analytics.track(AnalyticsEvent.APP_UPDATE_STORE_OPENED, { + source: "banner", + }); }); }, [openAppStore]); diff --git a/src/components/screens/SwapScreen/hooks/useSwapNavigation.ts b/src/components/screens/SwapScreen/hooks/useSwapNavigation.ts index 1be4d478c..491572391 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapNavigation.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapNavigation.ts @@ -12,9 +12,9 @@ type SwapNavigation = NativeStackNavigationProp< /** * Centralises the SwapAmountScreen's two "open the SwapToScreen picker" - * navigation callbacks. Each emits its own analytics event - * (SWAP_TO_PICKER_OPENED vs SWAP_FROM_PICKER_OPENED) tagged with the - * picker entrypoint (cta vs dropdown), then routes to the same + * navigation callbacks. Each emits the shared SWAP_PICKER_OPENED event + * discriminated by `side` (to vs from) and tagged with the picker + * entrypoint (cta vs dropdown via `source`), then routes to the same * SWAP_SCREEN with the corresponding selectionType. * * Returns: @@ -40,7 +40,7 @@ export const useSwapNavigation = ({ } => { const openDestinationPicker = useCallback( (source: SwapPickerEntrypoint = SwapPickerEntrypoint.DROPDOWN) => { - analytics.track(AnalyticsEvent.SWAP_TO_PICKER_OPENED, { source }); + analytics.track(AnalyticsEvent.SWAP_PICKER_OPENED, { side: "to", source }); navigation.navigate(SWAP_ROUTES.SWAP_SCREEN, { selectionType: SWAP_SELECTION_TYPES.DESTINATION, }); @@ -54,7 +54,10 @@ export const useSwapNavigation = ({ const openSourcePicker = useCallback( (source: SwapPickerEntrypoint) => { - analytics.track(AnalyticsEvent.SWAP_FROM_PICKER_OPENED, { source }); + analytics.track(AnalyticsEvent.SWAP_PICKER_OPENED, { + side: "from", + source, + }); navigation.navigate(SWAP_ROUTES.SWAP_SCREEN, { selectionType: SWAP_SELECTION_TYPES.SOURCE, }); diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index 80ead9bd1..419fc5131 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -70,130 +70,168 @@ export enum AnalyticsEvent { VIEW_SEARCH_TOKEN = "loaded screen: search asset", VIEW_ADD_TOKEN_MANUALLY = "loaded screen: add asset manually", - // User Action Events (Manual tracking) - CREATE_PASSWORD_SUCCESS = "account creator: create password: success", - CREATE_PASSWORD_FAIL = "account creator: create password: error", - VIEWED_RECOVERY_PHRASE = "account creator: viewed phrase", - CONFIRM_RECOVERY_PHRASE_SUCCESS = "account creator: confirm phrase: confirmed phrase", - CONFIRM_RECOVERY_PHRASE_FAIL = "account creator: confirm phrase: error confirming", - ACCOUNT_CREATOR_CONFIRM_MNEMONIC_BACK = "account creator: confirm phrase: back to phrase", - ACCOUNT_CREATOR_FINISHED = "account creator finished: closed account creator flow", - - // Authentication Events - RE_AUTH_SUCCESS = "re-auth: success", - RE_AUTH_FAIL = "re-auth: error", - RECOVER_ACCOUNT_SUCCESS = "recover account: success", - RECOVER_ACCOUNT_FAIL = "recover account: error", - - // Send Payment Events - SEND_PAYMENT_SUCCESS = "send payment: payment success", - SEND_PAYMENT_FAIL = "send payment: error", - SEND_PAYMENT_SET_MAX = "send payment: set max", - SEND_PAYMENT_TYPE_PAYMENT = "send payment: selected type payment", - SEND_PAYMENT_TYPE_PATH_PAYMENT = "send payment: selected type path payment", - SEND_PAYMENT_RECENT_ADDRESS = "send payment: recent address", - SWAP_SUCCESS = "swap: success", - SWAP_FAIL = "swap: error", - SWAP_TO_PICKER_OPENED = "swap: to-picker opened", - SWAP_FROM_PICKER_OPENED = "swap: from-picker opened", - SWAP_DIRECTION_TOGGLED = "swap: direction toggled", - SWAP_TRENDING_TOKEN_TAPPED = "swap: trending token tapped", - SWAP_TRENDING_SWAP_TO_PRESSED = "swap: trending swap-to pressed", - SWAP_DESTINATION_SELECTED = "swap: destination selected", - SWAP_SOURCE_SELECTED = "swap: source selected", - SWAP_TRUSTLINE_ADDED = "swap: trustline added", - SWAP_XLM_RESERVE_INSUFFICIENT_SHOWN = "swap: xlm reserve insufficient shown", - SWAP_QUOTE_EXPIRED = "swap: quote expired", - - // Send Collectible Events - SEND_COLLECTIBLE_SUCCESS = "send collectible: success", - SEND_COLLECTIBLE_FAIL = "send collectible: error", - - // Copy Events - COPY_PUBLIC_KEY = "viewPublicKey: copied public key", - COPY_BACKUP_PHRASE = "backup phrase: copied phrase", - DOWNLOAD_BACKUP_PHRASE = "backup phrase: downloaded phrase", - - // Transaction & Simulation Events - SIMULATE_TOKEN_PAYMENT_ERROR = "failed to simulate token payment", - SIGN_TRANSACTION_SUCCESS = "sign transaction: confirmed", - SIGN_TRANSACTION_FAIL = "sign transaction: rejected", - SIGN_TRANSACTION_MEMO_REQUIRED_FAIL = "sign transaction: memo required error", - SUBMIT_TRANSACTION_SUCCESS = "submit transaction: confirmed", - SIGN_MESSAGE_SUCCESS = "sign message: confirmed", - SIGN_MESSAGE_FAIL = "sign message: error", - SIGN_AUTH_ENTRY_SUCCESS = "sign auth entry: confirmed", - SIGN_AUTH_ENTRY_FAIL = "sign auth entry: error", - - // Token Management Events - ADD_TOKEN_SUCCESS = "manage asset: add asset", - ADD_UNSAFE_TOKEN_SUCCESS = "manage asset: add unsafe asset", - REMOVE_TOKEN_SUCCESS = "manage asset: remove asset", - TOKEN_MANAGEMENT_FAIL = "manage asset: error", - ADD_TOKEN_CONFIRMED = "add token: confirmed", - ADD_TOKEN_REJECTED = "add token: rejected", - REMOVE_TOKEN_CONFIRMED = "remove token: confirmed", - REMOVE_TOKEN_REJECTED = "remove token: rejected", - MANAGE_TOKEN_LISTS_MODIFY = "manage asset list: modify asset list", - - // Trustline Error Events - TRUSTLINE_INSUFFICIENT_BALANCE_FAIL = "trustline removal error: asset has balance", - TRUSTLINE_HAS_LIABILITIES_FAIL = "trustline removal error: asset has buying liabilties", - TRUSTLINE_LOW_RESERVE_FAIL = "trustline removal error: asset has low reserve", - - // Account Management Events - ACCOUNT_SCREEN_ADD_ACCOUNT = "account screen: created new account", - ACCOUNT_SCREEN_COPY_PUBLIC_KEY = "account screen: copied public key", - ACCOUNT_SCREEN_IMPORT_ACCOUNT = "account screen: imported new account", - ACCOUNT_SCREEN_IMPORT_ACCOUNT_FAIL = "account screen: imported new account: error", - VIEW_PUBLIC_KEY_ACCOUNT_RENAMED = "viewPublicKey: renamed account", - VIEW_PUBLIC_KEY_CLICKED_STELLAR_EXPERT = "viewPublicKey: clicked StellarExpert", - - // WalletConnect/dApp Events - GRANT_DAPP_ACCESS_SUCCESS = "grant access: granted", - GRANT_DAPP_ACCESS_FAIL = "grant access: rejected", - - // History Events - HISTORY_OPEN_FULL_HISTORY = "history: opened full history on external website", - HISTORY_OPEN_ITEM = "history: opened item on external website", - + // --------------------------------------------------------------------------- + // Domain (action / outcome) events (#2883) + // + // Wire strings follow the shared cross-platform grammar `domain.action_past` + // (dot domain, snake_case verb phrase). Terminal outcomes are separate events + // (`completed` / `failed` / `rejected` / `blocked` / `submitted`). Several + // legacy events collapse into one event carrying a discriminator property + // (scan_target / decision / side / payment_type / safety / operation / + // reason_code) supplied at the call site. `schema_version`, `surface`, + // `network` and `account_id_hash` are added by buildCommonContext and must + // NOT be hand-added at call sites. + // --------------------------------------------------------------------------- + + // Onboarding / account creation + CREATE_PASSWORD_SUCCESS = "onboarding.password_created", + // carries reason_code + CREATE_PASSWORD_FAIL = "onboarding.password_create_failed", + VIEWED_RECOVERY_PHRASE = "onboarding.recovery_phrase_viewed", + CONFIRM_RECOVERY_PHRASE_SUCCESS = "onboarding.recovery_phrase_confirmed", + // carries reason_code + CONFIRM_RECOVERY_PHRASE_FAIL = "onboarding.recovery_phrase_confirm_failed", + ACCOUNT_CREATOR_CONFIRM_MNEMONIC_BACK = "onboarding.recovery_phrase_back_clicked", + ACCOUNT_CREATOR_FINISHED = "onboarding.completed", + + // Authentication / recovery + // NOTE: re-auth has no cross-platform mapping in #2883; the grammar is + // applied consistently here (terminal completed / failed) and flagged for + // review. + RE_AUTH_SUCCESS = "reauth.completed", + RE_AUTH_FAIL = "reauth.failed", + RECOVER_ACCOUNT_SUCCESS = "account_recovery.completed", + // carries reason_code + RECOVER_ACCOUNT_FAIL = "account_recovery.failed", + + // Payments / send + // payment.completed carries payment_type (payment | path_payment). Path / + // routed payment outcomes are reported as swaps (see the analytics helper), + // so a path-payment success emits swap.completed and a failure swap.failed. + SEND_PAYMENT_SUCCESS = "payment.completed", + // carries reason_code (direct payments only; path outcomes -> swap.failed) + SEND_PAYMENT_FAIL = "payment.failed", + SEND_PAYMENT_SET_MAX = "payment.max_amount_selected", + // Consolidates the direct / path payment-type selections; carries + // payment_type (payment | path_payment). + PAYMENT_TYPE_SELECTED = "payment.type_selected", + SEND_PAYMENT_RECENT_ADDRESS = "payment.recipient_recent_selected", + // carries reason_code + SIMULATE_TOKEN_PAYMENT_ERROR = "payment.simulation_failed", + + // Swap + SWAP_SUCCESS = "swap.completed", + // carries reason_code + SWAP_FAIL = "swap.failed", + // Consolidates the to-/from-picker opened events; carries side (from | to). + SWAP_PICKER_OPENED = "swap.picker_opened", + SWAP_DIRECTION_TOGGLED = "swap.direction_toggled", + SWAP_TRENDING_TOKEN_TAPPED = "swap.trending_token_tapped", + SWAP_TRENDING_SWAP_TO_PRESSED = "swap.trending_swap_to_pressed", + SWAP_DESTINATION_SELECTED = "swap.destination_selected", + SWAP_SOURCE_SELECTED = "swap.source_selected", + SWAP_TRUSTLINE_ADDED = "swap.trustline_added", + SWAP_XLM_RESERVE_INSUFFICIENT_SHOWN = "swap.xlm_reserve_insufficient_shown", + SWAP_QUOTE_EXPIRED = "swap.quote_expired", + + // Send collectible + SEND_COLLECTIBLE_SUCCESS = "collectible_send.completed", + // carries reason_code + SEND_COLLECTIBLE_FAIL = "collectible_send.failed", + + // Signing / dApp + GRANT_DAPP_ACCESS_SUCCESS = "dapp_access.granted", + GRANT_DAPP_ACCESS_FAIL = "dapp_access.rejected", + SIGN_TRANSACTION_SUCCESS = "signing.transaction_approved", + SIGN_TRANSACTION_FAIL = "signing.transaction_rejected", + // carries reason_code=memo_required + SIGN_TRANSACTION_MEMO_REQUIRED_FAIL = "signing.transaction_blocked", + SUBMIT_TRANSACTION_SUCCESS = "transaction.submitted", + SIGN_MESSAGE_SUCCESS = "signing.message_approved", + // Runtime signing failure (carries reason_code). + SIGN_MESSAGE_FAIL = "signing.message_failed", + // Catalog member for the user-reject case. The mobile reject path currently + // has no analytics call, so this is not emitted yet; kept for cross-platform + // parity and flagged for review. + SIGN_MESSAGE_REJECTED = "signing.message_rejected", + SIGN_AUTH_ENTRY_SUCCESS = "signing.auth_entry_approved", + // Runtime signing failure (carries reason_code). + SIGN_AUTH_ENTRY_FAIL = "signing.auth_entry_failed", + // Catalog member for the user-reject case; see SIGN_MESSAGE_REJECTED. + SIGN_AUTH_ENTRY_REJECTED = "signing.auth_entry_rejected", + + // Assets / trustlines + // asset.added carries safety=unsafe when an unsafe asset was added. + ADD_TOKEN_SUCCESS = "asset.added", + REMOVE_TOKEN_SUCCESS = "asset.removed", + // carries operation (add | remove) + reason_code + TOKEN_MANAGEMENT_FAIL = "asset.operation_failed", + // Consolidates the add-token confirmed / rejected prompt responses; carries + // decision (confirm | reject) + source. + ASSET_ADD_RESPONDED = "asset_add.responded", + // Consolidates the remove-token confirmed / rejected prompt responses; + // carries decision (confirm | reject). + ASSET_REMOVE_RESPONDED = "asset_remove.responded", + MANAGE_TOKEN_LISTS_MODIFY = "asset_list.modified", + // Consolidates the three trustline-removal failure reasons; carries + // reason_code (has_balance | buying_liabilities | low_reserve). + TRUSTLINE_REMOVE_FAILED = "trustline_remove.failed", + + // Account management + ACCOUNT_SCREEN_ADD_ACCOUNT = "account.created", + ACCOUNT_SCREEN_IMPORT_ACCOUNT = "account.imported", + // carries reason_code + ACCOUNT_SCREEN_IMPORT_ACCOUNT_FAIL = "account.import_failed", + VIEW_PUBLIC_KEY_ACCOUNT_RENAMED = "account.renamed", + COPY_PUBLIC_KEY = "account.public_key_copied", + VIEW_PUBLIC_KEY_CLICKED_STELLAR_EXPERT = "account.stellar_expert_opened", + + // Recovery phrase + COPY_BACKUP_PHRASE = "recovery_phrase.copied", + DOWNLOAD_BACKUP_PHRASE = "recovery_phrase.downloaded", + + // History + HISTORY_OPEN_FULL_HISTORY = "history.full_history_opened", + HISTORY_OPEN_ITEM = "history.item_opened", + + // Canonical app-open event (property-model foundation, #2883). APP_OPENED = "event: App Opened", - // Mobile-Only Events - QR_SCAN_SUCCESS = "mobile: qr scan success", - QR_SCAN_ERROR = "mobile: qr scan error", - - // App Update Events - APP_UPDATE_OPEN_STORE_FROM_BANNER = "app update: opened app store from banner", - APP_UPDATE_OPEN_STORE_FROM_SCREEN = "app update: opened app store from screen", - APP_UPDATE_CONFIRMED_SKIP_ON_SCREEN = "app update: confirmed skip on screen", - - // Blockaid Events - BLOCKAID_BULK_TOKEN_SCAN = "blockaid: bulk scanned tokens", - BLOCKAID_TOKEN_SCAN = "blockaid: scanned asset", - BLOCKAID_SITE_SCAN = "blockaid: scanned domain", - BLOCKAID_TRANSACTION_SCAN = "blockaid: scanned transaction", - - // Onramp Events - COINBASE_ONRAMP_OPENED = "coinbase onramp: opened", - - // Jailbreak Events - DEVICE_JAILBREAK_DETECTED = "device security: jailbreak detected", - DEVICE_JAILBREAK_FAILED = "device security: jailbreak detection failed", - - // Discover Events - DISCOVER_PROTOCOL_OPENED = "discover: protocol opened", - DISCOVER_PROTOCOL_DETAILS_VIEWED = "discover: protocol details viewed", - DISCOVER_PROTOCOL_OPENED_FROM_DETAILS = "discover: protocol opened from details", - DISCOVER_TAB_CREATED = "discover: tab created", - DISCOVER_TAB_CLOSED = "discover: tab closed", - DISCOVER_ALL_TABS_CLOSED = "discover: all tabs closed", - DISCOVER_WELCOME_MODAL_VIEWED = "discover: welcome modal viewed", + // Blockaid + // Consolidates the four scan events; carries scan_target + // (domain | transaction | asset | asset_bulk) + result. A scan failure emits + // BLOCKAID_SCAN_FAILED (scan_target + reason_code). + BLOCKAID_SCAN_COMPLETED = "blockaid.scan_completed", + BLOCKAID_SCAN_FAILED = "blockaid.scan_failed", + + // Onramp + COINBASE_ONRAMP_OPENED = "onramp.coinbase_opened", + + // Discover + DISCOVER_PROTOCOL_OPENED = "discover.protocol_opened", + DISCOVER_PROTOCOL_DETAILS_VIEWED = "discover.protocol_details_viewed", + DISCOVER_PROTOCOL_OPENED_FROM_DETAILS = "discover.protocol_opened_from_details", + DISCOVER_TAB_CREATED = "discover.tab_created", + DISCOVER_TAB_CLOSED = "discover.tab_closed", + DISCOVER_ALL_TABS_CLOSED = "discover.all_tabs_closed", + DISCOVER_WELCOME_MODAL_VIEWED = "discover.welcome_modal_viewed", + + // Platform-only events (retained; renamed to the shared grammar) + QR_SCAN_SUCCESS = "qr_scan.completed", + // carries reason_code + QR_SCAN_ERROR = "qr_scan.failed", + // Consolidates the banner / screen store-open events; carries + // source (banner | screen). + APP_UPDATE_STORE_OPENED = "app_update.store_opened", + APP_UPDATE_CONFIRMED_SKIP_ON_SCREEN = "app_update.skip_confirmed", + DEVICE_JAILBREAK_DETECTED = "device_security.jailbreak_detected", + // carries reason_code + DEVICE_JAILBREAK_FAILED = "device_security.jailbreak_detection_failed", } /** - * Tags how the user reached the Swap source / destination picker, for the - * SWAP_FROM_PICKER_OPENED + SWAP_TO_PICKER_OPENED analytics events. + * Tags how the user reached the Swap source / destination picker, carried as + * the `source` property on the SWAP_PICKER_OPENED analytics event. * * - CTA: the missing-side prompt button (e.g. "Select a token" / "Sell") * on SwapAmountScreen fired the navigation. diff --git a/src/hooks/useManageTokens.ts b/src/hooks/useManageTokens.ts index a2d7ad262..47faa2222 100644 --- a/src/hooks/useManageTokens.ts +++ b/src/hooks/useManageTokens.ts @@ -186,12 +186,13 @@ export const useManageTokens = ({ }); } analytics.track(AnalyticsEvent.ADD_TOKEN_SUCCESS, { + asset_code: tokenCode, asset: `${tokenCode}:${issuer}`, }); } catch (error) { analytics.track(AnalyticsEvent.TOKEN_MANAGEMENT_FAIL, { - error: error instanceof Error ? error.message : String(error), - action: "add", + reason_code: error instanceof Error ? error.message : String(error), + operation: "add", asset: `${tokenCode}:${issuer}`, }); @@ -314,12 +315,13 @@ export const useManageTokens = ({ }); } analytics.track(AnalyticsEvent.REMOVE_TOKEN_SUCCESS, { + asset_code: tokenCode, asset: tokenIdentifier, }); } catch (error) { analytics.track(AnalyticsEvent.TOKEN_MANAGEMENT_FAIL, { - error: error instanceof Error ? error.message : String(error), - action: "remove", + reason_code: error instanceof Error ? error.message : String(error), + operation: "remove", asset: tokenIdentifier, }); diff --git a/src/services/analytics/transactions.ts b/src/services/analytics/transactions.ts index b21d11fbc..e2c868943 100644 --- a/src/services/analytics/transactions.ts +++ b/src/services/analytics/transactions.ts @@ -1,5 +1,6 @@ import { AnalyticsEvent } from "config/analyticsConfig"; import { track } from "services/analytics/core"; +import { TransactionOperationType } from "services/analytics/types"; import type { SignedTransactionEvent, SimulationTransactionType, @@ -13,7 +14,7 @@ export const trackSignedTransaction = (data: SignedTransactionEvent): void => { track(AnalyticsEvent.SIGN_TRANSACTION_SUCCESS, { transactionHash: data.transactionHash, transactionType: data.transactionType, - ...(data.dappDomain ? { dappDomain: data.dappDomain } : {}), + ...(data.dappDomain ? { origin: data.dappDomain } : {}), }); }; @@ -21,15 +22,17 @@ export const trackSignedMessage = (data: { messageLength: number; dappDomain?: string; }): void => { + // NOTE: mobile has a single message-signing flow, so message_type is not + // discriminated here; flagged for review. track(AnalyticsEvent.SIGN_MESSAGE_SUCCESS, { messageLength: data.messageLength, - ...(data.dappDomain ? { dappDomain: data.dappDomain } : {}), + ...(data.dappDomain ? { origin: data.dappDomain } : {}), }); }; export const trackSignedAuthEntry = (data: { dappDomain?: string }): void => { track(AnalyticsEvent.SIGN_AUTH_ENTRY_SUCCESS, { - ...(data.dappDomain ? { dappDomain: data.dappDomain } : {}), + ...(data.dappDomain ? { origin: data.dappDomain } : {}), }); }; @@ -37,9 +40,13 @@ export const trackSignedMessageError = (data: { error: string; dappDomain?: string; }): void => { + // This is the runtime signing-failure path (a caught exception while + // signing), which is distinct from a user rejection. The user-reject case + // is not currently instrumented on mobile (SIGN_MESSAGE_REJECTED exists in + // the catalog for parity but has no emit site yet). track(AnalyticsEvent.SIGN_MESSAGE_FAIL, { - error: data.error, - ...(data.dappDomain ? { dappDomain: data.dappDomain } : {}), + reason_code: data.error, + ...(data.dappDomain ? { origin: data.dappDomain } : {}), }); }; @@ -47,9 +54,10 @@ export const trackSignedAuthEntryError = (data: { error: string; dappDomain?: string; }): void => { + // Runtime signing-failure path; see trackSignedMessageError. track(AnalyticsEvent.SIGN_AUTH_ENTRY_FAIL, { - error: data.error, - ...(data.dappDomain ? { dappDomain: data.dappDomain } : {}), + reason_code: data.error, + ...(data.dappDomain ? { origin: data.dappDomain } : {}), }); }; @@ -59,7 +67,7 @@ export const trackSubmittedTransaction = ( track(AnalyticsEvent.SUBMIT_TRANSACTION_SUCCESS, { transactionHash: data.transactionHash, transactionType: data.transactionType, - ...(data.dappDomain ? { dappDomain: data.dappDomain } : {}), + ...(data.dappDomain ? { origin: data.dappDomain } : {}), }); }; @@ -68,7 +76,7 @@ export const trackSimulationError = ( transactionType: SimulationTransactionType, ): void => { track(AnalyticsEvent.SIMULATE_TOKEN_PAYMENT_ERROR, { - error, + reason_code: error, transactionType, }); }; @@ -76,8 +84,20 @@ export const trackSimulationError = ( export const trackSendPaymentSuccess = ( data: TransactionSuccessEvent, ): void => { + // Path / routed payment outcomes are reported as swaps (product decision): + // a path-payment success emits swap.completed, a direct payment emits + // payment.completed with payment_type=payment. + if (data.operationType === TransactionOperationType.PathPayment) { + track(AnalyticsEvent.SWAP_SUCCESS, { + from_asset_code: data.sourceToken, + operationType: data.operationType, + }); + return; + } + track(AnalyticsEvent.SEND_PAYMENT_SUCCESS, { - sourceAsset: data.sourceToken, + from_asset_code: data.sourceToken, + payment_type: "payment", operationType: data.operationType, }); }; @@ -93,8 +113,8 @@ export const trackSendCollectibleSuccess = ( export const trackSwapSuccess = (data: SwapSuccessEvent): void => { track(AnalyticsEvent.SWAP_SUCCESS, { - sourceAsset: data.sourceToken, - destinationAsset: data.destToken, + from_asset_code: data.sourceToken, + to_asset_code: data.destToken, sourceAmount: data.sourceAmount, destinationAmount: data.destAmount, allowedSlippage: data.allowedSlippage, @@ -103,22 +123,34 @@ export const trackSwapSuccess = (data: SwapSuccessEvent): void => { }; export const trackTransactionError = (data: TransactionErrorEvent): void => { - const event = data.isSwap - ? AnalyticsEvent.SWAP_FAIL - : AnalyticsEvent.SEND_PAYMENT_FAIL; + // Route the outcome to the right terminal event: + // - collectible sends -> collectible_send.failed + // - swaps and path / routed payments -> swap.failed (product decision) + // - direct payments -> payment.failed + const isSwapLike = + data.isSwap || + data.operationType === TransactionOperationType.PathPayment || + data.operationType === TransactionOperationType.Swap; + + let event = AnalyticsEvent.SEND_PAYMENT_FAIL; + if (data.operationType === TransactionOperationType.SendCollectible) { + event = AnalyticsEvent.SEND_COLLECTIBLE_FAIL; + } else if (isSwapLike) { + event = AnalyticsEvent.SWAP_FAIL; + } track(event, { - error: data.error, + reason_code: data.error, errorCode: data.errorCode, operationType: data.operationType, isSwap: data.isSwap, - // Swap-specific fields are gated so SEND_PAYMENT_FAIL events from - // non-swap callers don't get polluted with undefined sourceAsset / - // destinationAsset / sourceAmount / destinationAmount keys. - ...(data.isSwap + // Swap-specific fields are gated so payment.failed events from non-swap + // callers don't get polluted with undefined from_asset_code / + // to_asset_code / sourceAmount / destinationAmount keys. + ...(isSwapLike ? { - sourceAsset: data.sourceToken, - destinationAsset: data.destToken, + from_asset_code: data.sourceToken, + to_asset_code: data.destToken, sourceAmount: data.sourceAmount, destinationAmount: data.destAmount, } @@ -127,19 +159,31 @@ export const trackTransactionError = (data: TransactionErrorEvent): void => { }; export const trackAddTokenConfirmed = (token?: string): void => { - track(AnalyticsEvent.ADD_TOKEN_CONFIRMED, { asset: token }); + track(AnalyticsEvent.ASSET_ADD_RESPONDED, { + decision: "confirm", + asset: token, + }); }; export const trackAddTokenRejected = (token?: string): void => { - track(AnalyticsEvent.ADD_TOKEN_REJECTED, { asset: token }); + track(AnalyticsEvent.ASSET_ADD_RESPONDED, { + decision: "reject", + asset: token, + }); }; export const trackRemoveTokenConfirmed = (token?: string): void => { - track(AnalyticsEvent.REMOVE_TOKEN_CONFIRMED, { asset: token }); + track(AnalyticsEvent.ASSET_REMOVE_RESPONDED, { + decision: "confirm", + asset: token, + }); }; export const trackRemoveTokenRejected = (token?: string): void => { - track(AnalyticsEvent.REMOVE_TOKEN_REJECTED, { asset: token }); + track(AnalyticsEvent.ASSET_REMOVE_RESPONDED, { + decision: "reject", + asset: token, + }); }; export const trackAccountScreenImportAccountFail = (error: string): void => { @@ -157,14 +201,17 @@ export const trackViewPublicKeyAccountRenamed = ( }; export const trackGrantAccessSuccess = (domain?: string): void => { - track(AnalyticsEvent.GRANT_DAPP_ACCESS_SUCCESS, { domain }); + track(AnalyticsEvent.GRANT_DAPP_ACCESS_SUCCESS, { origin: domain }); }; export const trackGrantAccessFail = ( domain?: string, reason?: string, ): void => { - track(AnalyticsEvent.GRANT_DAPP_ACCESS_FAIL, { domain, reason }); + track(AnalyticsEvent.GRANT_DAPP_ACCESS_FAIL, { + origin: domain, + reason_code: reason, + }); }; export const trackHistoryOpenItem = (transactionHash: string): void => { @@ -220,5 +267,5 @@ export const trackQRScanSuccess = ( }; export const trackQRScanError = (context: string, error: string): void => { - track(AnalyticsEvent.QR_SCAN_ERROR, { context, error }); + track(AnalyticsEvent.QR_SCAN_ERROR, { context, reason_code: error }); }; diff --git a/src/services/blockaid/api.ts b/src/services/blockaid/api.ts index 63f35e735..5fc86ed18 100644 --- a/src/services/blockaid/api.ts +++ b/src/services/blockaid/api.ts @@ -8,7 +8,13 @@ import { freighterBackendV1 } from "services/backend"; import { BLOCKAID_ENDPOINTS, BLOCKAID_ERROR_MESSAGES, + SecurityLevel, } from "services/blockaid/constants"; +import { + assessSiteSecurity, + assessTokenSecurity, + assessTransactionSecurity, +} from "services/blockaid/helper"; import { BlockaidApiResponse, ScanTokenParams, @@ -17,6 +23,51 @@ import { ScanBulkTokensParams, } from "services/blockaid/types"; +/** + * Blockaid scan targets for the consolidated `blockaid.scan_completed` / + * `blockaid.scan_failed` events. The `scan_target` property discriminates + * which kind of scan ran. + */ +type BlockaidScanTarget = "domain" | "transaction" | "asset" | "asset_bulk"; + +/** + * Emits the consolidated scan-failure event. Blockaid only runs on mainnet, so + * the NETWORK_NOT_SUPPORTED short-circuit on other networks is an expected skip + * rather than a scan failure and is not reported. + */ +/** + * Reduces a bulk token scan to a single worst-case security level for the + * `result` property (malicious > suspicious > safe/unable-to-scan). + */ +const aggregateBulkResult = ( + scanResult: Blockaid.TokenBulkScanResponse, +): SecurityLevel => { + const levels = Object.values(scanResult.results ?? {}).map( + (result) => assessTokenSecurity(result).level, + ); + + if (levels.includes(SecurityLevel.MALICIOUS)) return SecurityLevel.MALICIOUS; + if (levels.includes(SecurityLevel.SUSPICIOUS)) { + return SecurityLevel.SUSPICIOUS; + } + return SecurityLevel.SAFE; +}; + +const trackScanFailed = ( + scanTarget: BlockaidScanTarget, + error: unknown, +): void => { + if (axios.isCancel(error)) return; + + const reasonCode = error instanceof Error ? error.message : String(error); + if (reasonCode === BLOCKAID_ERROR_MESSAGES.NETWORK_NOT_SUPPORTED) return; + + analytics.track(AnalyticsEvent.BLOCKAID_SCAN_FAILED, { + scan_target: scanTarget, + reason_code: reasonCode, + }); +}; + const formatAddress = (tokenCode: string, tokenIssuer?: string): string => { if (tokenIssuer) { return `${tokenCode}-${tokenIssuer}`; @@ -58,13 +109,15 @@ export const scanToken = async ( const scanResult = response.data.data as Blockaid.TokenScanResponse; - analytics.track(AnalyticsEvent.BLOCKAID_TOKEN_SCAN, { + analytics.track(AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, { + scan_target: "asset", + result: assessTokenSecurity(scanResult).level, tokenCode, - network, }); return scanResult; } catch (error) { + trackScanFailed("asset", error); throw new Error(BLOCKAID_ERROR_MESSAGES.TOKEN_SCAN_FAILED); } }; @@ -96,13 +149,16 @@ export const scanBulkTokens = async ( const scanResult = response.data.data as Blockaid.TokenBulkScanResponse; - analytics.track(AnalyticsEvent.BLOCKAID_BULK_TOKEN_SCAN, { - addressList, - network, + analytics.track(AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, { + scan_target: "asset_bulk", + // Aggregate verdict across the batch: the worst per-token security level. + result: aggregateBulkResult(scanResult), + addressCount: addressList.length, }); return scanResult; } catch (error) { + trackScanFailed("asset_bulk", error); // Rethrow cancellations untouched so callers can treat a superseded // request as benign; wrap everything else, preserving the cause. if (axios.isCancel(error)) throw error; @@ -139,13 +195,14 @@ export const scanSite = async ( const scanResult = response.data.data as Blockaid.SiteScanResponse; - analytics.track(AnalyticsEvent.BLOCKAID_SITE_SCAN, { - url, - network, + analytics.track(AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, { + scan_target: "domain", + result: assessSiteSecurity(scanResult).level, }); return scanResult; } catch (error) { + trackScanFailed("domain", error); throw new Error(BLOCKAID_ERROR_MESSAGES.SITE_SCAN_FAILED); } }; @@ -177,14 +234,14 @@ export const scanTransaction = async ( const scanResult = response.data .data as Blockaid.StellarTransactionScanResponse; - analytics.track(AnalyticsEvent.BLOCKAID_TRANSACTION_SCAN, { - xdr, - url, - network, + analytics.track(AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, { + scan_target: "transaction", + result: assessTransactionSecurity(scanResult).level, }); return scanResult; } catch (error) { + trackScanFailed("transaction", error); throw new Error(BLOCKAID_ERROR_MESSAGES.TRANSACTION_SCAN_FAILED); } }; From 938b35443d87e0463082fcb7985558d494f65645 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 17:02:41 +0000 Subject: [PATCH 02/13] test(analytics): cover renamed and consolidated domain events (#2883) - Extend core.test.ts with a domain-event catalog block: verifies the renamed wire strings, the scan_target/side/decision/source discriminators, blockaid.scan_failed, and a grammar guard asserting every non-screen domain event stays on domain.action_past. - Extend blockaid/api.test.ts to assert scan_completed (asset/asset_bulk) and the new scan_failed emission. - Lock a representative slice of the catalog in Analytics.test.ts. - Update the swap picker assertions to the consolidated swap.picker_opened event with side. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XFcohvcYZtftSU5Kcypg5P --- .../SwapScreen/SwapAmountScreen.test.tsx | 31 +++--- __tests__/services/Analytics.test.ts | 24 +++++ __tests__/services/blockaid/api.test.ts | 57 ++++++++++ src/services/analytics/core.test.ts | 100 ++++++++++++++++++ 4 files changed, 196 insertions(+), 16 deletions(-) diff --git a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx index d6655a381..6366f6b9b 100644 --- a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx +++ b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx @@ -1197,7 +1197,7 @@ describe("SwapAmountScreen", () => { expect(queryByTestId("swap-receive-pill")).toBeNull(); }); - it("fires SWAP_TO_PICKER_OPENED with source:dropdown when the Receive pill is tapped", () => { + it("fires SWAP_PICKER_OPENED side:to with source:dropdown when the Receive pill is tapped", () => { jest.spyOn(analytics, "track").mockClear(); setSwapStoreState({ @@ -1219,8 +1219,8 @@ describe("SwapAmountScreen", () => { fireEvent.press(getByTestId("swap-receive-pill")); expect(analytics.track).toHaveBeenCalledWith( - AnalyticsEvent.SWAP_TO_PICKER_OPENED, - { source: "dropdown" }, + AnalyticsEvent.SWAP_PICKER_OPENED, + { side: "to", source: "dropdown" }, ); }); }); @@ -1354,7 +1354,7 @@ describe("SwapAmountScreen", () => { jest.spyOn(analytics, "track").mockClear(); }); - it("fires SWAP_TO_PICKER_OPENED with source:cta when the 'Select a token' CTA is pressed", async () => { + it("fires SWAP_PICKER_OPENED side:to with source:cta when the 'Select a token' CTA is pressed", async () => { setSwapStoreState({ destinationToken: null, sourceAmount: "0" }); const navigation = makeNavigation(); @@ -1370,12 +1370,12 @@ describe("SwapAmountScreen", () => { }); expect(analytics.track).toHaveBeenCalledWith( - AnalyticsEvent.SWAP_TO_PICKER_OPENED, - { source: "cta" }, + AnalyticsEvent.SWAP_PICKER_OPENED, + { side: "to", source: "cta" }, ); }); - it("fires SWAP_TO_PICKER_OPENED with source:dropdown when the Receive dropdown is tapped", () => { + it("fires SWAP_PICKER_OPENED side:to with source:dropdown when the Receive dropdown is tapped", () => { // destinationToken is null so the "Select a token" placeholder pill is rendered setSwapStoreState({ destinationToken: null, sourceAmount: "0" }); @@ -1386,12 +1386,12 @@ describe("SwapAmountScreen", () => { fireEvent.press(getByTestId("swap-receive-choose-pill")); expect(analytics.track).toHaveBeenCalledWith( - AnalyticsEvent.SWAP_TO_PICKER_OPENED, - { source: "dropdown" }, + AnalyticsEvent.SWAP_PICKER_OPENED, + { side: "to", source: "dropdown" }, ); }); - it("fires SWAP_FROM_PICKER_OPENED with source:dropdown when the Sell pill is tapped", () => { + it("fires SWAP_PICKER_OPENED side:from with source:dropdown when the Sell pill is tapped", () => { // Held source (XLM) → "swap-sell-pill" is rendered; press routes // through navigateToSelectSourceTokenScreen which is the only call // site for the source-side picker event. @@ -1407,14 +1407,13 @@ describe("SwapAmountScreen", () => { fireEvent.press(getByTestId("swap-sell-pill")); expect(analytics.track).toHaveBeenCalledWith( - AnalyticsEvent.SWAP_FROM_PICKER_OPENED, - { source: "dropdown" }, + AnalyticsEvent.SWAP_PICKER_OPENED, + { side: "from", source: "dropdown" }, ); - // Negative assertion: source picker MUST NOT reuse the destination - // event after the caa4aeab split. + // Negative assertion: the source picker MUST tag side:from, never side:to. expect(analytics.track).not.toHaveBeenCalledWith( - AnalyticsEvent.SWAP_TO_PICKER_OPENED, - { source: "dropdown" }, + AnalyticsEvent.SWAP_PICKER_OPENED, + { side: "to", source: "dropdown" }, ); }); diff --git a/__tests__/services/Analytics.test.ts b/__tests__/services/Analytics.test.ts index 0da683161..36cf6702f 100644 --- a/__tests__/services/Analytics.test.ts +++ b/__tests__/services/Analytics.test.ts @@ -14,4 +14,28 @@ describe("Analytics Service", () => { expect(AnalyticsEvent.SEND_PAYMENT_FAIL).toBeDefined(); expect(AnalyticsEvent.RE_AUTH_SUCCESS).toBeDefined(); }); + + it("maps domain events to the shared cross-platform wire strings (#2883)", () => { + // A representative slice of the renamed / consolidated catalog. These are + // the values sent to Amplitude and are a dashboard contract; changing one + // requires coordinating with analytics. + expect(AnalyticsEvent.SEND_PAYMENT_SUCCESS).toBe("payment.completed"); + expect(AnalyticsEvent.SEND_PAYMENT_FAIL).toBe("payment.failed"); + expect(AnalyticsEvent.SWAP_SUCCESS).toBe("swap.completed"); + expect(AnalyticsEvent.SWAP_PICKER_OPENED).toBe("swap.picker_opened"); + expect(AnalyticsEvent.SIGN_TRANSACTION_SUCCESS).toBe( + "signing.transaction_approved", + ); + expect(AnalyticsEvent.SIGN_TRANSACTION_FAIL).toBe( + "signing.transaction_rejected", + ); + expect(AnalyticsEvent.BLOCKAID_SCAN_COMPLETED).toBe( + "blockaid.scan_completed", + ); + expect(AnalyticsEvent.BLOCKAID_SCAN_FAILED).toBe("blockaid.scan_failed"); + expect(AnalyticsEvent.ASSET_ADD_RESPONDED).toBe("asset_add.responded"); + expect(AnalyticsEvent.APP_UPDATE_STORE_OPENED).toBe( + "app_update.store_opened", + ); + }); }); diff --git a/__tests__/services/blockaid/api.test.ts b/__tests__/services/blockaid/api.test.ts index aa5d4663a..81d33c4d8 100644 --- a/__tests__/services/blockaid/api.test.ts +++ b/__tests__/services/blockaid/api.test.ts @@ -1,5 +1,7 @@ import axios from "axios"; +import { AnalyticsEvent } from "config/analyticsConfig"; import { NETWORKS } from "config/constants"; +import { analytics } from "services/analytics"; import { freighterBackendV1 } from "services/backend"; import { scanBulkTokens, scanToken } from "services/blockaid/api"; @@ -10,6 +12,7 @@ jest.mock("services/analytics", () => ({ analytics: { track: jest.fn() } })); jest.mock("helpers/networks", () => ({ isMainnet: () => true })); const mockGet = freighterBackendV1.get as jest.Mock; +const mockTrack = analytics.track as jest.Mock; describe("scanBulkTokens error handling", () => { beforeEach(() => jest.clearAllMocks()); @@ -67,3 +70,57 @@ describe("scanToken native XLM handling", () => { expect(mockGet).toHaveBeenCalled(); }); }); + +describe("blockaid scan analytics (#2883 consolidation)", () => { + beforeEach(() => jest.clearAllMocks()); + + it("emits the consolidated scan_completed event with scan_target=asset + result", async () => { + mockGet.mockResolvedValue({ + data: { data: { result_type: "Malicious" } }, + }); + + await scanToken({ + tokenCode: "USDC", + tokenIssuer: "GISSUER", + network: NETWORKS.PUBLIC, + }); + + expect(mockTrack).toHaveBeenCalledWith( + AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, + expect.objectContaining({ scan_target: "asset", result: "MALICIOUS" }), + ); + }); + + it("emits the consolidated scan_completed event with scan_target=asset_bulk", async () => { + mockGet.mockResolvedValue({ + data: { data: { results: { "USDC-GISSUER": { result_type: "Benign" } } } }, + }); + + await scanBulkTokens({ + addressList: ["USDC-GISSUER"], + network: NETWORKS.PUBLIC, + }); + + expect(mockTrack).toHaveBeenCalledWith( + AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, + expect.objectContaining({ scan_target: "asset_bulk" }), + ); + }); + + it("emits the added scan_failed event when a scan throws", async () => { + mockGet.mockRejectedValue(new Error("backend down")); + + await expect( + scanToken({ + tokenCode: "USDC", + tokenIssuer: "GISSUER", + network: NETWORKS.PUBLIC, + }), + ).rejects.toThrow(); + + expect(mockTrack).toHaveBeenCalledWith( + AnalyticsEvent.BLOCKAID_SCAN_FAILED, + expect.objectContaining({ scan_target: "asset" }), + ); + }); +}); diff --git a/src/services/analytics/core.test.ts b/src/services/analytics/core.test.ts index 5e993f757..3282cd794 100644 --- a/src/services/analytics/core.test.ts +++ b/src/services/analytics/core.test.ts @@ -587,3 +587,103 @@ describe("syncIdentifyTraits (consent gating)", () => { expect(identify!).toHaveBeenCalledTimes(1); }); }); + +describe("domain event catalog (#2883)", () => { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + const amplitudeMock = require("@amplitude/analytics-react-native"); + + beforeEach(() => { + initAnalytics(); + (amplitudeMock.track as jest.Mock).mockClear(); + }); + + it("emits the renamed domain wire strings verbatim, merged with common context", () => { + track(AnalyticsEvent.SEND_PAYMENT_SUCCESS, { payment_type: "payment" }); + + expect(amplitudeMock.track).toHaveBeenCalledWith( + "payment.completed", + expect.objectContaining({ + payment_type: "payment", + // schema_version / surface / network come from buildCommonContext and + // must not be hand-added at call sites. + schema_version: "2", + surface: "mobile_ios", + }), + ); + }); + + it("carries the scan_target + result discriminators on the consolidated blockaid scan event", () => { + track(AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, { + scan_target: "asset", + result: "SAFE", + }); + + expect(amplitudeMock.track).toHaveBeenCalledWith( + "blockaid.scan_completed", + expect.objectContaining({ scan_target: "asset", result: "SAFE" }), + ); + }); + + it("emits the added blockaid.scan_failed event with scan_target + reason_code", () => { + track(AnalyticsEvent.BLOCKAID_SCAN_FAILED, { + scan_target: "domain", + reason_code: "boom", + }); + + expect(amplitudeMock.track).toHaveBeenCalledWith( + "blockaid.scan_failed", + expect.objectContaining({ scan_target: "domain", reason_code: "boom" }), + ); + }); + + it("consolidates the swap pickers into swap.picker_opened discriminated by side", () => { + track(AnalyticsEvent.SWAP_PICKER_OPENED, { side: "from", source: "cta" }); + track(AnalyticsEvent.SWAP_PICKER_OPENED, { + side: "to", + source: "dropdown", + }); + + expect(amplitudeMock.track).toHaveBeenNthCalledWith( + 1, + "swap.picker_opened", + expect.objectContaining({ side: "from", source: "cta" }), + ); + expect(amplitudeMock.track).toHaveBeenNthCalledWith( + 2, + "swap.picker_opened", + expect.objectContaining({ side: "to", source: "dropdown" }), + ); + }); + + it("consolidates the add-token prompt responses into asset_add.responded by decision", () => { + track(AnalyticsEvent.ASSET_ADD_RESPONDED, { decision: "reject" }); + + expect(amplitudeMock.track).toHaveBeenCalledWith( + "asset_add.responded", + expect.objectContaining({ decision: "reject" }), + ); + }); + + it("consolidates the store-open events into app_update.store_opened by source", () => { + track(AnalyticsEvent.APP_UPDATE_STORE_OPENED, { source: "banner" }); + + expect(amplitudeMock.track).toHaveBeenCalledWith( + "app_update.store_opened", + expect.objectContaining({ source: "banner" }), + ); + }); + + it("keeps every non-screen domain event on the domain.action_past grammar", () => { + // The property-model foundation owns these three; everything else must be + // a single-dot, snake_case `domain.action_past` string. + const FOUNDATION_VALUES = new Set([AnalyticsEvent.APP_OPENED]); + const GRAMMAR = /^[a-z0-9]+(?:_[a-z0-9]+)*\.[a-z0-9]+(?:_[a-z0-9]+)*$/; + + Object.entries(AnalyticsEvent) + .filter(([key]) => key !== "SCREEN_VIEWED" && !key.startsWith("VIEW_")) + .filter(([, value]) => !FOUNDATION_VALUES.has(value)) + .forEach(([, value]) => { + expect(value).toMatch(GRAMMAR); + }); + }); +}); From b3af98ed86dc3ed995477d05369a4e4238f2cc48 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 21 Jul 2026 19:21:32 -0400 Subject: [PATCH 03/13] analytics: cross-platform property-shape parity + deferred fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconcile the mobile analytics catalog with the freighter extension against the shared cross-platform analytics schema, plus deferred signing / trustline items. - Property shapes: asset_code+asset_issuer (split combined asset); payment. completed asset_code (drop operationType); swap.* {from,to}_asset_code (path-payment branch gains to_asset_code); reason_code-only failures (drop errorCode/operationType/isSwap); collectible snake_case; public_key_copied / recovery_phrase.copied carry no context/action; account.renamed source (drop names); history.item_opened/full_history_opened source; discover protocol_id; reauth drops constant context/method. - Re-point mnemonic-restore to account_recovery.* (was mislabeled account.import*); secret-key path keeps account.import* with import_method. - Blockaid result normalized to safe|warn|block|unknown. - Wire user-reject events (signing.message_rejected/auth_entry_rejected) in the WalletKit reject path. - Emit trustline_remove.failed with has_balance/buying_liabilities/low_reserve derived from Horizon op result codes + balances store. Contract-breaking wire/payload changes (hard cutover per #2883) — notify dashboard owners. Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/hooks/useManageTokens.test.tsx | 2 + __tests__/services/analytics/discover.test.ts | 16 ++- __tests__/services/blockaid/api.test.ts | 6 +- .../screens/HistoryScreen/HistoryList.tsx | 2 +- ...sactionDetailsBottomSheetCustomContent.tsx | 4 +- .../screens/HomeScreen/ManageAccounts.tsx | 5 +- .../screens/ValidateRecoveryPhraseScreen.tsx | 4 +- src/config/analyticsConfig.ts | 2 +- src/ducks/auth.ts | 27 +++- src/hooks/useManageTokens.ts | 48 ++++++- src/providers/WalletKitProvider.tsx | 14 ++ src/services/analytics/discover.ts | 11 +- src/services/analytics/index.ts | 6 + src/services/analytics/transactions.ts | 123 ++++++++++-------- src/services/blockaid/api.ts | 30 ++++- 15 files changed, 211 insertions(+), 89 deletions(-) diff --git a/__tests__/hooks/useManageTokens.test.tsx b/__tests__/hooks/useManageTokens.test.tsx index 9c6e1e761..e7fa35180 100644 --- a/__tests__/hooks/useManageTokens.test.tsx +++ b/__tests__/hooks/useManageTokens.test.tsx @@ -53,6 +53,8 @@ jest.mock("services/stellar", () => ({ Promise.resolve(mockBuildChangeTrustTx(params)), signTransaction: (params: SignTxParams) => mockSignTransaction(params), submitTx: (params: SubmitTxParams) => Promise.resolve(mockSubmitTx(params)), + isHorizonError: (val: unknown) => + typeof val === "object" && val !== null && "response" in val, })); jest.mock("helpers/balances", () => ({ diff --git a/__tests__/services/analytics/discover.test.ts b/__tests__/services/analytics/discover.test.ts index 5e0984a6b..08b66f7c3 100644 --- a/__tests__/services/analytics/discover.test.ts +++ b/__tests__/services/analytics/discover.test.ts @@ -67,10 +67,11 @@ describe("Discover Analytics", () => { expect(track).toHaveBeenCalledWith( AnalyticsEvent.DISCOVER_PROTOCOL_OPENED, { + protocol_id: "StellarX", url: "https://stellarx.com/", - protocolName: "StellarX", + protocol_name: "StellarX", source: "trending_carousel", - isKnownProtocol: true, + is_known_protocol: true, }, ); }); @@ -87,10 +88,11 @@ describe("Discover Analytics", () => { expect(track).toHaveBeenCalledWith( AnalyticsEvent.DISCOVER_PROTOCOL_OPENED, { + protocol_id: undefined, url: "https://unknown-site.com/", - protocolName: undefined, + protocol_name: undefined, source: "url_bar", - isKnownProtocol: false, + is_known_protocol: false, }, ); }); @@ -118,7 +120,8 @@ describe("Discover Analytics", () => { expect(track).toHaveBeenCalledWith( AnalyticsEvent.DISCOVER_PROTOCOL_DETAILS_VIEWED, { - protocolName: "StellarX", + protocol_id: "StellarX", + protocol_name: "StellarX", tags: ["DEX", "Swap"], }, ); @@ -135,7 +138,8 @@ describe("Discover Analytics", () => { expect(track).toHaveBeenCalledWith( AnalyticsEvent.DISCOVER_PROTOCOL_OPENED_FROM_DETAILS, { - protocolName: "StellarX", + protocol_id: "StellarX", + protocol_name: "StellarX", url: "https://stellarx.com/", }, ); diff --git a/__tests__/services/blockaid/api.test.ts b/__tests__/services/blockaid/api.test.ts index 81d33c4d8..c1823e092 100644 --- a/__tests__/services/blockaid/api.test.ts +++ b/__tests__/services/blockaid/api.test.ts @@ -87,13 +87,15 @@ describe("blockaid scan analytics (#2883 consolidation)", () => { expect(mockTrack).toHaveBeenCalledWith( AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, - expect.objectContaining({ scan_target: "asset", result: "MALICIOUS" }), + expect.objectContaining({ scan_target: "asset", result: "block" }), ); }); it("emits the consolidated scan_completed event with scan_target=asset_bulk", async () => { mockGet.mockResolvedValue({ - data: { data: { results: { "USDC-GISSUER": { result_type: "Benign" } } } }, + data: { + data: { results: { "USDC-GISSUER": { result_type: "Benign" } } }, + }, }); await scanBulkTokens({ diff --git a/src/components/screens/HistoryScreen/HistoryList.tsx b/src/components/screens/HistoryScreen/HistoryList.tsx index 2920b9454..e70d86880 100644 --- a/src/components/screens/HistoryScreen/HistoryList.tsx +++ b/src/components/screens/HistoryScreen/HistoryList.tsx @@ -93,7 +93,7 @@ const HistoryList: React.FC = ({ (transactionDetail: TransactionDetails) => { setTransactionDetails(transactionDetail); transactionDetailsBottomSheetModalRef.current?.present(); - analytics.trackHistoryOpenItem(transactionDetail.operation.id); + analytics.trackHistoryOpenItem("history_list"); }, [], ); diff --git a/src/components/screens/HistoryScreen/TransactionDetailsBottomSheetCustomContent.tsx b/src/components/screens/HistoryScreen/TransactionDetailsBottomSheetCustomContent.tsx index 2aa30037f..c8d86677a 100644 --- a/src/components/screens/HistoryScreen/TransactionDetailsBottomSheetCustomContent.tsx +++ b/src/components/screens/HistoryScreen/TransactionDetailsBottomSheetCustomContent.tsx @@ -372,7 +372,9 @@ export const TransactionDetailsFooter: React.FC< tertiary icon={} onPress={() => { - analytics.track(AnalyticsEvent.HISTORY_OPEN_FULL_HISTORY); + analytics.track(AnalyticsEvent.HISTORY_OPEN_FULL_HISTORY, { + source: "transaction_detail", + }); openInAppBrowser(externalUrl); }} > diff --git a/src/components/screens/HomeScreen/ManageAccounts.tsx b/src/components/screens/HomeScreen/ManageAccounts.tsx index 602816c1d..d003bb32a 100644 --- a/src/components/screens/HomeScreen/ManageAccounts.tsx +++ b/src/components/screens/HomeScreen/ManageAccounts.tsx @@ -106,10 +106,7 @@ const ManageAccounts: React.FC = ({ async (newAccountName: string) => { if (!accountToRename || !activeAccount) return; - analytics.trackViewPublicKeyAccountRenamed( - accountToRename.name, - newAccountName, - ); + analytics.trackViewPublicKeyAccountRenamed(); await renameAccount({ accountName: newAccountName, diff --git a/src/components/screens/ValidateRecoveryPhraseScreen.tsx b/src/components/screens/ValidateRecoveryPhraseScreen.tsx index 0e93b6bbc..7aa62a302 100644 --- a/src/components/screens/ValidateRecoveryPhraseScreen.tsx +++ b/src/components/screens/ValidateRecoveryPhraseScreen.tsx @@ -115,7 +115,9 @@ export const ValidateRecoveryPhraseScreen: React.FC< if (!canContinue) { // Word is incorrect - show error and generate new words setError(t("validateRecoveryPhraseScreen.errorText")); - analytics.track(AnalyticsEvent.CONFIRM_RECOVERY_PHRASE_FAIL); + analytics.track(AnalyticsEvent.CONFIRM_RECOVERY_PHRASE_FAIL, { + reason_code: "incorrect_word", + }); regenerateWords(); return; } diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index e1a739691..617811788 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -159,7 +159,7 @@ export enum AnalyticsEvent { SIGN_AUTH_ENTRY_REJECTED = "signing.auth_entry_rejected", // Assets / trustlines - // asset.added carries safety=unsafe when an unsafe asset was added. + // asset.added carries asset_code + asset (CODE:ISSUER). ADD_TOKEN_SUCCESS = "asset.added", REMOVE_TOKEN_SUCCESS = "asset.removed", // carries operation (add | remove) + reason_code diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index c3870826e..5c5d9a616 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -1601,16 +1601,21 @@ const importWallet = async ({ AUTH_STATUS.AUTHENTICATED, ); - analytics.track(AnalyticsEvent.ACCOUNT_SCREEN_IMPORT_ACCOUNT); + // Restoring a wallet from a recovery phrase is account_recovery.*, NOT + // account.import* (that is the secret-key path). This corrects a + // cross-platform mislabel — the extension already classifies it this way. + analytics.track(AnalyticsEvent.RECOVER_ACCOUNT_SUCCESS, { + recovery_method: "recovery_phrase", + }); } catch (error) { // Scrub Stellar StrKeys before sending to analytics — this is a // third-party sink (Amplitude) not covered by Sentry's beforeSend, and the // forwarded message can include uncontrolled native error text. const importFailureMessage = error instanceof Error ? error.message : String(error); - analytics.trackAccountScreenImportAccountFail( - scrubStrKeys(importFailureMessage) ?? importFailureMessage, - ); + analytics.track(AnalyticsEvent.RECOVER_ACCOUNT_FAIL, { + reason_code: scrubStrKeys(importFailureMessage) ?? importFailureMessage, + }); // Clean up any partial data on error clearAccountData(); await clearAllData(); @@ -1983,7 +1988,10 @@ const importSecretKeyLocal = async ( importedFromSecretKey: true, }); - analytics.track(AnalyticsEvent.ACCOUNT_SCREEN_IMPORT_ACCOUNT); + // account.imported carries import_method (secret-key path). + analytics.track(AnalyticsEvent.ACCOUNT_SCREEN_IMPORT_ACCOUNT, { + import_method: "secret_key", + }); } catch (error) { // Scrub Stellar StrKeys before sending to analytics — third-party sink // (Amplitude) not covered by Sentry's beforeSend. @@ -2321,6 +2329,15 @@ export const useAuthenticationStore = create()((set, get) => ({ return true; } catch (error) { logger.error("useAuthenticationStore.signUp", "Sign up failed", error); + // Mirror the extension's onboarding.password_create_failed emit so the + // create-password failure funnel has cross-platform coverage. + // Scrub Stellar StrKeys first — analytics is a third-party sink + // (Amplitude) and the error text can include uncontrolled native content. + const signUpFailureMessage = + error instanceof Error ? error.message : String(error); + analytics.track(AnalyticsEvent.CREATE_PASSWORD_FAIL, { + reason_code: scrubStrKeys(signUpFailureMessage) ?? signUpFailureMessage, + }); set({ error: getUserFacingError(error, "authStore.error.failedToSignUp"), isLoading: false, diff --git a/src/hooks/useManageTokens.ts b/src/hooks/useManageTokens.ts index 47faa2222..f6adcc46d 100644 --- a/src/hooks/useManageTokens.ts +++ b/src/hooks/useManageTokens.ts @@ -13,6 +13,7 @@ import { FormattedSearchTokenRecord, } from "config/types"; import { ActiveAccount } from "ducks/auth"; +import { useBalancesStore } from "ducks/balances"; import { formatTokenIdentifier } from "helpers/balances"; import useAppTranslation from "hooks/useAppTranslation"; import { isWalletUnlocked } from "hooks/useGetActiveAccount"; @@ -21,6 +22,7 @@ import { useState } from "react"; import { analytics } from "services/analytics"; import { buildChangeTrustTx, + isHorizonError, signTransaction, submitTx, } from "services/stellar"; @@ -187,13 +189,14 @@ export const useManageTokens = ({ } analytics.track(AnalyticsEvent.ADD_TOKEN_SUCCESS, { asset_code: tokenCode, - asset: `${tokenCode}:${issuer}`, + asset_issuer: issuer, }); } catch (error) { analytics.track(AnalyticsEvent.TOKEN_MANAGEMENT_FAIL, { reason_code: error instanceof Error ? error.message : String(error), operation: "add", - asset: `${tokenCode}:${issuer}`, + asset_code: tokenCode, + asset_issuer: issuer, }); logger.error( @@ -316,15 +319,52 @@ export const useManageTokens = ({ } analytics.track(AnalyticsEvent.REMOVE_TOKEN_SUCCESS, { asset_code: tokenCode, - asset: tokenIdentifier, + asset_issuer: tokenIdentifier.split(":")[1], }); } catch (error) { analytics.track(AnalyticsEvent.TOKEN_MANAGEMENT_FAIL, { reason_code: error instanceof Error ? error.message : String(error), operation: "remove", - asset: tokenIdentifier, + asset_code: tokenCode, + asset_issuer: tokenIdentifier.split(":")[1], }); + // Additionally emit the granular trustline_remove.failed, mirroring + // the extension. Derive reason_code from the CHANGE_TRUST op result codes + // (op_low_reserve -> low_reserve; op_invalid_limit split by whether the + // asset carries buying liabilities). Only fires for the trustline-specific + // failures — wallet-locked/build/sign errors won't set a reason. + const opResultCodes = isHorizonError(error) + ? ( + error as { + response?: { + data?: { + extras?: { result_codes?: { operations?: string[] } }; + }; + }; + } + ).response?.data?.extras?.result_codes?.operations + : undefined; + let trustlineReason: string | undefined; + if (opResultCodes?.includes("op_low_reserve")) { + trustlineReason = "low_reserve"; + } else if (opResultCodes?.includes("op_invalid_limit")) { + const balance = useBalancesStore.getState().balances[tokenIdentifier]; + const buyingLiabilities = + balance && "buyingLiabilities" in balance + ? Number(balance.buyingLiabilities ?? 0) + : 0; + trustlineReason = + buyingLiabilities > 0 ? "buying_liabilities" : "has_balance"; + } + if (trustlineReason) { + analytics.track(AnalyticsEvent.TRUSTLINE_REMOVE_FAILED, { + reason_code: trustlineReason, + asset_code: tokenCode, + asset_issuer: tokenIdentifier.split(":")[1], + }); + } + logger.error( "useManageTokens.removeToken", "Error removing token", diff --git a/src/providers/WalletKitProvider.tsx b/src/providers/WalletKitProvider.tsx index adf9b6867..76807e32b 100644 --- a/src/providers/WalletKitProvider.tsx +++ b/src/providers/WalletKitProvider.tsx @@ -443,6 +443,20 @@ export const WalletKitProvider: React.FC = ({ sessionRequest: requestEvent, message: t("walletKit.userRejected"), }); + + // This is the genuine user-reject path. Instrument message / + // auth-entry rejections (distinct from the runtime *_FAIL events). Only + // these two methods have catalog reject members; SIGN_XDR/SIGN_AND_SUBMIT + // rejections are the transaction path, not covered here. + const dappDomain = + getDappMetadataFromEvent(requestEvent, activeSessions)?.url || ""; + if (requestMethod === StellarRpcMethods.SIGN_MESSAGE) { + analytics.trackSignedMessageRejected(dappDomain ? { dappDomain } : {}); + } else if (requestMethod === StellarRpcMethods.SIGN_AUTH_ENTRY) { + analytics.trackSignedAuthEntryRejected( + dappDomain ? { dappDomain } : {}, + ); + } } setTimeout(() => { diff --git a/src/services/analytics/discover.ts b/src/services/analytics/discover.ts index da4f60774..ffb9dc488 100644 --- a/src/services/analytics/discover.ts +++ b/src/services/analytics/discover.ts @@ -48,10 +48,11 @@ export const trackDiscoverProtocolOpened = ( ): void => { const matchedProtocol = findMatchedProtocol({ protocols, searchUrl: url }); track(AnalyticsEvent.DISCOVER_PROTOCOL_OPENED, { + protocol_id: matchedProtocol?.name, url: stripQueryParams(url), - protocolName: matchedProtocol?.name, + protocol_name: matchedProtocol?.name, source, - isKnownProtocol: !!matchedProtocol, + is_known_protocol: !!matchedProtocol, }); }; @@ -60,7 +61,8 @@ export const trackDiscoverProtocolDetailsViewed = ( tags: string[], ): void => { track(AnalyticsEvent.DISCOVER_PROTOCOL_DETAILS_VIEWED, { - protocolName, + protocol_id: protocolName, + protocol_name: protocolName, tags, }); }; @@ -70,7 +72,8 @@ export const trackDiscoverProtocolOpenedFromDetails = ( url: string, ): void => { track(AnalyticsEvent.DISCOVER_PROTOCOL_OPENED_FROM_DETAILS, { - protocolName, + protocol_id: protocolName, + protocol_name: protocolName, url: stripQueryParams(url), }); }; diff --git a/src/services/analytics/index.ts b/src/services/analytics/index.ts index e2eb33d84..8bd89f1fc 100644 --- a/src/services/analytics/index.ts +++ b/src/services/analytics/index.ts @@ -20,6 +20,8 @@ import { trackSignedAuthEntry, trackSignedMessageError, trackSignedAuthEntryError, + trackSignedMessageRejected, + trackSignedAuthEntryRejected, trackSubmittedTransaction, trackSimulationError, trackReAuthSuccess, @@ -63,6 +65,8 @@ export interface AnalyticsInstance { readonly trackSignedAuthEntry: typeof trackSignedAuthEntry; readonly trackSignedMessageError: typeof trackSignedMessageError; readonly trackSignedAuthEntryError: typeof trackSignedAuthEntryError; + readonly trackSignedMessageRejected: typeof trackSignedMessageRejected; + readonly trackSignedAuthEntryRejected: typeof trackSignedAuthEntryRejected; readonly trackSubmittedTransaction: typeof trackSubmittedTransaction; readonly trackSimulationError: typeof trackSimulationError; readonly trackCopyPublicKey: typeof trackCopyPublicKey; @@ -132,6 +136,8 @@ export const analytics: AnalyticsInstance = { trackSignedAuthEntry, trackSignedMessageError, trackSignedAuthEntryError, + trackSignedMessageRejected, + trackSignedAuthEntryRejected, trackSubmittedTransaction, trackSimulationError, trackCopyPublicKey, diff --git a/src/services/analytics/transactions.ts b/src/services/analytics/transactions.ts index e2c868943..29051f1ba 100644 --- a/src/services/analytics/transactions.ts +++ b/src/services/analytics/transactions.ts @@ -22,10 +22,11 @@ export const trackSignedMessage = (data: { messageLength: number; dappDomain?: string; }): void => { - // NOTE: mobile has a single message-signing flow, so message_type is not - // discriminated here; flagged for review. + // signing.message_approved carries message_type (parity with the + // extension); mobile signs raw blobs. messageLength dropped. `origin` kept as + // an RFC-optional extra — the extension lacks it (flagged signing-origin gap). track(AnalyticsEvent.SIGN_MESSAGE_SUCCESS, { - messageLength: data.messageLength, + message_type: "blob", ...(data.dappDomain ? { origin: data.dappDomain } : {}), }); }; @@ -45,6 +46,7 @@ export const trackSignedMessageError = (data: { // is not currently instrumented on mobile (SIGN_MESSAGE_REJECTED exists in // the catalog for parity but has no emit site yet). track(AnalyticsEvent.SIGN_MESSAGE_FAIL, { + message_type: "blob", reason_code: data.error, ...(data.dappDomain ? { origin: data.dappDomain } : {}), }); @@ -61,6 +63,26 @@ export const trackSignedAuthEntryError = (data: { }); }; +// User-rejection paths (D4) — distinct from the runtime *_FAIL helpers above. +// Fired when the user cancels/dismisses a dApp sign-message / sign-auth-entry +// request. Keeps the rejection vs runtime-failure split on both platforms. +export const trackSignedMessageRejected = (data: { + dappDomain?: string; +}): void => { + track(AnalyticsEvent.SIGN_MESSAGE_REJECTED, { + message_type: "blob", + ...(data.dappDomain ? { origin: data.dappDomain } : {}), + }); +}; + +export const trackSignedAuthEntryRejected = (data: { + dappDomain?: string; +}): void => { + track(AnalyticsEvent.SIGN_AUTH_ENTRY_REJECTED, { + ...(data.dappDomain ? { origin: data.dappDomain } : {}), + }); +}; + export const trackSubmittedTransaction = ( data: SubmittedTransactionEvent, ): void => { @@ -90,15 +112,14 @@ export const trackSendPaymentSuccess = ( if (data.operationType === TransactionOperationType.PathPayment) { track(AnalyticsEvent.SWAP_SUCCESS, { from_asset_code: data.sourceToken, - operationType: data.operationType, + to_asset_code: data.destToken, }); return; } track(AnalyticsEvent.SEND_PAYMENT_SUCCESS, { - from_asset_code: data.sourceToken, payment_type: "payment", - operationType: data.operationType, + asset_code: data.sourceToken, }); }; @@ -106,8 +127,8 @@ export const trackSendCollectibleSuccess = ( data: TransactionSuccessEvent, ): void => { track(AnalyticsEvent.SEND_COLLECTIBLE_SUCCESS, { - collectionAddress: data.collectionAddress, - tokenId: data.tokenId, + collection_address: data.collectionAddress, + token_id: data.tokenId, }); }; @@ -115,10 +136,6 @@ export const trackSwapSuccess = (data: SwapSuccessEvent): void => { track(AnalyticsEvent.SWAP_SUCCESS, { from_asset_code: data.sourceToken, to_asset_code: data.destToken, - sourceAmount: data.sourceAmount, - destinationAmount: data.destAmount, - allowedSlippage: data.allowedSlippage, - isSwap: data.isSwap, }); }; @@ -139,23 +156,22 @@ export const trackTransactionError = (data: TransactionErrorEvent): void => { event = AnalyticsEvent.SWAP_FAIL; } - track(event, { - reason_code: data.error, - errorCode: data.errorCode, - operationType: data.operationType, - isSwap: data.isSwap, - // Swap-specific fields are gated so payment.failed events from non-swap - // callers don't get polluted with undefined from_asset_code / - // to_asset_code / sourceAmount / destinationAmount keys. - ...(isSwapLike - ? { - from_asset_code: data.sourceToken, - to_asset_code: data.destToken, - sourceAmount: data.sourceAmount, - destinationAmount: data.destAmount, - } - : {}), - }); + // Shared required sets: payment.failed {payment_type, reason_code}; + // swap.failed {from_asset_code, to_asset_code, reason_code}; + // collectible_send.failed {reason_code}. Legacy extras (errorCode, + // operationType, isSwap, amounts) dropped for cross-platform parity. + let props: Record = { reason_code: data.error }; + if (event === AnalyticsEvent.SWAP_FAIL) { + props = { + from_asset_code: data.sourceToken, + to_asset_code: data.destToken, + reason_code: data.error, + }; + } else if (event === AnalyticsEvent.SEND_PAYMENT_FAIL) { + props.payment_type = "payment"; + } + + track(event, props); }; export const trackAddTokenConfirmed = (token?: string): void => { @@ -187,16 +203,21 @@ export const trackRemoveTokenRejected = (token?: string): void => { }; export const trackAccountScreenImportAccountFail = (error: string): void => { - track(AnalyticsEvent.ACCOUNT_SCREEN_IMPORT_ACCOUNT_FAIL, { error }); + // Failure reason is carried on `reason_code` per the shared failure grammar + // (matches the catalog note and the extension `account.import_failed` shape). + // account.import_failed carries import_method. This helper is the + // secret-key import path (mnemonic restore emits account_recovery.* instead). + track(AnalyticsEvent.ACCOUNT_SCREEN_IMPORT_ACCOUNT_FAIL, { + import_method: "secret_key", + reason_code: error, + }); }; -export const trackViewPublicKeyAccountRenamed = ( - oldName: string, - newName: string, -): void => { +export const trackViewPublicKeyAccountRenamed = (): void => { + // account.renamed carries `source`, never the account labels. + // Mobile only renames from the manage-accounts list. track(AnalyticsEvent.VIEW_PUBLIC_KEY_ACCOUNT_RENAMED, { - oldName, - newName, + source: "wallets", }); }; @@ -214,8 +235,9 @@ export const trackGrantAccessFail = ( }); }; -export const trackHistoryOpenItem = (transactionHash: string): void => { - track(AnalyticsEvent.HISTORY_OPEN_ITEM, { transactionHash }); +export const trackHistoryOpenItem = (source: string): void => { + // history.item_opened carries `source` (not the tx hash). + track(AnalyticsEvent.HISTORY_OPEN_ITEM, { source }); }; /** @@ -225,11 +247,9 @@ const trackAuthEvent = ( event: AnalyticsEvent, additional?: Record, ): void => { - track(event, { - context: "user_authentication", - method: "password", // TODO: Add other methods (eg: fingerprint, face id, etc) - ...additional, - }); + // reauth.* is not in the shared cross-platform catalog (documented deviation). Dropping + // the constant context/method for parity with the extension's {} / {reason_code}. + track(event, { ...additional }); }; export const trackReAuthSuccess = (): void => { @@ -240,23 +260,14 @@ export const trackReAuthFail = (): void => { trackAuthEvent(AnalyticsEvent.RE_AUTH_FAIL); }; -/** - * Generic helper for simple user actions with context. - */ -const trackUserAction = ( - event: AnalyticsEvent, - context: string, - action: string, -): void => { - track(event, { context, action }); -}; - export const trackCopyPublicKey = (): void => { - trackUserAction(AnalyticsEvent.COPY_PUBLIC_KEY, "home_screen", "copy"); + // account.public_key_copied carries no source and never the key. + track(AnalyticsEvent.COPY_PUBLIC_KEY); }; export const trackCopyBackupPhrase = (): void => { - trackUserAction(AnalyticsEvent.COPY_BACKUP_PHRASE, "backup_phrase", "copy"); + // recovery_phrase.copied carries only schema_version (via context). + track(AnalyticsEvent.COPY_BACKUP_PHRASE); }; export const trackQRScanSuccess = ( diff --git a/src/services/blockaid/api.ts b/src/services/blockaid/api.ts index 5fc86ed18..5f051ade3 100644 --- a/src/services/blockaid/api.ts +++ b/src/services/blockaid/api.ts @@ -53,6 +53,26 @@ const aggregateBulkResult = ( return SecurityLevel.SAFE; }; +/** + * Map the internal SecurityLevel to the shared cross-platform `result` + * vocabulary (`safe | warn | block | unknown`) so blockaid.scan_completed.result + * aligns with the extension. + */ +const toBlockaidResultLevel = ( + level: SecurityLevel, +): "safe" | "warn" | "block" | "unknown" => { + switch (level) { + case SecurityLevel.SAFE: + return "safe"; + case SecurityLevel.SUSPICIOUS: + return "warn"; + case SecurityLevel.MALICIOUS: + return "block"; + default: + return "unknown"; + } +}; + const trackScanFailed = ( scanTarget: BlockaidScanTarget, error: unknown, @@ -111,7 +131,7 @@ export const scanToken = async ( analytics.track(AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, { scan_target: "asset", - result: assessTokenSecurity(scanResult).level, + result: toBlockaidResultLevel(assessTokenSecurity(scanResult).level), tokenCode, }); @@ -152,7 +172,7 @@ export const scanBulkTokens = async ( analytics.track(AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, { scan_target: "asset_bulk", // Aggregate verdict across the batch: the worst per-token security level. - result: aggregateBulkResult(scanResult), + result: toBlockaidResultLevel(aggregateBulkResult(scanResult)), addressCount: addressList.length, }); @@ -197,7 +217,7 @@ export const scanSite = async ( analytics.track(AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, { scan_target: "domain", - result: assessSiteSecurity(scanResult).level, + result: toBlockaidResultLevel(assessSiteSecurity(scanResult).level), }); return scanResult; @@ -236,7 +256,9 @@ export const scanTransaction = async ( analytics.track(AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, { scan_target: "transaction", - result: assessTransactionSecurity(scanResult).level, + result: toBlockaidResultLevel( + assessTransactionSecurity(scanResult).level, + ), }); return scanResult; From 91bfd2e63a6e24907f5afe441e56825bfdf64650 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 21 Jul 2026 20:35:17 -0400 Subject: [PATCH 04/13] analytics: address cross-platform drift review (history event, snake_case props) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The "View on Stellar Expert" tap in the transaction-detail sheet now emits history.item_opened {source:"transaction_detail"} (was history.full_history_opened) — it opens a specific operation, matching the extension's mapping for the same gesture. - snake_case the blockaid scan_completed extras (token_code, address_count). - Fix stale catalog/JSDoc comments (asset.added carries asset_issuer; asset_add/asset_remove.responded carry `asset`; relocate the scan-failure doc block onto trackScanFailed). - Test: use the lowercase wire value ("safe") for the blockaid result pass-through. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../TransactionDetailsBottomSheetCustomContent.tsx | 5 ++++- src/config/analyticsConfig.ts | 6 +++--- src/services/analytics/core.test.ts | 4 ++-- src/services/blockaid/api.ts | 14 +++++++------- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/components/screens/HistoryScreen/TransactionDetailsBottomSheetCustomContent.tsx b/src/components/screens/HistoryScreen/TransactionDetailsBottomSheetCustomContent.tsx index c8d86677a..5346620c4 100644 --- a/src/components/screens/HistoryScreen/TransactionDetailsBottomSheetCustomContent.tsx +++ b/src/components/screens/HistoryScreen/TransactionDetailsBottomSheetCustomContent.tsx @@ -372,7 +372,10 @@ export const TransactionDetailsFooter: React.FC< tertiary icon={} onPress={() => { - analytics.track(AnalyticsEvent.HISTORY_OPEN_FULL_HISTORY, { + // Opening THIS operation on Stellar Expert is an item-open, not a + // full-history-list open — matches the extension's history.item_opened + // {source:"transaction_detail"} for the same gesture. + analytics.track(AnalyticsEvent.HISTORY_OPEN_ITEM, { source: "transaction_detail", }); openInAppBrowser(externalUrl); diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index 617811788..116ab5102 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -159,16 +159,16 @@ export enum AnalyticsEvent { SIGN_AUTH_ENTRY_REJECTED = "signing.auth_entry_rejected", // Assets / trustlines - // asset.added carries asset_code + asset (CODE:ISSUER). + // asset.added carries asset_code + asset_issuer. ADD_TOKEN_SUCCESS = "asset.added", REMOVE_TOKEN_SUCCESS = "asset.removed", // carries operation (add | remove) + reason_code TOKEN_MANAGEMENT_FAIL = "asset.operation_failed", // Consolidates the add-token confirmed / rejected prompt responses; carries - // decision (confirm | reject) + source. + // decision (confirm | reject) + asset. ASSET_ADD_RESPONDED = "asset_add.responded", // Consolidates the remove-token confirmed / rejected prompt responses; - // carries decision (confirm | reject). + // carries decision (confirm | reject) + asset. ASSET_REMOVE_RESPONDED = "asset_remove.responded", MANAGE_TOKEN_LISTS_MODIFY = "asset_list.modified", // Consolidates the three trustline-removal failure reasons; carries diff --git a/src/services/analytics/core.test.ts b/src/services/analytics/core.test.ts index 3282cd794..4d606dc77 100644 --- a/src/services/analytics/core.test.ts +++ b/src/services/analytics/core.test.ts @@ -615,12 +615,12 @@ describe("domain event catalog (#2883)", () => { it("carries the scan_target + result discriminators on the consolidated blockaid scan event", () => { track(AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, { scan_target: "asset", - result: "SAFE", + result: "safe", }); expect(amplitudeMock.track).toHaveBeenCalledWith( "blockaid.scan_completed", - expect.objectContaining({ scan_target: "asset", result: "SAFE" }), + expect.objectContaining({ scan_target: "asset", result: "safe" }), ); }); diff --git a/src/services/blockaid/api.ts b/src/services/blockaid/api.ts index 5f051ade3..bf78ff472 100644 --- a/src/services/blockaid/api.ts +++ b/src/services/blockaid/api.ts @@ -30,11 +30,6 @@ import { */ type BlockaidScanTarget = "domain" | "transaction" | "asset" | "asset_bulk"; -/** - * Emits the consolidated scan-failure event. Blockaid only runs on mainnet, so - * the NETWORK_NOT_SUPPORTED short-circuit on other networks is an expected skip - * rather than a scan failure and is not reported. - */ /** * Reduces a bulk token scan to a single worst-case security level for the * `result` property (malicious > suspicious > safe/unable-to-scan). @@ -73,6 +68,11 @@ const toBlockaidResultLevel = ( } }; +/** + * Emits the consolidated scan-failure event. Blockaid only runs on mainnet, so + * the NETWORK_NOT_SUPPORTED short-circuit on other networks is an expected skip + * rather than a scan failure and is not reported. + */ const trackScanFailed = ( scanTarget: BlockaidScanTarget, error: unknown, @@ -132,7 +132,7 @@ export const scanToken = async ( analytics.track(AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, { scan_target: "asset", result: toBlockaidResultLevel(assessTokenSecurity(scanResult).level), - tokenCode, + token_code: tokenCode, }); return scanResult; @@ -173,7 +173,7 @@ export const scanBulkTokens = async ( scan_target: "asset_bulk", // Aggregate verdict across the batch: the worst per-token security level. result: toBlockaidResultLevel(aggregateBulkResult(scanResult)), - addressCount: addressList.length, + address_count: addressList.length, }); return scanResult; From 751d456b571568c8e97e00826f84cf83c38ede53 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 21 Jul 2026 21:15:19 -0400 Subject: [PATCH 05/13] analytics: wire transaction-reject + split dapp_access blocked/rejected + source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - signing.transaction_rejected now emitted on the dApp tx-reject path (SIGN_XDR / SIGN_AND_SUBMIT_XDR), mirroring the approve side and the extension. - dapp_access.rejected now carries origin only (genuine user cancel); the not-authenticated auto-reject moves to a new dapp_access.blocked {origin, reason_code:"not_authenticated"} — a system block, not a user decision. - asset_add/asset_remove.responded carry source:"manage_assets" (manual in-app path), distinguishing from the extension's dApp source:"dapp_api". - Doc: signing.transaction_blocked (memo_required) not emitted on mobile — the memo-required state is a passive UI gate with no reachable block point. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../DappConnectionBottomSheetContent.tsx | 1 - src/config/analyticsConfig.ts | 12 ++++++-- src/providers/WalletKitProvider.tsx | 13 ++++++-- src/services/analytics/index.ts | 6 ++++ src/services/analytics/transactions.ts | 30 +++++++++++++++++-- 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/components/screens/WalletKit/DappConnectionBottomSheetContent.tsx b/src/components/screens/WalletKit/DappConnectionBottomSheetContent.tsx index eab275ed6..335a16444 100644 --- a/src/components/screens/WalletKit/DappConnectionBottomSheetContent.tsx +++ b/src/components/screens/WalletKit/DappConnectionBottomSheetContent.tsx @@ -161,7 +161,6 @@ const DappConnectionBottomSheetContent: React.FC< if (proposalEvent) { analytics.trackGrantAccessFail( proposalEvent.params.proposer.metadata.url, - "user_rejected", ); } diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index 116ab5102..7ca143e26 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -139,10 +139,16 @@ export enum AnalyticsEvent { // Signing / dApp GRANT_DAPP_ACCESS_SUCCESS = "dapp_access.granted", + // User declined the connection prompt; carries origin only. GRANT_DAPP_ACCESS_FAIL = "dapp_access.rejected", + // System auto-declined a connection (not a user decision); carries + // origin + reason_code (e.g. not_authenticated). + GRANT_DAPP_ACCESS_BLOCKED = "dapp_access.blocked", SIGN_TRANSACTION_SUCCESS = "signing.transaction_approved", SIGN_TRANSACTION_FAIL = "signing.transaction_rejected", - // carries reason_code=memo_required + // signing.transaction_blocked (memo_required): NOT emitted on mobile — the + // memo-required state is a passive UI gate (disabled confirm button), not a + // reachable block/refuse branch. Extension emits it; kept for a shared catalog. SIGN_TRANSACTION_MEMO_REQUIRED_FAIL = "signing.transaction_blocked", SUBMIT_TRANSACTION_SUCCESS = "transaction.submitted", SIGN_MESSAGE_SUCCESS = "signing.message_approved", @@ -165,10 +171,10 @@ export enum AnalyticsEvent { // carries operation (add | remove) + reason_code TOKEN_MANAGEMENT_FAIL = "asset.operation_failed", // Consolidates the add-token confirmed / rejected prompt responses; carries - // decision (confirm | reject) + asset. + // decision (confirm | reject) + asset + source (manage_assets on mobile). ASSET_ADD_RESPONDED = "asset_add.responded", // Consolidates the remove-token confirmed / rejected prompt responses; - // carries decision (confirm | reject) + asset. + // carries decision (confirm | reject) + asset + source (manage_assets). ASSET_REMOVE_RESPONDED = "asset_remove.responded", MANAGE_TOKEN_LISTS_MODIFY = "asset_list.modified", // Consolidates the three trustline-removal failure reasons; carries diff --git a/src/providers/WalletKitProvider.tsx b/src/providers/WalletKitProvider.tsx index 76807e32b..92ac5e52e 100644 --- a/src/providers/WalletKitProvider.tsx +++ b/src/providers/WalletKitProvider.tsx @@ -456,6 +456,13 @@ export const WalletKitProvider: React.FC = ({ analytics.trackSignedAuthEntryRejected( dappDomain ? { dappDomain } : {}, ); + } else if ( + requestMethod === StellarRpcMethods.SIGN_XDR || + requestMethod === StellarRpcMethods.SIGN_AND_SUBMIT_XDR + ) { + analytics.trackSignedTransactionRejected( + dappDomain ? { dappDomain } : {}, + ); } } @@ -891,9 +898,11 @@ export const WalletKitProvider: React.FC = ({ message: t("walletKit.userNotAuthenticated"), }); - analytics.trackGrantAccessFail( + // Auto-declined because the wallet isn't authenticated — a system block, + // not a user rejection → dapp_access.blocked (distinct from .rejected). + analytics.trackGrantAccessBlocked( sessionProposal.params.proposer.metadata.url, - "user_not_authenticated", + "not_authenticated", ); clearEvent(); diff --git a/src/services/analytics/index.ts b/src/services/analytics/index.ts index 8bd89f1fc..9dbbc5410 100644 --- a/src/services/analytics/index.ts +++ b/src/services/analytics/index.ts @@ -22,6 +22,7 @@ import { trackSignedAuthEntryError, trackSignedMessageRejected, trackSignedAuthEntryRejected, + trackSignedTransactionRejected, trackSubmittedTransaction, trackSimulationError, trackReAuthSuccess, @@ -41,6 +42,7 @@ import { trackViewPublicKeyAccountRenamed, trackGrantAccessSuccess, trackGrantAccessFail, + trackGrantAccessBlocked, trackHistoryOpenItem, trackSendCollectibleSuccess, } from "services/analytics/transactions"; @@ -67,6 +69,7 @@ export interface AnalyticsInstance { readonly trackSignedAuthEntryError: typeof trackSignedAuthEntryError; readonly trackSignedMessageRejected: typeof trackSignedMessageRejected; readonly trackSignedAuthEntryRejected: typeof trackSignedAuthEntryRejected; + readonly trackSignedTransactionRejected: typeof trackSignedTransactionRejected; readonly trackSubmittedTransaction: typeof trackSubmittedTransaction; readonly trackSimulationError: typeof trackSimulationError; readonly trackCopyPublicKey: typeof trackCopyPublicKey; @@ -91,6 +94,7 @@ export interface AnalyticsInstance { // WalletConnect/dApp analytics readonly trackGrantAccessSuccess: typeof trackGrantAccessSuccess; readonly trackGrantAccessFail: typeof trackGrantAccessFail; + readonly trackGrantAccessBlocked: typeof trackGrantAccessBlocked; // History analytics readonly trackHistoryOpenItem: typeof trackHistoryOpenItem; @@ -138,6 +142,7 @@ export const analytics: AnalyticsInstance = { trackSignedAuthEntryError, trackSignedMessageRejected, trackSignedAuthEntryRejected, + trackSignedTransactionRejected, trackSubmittedTransaction, trackSimulationError, trackCopyPublicKey, @@ -156,6 +161,7 @@ export const analytics: AnalyticsInstance = { trackViewPublicKeyAccountRenamed, trackGrantAccessSuccess, trackGrantAccessFail, + trackGrantAccessBlocked, trackHistoryOpenItem, trackDiscoverProtocolOpened, trackDiscoverProtocolDetailsViewed, diff --git a/src/services/analytics/transactions.ts b/src/services/analytics/transactions.ts index 29051f1ba..4ef2908f5 100644 --- a/src/services/analytics/transactions.ts +++ b/src/services/analytics/transactions.ts @@ -83,6 +83,17 @@ export const trackSignedAuthEntryRejected = (data: { }); }; +export const trackSignedTransactionRejected = (data: { + dappDomain?: string; +}): void => { + // Mirrors the approve side (trackSignedTransaction) for the tx-signing methods + // (SIGN_XDR / SIGN_AND_SUBMIT_XDR); parity with the extension's + // signing.transaction_rejected. + track(AnalyticsEvent.SIGN_TRANSACTION_FAIL, { + ...(data.dappDomain ? { origin: data.dappDomain } : {}), + }); +}; + export const trackSubmittedTransaction = ( data: SubmittedTransactionEvent, ): void => { @@ -174,10 +185,14 @@ export const trackTransactionError = (data: TransactionErrorEvent): void => { track(event, props); }; +// Mobile add/remove-token responses originate from the in-app manage-assets UI +// (there is no dApp add-token RPC on mobile), so source is fixed. The extension +// emits the same events with source:"dapp_api" for its injected-API prompt. export const trackAddTokenConfirmed = (token?: string): void => { track(AnalyticsEvent.ASSET_ADD_RESPONDED, { decision: "confirm", asset: token, + source: "manage_assets", }); }; @@ -185,6 +200,7 @@ export const trackAddTokenRejected = (token?: string): void => { track(AnalyticsEvent.ASSET_ADD_RESPONDED, { decision: "reject", asset: token, + source: "manage_assets", }); }; @@ -192,6 +208,7 @@ export const trackRemoveTokenConfirmed = (token?: string): void => { track(AnalyticsEvent.ASSET_REMOVE_RESPONDED, { decision: "confirm", asset: token, + source: "manage_assets", }); }; @@ -199,6 +216,7 @@ export const trackRemoveTokenRejected = (token?: string): void => { track(AnalyticsEvent.ASSET_REMOVE_RESPONDED, { decision: "reject", asset: token, + source: "manage_assets", }); }; @@ -225,11 +243,19 @@ export const trackGrantAccessSuccess = (domain?: string): void => { track(AnalyticsEvent.GRANT_DAPP_ACCESS_SUCCESS, { origin: domain }); }; -export const trackGrantAccessFail = ( +// User declined the connection prompt. dapp_access.rejected carries origin +// only — a user rejection has no failure reason_code (matches the extension). +export const trackGrantAccessFail = (domain?: string): void => { + track(AnalyticsEvent.GRANT_DAPP_ACCESS_FAIL, { origin: domain }); +}; + +// System auto-declined a connection (e.g. wallet not authenticated) — NOT a +// user decision, so it's a distinct event carrying the block reason_code. +export const trackGrantAccessBlocked = ( domain?: string, reason?: string, ): void => { - track(AnalyticsEvent.GRANT_DAPP_ACCESS_FAIL, { + track(AnalyticsEvent.GRANT_DAPP_ACCESS_BLOCKED, { origin: domain, reason_code: reason, }); From 6a759741c63ec2392cb5c56fcbc8b60bb63c460b Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 21 Jul 2026 22:12:13 -0400 Subject: [PATCH 06/13] analytics: normalize origin to hostname; fix stale comments; asset_issuer - `origin` on all signing / dApp-access events is normalized to the bare dApp hostname (was the raw full URL from WalletConnect metadata), matching the extension's hostname-based origin so cross-platform funnels merge. Centralized via an originProps() helper in transactions.ts (getDisplayHost). - asset_issuer on the remove paths uses the real `tokenIssuer` variable instead of a colon-split that yielded undefined for colon-less contract identifiers. - Correct stale comments: the message/auth-entry/transaction reject paths ARE emitted (WalletKitProvider); `origin` matches the extension. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config/analyticsConfig.ts | 5 ++-- src/hooks/useManageTokens.ts | 6 ++-- src/providers/WalletKitProvider.tsx | 7 ++--- src/services/analytics/transactions.ts | 41 +++++++++++++++----------- 4 files changed, 32 insertions(+), 27 deletions(-) diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index 7ca143e26..2015cfe56 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -154,9 +154,8 @@ export enum AnalyticsEvent { SIGN_MESSAGE_SUCCESS = "signing.message_approved", // Runtime signing failure (carries reason_code). SIGN_MESSAGE_FAIL = "signing.message_failed", - // Catalog member for the user-reject case. The mobile reject path currently - // has no analytics call, so this is not emitted yet; kept for cross-platform - // parity and flagged for review. + // User-reject case; emitted from WalletKitProvider's reject path (distinct + // from the runtime SIGN_MESSAGE_FAIL). SIGN_MESSAGE_REJECTED = "signing.message_rejected", SIGN_AUTH_ENTRY_SUCCESS = "signing.auth_entry_approved", // Runtime signing failure (carries reason_code). diff --git a/src/hooks/useManageTokens.ts b/src/hooks/useManageTokens.ts index f6adcc46d..093e336da 100644 --- a/src/hooks/useManageTokens.ts +++ b/src/hooks/useManageTokens.ts @@ -319,14 +319,14 @@ export const useManageTokens = ({ } analytics.track(AnalyticsEvent.REMOVE_TOKEN_SUCCESS, { asset_code: tokenCode, - asset_issuer: tokenIdentifier.split(":")[1], + asset_issuer: tokenIssuer, }); } catch (error) { analytics.track(AnalyticsEvent.TOKEN_MANAGEMENT_FAIL, { reason_code: error instanceof Error ? error.message : String(error), operation: "remove", asset_code: tokenCode, - asset_issuer: tokenIdentifier.split(":")[1], + asset_issuer: tokenIssuer, }); // Additionally emit the granular trustline_remove.failed, mirroring @@ -361,7 +361,7 @@ export const useManageTokens = ({ analytics.track(AnalyticsEvent.TRUSTLINE_REMOVE_FAILED, { reason_code: trustlineReason, asset_code: tokenCode, - asset_issuer: tokenIdentifier.split(":")[1], + asset_issuer: tokenIssuer, }); } diff --git a/src/providers/WalletKitProvider.tsx b/src/providers/WalletKitProvider.tsx index 92ac5e52e..7b8c26f20 100644 --- a/src/providers/WalletKitProvider.tsx +++ b/src/providers/WalletKitProvider.tsx @@ -444,10 +444,9 @@ export const WalletKitProvider: React.FC = ({ message: t("walletKit.userRejected"), }); - // This is the genuine user-reject path. Instrument message / - // auth-entry rejections (distinct from the runtime *_FAIL events). Only - // these two methods have catalog reject members; SIGN_XDR/SIGN_AND_SUBMIT - // rejections are the transaction path, not covered here. + // This is the genuine user-reject path. Message, auth-entry, and + // transaction (SIGN_XDR / SIGN_AND_SUBMIT_XDR) rejections each emit their + // signing.*_rejected event (distinct from the runtime *_FAIL events). const dappDomain = getDappMetadataFromEvent(requestEvent, activeSessions)?.url || ""; if (requestMethod === StellarRpcMethods.SIGN_MESSAGE) { diff --git a/src/services/analytics/transactions.ts b/src/services/analytics/transactions.ts index 4ef2908f5..0b2a46964 100644 --- a/src/services/analytics/transactions.ts +++ b/src/services/analytics/transactions.ts @@ -1,4 +1,5 @@ import { AnalyticsEvent } from "config/analyticsConfig"; +import { getDisplayHost } from "helpers/protocols"; import { track } from "services/analytics/core"; import { TransactionOperationType } from "services/analytics/types"; import type { @@ -10,11 +11,18 @@ import type { TransactionErrorEvent, } from "services/analytics/types"; +// `origin` is the bare dApp hostname (never a full URL) — matches the +// extension's getUrlHostname-based origin so cross-platform funnels merge. +const originProps = (url?: string): { origin?: string } => { + const host = url ? getDisplayHost(url) : null; + return host ? { origin: host } : {}; +}; + export const trackSignedTransaction = (data: SignedTransactionEvent): void => { track(AnalyticsEvent.SIGN_TRANSACTION_SUCCESS, { transactionHash: data.transactionHash, transactionType: data.transactionType, - ...(data.dappDomain ? { origin: data.dappDomain } : {}), + ...originProps(data.dappDomain), }); }; @@ -23,17 +31,17 @@ export const trackSignedMessage = (data: { dappDomain?: string; }): void => { // signing.message_approved carries message_type (parity with the - // extension); mobile signs raw blobs. messageLength dropped. `origin` kept as - // an RFC-optional extra — the extension lacks it (flagged signing-origin gap). + // extension); mobile signs raw blobs. messageLength dropped. `origin` matches + // the extension's hostname-based origin. track(AnalyticsEvent.SIGN_MESSAGE_SUCCESS, { message_type: "blob", - ...(data.dappDomain ? { origin: data.dappDomain } : {}), + ...originProps(data.dappDomain), }); }; export const trackSignedAuthEntry = (data: { dappDomain?: string }): void => { track(AnalyticsEvent.SIGN_AUTH_ENTRY_SUCCESS, { - ...(data.dappDomain ? { origin: data.dappDomain } : {}), + ...originProps(data.dappDomain), }); }; @@ -42,13 +50,12 @@ export const trackSignedMessageError = (data: { dappDomain?: string; }): void => { // This is the runtime signing-failure path (a caught exception while - // signing), which is distinct from a user rejection. The user-reject case - // is not currently instrumented on mobile (SIGN_MESSAGE_REJECTED exists in - // the catalog for parity but has no emit site yet). + // signing), distinct from a user rejection. The user-reject case is + // instrumented separately (trackSignedMessageRejected, below). track(AnalyticsEvent.SIGN_MESSAGE_FAIL, { message_type: "blob", reason_code: data.error, - ...(data.dappDomain ? { origin: data.dappDomain } : {}), + ...originProps(data.dappDomain), }); }; @@ -59,7 +66,7 @@ export const trackSignedAuthEntryError = (data: { // Runtime signing-failure path; see trackSignedMessageError. track(AnalyticsEvent.SIGN_AUTH_ENTRY_FAIL, { reason_code: data.error, - ...(data.dappDomain ? { origin: data.dappDomain } : {}), + ...originProps(data.dappDomain), }); }; @@ -71,7 +78,7 @@ export const trackSignedMessageRejected = (data: { }): void => { track(AnalyticsEvent.SIGN_MESSAGE_REJECTED, { message_type: "blob", - ...(data.dappDomain ? { origin: data.dappDomain } : {}), + ...originProps(data.dappDomain), }); }; @@ -79,7 +86,7 @@ export const trackSignedAuthEntryRejected = (data: { dappDomain?: string; }): void => { track(AnalyticsEvent.SIGN_AUTH_ENTRY_REJECTED, { - ...(data.dappDomain ? { origin: data.dappDomain } : {}), + ...originProps(data.dappDomain), }); }; @@ -90,7 +97,7 @@ export const trackSignedTransactionRejected = (data: { // (SIGN_XDR / SIGN_AND_SUBMIT_XDR); parity with the extension's // signing.transaction_rejected. track(AnalyticsEvent.SIGN_TRANSACTION_FAIL, { - ...(data.dappDomain ? { origin: data.dappDomain } : {}), + ...originProps(data.dappDomain), }); }; @@ -100,7 +107,7 @@ export const trackSubmittedTransaction = ( track(AnalyticsEvent.SUBMIT_TRANSACTION_SUCCESS, { transactionHash: data.transactionHash, transactionType: data.transactionType, - ...(data.dappDomain ? { origin: data.dappDomain } : {}), + ...originProps(data.dappDomain), }); }; @@ -240,13 +247,13 @@ export const trackViewPublicKeyAccountRenamed = (): void => { }; export const trackGrantAccessSuccess = (domain?: string): void => { - track(AnalyticsEvent.GRANT_DAPP_ACCESS_SUCCESS, { origin: domain }); + track(AnalyticsEvent.GRANT_DAPP_ACCESS_SUCCESS, originProps(domain)); }; // User declined the connection prompt. dapp_access.rejected carries origin // only — a user rejection has no failure reason_code (matches the extension). export const trackGrantAccessFail = (domain?: string): void => { - track(AnalyticsEvent.GRANT_DAPP_ACCESS_FAIL, { origin: domain }); + track(AnalyticsEvent.GRANT_DAPP_ACCESS_FAIL, originProps(domain)); }; // System auto-declined a connection (e.g. wallet not authenticated) — NOT a @@ -256,7 +263,7 @@ export const trackGrantAccessBlocked = ( reason?: string, ): void => { track(AnalyticsEvent.GRANT_DAPP_ACCESS_BLOCKED, { - origin: domain, + ...originProps(domain), reason_code: reason, }); }; From fa3dfc9d05db50c0b57f6d22f449b62c977687af Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 21 Jul 2026 22:20:49 -0400 Subject: [PATCH 07/13] analytics: drift-review decisions (drop tx hash/type, snake_case, asset_code, count) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop transactionHash/transactionType from signing.transaction_approved and transaction.submitted (N1) — parity with the extension (which emits neither); transactionType was never actually populated, and transaction_hash is a high-cardinality, identity-linking dimension. - payment.simulation_failed: transactionType -> transaction_type (N2, snake_case). - asset_add.responded / asset_remove.responded: asset -> asset_code (N3). - account.created (ACCOUNT_SCREEN_ADD_ACCOUNT): carries number_of_accounts (N4); see the call-site comment re: tap-time vs creation-success semantics. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../screens/HomeScreen/ManageAccounts.tsx | 10 ++++++++-- src/config/analyticsConfig.ts | 4 ++-- src/helpers/walletKitUtil.ts | 2 -- src/services/analytics/transactions.ts | 14 +++++--------- src/services/analytics/types.ts | 4 ---- 5 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/components/screens/HomeScreen/ManageAccounts.tsx b/src/components/screens/HomeScreen/ManageAccounts.tsx index d003bb32a..613ac1986 100644 --- a/src/components/screens/HomeScreen/ManageAccounts.tsx +++ b/src/components/screens/HomeScreen/ManageAccounts.tsx @@ -96,11 +96,17 @@ const ManageAccounts: React.FC = ({ const handleAddAnotherWallet = useCallback(() => { if (!navigation) return; - analytics.track(AnalyticsEvent.ACCOUNT_SCREEN_ADD_ACCOUNT); + // Post-creation count, matching the extension's allAccounts.length. + // NB: this fires at tap time (before the account exists); it over-counts if + // the add flow is abandoned. Longer-term this belongs on the creation-success + // path (ducks/auth createAccount) — flagged for a product/analytics decision. + analytics.track(AnalyticsEvent.ACCOUNT_SCREEN_ADD_ACCOUNT, { + number_of_accounts: accounts.length + 1, + }); bottomSheetRef.current?.dismiss(); navigation.navigate(ROOT_NAVIGATOR_ROUTES.MANAGE_WALLETS_STACK); - }, [navigation, bottomSheetRef]); + }, [navigation, bottomSheetRef, accounts.length]); const handleRenameAccount = useCallback( async (newAccountName: string) => { diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index 2015cfe56..0e20b8bc0 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -170,10 +170,10 @@ export enum AnalyticsEvent { // carries operation (add | remove) + reason_code TOKEN_MANAGEMENT_FAIL = "asset.operation_failed", // Consolidates the add-token confirmed / rejected prompt responses; carries - // decision (confirm | reject) + asset + source (manage_assets on mobile). + // decision (confirm | reject) + asset_code + source (manage_assets on mobile). ASSET_ADD_RESPONDED = "asset_add.responded", // Consolidates the remove-token confirmed / rejected prompt responses; - // carries decision (confirm | reject) + asset + source (manage_assets). + // carries decision (confirm | reject) + asset_code + source (manage_assets). ASSET_REMOVE_RESPONDED = "asset_remove.responded", MANAGE_TOKEN_LISTS_MODIFY = "asset_list.modified", // Consolidates the three trustline-removal failure reasons; carries diff --git a/src/helpers/walletKitUtil.ts b/src/helpers/walletKitUtil.ts index 91833d365..4fb5b75fd 100644 --- a/src/helpers/walletKitUtil.ts +++ b/src/helpers/walletKitUtil.ts @@ -514,7 +514,6 @@ export const approveSessionRequest = async ({ dappDomain = dappMetadata?.url; analytics.trackSignedTransaction({ - transactionHash: transaction.hash().toString("hex"), ...(dappDomain ? { dappDomain } : {}), }); } catch (error) { @@ -541,7 +540,6 @@ export const approveSessionRequest = async ({ }); analytics.trackSubmittedTransaction({ - transactionHash: transaction.hash().toString("hex"), ...(dappDomain ? { dappDomain } : {}), }); } catch (error) { diff --git a/src/services/analytics/transactions.ts b/src/services/analytics/transactions.ts index 0b2a46964..378b3e26e 100644 --- a/src/services/analytics/transactions.ts +++ b/src/services/analytics/transactions.ts @@ -20,8 +20,6 @@ const originProps = (url?: string): { origin?: string } => { export const trackSignedTransaction = (data: SignedTransactionEvent): void => { track(AnalyticsEvent.SIGN_TRANSACTION_SUCCESS, { - transactionHash: data.transactionHash, - transactionType: data.transactionType, ...originProps(data.dappDomain), }); }; @@ -105,8 +103,6 @@ export const trackSubmittedTransaction = ( data: SubmittedTransactionEvent, ): void => { track(AnalyticsEvent.SUBMIT_TRANSACTION_SUCCESS, { - transactionHash: data.transactionHash, - transactionType: data.transactionType, ...originProps(data.dappDomain), }); }; @@ -117,7 +113,7 @@ export const trackSimulationError = ( ): void => { track(AnalyticsEvent.SIMULATE_TOKEN_PAYMENT_ERROR, { reason_code: error, - transactionType, + transaction_type: transactionType, }); }; @@ -198,7 +194,7 @@ export const trackTransactionError = (data: TransactionErrorEvent): void => { export const trackAddTokenConfirmed = (token?: string): void => { track(AnalyticsEvent.ASSET_ADD_RESPONDED, { decision: "confirm", - asset: token, + asset_code: token, source: "manage_assets", }); }; @@ -206,7 +202,7 @@ export const trackAddTokenConfirmed = (token?: string): void => { export const trackAddTokenRejected = (token?: string): void => { track(AnalyticsEvent.ASSET_ADD_RESPONDED, { decision: "reject", - asset: token, + asset_code: token, source: "manage_assets", }); }; @@ -214,7 +210,7 @@ export const trackAddTokenRejected = (token?: string): void => { export const trackRemoveTokenConfirmed = (token?: string): void => { track(AnalyticsEvent.ASSET_REMOVE_RESPONDED, { decision: "confirm", - asset: token, + asset_code: token, source: "manage_assets", }); }; @@ -222,7 +218,7 @@ export const trackRemoveTokenConfirmed = (token?: string): void => { export const trackRemoveTokenRejected = (token?: string): void => { track(AnalyticsEvent.ASSET_REMOVE_RESPONDED, { decision: "reject", - asset: token, + asset_code: token, source: "manage_assets", }); }; diff --git a/src/services/analytics/types.ts b/src/services/analytics/types.ts index 992ab685d..f74b21821 100644 --- a/src/services/analytics/types.ts +++ b/src/services/analytics/types.ts @@ -10,14 +10,10 @@ export type AnalyticsEventName = AnalyticsEvent; export type AnalyticsProps = Record | undefined; export interface SignedTransactionEvent { - transactionHash: string; - transactionType?: TransactionType | string; dappDomain?: string; } export interface SubmittedTransactionEvent { - transactionHash: string; - transactionType?: TransactionType | string; dappDomain?: string; } From b968fe634a0e0f413c1e35a8f55e181903ef6ca7 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 21 Jul 2026 23:22:42 -0400 Subject: [PATCH 08/13] analytics: round-4 drift fixes (reason_code result codes, count timing, reauth/onramp parity) - transaction.failed / swap.failed: reason_code now prefers the machine-readable Horizon result code, falling back to a StrKey-scrubbed message, so it buckets with the extension's SubmitFail derivation. Threaded resultCodes through the payment, collectible, and swap submit sites. - account.created: emit on the creation-success path with the real post-creation account count instead of at tap time (dropped the over-counting ManageAccounts tap emit). - onboarding.password_created: add the success side on signUp success (was fail-only on mobile). - reauth.failed: carry a scrubbed reason_code, matching the extension. - onramp.coinbase_opened: carry { asset }. - Catalog annotations for reserved / platform-specific events. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../screens/HomeScreen/ManageAccounts.tsx | 13 ++++------- .../screens/SendCollectibleReview.tsx | 7 +++++- .../screens/TransactionAmountScreen.tsx | 9 +++++++- .../SwapScreen/hooks/useSwapTransaction.ts | 12 ++++++++++ src/config/analyticsConfig.ts | 8 +++++-- src/ducks/auth.ts | 17 +++++++++++++- src/hooks/useCoinbaseOnramp.tsx | 4 +++- src/services/analytics/transactions.ts | 22 ++++++++++++++----- 8 files changed, 71 insertions(+), 21 deletions(-) diff --git a/src/components/screens/HomeScreen/ManageAccounts.tsx b/src/components/screens/HomeScreen/ManageAccounts.tsx index 613ac1986..3902461ca 100644 --- a/src/components/screens/HomeScreen/ManageAccounts.tsx +++ b/src/components/screens/HomeScreen/ManageAccounts.tsx @@ -96,17 +96,12 @@ const ManageAccounts: React.FC = ({ const handleAddAnotherWallet = useCallback(() => { if (!navigation) return; - // Post-creation count, matching the extension's allAccounts.length. - // NB: this fires at tap time (before the account exists); it over-counts if - // the add flow is abandoned. Longer-term this belongs on the creation-success - // path (ducks/auth createAccount) — flagged for a product/analytics decision. - analytics.track(AnalyticsEvent.ACCOUNT_SCREEN_ADD_ACCOUNT, { - number_of_accounts: accounts.length + 1, - }); - + // account.created (ACCOUNT_SCREEN_ADD_ACCOUNT) fires on the actual + // creation-success path (ducks/auth createAccount), not here at tap time — + // so it isn't counted when the add flow is abandoned. bottomSheetRef.current?.dismiss(); navigation.navigate(ROOT_NAVIGATOR_ROUTES.MANAGE_WALLETS_STACK); - }, [navigation, bottomSheetRef, accounts.length]); + }, [navigation, bottomSheetRef]); const handleRenameAccount = useCallback( async (newAccountName: string) => { diff --git a/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx b/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx index b53ae799a..776733267 100644 --- a/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx +++ b/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx @@ -391,8 +391,13 @@ const SendCollectibleReviewScreen: React.FC< tokenId: selectedCollectible.tokenId, }); } else { + const { error: submitError, submitErrorResultCodes } = + useTransactionBuilderStore.getState(); analytics.trackTransactionError({ - error: "Transaction failed", + error: submitError || "Transaction failed", + errorCode: + submitErrorResultCodes?.operations?.[0] || + submitErrorResultCodes?.transaction, operationType: TransactionOperationType.SendCollectible, }); } diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index 4a600e11f..ebbee97ee 100644 --- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx @@ -776,8 +776,15 @@ const TransactionAmountScreen: React.FC = ({ sourceToken: selectedBalance?.tokenCode || "unknown", }); } else { + // Prefer the Horizon op/tx result code as reason_code (buckets with + // the extension); submitTransaction stashes it rather than throwing. + const { error: submitError, submitErrorResultCodes } = + useTransactionBuilderStore.getState(); analytics.trackTransactionError({ - error: "Transaction failed", + error: submitError || "Transaction failed", + errorCode: + submitErrorResultCodes?.operations?.[0] || + submitErrorResultCodes?.transaction, operationType: TransactionOperationType.Payment, }); } diff --git a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts index d8f32ea1f..3f124c804 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts @@ -217,10 +217,12 @@ export const useSwapTransaction = ({ const errorMessage = submitError || "Failed to submit transaction"; const submitFailure = new Error(errorMessage) as Error & { quoteExpiredCodes?: string[]; + resultCodes?: { transaction?: string; operations?: string[] } | null; }; submitFailure.quoteExpiredCodes = getQuoteExpiredOperationCodes( submitErrorResultCodes, ); + submitFailure.resultCodes = submitErrorResultCodes; throw submitFailure; } @@ -305,8 +307,18 @@ export const useSwapTransaction = ({ return; } + const submitResultCodes = + error instanceof Error + ? ( + error as Error & { + resultCodes?: { transaction?: string; operations?: string[] }; + } + ).resultCodes + : undefined; analytics.trackTransactionError({ error: error instanceof Error ? error.message : String(error), + errorCode: + submitResultCodes?.operations?.[0] || submitResultCodes?.transaction, isSwap: true, sourceToken: sourceBalance?.tokenCode, destToken: destinationTokenInput?.tokenCode, diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index 0e20b8bc0..4ac91a86f 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -110,8 +110,9 @@ export enum AnalyticsEvent { // carries reason_code (direct payments only; path outcomes -> swap.failed) SEND_PAYMENT_FAIL = "payment.failed", SEND_PAYMENT_SET_MAX = "payment.max_amount_selected", - // Consolidates the direct / path payment-type selections; carries - // payment_type (payment | path_payment). + // Reserved: not currently emitted on either platform (the legacy + // type-selection events were retired without a replacement call site). Would + // carry payment_type (payment | path_payment) if wired. PAYMENT_TYPE_SELECTED = "payment.type_selected", SEND_PAYMENT_RECENT_ADDRESS = "payment.recipient_recent_selected", // carries reason_code @@ -194,6 +195,9 @@ export enum AnalyticsEvent { DOWNLOAD_BACKUP_PHRASE = "recovery_phrase.downloaded", // History + // Extension-only: mobile's history is a full tab screen (covered by + // screen.viewed); there is no discrete "open full history" gesture to + // instrument. Kept for a shared catalog. HISTORY_OPEN_FULL_HISTORY = "history.full_history_opened", HISTORY_OPEN_ITEM = "history.item_opened", diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 5c5d9a616..74ca1b3e8 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -2318,6 +2318,9 @@ export const useAuthenticationStore = create()((set, get) => ({ // generated so it doesn't inherit a previous wallet's timer. await resetAutoLockForNewWallet(); await signUp(params); + // Mirror the extension's onboarding.password_created (createAccount.fulfilled) + // so the create-password funnel has a success side on mobile (was fail-only). + analytics.track(AnalyticsEvent.CREATE_PASSWORD_SUCCESS); set({ ...initialState, navigationRef: get().navigationRef, @@ -2436,7 +2439,11 @@ export const useAuthenticationStore = create()((set, get) => ({ get().navigateToLockScreen(); }); } catch (error) { - analytics.trackReAuthFail(); + const reAuthFailureMessage = + error instanceof Error ? error.message : String(error); + analytics.trackReAuthFail( + scrubStrKeys(reAuthFailureMessage) ?? reAuthFailureMessage, + ); logger.error("useAuthenticationStore.signIn", "Sign in failed", error); set({ error: getUserFacingError(error, "authStore.error.failedToSignIn"), @@ -3026,6 +3033,14 @@ export const useAuthenticationStore = create()((set, get) => ({ await Promise.all([get().getAllAccounts(), get().fetchActiveAccount()]); + // account.created on the creation-success path with the real post-creation + // count (matches the extension's addAccount.fulfilled -> allAccounts.length). + // Replaces the former tap-time emit in ManageAccounts, which over-counted + // abandoned add flows. + analytics.track(AnalyticsEvent.ACCOUNT_SCREEN_ADD_ACCOUNT, { + number_of_accounts: get().allAccounts.length, + }); + set({ isCreatingAccount: false, error: null }); } catch (error) { logger.error( diff --git a/src/hooks/useCoinbaseOnramp.tsx b/src/hooks/useCoinbaseOnramp.tsx index b3ff7e3bc..516c3ebd2 100644 --- a/src/hooks/useCoinbaseOnramp.tsx +++ b/src/hooks/useCoinbaseOnramp.tsx @@ -70,7 +70,9 @@ function useCoinbaseOnramp({ token }: UseCoinbaseOnrampParams) { const data = await fetchData(); const url = getCoinbaseUrl({ sessionToken: data.token, token }); - analytics.track(AnalyticsEvent.COINBASE_ONRAMP_OPENED); + // `asset` matches the extension's onramp.coinbase_opened { asset } + // (undefined when no asset was preselected, same as extension). + analytics.track(AnalyticsEvent.COINBASE_ONRAMP_OPENED, { asset: token }); await openInAppBrowser(url); } catch (error) { diff --git a/src/services/analytics/transactions.ts b/src/services/analytics/transactions.ts index 378b3e26e..3f2741c44 100644 --- a/src/services/analytics/transactions.ts +++ b/src/services/analytics/transactions.ts @@ -1,5 +1,6 @@ import { AnalyticsEvent } from "config/analyticsConfig"; import { getDisplayHost } from "helpers/protocols"; +import { scrubStrKeys } from "helpers/stellarStrKey"; import { track } from "services/analytics/core"; import { TransactionOperationType } from "services/analytics/types"; import type { @@ -172,14 +173,18 @@ export const trackTransactionError = (data: TransactionErrorEvent): void => { // Shared required sets: payment.failed {payment_type, reason_code}; // swap.failed {from_asset_code, to_asset_code, reason_code}; - // collectible_send.failed {reason_code}. Legacy extras (errorCode, - // operationType, isSwap, amounts) dropped for cross-platform parity. - let props: Record = { reason_code: data.error }; + // collectible_send.failed {reason_code}. operationType/isSwap/amounts dropped + // for cross-platform parity. reason_code prefers the machine-readable Horizon + // result code (op_underfunded, tx_insufficient_balance, ...) so it buckets + // with the extension's SubmitFail derivation; a scrubbed free-text message is + // the fallback for non-Horizon failures (signing / precondition errors). + const reasonCode = data.errorCode ?? scrubStrKeys(data.error) ?? data.error; + let props: Record = { reason_code: reasonCode }; if (event === AnalyticsEvent.SWAP_FAIL) { props = { from_asset_code: data.sourceToken, to_asset_code: data.destToken, - reason_code: data.error, + reason_code: reasonCode, }; } else if (event === AnalyticsEvent.SEND_PAYMENT_FAIL) { props.payment_type = "payment"; @@ -285,8 +290,13 @@ export const trackReAuthSuccess = (): void => { trackAuthEvent(AnalyticsEvent.RE_AUTH_SUCCESS); }; -export const trackReAuthFail = (): void => { - trackAuthEvent(AnalyticsEvent.RE_AUTH_FAIL); +export const trackReAuthFail = (reasonCode?: string): void => { + // reason_code parity with the extension's reauth.failed; callers must scrub + // StrKeys before passing native error text. + trackAuthEvent( + AnalyticsEvent.RE_AUTH_FAIL, + reasonCode ? { reason_code: reasonCode } : undefined, + ); }; export const trackCopyPublicKey = (): void => { From cd866eaf10c116e8e9c3c0f83f4fa699bce79a35 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Tue, 21 Jul 2026 23:31:57 -0400 Subject: [PATCH 09/13] =?UTF-8?q?analytics:=20D8=20=E2=80=94=20consolidate?= =?UTF-8?q?=20onboarding.completed=20to=20a=20single=20terminal=20point?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile emitted onboarding.completed from four UI sites (ValidateRecoveryPhrase, RecoveryPhrase, and both BiometricsEnableScreen branches), risking double-counts and, on the import/recover path, firing alongside account_recovery.completed. Consolidate to one deterministic emit in the signUp store action's success path (the create-account terminal point, mirroring the extension's confirmMnemonicPhrase.fulfilled). The import/recover flow keeps account_recovery.completed only (module importWallet), matching the extension's create-vs-recover split — so import no longer double-signals as onboarding. This intentionally drops onboarding.completed from the import flow and shifts the create completion point to wallet creation; historical continuity of the series is deprioritized in favor of cross-platform parity (per product direction). Removed the now-unused analytics imports from BiometricsEnableScreen. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../BiometricsEnableScreen/BiometricsEnableScreen.tsx | 11 +++++------ src/components/screens/RecoveryPhraseScreen.tsx | 3 ++- .../screens/ValidateRecoveryPhraseScreen.tsx | 3 ++- src/config/analyticsConfig.ts | 6 ++++++ src/ducks/auth.ts | 6 ++++++ 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.tsx b/src/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.tsx index f17eaaefe..222ce5033 100644 --- a/src/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.tsx +++ b/src/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.tsx @@ -9,7 +9,6 @@ import { OnboardLayout } from "components/layout/OnboardLayout"; import { IconPosition } from "components/sds/Button"; import Icon from "components/sds/Icon"; import { Text } from "components/sds/Typography"; -import { AnalyticsEvent } from "config/analyticsConfig"; import { BiometricsSource, FACE_ID_BIOMETRY_TYPES, @@ -34,7 +33,6 @@ import React, { useCallback, useMemo, useState } from "react"; import { View, Image } from "react-native"; import { BIOMETRY_TYPE } from "react-native-keychain"; import { Svg, Defs, Rect, LinearGradient, Stop } from "react-native-svg"; -import { analytics } from "services/analytics"; import { dataStorage } from "services/storage/storageFactory"; type BiometricsOnboardingScreenProps = NativeStackScreenProps< @@ -210,7 +208,9 @@ export const BiometricsOnboardingScreen: React.FC< return Promise.resolve(); }); - analytics.track(AnalyticsEvent.ACCOUNT_CREATOR_FINISHED); + // onboarding.completed / account_recovery.completed now fire from the + // signUp / importWallet store actions respectively (single terminal + // point), so the biometrics screen no longer emits completion itself. } catch (error) { logger.error( "BiometricsOnboardingScreen", @@ -264,11 +264,10 @@ export const BiometricsOnboardingScreen: React.FC< if (!success) { notifySetupFailed(); - return; } - // Track analytics for successful completion - analytics.track(AnalyticsEvent.ACCOUNT_CREATOR_FINISHED); + // Completion (onboarding.completed / account_recovery.completed) is + // emitted from the signUp / importWallet store actions, not here. } catch (error) { logger.error( "BiometricsOnboardingScreen", diff --git a/src/components/screens/RecoveryPhraseScreen.tsx b/src/components/screens/RecoveryPhraseScreen.tsx index 75727a969..83a5256d8 100644 --- a/src/components/screens/RecoveryPhraseScreen.tsx +++ b/src/components/screens/RecoveryPhraseScreen.tsx @@ -136,7 +136,8 @@ export const RecoveryPhraseScreen: React.FC = ({ return; } clearLoginData(); // Clear sensitive data after successful signup - analytics.track(AnalyticsEvent.ACCOUNT_CREATOR_FINISHED); + // onboarding.completed now fires once from the signUp store action's + // success path (single terminal point), not per UI screen. }); } diff --git a/src/components/screens/ValidateRecoveryPhraseScreen.tsx b/src/components/screens/ValidateRecoveryPhraseScreen.tsx index 7aa62a302..aa67fb7be 100644 --- a/src/components/screens/ValidateRecoveryPhraseScreen.tsx +++ b/src/components/screens/ValidateRecoveryPhraseScreen.tsx @@ -93,7 +93,8 @@ export const ValidateRecoveryPhraseScreen: React.FC< } clearLoginData(); // Clear sensitive data after successful signup analytics.track(AnalyticsEvent.CONFIRM_RECOVERY_PHRASE_SUCCESS); - analytics.track(AnalyticsEvent.ACCOUNT_CREATOR_FINISHED); + // onboarding.completed now fires once from the signUp store action's + // success path (single terminal point), not per UI screen. }); } }, [ diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index 4ac91a86f..7870f1576 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -90,6 +90,12 @@ export enum AnalyticsEvent { // carries reason_code CONFIRM_RECOVERY_PHRASE_FAIL = "onboarding.recovery_phrase_confirm_failed", ACCOUNT_CREATOR_CONFIRM_MNEMONIC_BACK = "onboarding.recovery_phrase_back_clicked", + // Fires once from the signUp store action's success path (create-account + // terminal point) — NOT from the recovery-phrase / biometrics UI screens. + // The import/recover flow emits account_recovery.completed instead, matching + // the extension's create-vs-recover split. Completion lifecycle still differs + // from the extension (mobile: wallet-creation call; extension: mnemonic + // confirm), so the two series aren't naively comparable. ACCOUNT_CREATOR_FINISHED = "onboarding.completed", // Authentication / recovery diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 74ca1b3e8..29b1d8dee 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -2321,6 +2321,12 @@ export const useAuthenticationStore = create()((set, get) => ({ // Mirror the extension's onboarding.password_created (createAccount.fulfilled) // so the create-password funnel has a success side on mobile (was fail-only). analytics.track(AnalyticsEvent.CREATE_PASSWORD_SUCCESS); + // onboarding.completed at the single create-account terminal point (mirrors + // the extension's confirmMnemonicPhrase.fulfilled). Consolidated here from + // four UI-site emits (recovery-phrase / biometrics screens) that risked + // double-counting. The import/recover flow emits account_recovery.completed + // instead (see module importWallet), matching the extension's split. + analytics.track(AnalyticsEvent.ACCOUNT_CREATOR_FINISHED); set({ ...initialState, navigationRef: get().navigationRef, From 17af31ce2dfea5086121481770231cef2517e864 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 22 Jul 2026 00:00:06 -0400 Subject: [PATCH 10/13] analytics: end the D1/D2 recurrence (reason_code + blockaid result fallbacks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both drift findings kept recurring because prior rounds aligned the primary value each platform emits but left the fallback/default divergent. Fix the edges so there is nothing left to re-flag: - D1 (reason_code): trackTransactionError now emits data.errorCode ?? "unknown", byte-for-byte identical to the extension's SubmitFail derivation. Dropped the scrubbed free-text fallback that produced unbounded reason_code cardinality the extension never emits (full message still logged to Sentry). Regression test asserts the free-text never leaks into reason_code. - D2 (blockaid result): the asset / asset_bulk analytics `result` is now derived DIRECTLY from the raw Blockaid result_type (new resultFromResultType), not the UI SecurityLevel — so an unclassifiable token is `unknown` on both platforms, not silently `safe`. UI security assessment is untouched. Regression tests cover missing and unrecognized result_type. Security-hygiene (defense-in-depth, third-party sink): scrub StrKeys on the remaining free-text reason_code paths the PR had missed — payment.simulation_failed and asset.operation_failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../services/analytics/transactions.test.ts | 51 ++++++++++++++++++ __tests__/services/blockaid/api.test.ts | 37 +++++++++++++ src/hooks/useManageTokens.ts | 12 ++++- src/services/analytics/transactions.ts | 18 ++++--- src/services/blockaid/api.ts | 54 +++++++++++++------ 5 files changed, 149 insertions(+), 23 deletions(-) create mode 100644 __tests__/services/analytics/transactions.test.ts diff --git a/__tests__/services/analytics/transactions.test.ts b/__tests__/services/analytics/transactions.test.ts new file mode 100644 index 000000000..19e440933 --- /dev/null +++ b/__tests__/services/analytics/transactions.test.ts @@ -0,0 +1,51 @@ +import { AnalyticsEvent } from "config/analyticsConfig"; +import { trackTransactionError } from "services/analytics/transactions"; +import { TransactionOperationType } from "services/analytics/types"; + +jest.mock("services/analytics/core", () => ({ + track: jest.fn(), +})); + +const { track } = jest.requireMock("services/analytics/core"); + +describe("trackTransactionError reason_code (D1 cross-platform parity)", () => { + beforeEach(() => jest.clearAllMocks()); + + it("uses the Horizon result code as reason_code when present", () => { + trackTransactionError({ + error: "GADEADBEEF... op failed: some free text", + errorCode: "op_underfunded", + isSwap: true, + sourceToken: "XLM", + destToken: "USDC", + }); + + expect(track).toHaveBeenCalledWith( + AnalyticsEvent.SWAP_FAIL, + expect.objectContaining({ reason_code: "op_underfunded" }), + ); + }); + + it("falls back to unknown — NOT the free-text message — when no result code is present", () => { + // Matches the extension's `resultCodes... || "unknown"`. The free-text + // message (which could carry a StrKey and blows up cardinality) must never + // become reason_code. + const freeText = "Signing failed for GABC12345... unexpected error"; + + trackTransactionError({ + error: freeText, + operationType: TransactionOperationType.Payment, + }); + + expect(track).toHaveBeenCalledWith( + AnalyticsEvent.SEND_PAYMENT_FAIL, + expect.objectContaining({ + reason_code: "unknown", + payment_type: "payment", + }), + ); + // Explicitly assert the free-text never leaks into reason_code. + const props = track.mock.calls[0][1]; + expect(props.reason_code).not.toContain(freeText); + }); +}); diff --git a/__tests__/services/blockaid/api.test.ts b/__tests__/services/blockaid/api.test.ts index c1823e092..2ad8c7011 100644 --- a/__tests__/services/blockaid/api.test.ts +++ b/__tests__/services/blockaid/api.test.ts @@ -109,6 +109,43 @@ describe("blockaid scan analytics (#2883 consolidation)", () => { ); }); + // D2 parity: an unclassifiable token (missing/unrecognized result_type) must + // report result:"unknown", NOT "safe". The UI SecurityLevel defaults such + // tokens to SAFE, but the analytics result is derived from the raw + // result_type so it buckets identically to the extension. + it("reports result=unknown for a token with no result_type (not safe)", async () => { + mockGet.mockResolvedValue({ data: { data: {} } }); + + await scanToken({ + tokenCode: "NOVEL", + tokenIssuer: "GISSUER", + network: NETWORKS.PUBLIC, + }); + + expect(mockTrack).toHaveBeenCalledWith( + AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, + expect.objectContaining({ scan_target: "asset", result: "unknown" }), + ); + }); + + it("reports result=unknown for an unrecognized result_type on a bulk scan", async () => { + mockGet.mockResolvedValue({ + data: { + data: { results: { "NOVEL-GISSUER": { result_type: "Weird" } } }, + }, + }); + + await scanBulkTokens({ + addressList: ["NOVEL-GISSUER"], + network: NETWORKS.PUBLIC, + }); + + expect(mockTrack).toHaveBeenCalledWith( + AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, + expect.objectContaining({ scan_target: "asset_bulk", result: "unknown" }), + ); + }); + it("emits the added scan_failed event when a scan throws", async () => { mockGet.mockRejectedValue(new Error("backend down")); diff --git a/src/hooks/useManageTokens.ts b/src/hooks/useManageTokens.ts index 093e336da..2bee17007 100644 --- a/src/hooks/useManageTokens.ts +++ b/src/hooks/useManageTokens.ts @@ -15,6 +15,7 @@ import { import { ActiveAccount } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; import { formatTokenIdentifier } from "helpers/balances"; +import { scrubStrKeys } from "helpers/stellarStrKey"; import useAppTranslation from "hooks/useAppTranslation"; import { isWalletUnlocked } from "hooks/useGetActiveAccount"; import { ToastOptions, useToast } from "providers/ToastProvider"; @@ -30,6 +31,13 @@ import { dataStorage } from "services/storage/storageFactory"; const WALLET_LOCKED_ERROR = "Wallet is locked"; +// asset.operation_failed reason_code is free-text; scrub Stellar StrKeys before +// it reaches Amplitude (a third-party sink not covered by Sentry's beforeSend). +const scrubReasonCode = (error: unknown): string => { + const message = error instanceof Error ? error.message : String(error); + return scrubStrKeys(message) ?? message; +}; + interface UseManageTokensProps { network: NETWORKS; account: ActiveAccount | null; @@ -193,7 +201,7 @@ export const useManageTokens = ({ }); } catch (error) { analytics.track(AnalyticsEvent.TOKEN_MANAGEMENT_FAIL, { - reason_code: error instanceof Error ? error.message : String(error), + reason_code: scrubReasonCode(error), operation: "add", asset_code: tokenCode, asset_issuer: issuer, @@ -323,7 +331,7 @@ export const useManageTokens = ({ }); } catch (error) { analytics.track(AnalyticsEvent.TOKEN_MANAGEMENT_FAIL, { - reason_code: error instanceof Error ? error.message : String(error), + reason_code: scrubReasonCode(error), operation: "remove", asset_code: tokenCode, asset_issuer: tokenIssuer, diff --git a/src/services/analytics/transactions.ts b/src/services/analytics/transactions.ts index 3f2741c44..f01049f0f 100644 --- a/src/services/analytics/transactions.ts +++ b/src/services/analytics/transactions.ts @@ -113,7 +113,9 @@ export const trackSimulationError = ( transactionType: SimulationTransactionType, ): void => { track(AnalyticsEvent.SIMULATE_TOKEN_PAYMENT_ERROR, { - reason_code: error, + // Scrub Stellar StrKeys — simulation errors are free-text and Amplitude is + // a third-party sink not covered by Sentry's beforeSend. + reason_code: scrubStrKeys(error) ?? error, transaction_type: transactionType, }); }; @@ -174,11 +176,15 @@ export const trackTransactionError = (data: TransactionErrorEvent): void => { // Shared required sets: payment.failed {payment_type, reason_code}; // swap.failed {from_asset_code, to_asset_code, reason_code}; // collectible_send.failed {reason_code}. operationType/isSwap/amounts dropped - // for cross-platform parity. reason_code prefers the machine-readable Horizon - // result code (op_underfunded, tx_insufficient_balance, ...) so it buckets - // with the extension's SubmitFail derivation; a scrubbed free-text message is - // the fallback for non-Horizon failures (signing / precondition errors). - const reasonCode = data.errorCode ?? scrubStrKeys(data.error) ?? data.error; + // for cross-platform parity. reason_code is the machine-readable Horizon + // result code (op_underfunded, tx_insufficient_balance, ...), falling back to + // the literal "unknown" — IDENTICAL to the extension's SubmitFail derivation + // (`resultCodes.operations?.[0] || resultCodes.transaction || "unknown"`). We + // deliberately do NOT fall back to the free-text error message: it produces + // unbounded reason_code cardinality the extension never emits, poisoning a + // shared payment/swap/collectible failure breakdown. The full message is still + // captured by the logger / Sentry for debugging. + const reasonCode = data.errorCode ?? "unknown"; let props: Record = { reason_code: reasonCode }; if (event === AnalyticsEvent.SWAP_FAIL) { props = { diff --git a/src/services/blockaid/api.ts b/src/services/blockaid/api.ts index bf78ff472..5a81b40c3 100644 --- a/src/services/blockaid/api.ts +++ b/src/services/blockaid/api.ts @@ -8,11 +8,11 @@ import { freighterBackendV1 } from "services/backend"; import { BLOCKAID_ENDPOINTS, BLOCKAID_ERROR_MESSAGES, + BLOCKAID_RESULT_TYPES, SecurityLevel, } from "services/blockaid/constants"; import { assessSiteSecurity, - assessTokenSecurity, assessTransactionSecurity, } from "services/blockaid/helper"; import { @@ -31,21 +31,45 @@ import { type BlockaidScanTarget = "domain" | "transaction" | "asset" | "asset_bulk"; /** - * Reduces a bulk token scan to a single worst-case security level for the - * `result` property (malicious > suspicious > safe/unable-to-scan). + * Analytics `result` derived DIRECTLY from the raw Blockaid result_type — not + * from the UI SecurityLevel. getSecurityLevel/assessTokenSecurity default a + * missing or unrecognized result_type to SAFE for the UI (a deliberate + * product choice), but for analytics that would inflate the `safe` bucket with + * tokens Blockaid could not classify. Mirroring the extension's + * toBlockaidResultLevel, an unclassifiable token is `unknown` on both + * platforms. `Spam` maps to `warn`. + */ +const resultFromResultType = ( + resultType?: string, +): "safe" | "warn" | "block" | "unknown" => { + switch (resultType) { + case BLOCKAID_RESULT_TYPES.BENIGN: + return "safe"; + case BLOCKAID_RESULT_TYPES.WARNING: + case BLOCKAID_RESULT_TYPES.SPAM: + return "warn"; + case BLOCKAID_RESULT_TYPES.MALICIOUS: + return "block"; + default: + return "unknown"; + } +}; + +/** + * Reduces a bulk token scan to a single worst-case analytics `result` + * (block > warn > safe > unknown), matching the extension's asset_bulk + * aggregation over per-token result_type. */ const aggregateBulkResult = ( scanResult: Blockaid.TokenBulkScanResponse, -): SecurityLevel => { - const levels = Object.values(scanResult.results ?? {}).map( - (result) => assessTokenSecurity(result).level, +): "safe" | "warn" | "block" | "unknown" => { + const levels = Object.values(scanResult.results ?? {}).map((result) => + resultFromResultType(result?.result_type), ); - - if (levels.includes(SecurityLevel.MALICIOUS)) return SecurityLevel.MALICIOUS; - if (levels.includes(SecurityLevel.SUSPICIOUS)) { - return SecurityLevel.SUSPICIOUS; - } - return SecurityLevel.SAFE; + if (levels.includes("block")) return "block"; + if (levels.includes("warn")) return "warn"; + if (levels.includes("safe")) return "safe"; + return "unknown"; }; /** @@ -131,7 +155,7 @@ export const scanToken = async ( analytics.track(AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, { scan_target: "asset", - result: toBlockaidResultLevel(assessTokenSecurity(scanResult).level), + result: resultFromResultType(scanResult.result_type), token_code: tokenCode, }); @@ -171,8 +195,8 @@ export const scanBulkTokens = async ( analytics.track(AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, { scan_target: "asset_bulk", - // Aggregate verdict across the batch: the worst per-token security level. - result: toBlockaidResultLevel(aggregateBulkResult(scanResult)), + // Aggregate verdict across the batch: the worst per-token result. + result: aggregateBulkResult(scanResult), address_count: addressList.length, }); From 42667326df9223ebdc09ade23b0a313013a66f2e Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 22 Jul 2026 00:32:07 -0400 Subject: [PATCH 11/13] analytics: scrub signing-failure reason_code + bound asset.operation_failed (D2/D3/D5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - D2 (security, high): trackSignedMessageError / trackSignedAuthEntryError now scrub StrKeys from reason_code before it reaches Amplitude, matching the extension's signBlob/signEntry.rejected handlers. A signing exception's message can embed a G…/S… key; mobile was shipping it verbatim. Regression test asserts the key is redacted to G***. - D3: asset.operation_failed.reason_code is now the bounded Horizon op result code (opResultCodes[0] ?? "unknown"), not free-text — same discipline already applied to payment.failed/swap.failed, and identical to the extension's `opCodes[0] || "unknown"`. Reuses the op-code extraction the remove path already had (now a shared opResultCodesOf helper); drops the free-text scrubReasonCode path entirely. - D5: swap.trustline_added now uses snake_case asset_code/asset_issuer (was camelCase tokenCode/tokenIssuer), matching sibling asset.added. Kept in lockstep with the extension's identical rename. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SwapScreen/useSwapTransaction.test.ts | 4 +- .../services/analytics/transactions.test.ts | 30 ++++++++++++- .../SwapScreen/hooks/useSwapTransaction.ts | 4 +- src/hooks/useManageTokens.ts | 43 ++++++++++--------- src/services/analytics/transactions.ts | 8 +++- 5 files changed, 62 insertions(+), 27 deletions(-) diff --git a/__tests__/components/screens/SwapScreen/useSwapTransaction.test.ts b/__tests__/components/screens/SwapScreen/useSwapTransaction.test.ts index 6613cbd6a..b8243f54c 100644 --- a/__tests__/components/screens/SwapScreen/useSwapTransaction.test.ts +++ b/__tests__/components/screens/SwapScreen/useSwapTransaction.test.ts @@ -330,8 +330,8 @@ describe("useSwapTransaction", () => { expect(mockTrack).toHaveBeenCalledWith( AnalyticsEvent.SWAP_TRUSTLINE_ADDED, expect.objectContaining({ - tokenCode: "USDC", - tokenIssuer: expect.any(String), + asset_code: "USDC", + asset_issuer: expect.any(String), }), ); }); diff --git a/__tests__/services/analytics/transactions.test.ts b/__tests__/services/analytics/transactions.test.ts index 19e440933..a05550bc7 100644 --- a/__tests__/services/analytics/transactions.test.ts +++ b/__tests__/services/analytics/transactions.test.ts @@ -1,5 +1,9 @@ import { AnalyticsEvent } from "config/analyticsConfig"; -import { trackTransactionError } from "services/analytics/transactions"; +import { + trackSignedAuthEntryError, + trackSignedMessageError, + trackTransactionError, +} from "services/analytics/transactions"; import { TransactionOperationType } from "services/analytics/types"; jest.mock("services/analytics/core", () => ({ @@ -49,3 +53,27 @@ describe("trackTransactionError reason_code (D1 cross-platform parity)", () => { expect(props.reason_code).not.toContain(freeText); }); }); + +describe("signing-failure reason_code scrubbing (D2 security hygiene)", () => { + beforeEach(() => jest.clearAllMocks()); + + // A 56-char G-StrKey (G + 55 base32 chars) that scrubStrKeys must redact + // before reason_code reaches Amplitude (a third-party sink). + const STRKEY = `G${"A".repeat(55)}`; + + it("scrubs StrKeys from signing.message_failed reason_code", () => { + trackSignedMessageError({ error: `signMessage failed for ${STRKEY}` }); + + const props = track.mock.calls[0][1]; + expect(props.reason_code).toBe("signMessage failed for G***"); + expect(props.reason_code).not.toContain(STRKEY); + }); + + it("scrubs StrKeys from signing.auth_entry_failed reason_code", () => { + trackSignedAuthEntryError({ error: `signAuthEntry failed for ${STRKEY}` }); + + const props = track.mock.calls[0][1]; + expect(props.reason_code).toBe("signAuthEntry failed for G***"); + expect(props.reason_code).not.toContain(STRKEY); + }); +}); diff --git a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts index 3f124c804..e7db2e132 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts @@ -244,8 +244,8 @@ export const useSwapTransaction = ({ const { destinationToken: swappedDestination } = useSwapStore.getState(); if (swappedDestination?.requiresTrustline) { analytics.track(AnalyticsEvent.SWAP_TRUSTLINE_ADDED, { - tokenCode: destinationTokenInput.tokenCode, - tokenIssuer: swappedDestination.issuer ?? "", + asset_code: destinationTokenInput.tokenCode, + asset_issuer: swappedDestination.issuer ?? "", }); } } catch (error) { diff --git a/src/hooks/useManageTokens.ts b/src/hooks/useManageTokens.ts index 2bee17007..812bc3fc9 100644 --- a/src/hooks/useManageTokens.ts +++ b/src/hooks/useManageTokens.ts @@ -15,7 +15,6 @@ import { import { ActiveAccount } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; import { formatTokenIdentifier } from "helpers/balances"; -import { scrubStrKeys } from "helpers/stellarStrKey"; import useAppTranslation from "hooks/useAppTranslation"; import { isWalletUnlocked } from "hooks/useGetActiveAccount"; import { ToastOptions, useToast } from "providers/ToastProvider"; @@ -31,12 +30,26 @@ import { dataStorage } from "services/storage/storageFactory"; const WALLET_LOCKED_ERROR = "Wallet is locked"; -// asset.operation_failed reason_code is free-text; scrub Stellar StrKeys before -// it reaches Amplitude (a third-party sink not covered by Sentry's beforeSend). -const scrubReasonCode = (error: unknown): string => { - const message = error instanceof Error ? error.message : String(error); - return scrubStrKeys(message) ?? message; -}; +// Extract the Horizon CHANGE_TRUST operation result codes (op_low_reserve, +// op_no_trust, op_invalid_limit, ...) from a thrown error, if present. +const opResultCodesOf = (error: unknown): string[] | undefined => + isHorizonError(error) + ? ( + error as { + response?: { + data?: { extras?: { result_codes?: { operations?: string[] } } }; + }; + } + ).response?.data?.extras?.result_codes?.operations + : undefined; + +// asset.operation_failed.reason_code = the first machine-readable op result +// code, or "unknown". Deliberately bounded (NOT the free-text error message) so +// it buckets with the extension's `opCodes[0] || "unknown"` on a shared +// failure dashboard, matching the discipline already applied to +// payment.failed/swap.failed. +const operationFailedReasonCode = (error: unknown): string => + opResultCodesOf(error)?.[0] ?? "unknown"; interface UseManageTokensProps { network: NETWORKS; @@ -201,7 +214,7 @@ export const useManageTokens = ({ }); } catch (error) { analytics.track(AnalyticsEvent.TOKEN_MANAGEMENT_FAIL, { - reason_code: scrubReasonCode(error), + reason_code: operationFailedReasonCode(error), operation: "add", asset_code: tokenCode, asset_issuer: issuer, @@ -331,7 +344,7 @@ export const useManageTokens = ({ }); } catch (error) { analytics.track(AnalyticsEvent.TOKEN_MANAGEMENT_FAIL, { - reason_code: scrubReasonCode(error), + reason_code: operationFailedReasonCode(error), operation: "remove", asset_code: tokenCode, asset_issuer: tokenIssuer, @@ -342,17 +355,7 @@ export const useManageTokens = ({ // (op_low_reserve -> low_reserve; op_invalid_limit split by whether the // asset carries buying liabilities). Only fires for the trustline-specific // failures — wallet-locked/build/sign errors won't set a reason. - const opResultCodes = isHorizonError(error) - ? ( - error as { - response?: { - data?: { - extras?: { result_codes?: { operations?: string[] } }; - }; - }; - } - ).response?.data?.extras?.result_codes?.operations - : undefined; + const opResultCodes = opResultCodesOf(error); let trustlineReason: string | undefined; if (opResultCodes?.includes("op_low_reserve")) { trustlineReason = "low_reserve"; diff --git a/src/services/analytics/transactions.ts b/src/services/analytics/transactions.ts index f01049f0f..a865c7124 100644 --- a/src/services/analytics/transactions.ts +++ b/src/services/analytics/transactions.ts @@ -53,7 +53,10 @@ export const trackSignedMessageError = (data: { // instrumented separately (trackSignedMessageRejected, below). track(AnalyticsEvent.SIGN_MESSAGE_FAIL, { message_type: "blob", - reason_code: data.error, + // Scrub Stellar StrKeys — a signing exception's message can embed a G…/S… + // key, and Amplitude is a third-party sink not covered by Sentry. Matches + // the extension's signBlob.rejected handler. + reason_code: scrubStrKeys(data.error) ?? data.error, ...originProps(data.dappDomain), }); }; @@ -64,7 +67,8 @@ export const trackSignedAuthEntryError = (data: { }): void => { // Runtime signing-failure path; see trackSignedMessageError. track(AnalyticsEvent.SIGN_AUTH_ENTRY_FAIL, { - reason_code: data.error, + // Scrub StrKeys before this reaches Amplitude (see trackSignedMessageError). + reason_code: scrubStrKeys(data.error) ?? data.error, ...originProps(data.dappDomain), }); }; From 60dc2fb4ad2a7503bba76a1518ac6856581dd17b Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Wed, 22 Jul 2026 21:29:59 -0400 Subject: [PATCH 12/13] analytics: transaction-scan result from raw result_type + scrub stragglers (D1/D4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - D1 (high, data correctness): blockaid.scan_completed{scan_target:"transaction"} now derives `result` from the raw validation.result_type (guarded like the extension's `"result_type" in validation` check; missing/unrecognized -> "unknown"), instead of assessTransactionSecurity().level. The UI model defaulted unclassifiable results to SAFE and mapped simulation.error to SUSPICIOUS/warn — both diverged from the extension and inflated mobile's safe/warn buckets. This mirrors the token path (resultFromResultType) fixed last round; transaction was the remaining straggler. Regression tests cover recognized, unclassifiable, and simulation.error cases. - Scrub stragglers (defense-in-depth, third-party sink): trackAccountScreen- ImportAccountFail (the secret-key import path — likeliest S… leak) and trackQRScanError (QR payloads carry G…/S… keys) now scrub inside the helper, matching the other track*Error helpers. Found via a full sweep of every free-text reason_code assignment, not just the flagged one. - D4 (catalog hygiene): reserve blockaid.warning_reported in mobile's catalog (extension-only emit) so the wire string can't be reinvented/drift. Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/services/blockaid/api.test.ts | 70 ++++++++++++++++++++++++- src/config/analyticsConfig.ts | 6 +++ src/services/analytics/transactions.ts | 12 ++++- src/services/blockaid/api.ts | 22 +++++--- 4 files changed, 99 insertions(+), 11 deletions(-) diff --git a/__tests__/services/blockaid/api.test.ts b/__tests__/services/blockaid/api.test.ts index 2ad8c7011..2d866daec 100644 --- a/__tests__/services/blockaid/api.test.ts +++ b/__tests__/services/blockaid/api.test.ts @@ -3,15 +3,20 @@ import { AnalyticsEvent } from "config/analyticsConfig"; import { NETWORKS } from "config/constants"; import { analytics } from "services/analytics"; import { freighterBackendV1 } from "services/backend"; -import { scanBulkTokens, scanToken } from "services/blockaid/api"; +import { + scanBulkTokens, + scanToken, + scanTransaction, +} from "services/blockaid/api"; jest.mock("services/backend", () => ({ - freighterBackendV1: { get: jest.fn() }, + freighterBackendV1: { get: jest.fn(), post: jest.fn() }, })); jest.mock("services/analytics", () => ({ analytics: { track: jest.fn() } })); jest.mock("helpers/networks", () => ({ isMainnet: () => true })); const mockGet = freighterBackendV1.get as jest.Mock; +const mockPost = freighterBackendV1.post as jest.Mock; const mockTrack = analytics.track as jest.Mock; describe("scanBulkTokens error handling", () => { @@ -146,6 +151,67 @@ describe("blockaid scan analytics (#2883 consolidation)", () => { ); }); + // D1 parity: transaction-scan result must derive from the raw + // validation.result_type (matching the extension + mobile's token path), NOT + // the UI security model that defaults unclassifiable -> safe and + // simulation.error -> warn. + it("transaction scan maps a recognized validation.result_type", async () => { + mockPost.mockResolvedValue({ + data: { data: { validation: { result_type: "Malicious" } } }, + }); + + await scanTransaction({ + xdr: "AAAA", + url: "https://dapp.example", + network: NETWORKS.PUBLIC, + }); + + expect(mockTrack).toHaveBeenCalledWith( + AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, + expect.objectContaining({ scan_target: "transaction", result: "block" }), + ); + }); + + it("transaction scan reports unknown for missing/unrecognized validation (not safe)", async () => { + mockPost.mockResolvedValue({ data: { data: { simulation: {} } } }); + + await scanTransaction({ + xdr: "AAAA", + url: "https://dapp.example", + network: NETWORKS.PUBLIC, + }); + + expect(mockTrack).toHaveBeenCalledWith( + AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, + expect.objectContaining({ + scan_target: "transaction", + result: "unknown", + }), + ); + }); + + it("transaction scan ignores simulation.error — a benign validation stays safe (not warn)", async () => { + mockPost.mockResolvedValue({ + data: { + data: { + validation: { result_type: "Benign" }, + simulation: { error: "simulation blew up" }, + }, + }, + }); + + await scanTransaction({ + xdr: "AAAA", + url: "https://dapp.example", + network: NETWORKS.PUBLIC, + }); + + expect(mockTrack).toHaveBeenCalledWith( + AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, + expect.objectContaining({ scan_target: "transaction", result: "safe" }), + ); + }); + it("emits the added scan_failed event when a scan throws", async () => { mockGet.mockRejectedValue(new Error("backend down")); diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index 7870f1576..fc65a1c77 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -216,6 +216,12 @@ export enum AnalyticsEvent { // BLOCKAID_SCAN_FAILED (scan_target + reason_code). BLOCKAID_SCAN_COMPLETED = "blockaid.scan_completed", BLOCKAID_SCAN_FAILED = "blockaid.scan_failed", + // Reserved: the extension emits this from its "report warning" affordance + // (reportAssetWarning / reportTransactionWarning); mobile has no such + // affordance yet. Kept in the catalog so the wire string isn't reinvented and + // drifts if mobile adds one — mirrors how one-sided events are reserved on + // both platforms (e.g. transaction.submitted, dapp_access.blocked). + BLOCKAID_WARNING_REPORTED = "blockaid.warning_reported", // Onramp COINBASE_ONRAMP_OPENED = "onramp.coinbase_opened", diff --git a/src/services/analytics/transactions.ts b/src/services/analytics/transactions.ts index a865c7124..4edd6a574 100644 --- a/src/services/analytics/transactions.ts +++ b/src/services/analytics/transactions.ts @@ -245,7 +245,10 @@ export const trackAccountScreenImportAccountFail = (error: string): void => { // secret-key import path (mnemonic restore emits account_recovery.* instead). track(AnalyticsEvent.ACCOUNT_SCREEN_IMPORT_ACCOUNT_FAIL, { import_method: "secret_key", - reason_code: error, + // Scrub inside the helper (not just at the caller): this is the secret-key + // import path, so a failure message is the likeliest place to embed an S… + // seed. Matches the other track*Error helpers. + reason_code: scrubStrKeys(error) ?? error, }); }; @@ -327,5 +330,10 @@ export const trackQRScanSuccess = ( }; export const trackQRScanError = (context: string, error: string): void => { - track(AnalyticsEvent.QR_SCAN_ERROR, { context, reason_code: error }); + // Scrub StrKeys — QR payloads routinely carry G…/S… keys, so a scan-error + // message can echo one into Amplitude (a third-party sink). + track(AnalyticsEvent.QR_SCAN_ERROR, { + context, + reason_code: scrubStrKeys(error) ?? error, + }); }; diff --git a/src/services/blockaid/api.ts b/src/services/blockaid/api.ts index 5a81b40c3..360d8a223 100644 --- a/src/services/blockaid/api.ts +++ b/src/services/blockaid/api.ts @@ -11,10 +11,7 @@ import { BLOCKAID_RESULT_TYPES, SecurityLevel, } from "services/blockaid/constants"; -import { - assessSiteSecurity, - assessTransactionSecurity, -} from "services/blockaid/helper"; +import { assessSiteSecurity } from "services/blockaid/helper"; import { BlockaidApiResponse, ScanTokenParams, @@ -278,11 +275,22 @@ export const scanTransaction = async ( const scanResult = response.data .data as Blockaid.StellarTransactionScanResponse; + // Derive from the raw validation.result_type (missing/unrecognized -> + // "unknown"), NOT assessTransactionSecurity().level — the UI model defaults + // unclassifiable results to SAFE and maps simulation.error to SUSPICIOUS, + // both of which would diverge from the extension (which reads the raw + // result_type and ignores simulation). Guard the validation union exactly + // like the extension's `"result_type" in validation` check. Mirrors mobile's + // own token path (resultFromResultType) so all scan targets bucket alike. + const { validation } = scanResult; + const validationResultType = + validation && "result_type" in validation + ? validation.result_type + : undefined; + analytics.track(AnalyticsEvent.BLOCKAID_SCAN_COMPLETED, { scan_target: "transaction", - result: toBlockaidResultLevel( - assessTransactionSecurity(scanResult).level, - ), + result: resultFromResultType(validationResultType), }); return scanResult; From 24d21744dbf8b47b85bd8a4aec4131e3c17c2be1 Mon Sep 17 00:00:00 2001 From: Piyal Basu Date: Thu, 23 Jul 2026 16:18:23 -0400 Subject: [PATCH 13/13] =?UTF-8?q?analytics:=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20scan=5Ffailed=20scrub,=20StrKey=20C/M,=20swap=20key?= =?UTF-8?q?=20parity,=20reject=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review comments from #938 (paired with the extension #2909 changes): - blockaid.scan_failed reason_code now scrubbed (trackScanFailed) — the last raw error path; matches the other track*Error helpers. - scrubStrKeys now covers contract (C…, 56) and muxed (M…, 69) StrKeys, not just G…/S…; widened to tolerate null. (Reviewer noted C/M; corrected the length — muxed is 69 chars, so it's an alternation, not [GSCM]{55}.) - swap.source_selected / destination_selected use snake_case asset_code / asset_issuer / requires_trustline; swap.quote_expired drops the raw amounts (privacy parity with completed/failed) and emits from_asset_code / to_asset_code (bare codes) + result_code. Kept in lockstep with the extension. - signing.*_rejected: extracted resolveDappRejectionEvent (pure, unit-tested) so an approved/completed request — or an approve attempt that threw — is never miscounted as a user reject. Added approvalInFlightRef to separate the WC fallback rejection from the analytics emit. - Completed the jest.setup.js StellarRpcMethods mock (was missing SIGN_MESSAGE / SIGN_AUTH_ENTRY) and moved core.test.ts into __tests__/ (requireActual path updated to keep bypassing the services/* mapper + global mock). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../screens/SwapScreen/SwapToScreen.test.tsx | 8 +-- .../SwapScreen/useSwapTransaction.test.ts | 16 +++-- __tests__/helpers/walletKitUtil.test.ts | 66 +++++++++++++++++++ .../services/analytics/core.test.ts | 43 +++++++----- jest.setup.js | 2 + .../SwapScreen/hooks/useSwapTransaction.ts | 14 ++-- .../hooks/useTrendingTokenDetail.ts | 6 +- .../SwapScreen/screens/SwapToScreen.tsx | 16 ++--- src/helpers/stellarStrKey.ts | 23 ++++--- src/helpers/walletKitUtil.ts | 37 +++++++++++ src/providers/WalletKitProvider.tsx | 50 +++++++++----- src/services/blockaid/api.ts | 5 +- 12 files changed, 214 insertions(+), 72 deletions(-) create mode 100644 __tests__/helpers/walletKitUtil.test.ts rename {src => __tests__}/services/analytics/core.test.ts (95%) diff --git a/__tests__/components/screens/SwapScreen/SwapToScreen.test.tsx b/__tests__/components/screens/SwapScreen/SwapToScreen.test.tsx index 1506f8a33..20df6fc6d 100644 --- a/__tests__/components/screens/SwapScreen/SwapToScreen.test.tsx +++ b/__tests__/components/screens/SwapScreen/SwapToScreen.test.tsx @@ -652,7 +652,7 @@ describe("SwapToScreen", () => { expect(analytics.track).toHaveBeenCalledWith( AnalyticsEvent.SWAP_DESTINATION_SELECTED, - expect.objectContaining({ source: "popular", tokenCode: "AQUA" }), + expect.objectContaining({ source: "popular", asset_code: "AQUA" }), ); }); @@ -675,7 +675,7 @@ describe("SwapToScreen", () => { expect(analytics.track).toHaveBeenCalledWith( AnalyticsEvent.SWAP_DESTINATION_SELECTED, - expect.objectContaining({ source: "search", tokenCode: "AQUA" }), + expect.objectContaining({ source: "search", asset_code: "AQUA" }), ); }); @@ -698,8 +698,8 @@ describe("SwapToScreen", () => { AnalyticsEvent.SWAP_SOURCE_SELECTED, expect.objectContaining({ source: "balances", - tokenCode: "USDC", - tokenIssuer: + asset_code: "USDC", + asset_issuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", }), ); diff --git a/__tests__/components/screens/SwapScreen/useSwapTransaction.test.ts b/__tests__/components/screens/SwapScreen/useSwapTransaction.test.ts index b8243f54c..8431d262f 100644 --- a/__tests__/components/screens/SwapScreen/useSwapTransaction.test.ts +++ b/__tests__/components/screens/SwapScreen/useSwapTransaction.test.ts @@ -387,13 +387,19 @@ describe("useSwapTransaction", () => { expect(mockTrack).toHaveBeenCalledWith( AnalyticsEvent.SWAP_QUOTE_EXPIRED, expect.objectContaining({ - sourceToken: "XLM", - destToken: "USDC", - sourceAmount: "1", - destAmount: "2.5", - resultCode: "op_under_dest_min", + from_asset_code: "XLM", + to_asset_code: "USDC", + result_code: "op_under_dest_min", }), ); + // Amounts are intentionally no longer emitted (parity with completed/failed). + const quoteExpiredCall = mockTrack.mock.calls.find( + (call: unknown[]) => + (call[0] as AnalyticsEvent) === AnalyticsEvent.SWAP_QUOTE_EXPIRED, + ); + expect(quoteExpiredCall?.[1]).not.toHaveProperty("sourceAmount"); + expect(quoteExpiredCall?.[1]).not.toHaveProperty("destAmount"); + expect(quoteExpiredCall?.[1]).not.toHaveProperty("allowedSlippage"); // Quote-expiry is a distinct funnel step — the generic SWAP_FAIL must NOT fire. expect(mockTrackTransactionError).not.toHaveBeenCalled(); expect(mockShowToast).toHaveBeenCalledWith( diff --git a/__tests__/helpers/walletKitUtil.test.ts b/__tests__/helpers/walletKitUtil.test.ts new file mode 100644 index 000000000..b1ed6c4c4 --- /dev/null +++ b/__tests__/helpers/walletKitUtil.test.ts @@ -0,0 +1,66 @@ +import { StellarRpcMethods } from "ducks/walletKit"; +import { resolveDappRejectionEvent } from "helpers/walletKitUtil"; + +describe("resolveDappRejectionEvent (dApp-request teardown → signing.*_rejected)", () => { + const base = { + requestMethod: StellarRpcMethods.SIGN_XDR, + hasRequestEvent: true, + hasResponded: false, + approvalInFlight: false, + }; + + it("maps each signing method to its rejection event on a genuine dismissal", () => { + expect( + resolveDappRejectionEvent({ + ...base, + requestMethod: StellarRpcMethods.SIGN_MESSAGE, + }), + ).toBe("message"); + expect( + resolveDappRejectionEvent({ + ...base, + requestMethod: StellarRpcMethods.SIGN_AUTH_ENTRY, + }), + ).toBe("auth_entry"); + expect( + resolveDappRejectionEvent({ + ...base, + requestMethod: StellarRpcMethods.SIGN_XDR, + }), + ).toBe("transaction"); + expect( + resolveDappRejectionEvent({ + ...base, + requestMethod: StellarRpcMethods.SIGN_AND_SUBMIT_XDR, + }), + ).toBe("transaction"); + }); + + // The reviewer's exact concern: an approved/completed request must NOT emit a + // rejection, even though handleClearDappRequest still runs in the .finally(). + it("returns null once the request has been responded to (approved/completed)", () => { + expect( + resolveDappRejectionEvent({ ...base, hasResponded: true }), + ).toBeNull(); + }); + + // approveSessionRequest threw: the WC fallback rejection still fires, but this + // is an approval attempt, not a user reject — so no analytics rejection. + it("returns null when an approval attempt was in flight (even if it threw)", () => { + expect( + resolveDappRejectionEvent({ ...base, approvalInFlight: true }), + ).toBeNull(); + }); + + it("returns null when there is no active request", () => { + expect( + resolveDappRejectionEvent({ ...base, hasRequestEvent: false }), + ).toBeNull(); + }); + + it("returns null for an unrecognized / undefined method", () => { + expect( + resolveDappRejectionEvent({ ...base, requestMethod: undefined }), + ).toBeNull(); + }); +}); diff --git a/src/services/analytics/core.test.ts b/__tests__/services/analytics/core.test.ts similarity index 95% rename from src/services/analytics/core.test.ts rename to __tests__/services/analytics/core.test.ts index 4d606dc77..909b4a31a 100644 --- a/src/services/analytics/core.test.ts +++ b/__tests__/services/analytics/core.test.ts @@ -3,15 +3,19 @@ // // Shared mock header for the analytics core suite. Tasks M2–M7 extend this // file, so the conventions below must stay: -// • The module under test is loaded via `jest.requireActual("./core")`, NOT -// an `import ... from "services/analytics/core"`. Two repo-wide mechanisms +// • The module under test is loaded via +// `jest.requireActual("../../../src/services/analytics/core")`, NOT an +// `import ... from "services/analytics/core"`. Two repo-wide mechanisms // otherwise hand back a mock instead of the real module: (1) the Jest // `^services/(.*)$` moduleNameMapper redirects bare `services/*` imports to // `__mocks__/` stubs, and (2) jest.setup.js globally // `jest.mock("services/analytics/core", ...)`. `requireActual` with a -// relative specifier bypasses both. It is a call expression (not an import +// relative specifier bypasses both (the mapper only rewrites bare +// `services/*` specifiers). It is a call expression (not an import // declaration), so it also satisfies the repo's no-relative-import lint -// rule, which a literal `import "./core"` would violate. +// rule that a literal relative `import` would violate. The extra `../` +// hops are because this suite now lives under `__tests__/` (moved from +// `src/services/analytics/`), while the real module stays in `src/`. // • `@amplitude/analytics-react-native` is mocked with an explicit factory, // not a bare auto-mock. Auto-mocking loads the real RN SDK to introspect // it, which crashes in the Jest env on native async-storage @@ -82,7 +86,7 @@ jest.mock("ducks/balances", () => ({ }, })); -// Load the REAL core module (see header for why requireActual + "./core"). +// Load the REAL core module (see header for why requireActual + relative path). const { getAccountIdHash, getSurface, @@ -91,7 +95,9 @@ const { initAnalytics, trackAppOpened, track, -} = jest.requireActual("./core"); +} = jest.requireActual( + "../../../src/services/analytics/core", +); describe("getAccountIdHash", () => { const PK = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; @@ -379,8 +385,9 @@ describe("syncIdentifyTraits (consent gating)", () => { let isolatedIdentify: jest.Mock; jest.isolateModules(() => { - mod = - jest.requireActual("./core"); + mod = jest.requireActual( + "../../../src/services/analytics/core", + ); // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires isolatedAnalyticsStore = require("ducks/analytics").useAnalyticsStore; // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires @@ -415,8 +422,9 @@ describe("syncIdentifyTraits (consent gating)", () => { let isolatedIdentify: jest.Mock; jest.isolateModules(() => { - mod = - jest.requireActual("./core"); + mod = jest.requireActual( + "../../../src/services/analytics/core", + ); // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires isolatedAnalyticsStore = require("ducks/analytics").useAnalyticsStore; // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires @@ -449,8 +457,9 @@ describe("syncIdentifyTraits (consent gating)", () => { let isolatedIdentify: jest.Mock; jest.isolateModules(() => { - mod = - jest.requireActual("./core"); + mod = jest.requireActual( + "../../../src/services/analytics/core", + ); // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires isolatedAnalyticsStore = require("ducks/analytics").useAnalyticsStore; // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires @@ -503,8 +512,9 @@ describe("syncIdentifyTraits (consent gating)", () => { let identify: jest.Mock; jest.isolateModules(() => { - mod = - jest.requireActual("./core"); + mod = jest.requireActual( + "../../../src/services/analytics/core", + ); // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires store = require("ducks/analytics").useAnalyticsStore; // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires @@ -551,8 +561,9 @@ describe("syncIdentifyTraits (consent gating)", () => { let identify: jest.Mock; jest.isolateModules(() => { - mod = - jest.requireActual("./core"); + mod = jest.requireActual( + "../../../src/services/analytics/core", + ); // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires store = require("ducks/analytics").useAnalyticsStore; // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires diff --git a/jest.setup.js b/jest.setup.js index 59d009ce7..943f51d20 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -249,6 +249,8 @@ jest.mock("ducks/walletKit", () => ({ StellarRpcMethods: { SIGN_XDR: "SIGN_XDR", SIGN_AND_SUBMIT_XDR: "SIGN_AND_SUBMIT_XDR", + SIGN_MESSAGE: "SIGN_MESSAGE", + SIGN_AUTH_ENTRY: "SIGN_AUTH_ENTRY", }, StellarRpcChains: { PUBLIC: "PUBLIC", diff --git a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts index e7db2e132..f41bbf081 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts @@ -267,15 +267,13 @@ export const useSwapTransaction = ({ // event instead of SWAP_FAIL and prompt the user to retry for a // fresh quote. `resultCode` carries the Horizon op code(s) that drove // the expiry so we can slice by reason. + // Amounts intentionally dropped (parity with swap.completed/failed, + // which carry no amounts). Bare asset codes so from/to_asset_code match + // the extension. analytics.track(AnalyticsEvent.SWAP_QUOTE_EXPIRED, { - sourceToken: sourceBalance?.tokenCode, - destToken: destinationTokenInput?.tokenCode, - sourceAmount, - destAmount: pathResult?.destinationAmount, - allowedSlippage: useSwapSettingsStore - .getState() - .swapSlippage?.toString(), - resultCode: quoteExpiredCodes.join(", "), + from_asset_code: sourceBalance?.tokenCode, + to_asset_code: destinationTokenInput?.tokenCode, + result_code: quoteExpiredCodes.join(", "), }); showToast({ diff --git a/src/components/screens/SwapScreen/hooks/useTrendingTokenDetail.ts b/src/components/screens/SwapScreen/hooks/useTrendingTokenDetail.ts index 6a0ee2865..316011926 100644 --- a/src/components/screens/SwapScreen/hooks/useTrendingTokenDetail.ts +++ b/src/components/screens/SwapScreen/hooks/useTrendingTokenDetail.ts @@ -106,9 +106,9 @@ export const useTrendingTokenDetail = ({ ? descriptorFromBalance(heldMatch) : descriptorFromSearchRecord(record); analytics.track(AnalyticsEvent.SWAP_DESTINATION_SELECTED, { - tokenCode: record.tokenCode, - tokenIssuer: descriptor.issuer ?? "", - requiresTrustline: descriptor.requiresTrustline, + asset_code: record.tokenCode, + asset_issuer: descriptor.issuer ?? "", + requires_trustline: descriptor.requiresTrustline, source: SwapSelectionSource.TRENDING, }); // If the new destination equals the current source, clear source so diff --git a/src/components/screens/SwapScreen/screens/SwapToScreen.tsx b/src/components/screens/SwapScreen/screens/SwapToScreen.tsx index b04037d7c..4fc64ba93 100644 --- a/src/components/screens/SwapScreen/screens/SwapToScreen.tsx +++ b/src/components/screens/SwapScreen/screens/SwapToScreen.tsx @@ -146,8 +146,8 @@ export const SwapToScreen: React.FC = ({ ? SwapSelectionSource.SEARCH : SwapSelectionSource.BALANCES; analytics.track(AnalyticsEvent.SWAP_SOURCE_SELECTED, { - tokenCode: balance.tokenCode ?? "", - tokenIssuer: descriptor.issuer ?? "", + asset_code: balance.tokenCode ?? "", + asset_issuer: descriptor.issuer ?? "", source: sourceSource, }); dismissThenApply(() => { @@ -163,9 +163,9 @@ export const SwapToScreen: React.FC = ({ }); } else { analytics.track(AnalyticsEvent.SWAP_DESTINATION_SELECTED, { - tokenCode: balance.tokenCode ?? "", - tokenIssuer: descriptor.issuer ?? "", - requiresTrustline: descriptor.requiresTrustline, + asset_code: balance.tokenCode ?? "", + asset_issuer: descriptor.issuer ?? "", + requires_trustline: descriptor.requiresTrustline, source, }); dismissThenApply(() => { @@ -188,9 +188,9 @@ export const SwapToScreen: React.FC = ({ ) => { const descriptor = descriptorFromSearchRecord(record); analytics.track(AnalyticsEvent.SWAP_DESTINATION_SELECTED, { - tokenCode: record.tokenCode, - tokenIssuer: descriptor.issuer ?? "", - requiresTrustline: descriptor.requiresTrustline, + asset_code: record.tokenCode, + asset_issuer: descriptor.issuer ?? "", + requires_trustline: descriptor.requiresTrustline, source, }); dismissThenApply(() => { diff --git a/src/helpers/stellarStrKey.ts b/src/helpers/stellarStrKey.ts index 6fa344d5b..6c74fd15d 100644 --- a/src/helpers/stellarStrKey.ts +++ b/src/helpers/stellarStrKey.ts @@ -1,20 +1,23 @@ /** - * Stellar StrKey identifiers (publicKeys `G...` and secret seeds `S...`) are - * base32-encoded with a fixed prefix and exact 56-char length. The pattern is - * anchored on word boundaries so a 56-char substring inside a longer - * alphanumeric run does not match. + * Stellar StrKey identifiers are base32-encoded with a fixed prefix. All of + * ed25519 publicKey (`G…`), secret seed (`S…`), and contract (`C…`) are 56 + * chars; muxed accounts (`M…`) encode an extra 64-bit id and are 69 chars. + * Both are anchored on word boundaries so a same-length substring inside a + * longer alphanumeric run does not match. * * Leaf module (no project imports) so it can be shared by both the Sentry * transport scrubbing and other PII-sensitive sinks (e.g. analytics) without * creating import cycles. */ -const STELLAR_STRKEY_PATTERN = /\b[GS][A-Z2-7]{55}\b/g; +const STELLAR_STRKEY_PATTERN = /\b(?:[GSC][A-Z2-7]{55}|M[A-Z2-7]{68})\b/g; /** * Replace any embedded Stellar StrKey with a short prefix sentinel - * ("G***" / "S***"). Preserves the prefix so triage can still distinguish a - * publicKey leak from a secret-seed leak (the latter is a critical bug — - * secrets should never reach this code path). + * ("G***" / "S***" / "C***" / "M***"). Preserves the prefix so triage can + * still distinguish a publicKey/contract/muxed leak from a secret-seed leak + * (the latter is a critical bug — secrets should never reach this code path). */ -export const scrubStrKeys = (s: string | undefined): string | undefined => - s?.replace(STELLAR_STRKEY_PATTERN, (match) => `${match[0]}***`); +export const scrubStrKeys = ( + s: string | null | undefined, +): string | undefined => + s?.replace(STELLAR_STRKEY_PATTERN, (match) => `${match[0]}***`) ?? undefined; diff --git a/src/helpers/walletKitUtil.ts b/src/helpers/walletKitUtil.ts index 4fb5b75fd..8b152ab0e 100644 --- a/src/helpers/walletKitUtil.ts +++ b/src/helpers/walletKitUtil.ts @@ -46,6 +46,43 @@ const stellarNamespaceMethods = [ /** Supported Stellar RPC events for WalletKit */ const stellarNamespaceEvents = [StellarRpcEvents.ACCOUNTS_CHANGED]; +/** + * Decide which signing.*_rejected event (if any) a dApp-request teardown should + * emit. Pure so the reject-vs-not decision is unit-testable without rendering + * the provider. + * + * A rejection is emitted ONLY for a genuine user dismissal of a still-pending + * request: + * - `hasResponded` (approveSessionRequest already sent a WC response) => the + * request was approved/completed, never a rejection. + * - `approvalInFlight` (the user hit Approve — success OR an unexpected throw in + * approveSessionRequest) => an approval attempt, not a dismissal; the throw + * still triggers a WC-level fallback rejection but must not be counted as a + * user reject in analytics. + * - no active `requestEvent` => nothing to reject. + */ +export const resolveDappRejectionEvent = (args: { + requestMethod?: StellarRpcMethods; + hasRequestEvent: boolean; + hasResponded: boolean; + approvalInFlight: boolean; +}): "message" | "auth_entry" | "transaction" | null => { + if (!args.hasRequestEvent || args.hasResponded || args.approvalInFlight) { + return null; + } + switch (args.requestMethod) { + case StellarRpcMethods.SIGN_MESSAGE: + return "message"; + case StellarRpcMethods.SIGN_AUTH_ENTRY: + return "auth_entry"; + case StellarRpcMethods.SIGN_XDR: + case StellarRpcMethods.SIGN_AND_SUBMIT_XDR: + return "transaction"; + default: + return null; + } +}; + /** Global WalletKit instance */ // eslint-disable-next-line import/no-mutable-exports export let walletKit: IWalletKit; diff --git a/src/providers/WalletKitProvider.tsx b/src/providers/WalletKitProvider.tsx index 7b8c26f20..c216380de 100644 --- a/src/providers/WalletKitProvider.tsx +++ b/src/providers/WalletKitProvider.tsx @@ -32,6 +32,7 @@ import { approveSessionRequest, rejectSessionRequest, rejectSessionProposal, + resolveDappRejectionEvent, } from "helpers/walletKitUtil"; import { validateSignMessageContent, @@ -140,6 +141,12 @@ export const WalletKitProvider: React.FC = ({ // its own response (success or handled error) so handleClearDappRequest doesn't // send a duplicate rejection when it fires via .finally(). const hasRespondedRef = useRef(false); + // True once the user has committed to approving (handleDappRequest called + // approveSessionRequest). Distinguishes an approval attempt — whether it + // succeeds or throws — from a genuine user dismissal, so the exceptional + // approve-threw path isn't miscounted as a signing.*_rejected. Reset with + // hasRespondedRef in the teardown. + const approvalInFlightRef = useRef(false); const xdr = useMemo( () => @@ -437,31 +444,36 @@ export const WalletKitProvider: React.FC = ({ // We need to explicitly reject the request here otherwise // the app will show the request again on next app launch. - // Skip if approveSessionRequest already sent a response. + // Skip if approveSessionRequest already sent a response. This WC-level + // fallback fires on BOTH a user dismissal AND the exceptional case where + // approveSessionRequest threw (so the dApp isn't left hanging). if (requestEvent && !hasRespondedRef.current) { rejectSessionRequest({ sessionRequest: requestEvent, message: t("walletKit.userRejected"), }); + } - // This is the genuine user-reject path. Message, auth-entry, and - // transaction (SIGN_XDR / SIGN_AND_SUBMIT_XDR) rejections each emit their - // signing.*_rejected event (distinct from the runtime *_FAIL events). + // Analytics rejection is narrower than the WC fallback: only a genuine user + // dismissal of a pending request counts. An approved/completed request + // (hasResponded) or an approve-attempt that threw (approvalInFlight) is not + // a user reject — resolveDappRejectionEvent encodes exactly that. + const rejectionEvent = resolveDappRejectionEvent({ + requestMethod: requestMethod as StellarRpcMethods | undefined, + hasRequestEvent: !!requestEvent, + hasResponded: hasRespondedRef.current, + approvalInFlight: approvalInFlightRef.current, + }); + if (rejectionEvent && requestEvent) { const dappDomain = getDappMetadataFromEvent(requestEvent, activeSessions)?.url || ""; - if (requestMethod === StellarRpcMethods.SIGN_MESSAGE) { - analytics.trackSignedMessageRejected(dappDomain ? { dappDomain } : {}); - } else if (requestMethod === StellarRpcMethods.SIGN_AUTH_ENTRY) { - analytics.trackSignedAuthEntryRejected( - dappDomain ? { dappDomain } : {}, - ); - } else if ( - requestMethod === StellarRpcMethods.SIGN_XDR || - requestMethod === StellarRpcMethods.SIGN_AND_SUBMIT_XDR - ) { - analytics.trackSignedTransactionRejected( - dappDomain ? { dappDomain } : {}, - ); + const payload = dappDomain ? { dappDomain } : {}; + if (rejectionEvent === "message") { + analytics.trackSignedMessageRejected(payload); + } else if (rejectionEvent === "auth_entry") { + analytics.trackSignedAuthEntryRejected(payload); + } else { + analytics.trackSignedTransactionRejected(payload); } } @@ -475,6 +487,7 @@ export const WalletKitProvider: React.FC = ({ saveMemo(""); clearEvent(); hasRespondedRef.current = false; + approvalInFlightRef.current = false; // Mark processing as complete and process pending request if any isProcessingRequestRef.current = false; @@ -555,6 +568,9 @@ export const WalletKitProvider: React.FC = ({ } setIsSigning(true); + // The user committed to approving — mark it so a teardown (including the + // exceptional approve-threw .catch path) is not miscounted as a user reject. + approvalInFlightRef.current = true; approveSessionRequest({ sessionRequest: requestEvent, diff --git a/src/services/blockaid/api.ts b/src/services/blockaid/api.ts index 360d8a223..cb05e8379 100644 --- a/src/services/blockaid/api.ts +++ b/src/services/blockaid/api.ts @@ -3,6 +3,7 @@ import axios from "axios"; import { AnalyticsEvent } from "config/analyticsConfig"; import { isNativeAssetId } from "config/constants"; import { isMainnet } from "helpers/networks"; +import { scrubStrKeys } from "helpers/stellarStrKey"; import { analytics } from "services/analytics"; import { freighterBackendV1 } from "services/backend"; import { @@ -105,7 +106,9 @@ const trackScanFailed = ( analytics.track(AnalyticsEvent.BLOCKAID_SCAN_FAILED, { scan_target: scanTarget, - reason_code: reasonCode, + // Scrub StrKeys — a backend/validation error can echo a G…/C… address, and + // this is the last raw error path (matches the other track*Error helpers). + reason_code: scrubStrKeys(reasonCode) ?? reasonCode, }); };