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/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 1adac7af6..e1a739691 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -68,130 +68,168 @@ export enum AnalyticsEvent { VIEW_SEARCH_TOKEN = "search_asset", VIEW_ADD_TOKEN_MANUALLY = "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/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); + }); + }); +}); 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); } };