diff --git a/app/scripts/lib/transaction/metrics-builders/smart-transactions.test.ts b/app/scripts/lib/transaction/metrics-builders/smart-transactions.test.ts index f076d2177e62..6f2434a25c43 100644 --- a/app/scripts/lib/transaction/metrics-builders/smart-transactions.test.ts +++ b/app/scripts/lib/transaction/metrics-builders/smart-transactions.test.ts @@ -4,8 +4,9 @@ import { createBuilderRequest } from './test-utils'; jest.mock('../../../../../shared/lib/metametrics', () => ({ getSmartTransactionMetricsProperties: jest.fn().mockReturnValue({ + is_smart_transactions_user_opt_in: true, + is_smart_transactions_available: true, is_smart_transaction: true, - gas_included: true, }), })); @@ -13,8 +14,9 @@ describe('smart-transactions builder', () => { it('maps smart transaction properties to event properties', async () => { const result = await getSmartTransactionProperties(createBuilderRequest()); expect(result.properties).toMatchObject({ + is_smart_transactions_user_opt_in: true, + is_smart_transactions_available: true, is_smart_transaction: true, - gas_included: true, }); expect(result.sensitiveProperties).toStrictEqual({}); }); diff --git a/app/scripts/lib/transaction/metrics-builders/test-utils.ts b/app/scripts/lib/transaction/metrics-builders/test-utils.ts index f50e62cf67fd..21cf5b2fcbb9 100644 --- a/app/scripts/lib/transaction/metrics-builders/test-utils.ts +++ b/app/scripts/lib/transaction/metrics-builders/test-utils.ts @@ -28,6 +28,8 @@ export const createBuilderRequest = ( snapAndHardwareMessenger: {} as any, trackEvent: jest.fn(), getIsSmartTransaction: jest.fn().mockReturnValue(false), + getSmartTransactionsPreferenceEnabled: jest.fn().mockReturnValue(false), + getSmartTransactionsEnabled: jest.fn().mockReturnValue(false), getSmartTransactionByMinedTxHash: jest.fn(), getMethodData: jest.fn().mockResolvedValue({ name: 'transfer' }), getIsConfirmationAdvancedDetailsOpen: jest.fn().mockReturnValue(false), diff --git a/app/scripts/messenger-client-init/confirmations/transaction-controller-init.test.ts b/app/scripts/messenger-client-init/confirmations/transaction-controller-init.test.ts index 3b9f5c74e6a4..f172301cb34a 100644 --- a/app/scripts/messenger-client-init/confirmations/transaction-controller-init.test.ts +++ b/app/scripts/messenger-client-init/confirmations/transaction-controller-init.test.ts @@ -10,6 +10,8 @@ import { TransactionControllerOptions, TransactionStatus, PublishHook, + PublishBatchHookRequest, + PublishBatchHookTransaction, } from '@metamask/transaction-controller'; import { TransactionPayPublishHook } from '@metamask/transaction-pay-controller'; import { @@ -24,7 +26,10 @@ import * as smartTransactionsModule from '../../lib/smart-transaction/smart-tran import * as sentinelApiModule from '../../lib/transaction/sentinel-api'; import * as selectorsModule from '../../../../shared/lib/selectors'; import { Delegation7702PublishHook } from '../../lib/transaction/hooks/delegation-7702-publish'; -import { TransactionControllerInit } from './transaction-controller-init'; +import { + TransactionControllerInit, + publishHook, +} from './transaction-controller-init'; jest.mock('@metamask/transaction-controller'); jest.mock('@metamask/transaction-pay-controller'); @@ -483,5 +488,453 @@ describe('Transaction Controller Init', () => { expect(jest.mocked(Delegation7702PublishHook)).toHaveBeenCalled(); }); + + it('records sentinel_relay submission via metrics fragment on delegation hook success', async () => { + const delegation7702HookFn: jest.MockedFn = jest.fn(); + delegation7702HookFn.mockResolvedValue({ transactionHash: '0xdelHash' }); + jest.mocked(Delegation7702PublishHook).mockImplementation( + () => + ({ + getHook: () => delegation7702HookFn, + }) as unknown as Delegation7702PublishHook, + ); + + const upsertFragmentMock = jest.fn(); + + type PHArgs = Parameters[0]; + await publishHook({ + flatState: {} as PHArgs['flatState'], + getTransactionMetricsRequest: () => + ({ + upsertTransactionUIMetricsFragment: upsertFragmentMock, + }) as unknown as ReturnType, + initMessenger: { + call: jest.fn(), + } as unknown as TransactionControllerInitMessenger, + keyringController: { + getKeyringForAccount: jest + .fn() + .mockResolvedValue({ type: 'HD Key Tree' }), + }, + signedTx: '0xsigned', + smartTransactionsController: + {} as PHArgs['smartTransactionsController'], + transactionController: { + isAtomicBatchSupported: jest.fn(), + } as unknown as PHArgs['transactionController'], + transactionMeta: { + ...mockTransactionMeta, + isExternalSign: true, + } as TransactionMeta, + }); + + expect(upsertFragmentMock).toHaveBeenCalledWith(mockTransactionMeta.id, { + // eslint-disable-next-line @typescript-eslint/naming-convention + properties: { transaction_submission_method: 'sentinel_relay' }, + }); + }); + + it('records sentinel_stx submission via metrics fragment on STX hook success', async () => { + jest + .mocked(smartTransactionsModule.getSmartTransactionCommonParams) + .mockReturnValue({ + isSmartTransaction: true, + featureFlags: { + extensionReturnTxHashAsap: false, + extensionReturnTxHashAsapBatch: false, + extensionSkipSmartTransactionStatusPage: false, + mobileActive: false, + extensionActive: false, + }, + isHardwareWalletAccount: false, + }); + + jest + .mocked(smartTransactionsModule.submitSmartTransactionHook) + .mockResolvedValue({ transactionHash: '0xstxHash' }); + + const upsertFragmentMock = jest.fn(); + + type PHArgs = Parameters[0]; + await publishHook({ + flatState: {} as PHArgs['flatState'], + getTransactionMetricsRequest: () => + ({ + upsertTransactionUIMetricsFragment: upsertFragmentMock, + }) as unknown as ReturnType, + initMessenger: { + call: jest.fn(), + } as unknown as TransactionControllerInitMessenger, + keyringController: { + getKeyringForAccount: jest + .fn() + .mockResolvedValue({ type: 'Ledger Hardware' }), + }, + signedTx: '0xsigned', + smartTransactionsController: + {} as PHArgs['smartTransactionsController'], + transactionController: {} as PHArgs['transactionController'], + transactionMeta: mockTransactionMeta, + }); + + expect(upsertFragmentMock).toHaveBeenCalledWith(mockTransactionMeta.id, { + // eslint-disable-next-line @typescript-eslint/naming-convention + properties: { transaction_submission_method: 'sentinel_stx' }, + }); + }); + + it('returns transaction hash even if upsertTransactionUIMetricsFragment throws on sentinel_relay path', async () => { + const delegation7702HookFn: jest.MockedFn = jest.fn(); + delegation7702HookFn.mockResolvedValue({ transactionHash: '0xdelHash' }); + jest.mocked(Delegation7702PublishHook).mockImplementation( + () => + ({ + getHook: () => delegation7702HookFn, + }) as unknown as Delegation7702PublishHook, + ); + + type PHArgs = Parameters[0]; + const result = await publishHook({ + flatState: {} as PHArgs['flatState'], + getTransactionMetricsRequest: () => + ({ + upsertTransactionUIMetricsFragment: jest + .fn() + .mockImplementation(() => { + throw new Error('metrics error'); + }), + }) as unknown as ReturnType, + initMessenger: { + call: jest.fn(), + } as unknown as TransactionControllerInitMessenger, + keyringController: { + getKeyringForAccount: jest + .fn() + .mockResolvedValue({ type: 'HD Key Tree' }), + }, + signedTx: '0xsigned', + smartTransactionsController: + {} as PHArgs['smartTransactionsController'], + transactionController: { + isAtomicBatchSupported: jest.fn(), + } as unknown as PHArgs['transactionController'], + transactionMeta: { + ...mockTransactionMeta, + isExternalSign: true, + } as TransactionMeta, + }); + + expect(result).toStrictEqual({ transactionHash: '0xdelHash' }); + }); + + it('returns transaction hash even if upsertTransactionUIMetricsFragment throws on sentinel_stx path', async () => { + jest + .mocked(smartTransactionsModule.getSmartTransactionCommonParams) + .mockReturnValue({ + isSmartTransaction: true, + featureFlags: { + extensionReturnTxHashAsap: false, + extensionReturnTxHashAsapBatch: false, + extensionSkipSmartTransactionStatusPage: false, + mobileActive: false, + extensionActive: false, + }, + isHardwareWalletAccount: false, + }); + + jest + .mocked(smartTransactionsModule.submitSmartTransactionHook) + .mockResolvedValue({ transactionHash: '0xstxHash' }); + + type PHArgs = Parameters[0]; + const result = await publishHook({ + flatState: {} as PHArgs['flatState'], + getTransactionMetricsRequest: () => + ({ + upsertTransactionUIMetricsFragment: jest + .fn() + .mockImplementation(() => { + throw new Error('metrics error'); + }), + }) as unknown as ReturnType, + initMessenger: { + call: jest.fn(), + } as unknown as TransactionControllerInitMessenger, + keyringController: { + getKeyringForAccount: jest + .fn() + .mockResolvedValue({ type: 'Ledger Hardware' }), + }, + signedTx: '0xsigned', + smartTransactionsController: + {} as PHArgs['smartTransactionsController'], + transactionController: {} as PHArgs['transactionController'], + transactionMeta: mockTransactionMeta, + }); + + expect(result).toStrictEqual({ transactionHash: '0xstxHash' }); + }); + }); + + describe('publishBatch hook', () => { + const mockTransactionMeta: TransactionMeta = { + id: 'batch-tx-last', + chainId: CHAIN_ID_MOCK, + status: TransactionStatus.approved, + time: Date.now(), + txParams: { + from: '0x0000000000000000000000000000000000000000', + }, + networkClientId: 'test-network', + }; + + it('calls upsertTransactionUIMetricsFragment with sentinel_stx for each batch tx with an id on STX success', async () => { + jest + .mocked(smartTransactionsModule.getSmartTransactionCommonParams) + .mockReturnValue({ + isSmartTransaction: true, + featureFlags: { + extensionReturnTxHashAsap: false, + extensionReturnTxHashAsapBatch: false, + extensionSkipSmartTransactionStatusPage: false, + mobileActive: false, + extensionActive: false, + }, + isHardwareWalletAccount: false, + }); + + jest + .mocked(smartTransactionsModule.submitBatchSmartTransactionHook) + .mockResolvedValue({ results: [] }); + + const upsertFragmentMock = jest.fn(); + const requestMock = buildInitRequestMock(); + requestMock.getTransactionMetricsRequest.mockReturnValue({ + upsertTransactionUIMetricsFragment: upsertFragmentMock, + } as unknown as ReturnType< + typeof requestMock.getTransactionMetricsRequest + >); + + TransactionControllerInit(requestMock); + + const { hooks } = transactionControllerClassMock.mock.calls[0][0]; + const controllerInstance = + transactionControllerClassMock.mock.instances[0]; + // @ts-expect-error Partial mock state + controllerInstance.state = { + transactions: [mockTransactionMeta], + }; + + await hooks?.publishBatch?.({ + transactions: [ + { id: 'batch-tx-1' } as unknown as PublishBatchHookTransaction, + { id: 'batch-tx-last' } as unknown as PublishBatchHookTransaction, + ], + } as unknown as PublishBatchHookRequest); + + expect(upsertFragmentMock).toHaveBeenCalledTimes(2); + expect(upsertFragmentMock).toHaveBeenCalledWith('batch-tx-1', { + // eslint-disable-next-line @typescript-eslint/naming-convention + properties: { transaction_submission_method: 'sentinel_stx' }, + }); + expect(upsertFragmentMock).toHaveBeenCalledWith('batch-tx-last', { + // eslint-disable-next-line @typescript-eslint/naming-convention + properties: { transaction_submission_method: 'sentinel_stx' }, + }); + }); + + it('skips upsertTransactionUIMetricsFragment for batch txs without an id', async () => { + jest + .mocked(smartTransactionsModule.getSmartTransactionCommonParams) + .mockReturnValue({ + isSmartTransaction: true, + featureFlags: { + extensionReturnTxHashAsap: false, + extensionReturnTxHashAsapBatch: false, + extensionSkipSmartTransactionStatusPage: false, + mobileActive: false, + extensionActive: false, + }, + isHardwareWalletAccount: false, + }); + + jest + .mocked(smartTransactionsModule.submitBatchSmartTransactionHook) + .mockResolvedValue({ results: [] }); + + const upsertFragmentMock = jest.fn(); + const requestMock = buildInitRequestMock(); + requestMock.getTransactionMetricsRequest.mockReturnValue({ + upsertTransactionUIMetricsFragment: upsertFragmentMock, + } as unknown as ReturnType< + typeof requestMock.getTransactionMetricsRequest + >); + + TransactionControllerInit(requestMock); + + const { hooks } = transactionControllerClassMock.mock.calls[0][0]; + const controllerInstance = + transactionControllerClassMock.mock.instances[0]; + // @ts-expect-error Partial mock state + controllerInstance.state = { + transactions: [mockTransactionMeta], + }; + + await hooks?.publishBatch?.({ + transactions: [ + {} as unknown as PublishBatchHookTransaction, + { id: 'batch-tx-last' } as unknown as PublishBatchHookTransaction, + ], + } as unknown as PublishBatchHookRequest); + + expect(upsertFragmentMock).toHaveBeenCalledTimes(1); + expect(upsertFragmentMock).toHaveBeenCalledWith('batch-tx-last', { + // eslint-disable-next-line @typescript-eslint/naming-convention + properties: { transaction_submission_method: 'sentinel_stx' }, + }); + }); + + it('does not call upsertTransactionUIMetricsFragment when publishBatchHook returns undefined', async () => { + jest + .mocked(smartTransactionsModule.getSmartTransactionCommonParams) + .mockReturnValue({ + isSmartTransaction: false, + featureFlags: { + extensionReturnTxHashAsap: false, + extensionReturnTxHashAsapBatch: false, + extensionSkipSmartTransactionStatusPage: false, + mobileActive: false, + extensionActive: false, + }, + isHardwareWalletAccount: false, + }); + + const upsertFragmentMock = jest.fn(); + const requestMock = buildInitRequestMock(); + requestMock.getTransactionMetricsRequest.mockReturnValue({ + upsertTransactionUIMetricsFragment: upsertFragmentMock, + } as unknown as ReturnType< + typeof requestMock.getTransactionMetricsRequest + >); + + TransactionControllerInit(requestMock); + + const { hooks } = transactionControllerClassMock.mock.calls[0][0]; + const controllerInstance = + transactionControllerClassMock.mock.instances[0]; + // @ts-expect-error Partial mock state + controllerInstance.state = { + transactions: [mockTransactionMeta], + }; + + await hooks?.publishBatch?.({ + transactions: [ + { id: 'batch-tx-last' } as unknown as PublishBatchHookTransaction, + ], + } as unknown as PublishBatchHookRequest); + + expect(upsertFragmentMock).not.toHaveBeenCalled(); + }); + + it('returns the result even if getTransactionMetricsRequest throws', async () => { + jest + .mocked(smartTransactionsModule.getSmartTransactionCommonParams) + .mockReturnValue({ + isSmartTransaction: true, + featureFlags: { + extensionReturnTxHashAsap: false, + extensionReturnTxHashAsapBatch: false, + extensionSkipSmartTransactionStatusPage: false, + mobileActive: false, + extensionActive: false, + }, + isHardwareWalletAccount: false, + }); + + const expectedResult = { results: [] }; + jest + .mocked(smartTransactionsModule.submitBatchSmartTransactionHook) + .mockResolvedValue(expectedResult); + + const requestMock = buildInitRequestMock(); + // getTransactionMetricsRequest is called once eagerly during init + // (addTransactionControllerListeners); let that succeed, then throw on + // the hook invocation to cover the try-catch guard. + requestMock.getTransactionMetricsRequest + .mockReturnValueOnce( + {} as unknown as ReturnType< + typeof requestMock.getTransactionMetricsRequest + >, + ) + .mockImplementation(() => { + throw new Error('metrics request error'); + }); + + TransactionControllerInit(requestMock); + + const { hooks } = transactionControllerClassMock.mock.calls[0][0]; + const controllerInstance = + transactionControllerClassMock.mock.instances[0]; + // @ts-expect-error Partial mock state + controllerInstance.state = { + transactions: [mockTransactionMeta], + }; + + const result = await hooks?.publishBatch?.({ + transactions: [ + { id: 'batch-tx-last' } as unknown as PublishBatchHookTransaction, + ], + } as unknown as PublishBatchHookRequest); + + expect(result).toStrictEqual(expectedResult); + }); + + it('returns the result even if upsertTransactionUIMetricsFragment throws', async () => { + jest + .mocked(smartTransactionsModule.getSmartTransactionCommonParams) + .mockReturnValue({ + isSmartTransaction: true, + featureFlags: { + extensionReturnTxHashAsap: false, + extensionReturnTxHashAsapBatch: false, + extensionSkipSmartTransactionStatusPage: false, + mobileActive: false, + extensionActive: false, + }, + isHardwareWalletAccount: false, + }); + + const expectedResult = { results: [] }; + jest + .mocked(smartTransactionsModule.submitBatchSmartTransactionHook) + .mockResolvedValue(expectedResult); + + const requestMock = buildInitRequestMock(); + requestMock.getTransactionMetricsRequest.mockReturnValue({ + upsertTransactionUIMetricsFragment: jest.fn().mockImplementation(() => { + throw new Error('metrics error'); + }), + } as unknown as ReturnType< + typeof requestMock.getTransactionMetricsRequest + >); + + TransactionControllerInit(requestMock); + + const { hooks } = transactionControllerClassMock.mock.calls[0][0]; + const controllerInstance = + transactionControllerClassMock.mock.instances[0]; + // @ts-expect-error Partial mock state + controllerInstance.state = { + transactions: [mockTransactionMeta], + }; + + const result = await hooks?.publishBatch?.({ + transactions: [ + { id: 'batch-tx-last' } as unknown as PublishBatchHookTransaction, + ], + } as unknown as PublishBatchHookRequest); + + expect(result).toStrictEqual(expectedResult); + }); }); }); diff --git a/app/scripts/messenger-client-init/confirmations/transaction-controller-init.ts b/app/scripts/messenger-client-init/confirmations/transaction-controller-init.ts index 9bc3177bfd72..24d9933ba0a3 100644 --- a/app/scripts/messenger-client-init/confirmations/transaction-controller-init.ts +++ b/app/scripts/messenger-client-init/confirmations/transaction-controller-init.ts @@ -62,6 +62,14 @@ const DISABLED_AUTOMATIC_GAS_FEE_UPDATE_TYPES = [ TransactionType.predictRelayDeposit, ]; +const TRANSACTION_SUBMISSION_METHOD_METRIC_NAME = + 'transaction_submission_method'; + +const TRANSACTION_SUBMISSION_METHOD = { + SENTINEL_STX: 'sentinel_stx', + SENTINEL_RELAY: 'sentinel_relay', +}; + export const TransactionControllerInit: ControllerInitFunction< TransactionController, TransactionControllerMessenger, @@ -205,6 +213,7 @@ export const TransactionControllerInit: ControllerInitFunction< publish: (transactionMeta, signedTx) => publishHook({ flatState: getFlatState(), + getTransactionMetricsRequest, initMessenger, keyringController, signedTx, @@ -212,15 +221,39 @@ export const TransactionControllerInit: ControllerInitFunction< transactionController: controller, transactionMeta, }), - publishBatch: async (_request: PublishBatchHookRequest) => - await publishBatchHook({ + publishBatch: async (_request: PublishBatchHookRequest) => { + const result = await publishBatchHook({ transactionController: controller, smartTransactionsController: smartTransactionsController(), hookControllerMessenger: initMessenger as SmartTransactionHookMessenger, flatState: getFlatState(), transactions: _request.transactions as PublishBatchHookTransaction[], - }), + }); + if (result) { + for (const batchTx of _request.transactions) { + if (batchTx.id) { + try { + getTransactionMetricsRequest().upsertTransactionUIMetricsFragment( + batchTx.id, + { + properties: { + [TRANSACTION_SUBMISSION_METHOD_METRIC_NAME]: + TRANSACTION_SUBMISSION_METHOD.SENTINEL_STX, + }, + }, + ); + } catch (e) { + console.error( + 'Failed to record sentinel_stx metrics fragment for batch tx', + e, + ); + } + } + } + } + return result; + }, }, // @ts-expect-error Keyring controller expects TxData returned but TransactionController expects TypedTransaction sign: (...args) => keyringController().signTransaction(...args), @@ -363,6 +396,7 @@ function getUIState(flatState: ControllerFlatState) { export async function publishHook({ flatState, + getTransactionMetricsRequest, initMessenger, keyringController, signedTx, @@ -371,6 +405,7 @@ export async function publishHook({ transactionMeta, }: { flatState: ControllerFlatState; + getTransactionMetricsRequest: () => TransactionMetricsRequest; initMessenger: TransactionControllerInitMessenger; keyringController: Parameters[1]; signedTx: string; @@ -415,6 +450,19 @@ export async function publishHook({ const result = await hook(transactionMeta, signedTx); if (result?.transactionHash) { + try { + getTransactionMetricsRequest().upsertTransactionUIMetricsFragment( + transactionMeta.id, + { + properties: { + [TRANSACTION_SUBMISSION_METHOD_METRIC_NAME]: + TRANSACTION_SUBMISSION_METHOD.SENTINEL_RELAY, + }, + }, + ); + } catch (e) { + console.error('Failed to record sentinel_relay metrics fragment', e); + } return result; } // else, fall back to regular regular transaction submission @@ -435,6 +483,19 @@ export async function publishHook({ }); if (result?.transactionHash) { + try { + getTransactionMetricsRequest().upsertTransactionUIMetricsFragment( + transactionMeta.id, + { + properties: { + [TRANSACTION_SUBMISSION_METHOD_METRIC_NAME]: + TRANSACTION_SUBMISSION_METHOD.SENTINEL_STX, + }, + }, + ); + } catch (e) { + console.error('Failed to record sentinel_stx metrics fragment', e); + } return result; } // else, fall back to regular regular transaction submission diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 42ad01e5f5c5..0320277b159f 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -184,7 +184,11 @@ import { } from '../../shared/lib/metamask-controller-utils'; import { isManifestV3 } from '../../shared/lib/mv3.utils'; import { convertNetworkId } from '../../shared/lib/network.utils'; -import { getIsSmartTransaction } from '../../shared/lib/selectors'; +import { + getIsSmartTransaction, + getSmartTransactionsPreferenceEnabled, + getSmartTransactionsEnabled, +} from '../../shared/lib/selectors'; import { TOKEN_TRANSFER_LOG_TOPIC_HASH, TRANSFER_SINFLE_LOG_TOPIC_HASH, @@ -1002,6 +1006,10 @@ export default class MetamaskController extends EventEmitter { .dismissSmartAccountSuggestionEnabled, getIsSmartTransaction: (chainId) => getIsSmartTransaction(this._getMetaMaskState(), chainId), + getSmartTransactionsPreferenceEnabled: () => + getSmartTransactionsPreferenceEnabled(this._getMetaMaskState()), + getSmartTransactionsEnabled: (chainId) => + getSmartTransactionsEnabled(this._getMetaMaskState(), chainId), isAtomicBatchSupported: this.txController.isAtomicBatchSupported.bind( this.txController, @@ -8264,6 +8272,12 @@ export default class MetamaskController extends EventEmitter { getIsSmartTransaction: (chainId) => { return getIsSmartTransaction(this._getMetaMaskState(), chainId); }, + getSmartTransactionsPreferenceEnabled: () => { + return getSmartTransactionsPreferenceEnabled(this._getMetaMaskState()); + }, + getSmartTransactionsEnabled: (chainId) => { + return getSmartTransactionsEnabled(this._getMetaMaskState(), chainId); + }, getSmartTransactionByMinedTxHash: (txHash) => { return this.smartTransactionsController.getSmartTransactionByMinedTxHash( txHash, diff --git a/app/scripts/metamask-controller.test.js b/app/scripts/metamask-controller.test.js index 10032c3feb13..b829f8c05161 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -334,6 +334,23 @@ jest.mock('../../shared/lib/environment', () => ({ getEnabledAdvancedPermissions: jest.fn(() => []), })); +jest.mock('../../shared/lib/selectors/smart-transactions', () => { + const actual = jest.requireActual( + '../../shared/lib/selectors/smart-transactions', + ); + return { + ...actual, + // Plain implementation avoids Reselect identity-function console warnings when + // getTransactionMetricsRequest tests call through MetaMaskController. + getSmartTransactionsPreferenceEnabled: (state) => { + const preferences = state?.metamask?.preferences ?? {}; + const optIn = preferences.smartTransactionsOptInStatus; + const DEFAULT_SMART_TRANSACTIONS_ENABLED = true; + return optIn ?? DEFAULT_SMART_TRANSACTIONS_ENABLED; + }, + }; +}); + jest.mock('./lib/forwardRequestToSnap', () => ({ forwardRequestToSnap: jest.fn().mockResolvedValue({}), })); @@ -1042,6 +1059,23 @@ describe('MetaMaskController', () => { }); }); + describe('getTransactionMetricsRequest', () => { + it('getSmartTransactionsPreferenceEnabled returns selector result from metamask state', () => { + metamaskController.preferencesController.update((state) => { + state.preferences.smartTransactionsOptInStatus = false; + }); + const { getSmartTransactionsPreferenceEnabled } = + metamaskController.getTransactionMetricsRequest(); + expect(getSmartTransactionsPreferenceEnabled()).toBe(false); + }); + + it('getSmartTransactionsEnabled returns selector result for a given chainId', () => { + const { getSmartTransactionsEnabled } = + metamaskController.getTransactionMetricsRequest(); + expect(getSmartTransactionsEnabled(MAINNET_CHAIN_ID)).toBe(false); + }); + }); + describe('submitPassword', () => { it('removes any identities that do not correspond to known accounts.', async () => { const localMetaMaskController = new MetaMaskController({ diff --git a/shared/lib/metametrics.test.ts b/shared/lib/metametrics.test.ts index 3d5535ea6a13..1910c66b84d0 100644 --- a/shared/lib/metametrics.test.ts +++ b/shared/lib/metametrics.test.ts @@ -39,7 +39,9 @@ const createTransactionMetricsRequest = (customProps = {}) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any snapAndHardwareMessenger: jest.fn() as any, trackEvent: jest.fn(), - getIsSmartTransaction: jest.fn(), + getIsSmartTransaction: jest.fn().mockReturnValue(false), + getSmartTransactionsPreferenceEnabled: jest.fn(), + getSmartTransactionsEnabled: jest.fn(), getSmartTransactionByMinedTxHash: jest.fn(), getMethodData: jest.fn(), getIsConfirmationAdvancedDetailsOpen: jest.fn(), @@ -76,18 +78,15 @@ const createTransactionMeta = () => { }, hash: txHash, error: null, - swapMetaData: { - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - gas_included: true, - }, }; }; describe('getSmartTransactionMetricsProperties', () => { - it('returns all smart transaction properties', () => { + it('returns all smart transaction properties when STX is active', () => { const transactionMetricsRequest = createTransactionMetricsRequest({ getIsSmartTransaction: () => true, + getSmartTransactionsPreferenceEnabled: () => true, + getSmartTransactionsEnabled: () => true, getSmartTransactionByMinedTxHash: () => { return { uuid: 'uuid', @@ -118,25 +117,52 @@ describe('getSmartTransactionMetricsProperties', () => { expect(result).toStrictEqual({ // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention - gas_included: true, + is_smart_transactions_user_opt_in: true, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + is_smart_transactions_available: true, // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention is_smart_transaction: true, // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention - smart_transaction_proxied: true, + stx_original_transaction_status: 'pending', + }); + }); + + it('returns correct properties when user has not opted in', () => { + const transactionMetricsRequest = createTransactionMetricsRequest({ + getIsSmartTransaction: () => false, + getSmartTransactionsPreferenceEnabled: () => false, + getSmartTransactionsEnabled: () => true, + }); + const transactionMeta = createTransactionMeta(); + + const result = getSmartTransactionMetricsProperties( + transactionMetricsRequest, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31973 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transactionMeta as any, + ); + + expect(result).toStrictEqual({ + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + is_smart_transactions_user_opt_in: false, // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention - smart_transaction_timed_out: true, + is_smart_transactions_available: true, // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention - stx_original_transaction_status: 'pending', + is_smart_transaction: false, }); }); - it('returns "is_smart_transaction: false" if it is not a smart transaction', () => { + it('returns correct properties when STX is not available for the chain', () => { const transactionMetricsRequest = createTransactionMetricsRequest({ getIsSmartTransaction: () => false, + getSmartTransactionsPreferenceEnabled: () => true, + getSmartTransactionsEnabled: () => false, }); const transactionMeta = createTransactionMeta(); @@ -148,18 +174,30 @@ describe('getSmartTransactionMetricsProperties', () => { ); expect(result).toStrictEqual({ + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + is_smart_transactions_user_opt_in: true, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + is_smart_transactions_available: false, // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention is_smart_transaction: false, }); }); - it('returns "is_smart_transaction" and "gas_included" params only if it is a smart transaction, but does not have statusMetadata', () => { + it('returns both new properties plus stx_original_transaction_status when statusMetadata is present', () => { const transactionMetricsRequest = createTransactionMetricsRequest({ getIsSmartTransaction: () => true, + getSmartTransactionsPreferenceEnabled: () => true, + getSmartTransactionsEnabled: () => true, getSmartTransactionByMinedTxHash: () => { return { - statusMetadata: null, + statusMetadata: { + originalTransactionStatus: 'pending', + timedOut: true, + proxied: true, + }, }; }, }); @@ -173,12 +211,18 @@ describe('getSmartTransactionMetricsProperties', () => { ); expect(result).toStrictEqual({ + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + is_smart_transactions_user_opt_in: true, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + is_smart_transactions_available: true, // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention is_smart_transaction: true, // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention - gas_included: true, + stx_original_transaction_status: 'pending', }); }); }); diff --git a/shared/lib/metametrics.ts b/shared/lib/metametrics.ts index a197febc687f..f206299573ab 100644 --- a/shared/lib/metametrics.ts +++ b/shared/lib/metametrics.ts @@ -4,16 +4,16 @@ import { TransactionMetricsRequest } from '../types/metametrics'; type SmartTransactionMetricsProperties = { // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention - is_smart_transaction: boolean; - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - gas_included: boolean; + is_smart_transactions_user_opt_in: boolean; // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention - smart_transaction_timed_out?: boolean; + is_smart_transactions_available: boolean; + /** + * @deprecated Use `is_smart_transactions_user_opt_in` and `is_smart_transactions_available` instead. + */ // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention - smart_transaction_proxied?: boolean; + is_smart_transaction: boolean; // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention stx_original_transaction_status?: string; @@ -23,18 +23,29 @@ export const getSmartTransactionMetricsProperties = ( transactionMetricsRequest: TransactionMetricsRequest, transactionMeta: TransactionMeta, ) => { + const isSmartTransactionsUserOptIn = + transactionMetricsRequest.getSmartTransactionsPreferenceEnabled(); + const isSmartTransactionsAvailable = + transactionMetricsRequest.getSmartTransactionsEnabled( + transactionMeta.chainId, + ); const isSmartTransaction = transactionMetricsRequest.getIsSmartTransaction( transactionMeta.chainId, ); const properties = { + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + is_smart_transactions_user_opt_in: isSmartTransactionsUserOptIn, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + is_smart_transactions_available: isSmartTransactionsAvailable, // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention is_smart_transaction: isSmartTransaction, } as SmartTransactionMetricsProperties; - if (!isSmartTransaction) { + if (!isSmartTransactionsUserOptIn || !isSmartTransactionsAvailable) { return properties; } - properties.gas_included = transactionMeta.swapMetaData?.gas_included; const smartTransaction = transactionMetricsRequest.getSmartTransactionByMinedTxHash( transactionMeta.hash, @@ -43,9 +54,6 @@ export const getSmartTransactionMetricsProperties = ( if (!smartTransactionStatusMetadata) { return properties; } - properties.smart_transaction_timed_out = - smartTransactionStatusMetadata.timedOut; - properties.smart_transaction_proxied = smartTransactionStatusMetadata.proxied; properties.stx_original_transaction_status = smartTransactionStatusMetadata.originalTransactionStatus; return properties; diff --git a/shared/types/metametrics.ts b/shared/types/metametrics.ts index 5b418442fbb2..8e36d0b8433a 100644 --- a/shared/types/metametrics.ts +++ b/shared/types/metametrics.ts @@ -54,6 +54,8 @@ export type TransactionMetricsRequest = { // eslint-disable-next-line @typescript-eslint/no-explicit-any trackEvent: (payload: any) => void; getIsSmartTransaction: (chainId: Hex) => boolean; + getSmartTransactionsPreferenceEnabled: () => boolean; + getSmartTransactionsEnabled: (chainId: Hex) => boolean; getSmartTransactionByMinedTxHash: ( txhash: string | undefined, ) => SmartTransaction; diff --git a/test/jest/console-baseline-unit.json b/test/jest/console-baseline-unit.json index 47cb0b525e77..ac200031ea2f 100644 --- a/test/jest/console-baseline-unit.json +++ b/test/jest/console-baseline-unit.json @@ -51,6 +51,10 @@ "error: StorageService: Failed to remove item:": 1, "error: StorageService: Failed to set item:": 1 }, + "app/scripts/messenger-client-init/confirmations/transaction-controller-init.test.ts": { + "error: Failed to record sentinel_relay metrics": 1, + "error: Failed to record sentinel_stx metrics": 3 + }, "app/scripts/messenger-client-init/core-backend/backend-websocket-service-init.test.ts": { "MetaMask: Backend WebSocket warnings": 1 },