diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index c463a6abc4..8c1c1f1a8d 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,10 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add Batch Sell analytics event types ([#9272](https://github.com/MetaMask/core/pull/9272)) + - `Batch Sell Token Page Viewed` + - `Batch Sell Token Page Continue Clicked` + - `Batch Sell Quote Page Viewed` + - `Batch Sell Quote Page Review Clicked` + - `Batch Sell Review Modal Submitted` + ### Changed - Bump `@metamask/multichain-network-controller` from `^3.1.4` to `^3.2.0` ([#9264](https://github.com/MetaMask/core/pull/9264)) +### Fixed + +- Fix Batch Sell-only `sentAmount.usd` metadata calculations to use each quote's source token exchange rate ([#9272](https://github.com/MetaMask/core/pull/9272)) + ## [77.0.0] ### Added diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index 1987be7d91..81565834b8 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -13,6 +13,7 @@ import type { MessengerEvents, MockAnyNamespace, } from '@metamask/messenger'; +import type { CaipAssetType } from '@metamask/utils'; import nock from 'nock'; import { flushPromises } from '../../../tests/helpers'; @@ -52,6 +53,8 @@ import { import * as featureFlagUtils from './utils/feature-flags'; import * as fetchUtils from './utils/fetch'; import { + BatchSellMetricsEventName, + BatchSellMetricsLocation, InputAmountPreset, MetaMetricsSwapsEventSource, MetricsActionType, @@ -2911,6 +2914,15 @@ describe('BridgeController', function () { expect(rootMessenger.call('BridgeController:getLocation')).toBe( MetaMetricsSwapsEventSource.TokenView, ); + + rootMessenger.call( + 'BridgeController:setLocation', + BatchSellMetricsLocation.AssetPicker, + ); + + expect(rootMessenger.call('BridgeController:getLocation')).toBe( + BatchSellMetricsLocation.AssetPicker, + ); }); }); }); @@ -3032,6 +3044,152 @@ describe('BridgeController', function () { }); }); + it('should track Batch Sell token page events with client chain ids', async () => { + await withController(async ({ rootMessenger }) => { + const chainIdSource = formatChainIdToCaip(ChainId.POLYGON); + const chainIdDestination = formatChainIdToCaip(ChainId.BASE); + const sourceTokenAddresses = [ + 'eip155:137/erc20:0x1111111111111111111111111111111111111111', + 'eip155:137/erc20:0x2222222222222222222222222222222222222222', + ] satisfies CaipAssetType[]; + const sourceTokenSymbols = ['LINK', 'UNI']; + + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellTokenPageViewed, + { + chain_id_source: chainIdSource, + chain_id_destination: chainIdDestination, + location: BatchSellMetricsLocation.TradeMenu, + }, + ); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellTokenPageContinueClicked, + { + chain_id_source: chainIdSource, + chain_id_destination: chainIdDestination, + location: BatchSellMetricsLocation.AssetPicker, + source_token_symbols: sourceTokenSymbols, + source_token_addresses: sourceTokenAddresses, + }, + ); + + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 1, + BatchSellMetricsEventName.BatchSellTokenPageViewed, + { + chain_id_source: chainIdSource, + chain_id_destination: chainIdDestination, + location: BatchSellMetricsLocation.TradeMenu, + }, + ); + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 2, + BatchSellMetricsEventName.BatchSellTokenPageContinueClicked, + { + chain_id_source: chainIdSource, + chain_id_destination: chainIdDestination, + location: BatchSellMetricsLocation.AssetPicker, + source_token_count: sourceTokenAddresses.length, + source_token_symbols: sourceTokenSymbols, + source_token_addresses: sourceTokenAddresses, + }, + ); + }); + }); + + it('should track Batch Sell quote page events with selected token metadata', async () => { + await withController(async ({ rootMessenger }) => { + await rootMessenger.call( + 'BridgeController:updateBridgeQuoteRequestParams', + { + walletAddress: '0x123', + srcChainId: ChainId.OPTIMISM, + destChainId: ChainId.OPTIMISM, + }, + { + stx_enabled: false, + security_warnings: [], + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + usd_amount_source: 100, + token_security_type_destination: null, + feature_id: FeatureId.BATCH_SELL, + }, + ); + jest.clearAllMocks(); + + const chainIdSource = formatChainIdToCaip(ChainId.POLYGON); + const chainIdDestination = formatChainIdToCaip(ChainId.BASE); + const sourceTokenAddresses = [ + 'eip155:137/erc20:0x1111111111111111111111111111111111111111', + 'eip155:137/erc20:0x2222222222222222222222222222222222222222', + ] satisfies CaipAssetType[]; + const destinationTokenAddress = + 'eip155:8453/erc20:0x3333333333333333333333333333333333333333' satisfies CaipAssetType; + const sharedProperties = { + location: BatchSellMetricsLocation.Deeplink, + source_token_symbols: ['WETH', 'OP'], + source_token_addresses: sourceTokenAddresses, + destination_token_symbol: 'USDC', + destination_token_address: destinationTokenAddress, + usd_amount_source_tokens: [10, 20], + usd_amount_source_total: 30, + source_token_slippages: [0.5, 1], + }; + const properties = { + chain_id_source: chainIdSource, + chain_id_destination: chainIdDestination, + ...sharedProperties, + }; + const expectedProperties = { + source_token_count: sourceTokenAddresses.length, + ...properties, + }; + + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellQuotePageViewed, + properties, + ); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellQuotePageReviewClicked, + properties, + ); + rootMessenger.call( + 'BridgeController:trackUnifiedSwapBridgeEvent', + BatchSellMetricsEventName.BatchSellReviewModalSubmitted, + { + ...properties, + usd_quoted_gas: 1, + usd_quoted_return: 29, + }, + ); + + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 1, + BatchSellMetricsEventName.BatchSellQuotePageViewed, + expectedProperties, + ); + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 2, + BatchSellMetricsEventName.BatchSellQuotePageReviewClicked, + expectedProperties, + ); + expect(trackMetaMetricsFn).toHaveBeenNthCalledWith( + 3, + BatchSellMetricsEventName.BatchSellReviewModalSubmitted, + { + ...expectedProperties, + usd_quoted_gas: 1, + usd_quoted_return: 29, + }, + ); + }); + }); + it('should track the FiatCryptoToggleClicked event', async () => { await withController(async ({ rootMessenger, controller }) => { jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn()); diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index da3ec1c4b2..0b37efac21 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -63,10 +63,15 @@ import { } from './utils/fetch'; import { AbortReason, + BatchSellMetricsEventName, MetaMetricsSwapsEventSource, MetricsActionType, UnifiedSwapBridgeEventName, } from './utils/metrics/constants'; +import type { + BridgeControllerMetricsEventName, + BridgeControllerMetricsLocation, +} from './utils/metrics/constants'; import { formatProviderLabel, getAccountHardwareType, @@ -225,7 +230,8 @@ export class BridgeController extends StaticIntervalPollingController( eventName: EventName, properties: CrossChainSwapsEventProperties, @@ -278,10 +283,7 @@ export class BridgeController extends StaticIntervalPollingController( + trackMetaMetricsFn: ( eventName: EventName, properties: CrossChainSwapsEventProperties, ) => void; @@ -716,7 +718,7 @@ export class BridgeController extends StaticIntervalPollingController { + setLocation = (location: BridgeControllerMetricsLocation) => { this.#location = location; }; @@ -725,7 +727,7 @@ export class BridgeController extends StaticIntervalPollingController { + getLocation = (): BridgeControllerMetricsLocation => { return this.#location; }; @@ -1165,8 +1167,7 @@ export class BridgeController extends StaticIntervalPollingController( eventName: EventName, propertiesFromClient: Pick< @@ -1174,7 +1175,7 @@ export class BridgeController extends StaticIntervalPollingController[EventName], quoteRequestIndex: number = 0, - ): CrossChainSwapsEventProperties => { + ) => { const clientProps = propertiesFromClient as Record; const baseProperties = { ...propertiesFromClient, @@ -1184,6 +1185,15 @@ export class BridgeController extends StaticIntervalPollingController; + const batchSellBaseProperties = { + chain_id_source: batchSellClientChainProperties.chain_id_source, + chain_id_destination: batchSellClientChainProperties.chain_id_destination, + location: clientProps?.location ?? this.#location, + }; const quoteRequest = this.state.quoteRequest[quoteRequestIndex]; switch (eventName) { case UnifiedSwapBridgeEventName.ButtonClicked: @@ -1204,6 +1214,56 @@ export class BridgeController extends StaticIntervalPollingController( eventName: EventName, propertiesFromClient: Pick< @@ -1366,7 +1425,10 @@ export class BridgeController extends StaticIntervalPollingController, + ); } catch (error) { console.error( `Error tracking cross-chain swaps MetaMetrics event ${eventName}`, diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index d66ab330dd..d1b61848a7 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -1,13 +1,19 @@ export { BridgeController } from './bridge-controller'; export { + BatchSellMetricsEventName, UnifiedSwapBridgeEventName, + BATCH_SELL_EVENT_CATEGORY, UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY, + BatchSellMetricsLocation, InputAmountPreset, MetaMetricsSwapsEventSource, PollingStatus, } from './utils/metrics/constants'; +export type { BridgeControllerMetricsEventName } from './utils/metrics/constants'; +export type { BridgeControllerMetricsLocation } from './utils/metrics/constants'; + export type { AccountHardwareType, RequiredEventContextFromClient, diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index 3c8c9f15ce..23d95fc601 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -1980,6 +1980,9 @@ describe('Bridge Selectors', () => { "90ae8e69-f03a-4cf6-bab7-ed4e3431eb37", ] `); + expect( + recommendedQuotes.map((quote) => quote?.sentAmount.usd), + ).toStrictEqual(['18', '140']); }); it('should return metadata when quotes are empty', () => { diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index 49f8b231cf..ad04ff3796 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -329,16 +329,7 @@ const selectBridgeQuotesWithMetadata = createBridgeSelector( [ ({ quotes }) => quotes, selectBridgeFeesPerGas, - createBridgeSelector( - [ - (state) => state, - ({ quoteRequest: [{ srcChainId, srcTokenAddress }] }) => - srcTokenAddress - ? formatAddressToAssetId(srcTokenAddress, srcChainId) - : undefined, - ], - selectExchangeRateByAssetId, - ), + (state) => state, createBridgeSelector( [ (state) => state, @@ -363,11 +354,20 @@ const selectBridgeQuotesWithMetadata = createBridgeSelector( ( quotes, bridgeFeesPerGas, - srcTokenExchangeRate, + exchangeRateSources, destTokenExchangeRate, nativeExchangeRate, ) => { const newQuotes = quotes.map((quote) => { + const sourceAssetId = + formatAddressToAssetId( + quote.quote.srcAsset.address, + quote.quote.srcChainId, + ) ?? quote.quote.srcAsset.assetId; + const srcTokenExchangeRate = selectExchangeRateByAssetId( + exchangeRateSources, + sourceAssetId, + ); const sentAmount = calcSentAmount(quote.quote, srcTokenExchangeRate); const toTokenAmount = calcToAmount( quote.quote.destTokenAmount, diff --git a/packages/bridge-controller/src/utils/metrics/constants.ts b/packages/bridge-controller/src/utils/metrics/constants.ts index d44b80b524..af28b18fa1 100644 --- a/packages/bridge-controller/src/utils/metrics/constants.ts +++ b/packages/bridge-controller/src/utils/metrics/constants.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ export const UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY = 'Unified SwapBridge'; +export const BATCH_SELL_EVENT_CATEGORY = 'Batch Sell'; /** * These event names map to events defined in the segment-schema: https://github.com/Consensys/segment-schema/tree/main/libraries/events/metamask-cross-chain-swaps @@ -26,6 +27,18 @@ export enum UnifiedSwapBridgeEventName { PollingStatusUpdated = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} Polling Status Updated`, } +export enum BatchSellMetricsEventName { + BatchSellTokenPageViewed = `${BATCH_SELL_EVENT_CATEGORY} Token Page Viewed`, + BatchSellTokenPageContinueClicked = `${BATCH_SELL_EVENT_CATEGORY} Token Page Continue Clicked`, + BatchSellQuotePageViewed = `${BATCH_SELL_EVENT_CATEGORY} Quote Page Viewed`, + BatchSellQuotePageReviewClicked = `${BATCH_SELL_EVENT_CATEGORY} Quote Page Review Clicked`, + BatchSellReviewModalSubmitted = `${BATCH_SELL_EVENT_CATEGORY} Review Modal Submitted`, +} + +export type BridgeControllerMetricsEventName = + | UnifiedSwapBridgeEventName + | BatchSellMetricsEventName; + export enum PollingStatus { MaxPollingReached = 'max_polling_reached', InvalidTransactionHash = 'invalid_transaction_hash', @@ -57,6 +70,17 @@ export enum MetaMetricsSwapsEventSource { Unknown = 'Unknown', } +export enum BatchSellMetricsLocation { + TradeMenu = 'trade_menu', + Deeplink = 'deeplink', + AssetPicker = 'asset_picker', + Unknown = 'Unknown', +} + +export type BridgeControllerMetricsLocation = + | MetaMetricsSwapsEventSource + | BatchSellMetricsLocation; + export enum InputAmountPreset { PERCENT_25 = '25%', PERCENT_50 = '50%', diff --git a/packages/bridge-controller/src/utils/metrics/types.ts b/packages/bridge-controller/src/utils/metrics/types.ts index 9e963d340e..82bf1b54e8 100644 --- a/packages/bridge-controller/src/utils/metrics/types.ts +++ b/packages/bridge-controller/src/utils/metrics/types.ts @@ -9,7 +9,10 @@ import type { } from '../../types'; import type { UnifiedSwapBridgeEventName, - MetaMetricsSwapsEventSource, + BatchSellMetricsEventName, + BatchSellMetricsLocation, + BridgeControllerMetricsEventName, + BridgeControllerMetricsLocation, MetricsActionType, MetricsSwapType, PollingStatus, @@ -111,10 +114,66 @@ export type QuoteWarning = | 'quote_expired' | 'tx_alert'; +type BatchSellChainProperties = { + chain_id_source: CaipChainId; + chain_id_destination: CaipChainId | null; +}; + +type BatchSellTokenPageEventContext = BatchSellChainProperties & { + location: BatchSellMetricsLocation; +}; + +type BatchSellSourceTokenEventContext = BatchSellTokenPageEventContext & { + source_token_symbols: string[]; + source_token_addresses: CaipAssetType[]; +}; + +type BatchSellQuotePageEventContext = BatchSellSourceTokenEventContext & { + destination_token_symbol: string; + destination_token_address: CaipAssetType; + usd_amount_source_tokens: number[]; + usd_amount_source_total: number; + source_token_slippages: number[]; +}; + +type BatchSellReviewModalSubmittedEventContext = + BatchSellQuotePageEventContext & + Pick; + +type BatchSellTokenPageEventProperties = BatchSellChainProperties & + BatchSellTokenPageEventContext; + +type BatchSellSourceTokenEventProperties = BatchSellChainProperties & + BatchSellSourceTokenEventContext & { + source_token_count: number; + }; + +type BatchSellQuotePageEventProperties = BatchSellChainProperties & + BatchSellQuotePageEventContext & { + source_token_count: number; + }; + +type BatchSellReviewModalSubmittedEventProperties = BatchSellChainProperties & + BatchSellReviewModalSubmittedEventContext & { + source_token_count: number; + }; + +type SharedEventContextFromClient = { + ab_tests?: Record; + active_ab_tests?: { key: string; value: string }[]; + feature_id: FeatureId; +}; + +type OptionalLocationContextFromClient = T extends { + location: unknown; +} + ? object + : { location?: BridgeControllerMetricsLocation }; + /** * Properties that are required to be provided when trackUnifiedSwapBridgeEvent is called. - * This is the base type without the `location` property which is added to all events - * via the RequiredEventContextFromClient mapped type. + * Most events receive an optional location via RequiredEventContextFromClient; + * Batch Sell events define their own required location enum. */ type RequiredEventContextFromClientBase = { [UnifiedSwapBridgeEventName.ButtonClicked]: Pick< @@ -287,6 +346,11 @@ type RequiredEventContextFromClientBase = { [UnifiedSwapBridgeEventName.AssetPickerOpened]: { asset_location: 'source' | 'destination'; }; + [BatchSellMetricsEventName.BatchSellTokenPageViewed]: BatchSellTokenPageEventContext; + [BatchSellMetricsEventName.BatchSellTokenPageContinueClicked]: BatchSellSourceTokenEventContext; + [BatchSellMetricsEventName.BatchSellQuotePageViewed]: BatchSellQuotePageEventContext; + [BatchSellMetricsEventName.BatchSellQuotePageReviewClicked]: BatchSellQuotePageEventContext; + [BatchSellMetricsEventName.BatchSellReviewModalSubmitted]: BatchSellReviewModalSubmittedEventContext; }; /** @@ -299,12 +363,13 @@ type RequiredEventContextFromClientBase = { * Both are kept for a migration window and are treated as separate payloads. */ export type RequiredEventContextFromClient = { - [K in keyof RequiredEventContextFromClientBase]: RequiredEventContextFromClientBase[K] & { - location?: MetaMetricsSwapsEventSource; - ab_tests?: Record; - active_ab_tests?: { key: string; value: string }[]; - feature_id: FeatureId; - }; + [K in keyof RequiredEventContextFromClientBase]: K extends BatchSellMetricsEventName + ? RequiredEventContextFromClientBase[K] + : RequiredEventContextFromClientBase[K] & + OptionalLocationContextFromClient< + RequiredEventContextFromClientBase[K] + > & + SharedEventContextFromClient; }; /** @@ -379,24 +444,35 @@ export type EventPropertiesFromControllerState = { > & { batch_id?: string; }; + [BatchSellMetricsEventName.BatchSellTokenPageViewed]: BatchSellTokenPageEventProperties; + [BatchSellMetricsEventName.BatchSellTokenPageContinueClicked]: BatchSellSourceTokenEventProperties; + [BatchSellMetricsEventName.BatchSellQuotePageViewed]: BatchSellQuotePageEventProperties; + [BatchSellMetricsEventName.BatchSellQuotePageReviewClicked]: BatchSellQuotePageEventProperties; + [BatchSellMetricsEventName.BatchSellReviewModalSubmitted]: BatchSellReviewModalSubmittedEventProperties; }; -/** - * trackUnifiedSwapBridgeEvent payload properties consist of required properties from the client - * and properties from the bridge controller - * - * `ab_tests` will be deprecated in favor of `active_ab_tests` in the future. - * `ab_tests` and `active_ab_tests` intentionally coexist during migration. - */ -export type CrossChainSwapsEventProperties< - T extends UnifiedSwapBridgeEventName, +type SharedCrossChainSwapsEventProperties< + T extends BridgeControllerMetricsEventName, > = | { feature_id: FeatureId; action_type: MetricsActionType; - location: MetaMetricsSwapsEventSource; + location: BridgeControllerMetricsLocation; ab_tests?: Record; active_ab_tests?: { key: string; value: string }[]; } | Pick[T] | Pick[T]; + +/** + * trackUnifiedSwapBridgeEvent payload properties consist of required properties from the client + * and properties from the bridge controller + * + * `ab_tests` will be deprecated in favor of `active_ab_tests` in the future. + * `ab_tests` and `active_ab_tests` intentionally coexist during migration. + */ +export type CrossChainSwapsEventProperties< + T extends BridgeControllerMetricsEventName, +> = T extends BatchSellMetricsEventName + ? Pick[T] + : SharedCrossChainSwapsEventProperties;