diff --git a/app/scripts/controllers/transactions/index.js b/app/scripts/controllers/transactions/index.js index 8d5b85410501..69830fc37127 100644 --- a/app/scripts/controllers/transactions/index.js +++ b/app/scripts/controllers/transactions/index.js @@ -20,9 +20,7 @@ import { import { TransactionStatus, TransactionType, - TokenStandard, TransactionEnvelopeType, - TransactionMetaMetricsEvent, TransactionApprovalAmountType, } from '../../../../shared/constants/transaction'; import { METAMASK_CONTROLLER_EVENTS } from '../../metamask-controller'; @@ -50,25 +48,16 @@ import { NetworkStatus, } from '../../../../shared/constants/network'; import { - determineTransactionAssetType, determineTransactionContractCode, determineTransactionType, isEIP1559Transaction, } from '../../../../shared/modules/transaction.utils'; import { ORIGIN_METAMASK } from '../../../../shared/constants/app'; -///: BEGIN:ONLY_INCLUDE_IN(blockaid) -import { - BlockaidReason, - BlockaidResultType, -} from '../../../../shared/constants/security-provider'; -///: END:ONLY_INCLUDE_IN import { calcGasTotal, getSwapsTokensReceivedFromTxMeta, - TRANSACTION_ENVELOPE_TYPE_NAMES, } from '../../../../shared/lib/transactions-controller-utils'; import { Numeric } from '../../../../shared/modules/Numeric'; -import getSnapAndHardwareInfoForMetrics from '../../lib/snap-keyring/metrics'; import TransactionStateManager from './tx-state-manager'; import TxGasUtil from './tx-gas-utils'; import PendingTransactionTracker from './pending-tx-tracker'; @@ -99,8 +88,6 @@ const VALID_UNAPPROVED_TRANSACTION_TYPES = [ * @typedef {import('../../../../shared/constants/gas').TxGasFees} TxGasFees */ -const METRICS_STATUS_FAILED = 'failed on-chain'; - /** * @typedef {object} CustomGasSettings * @property {string} [gas] - The gas limit to use for the transaction @@ -158,14 +145,8 @@ export default class TransactionController extends EventEmitter { this.blockTracker = opts.blockTracker; this.signEthTx = opts.signTransaction; this.inProcessOfSigning = new Set(); - this._trackMetaMetricsEvent = opts.trackMetaMetricsEvent; this._getParticipateInMetrics = opts.getParticipateInMetrics; this._getEIP1559GasFeeEstimates = opts.getEIP1559GasFeeEstimates; - this.createEventFragment = opts.createEventFragment; - this.updateEventFragment = opts.updateEventFragment; - this.finalizeEventFragment = opts.finalizeEventFragment; - this.getEventFragmentById = opts.getEventFragmentById; - this.getTokenStandardAndDetails = opts.getTokenStandardAndDetails; this.securityProviderRequest = opts.securityProviderRequest; this.getSelectedAddress = opts.getSelectedAddress; this.getAccountType = opts.getAccountType; @@ -724,25 +705,9 @@ export default class TransactionController extends EventEmitter { this.txStateManager.setTxStatusConfirmed(txId); this._markNonceDuplicatesDropped(txId); - const { submittedTime } = txMeta; - const metricsParams = { gas_used: gasUsed }; - - if (submittedTime) { - metricsParams.completion_time = - this._getTransactionCompletionTime(submittedTime); - } - - if (txReceipt.status === '0x0') { - metricsParams.status = METRICS_STATUS_FAILED; - // metricsParams.error = TODO: figure out a way to get the on-chain failure reason - } - - this._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.finalized, - undefined, - metricsParams, - ); + this.emit('transaction-finalized', { + transactionMeta: txMeta, + }); this.txStateManager.updateTransaction( txMeta, @@ -773,28 +738,6 @@ export default class TransactionController extends EventEmitter { this.txStateManager.updateTransaction(txMeta, 'transactions#setTxHash'); } - /** - * Convenience method for the UI to easily create event fragments when the - * fragment does not exist in state. - * - * @param {number} transactionId - The transaction id to create the event - * fragment for - * @param {valueOf} event - event type to create - * @param {string} actionId - actionId passed from UI - */ - async createTransactionEventFragment(transactionId, event, actionId) { - const txMeta = this.txStateManager.getTransaction(transactionId); - const { properties, sensitiveProperties } = - await this._buildEventFragmentProperties(txMeta); - this._createTransactionEventFragment( - txMeta, - event, - properties, - sensitiveProperties, - actionId, - ); - } - startIncomingTransactionPolling() { this.incomingTransactionHelper.start(); } @@ -955,25 +898,9 @@ export default class TransactionController extends EventEmitter { this.txStateManager.setTxStatusConfirmed(txId); this._markNonceDuplicatesDropped(txId); - const { submittedTime } = txMeta; - const metricsParams = { gas_used: gasUsed }; - - if (submittedTime) { - metricsParams.completion_time = - this._getTransactionCompletionTime(submittedTime); - } - - if (txReceipt.status === '0x0') { - metricsParams.status = METRICS_STATUS_FAILED; - // metricsParams.error = TODO: figure out a way to get the on-chain failure reason - } - - this._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.finalized, - undefined, - metricsParams, - ); + this.emit('transaction-finalized', { + transactionMeta: txMeta, + }); this.txStateManager.updateTransaction( txMeta, @@ -1300,8 +1227,8 @@ export default class TransactionController extends EventEmitter { * * @param {number} txId - the tx's Id * @param {string} rawTx - the hex string of the serialized signed transaction + * @param {string} actionId - actionId passed from UI * @returns {Promise} - * @param {number} actionId - actionId passed from UI */ async _publishTransaction(txId, rawTx, actionId) { const txMeta = this.txStateManager.getTransaction(txId); @@ -1329,11 +1256,10 @@ export default class TransactionController extends EventEmitter { this.txStateManager.setTxStatusSubmitted(txId); - this._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.submitted, + this.emit('transaction-submitted', { + transactionMeta: txMeta, actionId, - ); + }); } /** @@ -1855,11 +1781,10 @@ export default class TransactionController extends EventEmitter { // sign transaction const rawTx = await this._signTransaction(txId); await this._publishTransaction(txId, rawTx, actionId); - this._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.approved, + this.emit('transaction-approved', { + transactionMeta: txMeta, actionId, - ); + }); // must set transaction to submitted/failed before releasing lock nonceLock.releaseLock(); } catch (err) { @@ -1890,11 +1815,10 @@ export default class TransactionController extends EventEmitter { async _cancelTransaction(txId, actionId) { const txMeta = this.txStateManager.getTransaction(txId); this.txStateManager.setTxStatusRejected(txId); - this._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.rejected, + this.emit('transaction-rejected', { + transactionMeta: txMeta, actionId, - ); + }); } /** maps methods for convenience*/ @@ -2158,7 +2082,7 @@ export default class TransactionController extends EventEmitter { _trackSwapsMetrics(txMeta, approvalTxMeta) { if (this._getParticipateInMetrics() && txMeta.swapMetaData) { if (txMeta.txReceipt.status === '0x0') { - this._trackMetaMetricsEvent({ + this.emit('transaction-swap-failed', { event: 'Swap Failed', sensitiveProperties: { ...txMeta.swapMetaData }, category: MetaMetricsEventCategory.Swaps, @@ -2194,7 +2118,7 @@ export default class TransactionController extends EventEmitter { approvalTxMeta, ); - this._trackMetaMetricsEvent({ + this.emit('transaction-swap-finalized', { event: MetaMetricsEventName.SwapCompleted, category: MetaMetricsEventCategory.Swaps, sensitiveProperties: { @@ -2244,489 +2168,6 @@ export default class TransactionController extends EventEmitter { return null; } - /** - * The allowance amount in relation to the balance for that specific token - * - * @param {string} transactionApprovalAmountType - The transaction approval amount type - * @param {string} dappProposedTokenAmount - The dapp proposed token amount - * @param {string} currentTokenBalance - The balance of the token that is being send - */ - _allowanceAmountInRelationToTokenBalance( - transactionApprovalAmountType, - dappProposedTokenAmount, - currentTokenBalance, - ) { - if ( - (transactionApprovalAmountType === TransactionApprovalAmountType.custom || - transactionApprovalAmountType === - TransactionApprovalAmountType.dappProposed) && - dappProposedTokenAmount && - currentTokenBalance - ) { - return `${new BigNumber(dappProposedTokenAmount, 16) - .div(currentTokenBalance, 10) - .times(100) - .round(2)}`; - } - return null; - } - - async _buildEventFragmentProperties(txMeta, extraParams) { - const { - type, - time, - status, - chainId, - origin: referrer, - txParams: { - gasPrice, - gas: gasLimit, - maxFeePerGas, - maxPriorityFeePerGas, - estimateSuggested, - estimateUsed, - }, - defaultGasEstimates, - originalType, - replacedById, - customTokenAmount, - dappProposedTokenAmount, - currentTokenBalance, - originalApprovalAmount, - finalApprovalAmount, - contractMethodName, - securityProviderResponse, - ///: BEGIN:ONLY_INCLUDE_IN(blockaid) - securityAlertResponse, - ///: END:ONLY_INCLUDE_IN - } = txMeta; - - const source = referrer === ORIGIN_METAMASK ? 'user' : 'dapp'; - - const { assetType, tokenStandard } = await determineTransactionAssetType( - txMeta, - this.query, - this.getTokenStandardAndDetails, - ); - - const gasParams = {}; - - if (isEIP1559Transaction(txMeta)) { - gasParams.max_fee_per_gas = maxFeePerGas; - gasParams.max_priority_fee_per_gas = maxPriorityFeePerGas; - } else { - gasParams.gas_price = gasPrice; - } - - if (defaultGasEstimates) { - const { estimateType } = defaultGasEstimates; - if (estimateType) { - gasParams.default_estimate = estimateType; - let defaultMaxFeePerGas = txMeta.defaultGasEstimates.maxFeePerGas; - let defaultMaxPriorityFeePerGas = - txMeta.defaultGasEstimates.maxPriorityFeePerGas; - - if ( - [ - GasRecommendations.low, - GasRecommendations.medium, - GasRecommendations.high, - ].includes(estimateType) - ) { - const { gasFeeEstimates } = await this._getEIP1559GasFeeEstimates(); - if (gasFeeEstimates?.[estimateType]?.suggestedMaxFeePerGas) { - defaultMaxFeePerGas = - gasFeeEstimates[estimateType]?.suggestedMaxFeePerGas; - gasParams.default_max_fee_per_gas = defaultMaxFeePerGas; - } - if (gasFeeEstimates?.[estimateType]?.suggestedMaxPriorityFeePerGas) { - defaultMaxPriorityFeePerGas = - gasFeeEstimates[estimateType]?.suggestedMaxPriorityFeePerGas; - gasParams.default_max_priority_fee_per_gas = - defaultMaxPriorityFeePerGas; - } - } - } - - if (txMeta.defaultGasEstimates.gas) { - gasParams.default_gas = txMeta.defaultGasEstimates.gas; - } - if (txMeta.defaultGasEstimates.gasPrice) { - gasParams.default_gas_price = txMeta.defaultGasEstimates.gasPrice; - } - } - - if (estimateSuggested) { - gasParams.estimate_suggested = estimateSuggested; - } - - if (estimateUsed) { - gasParams.estimate_used = estimateUsed; - } - - if (extraParams?.gas_used) { - gasParams.gas_used = extraParams.gas_used; - } - - const gasParamsInGwei = this._getGasValuesInGWEI(gasParams); - - let eip1559Version = '0'; - if (txMeta.txParams.maxFeePerGas) { - eip1559Version = '2'; - } - - const contractInteractionTypes = [ - TransactionType.contractInteraction, - TransactionType.tokenMethodApprove, - TransactionType.tokenMethodSafeTransferFrom, - TransactionType.tokenMethodSetApprovalForAll, - TransactionType.tokenMethodTransfer, - TransactionType.tokenMethodTransferFrom, - TransactionType.smart, - TransactionType.swap, - TransactionType.swapApproval, - ].includes(type); - - const contractMethodNames = { - APPROVE: 'Approve', - }; - - let transactionApprovalAmountType; - let transactionContractMethod; - let transactionApprovalAmountVsProposedRatio; - let transactionApprovalAmountVsBalanceRatio; - let transactionType = TransactionType.simpleSend; - if (type === TransactionType.cancel) { - transactionType = TransactionType.cancel; - } else if (type === TransactionType.retry) { - transactionType = originalType; - } else if (type === TransactionType.deployContract) { - transactionType = TransactionType.deployContract; - } else if (contractInteractionTypes) { - transactionType = TransactionType.contractInteraction; - transactionContractMethod = contractMethodName; - if ( - transactionContractMethod === contractMethodNames.APPROVE && - tokenStandard === TokenStandard.ERC20 - ) { - if (dappProposedTokenAmount === '0' || customTokenAmount === '0') { - transactionApprovalAmountType = TransactionApprovalAmountType.revoke; - } else if ( - customTokenAmount && - customTokenAmount !== dappProposedTokenAmount - ) { - transactionApprovalAmountType = TransactionApprovalAmountType.custom; - } else if (dappProposedTokenAmount) { - transactionApprovalAmountType = - TransactionApprovalAmountType.dappProposed; - } - transactionApprovalAmountVsProposedRatio = - this._allowanceAmountInRelationToDappProposedValue( - transactionApprovalAmountType, - originalApprovalAmount, - finalApprovalAmount, - ); - transactionApprovalAmountVsBalanceRatio = - this._allowanceAmountInRelationToTokenBalance( - transactionApprovalAmountType, - dappProposedTokenAmount, - currentTokenBalance, - ); - } - } - - const replacedTxMeta = this._getTransaction(replacedById); - - const TRANSACTION_REPLACEMENT_METHODS = { - RETRY: TransactionType.retry, - CANCEL: TransactionType.cancel, - SAME_NONCE: 'other', - }; - - let transactionReplaced; - if (extraParams?.dropped) { - transactionReplaced = TRANSACTION_REPLACEMENT_METHODS.SAME_NONCE; - if (replacedTxMeta?.type === TransactionType.cancel) { - transactionReplaced = TRANSACTION_REPLACEMENT_METHODS.CANCEL; - } else if (replacedTxMeta?.type === TransactionType.retry) { - transactionReplaced = TRANSACTION_REPLACEMENT_METHODS.RETRY; - } - } - - let uiCustomizations; - - ///: BEGIN:ONLY_INCLUDE_IN(blockaid) - if (securityAlertResponse?.result_type === BlockaidResultType.Failed) { - uiCustomizations = ['security_alert_failed']; - } else { - ///: END:ONLY_INCLUDE_IN - // eslint-disable-next-line no-lonely-if - if (securityProviderResponse?.flagAsDangerous === 1) { - uiCustomizations = ['flagged_as_malicious']; - } else if (securityProviderResponse?.flagAsDangerous === 2) { - uiCustomizations = ['flagged_as_safety_unknown']; - } else { - uiCustomizations = null; - } - ///: BEGIN:ONLY_INCLUDE_IN(blockaid) - } - ///: END:ONLY_INCLUDE_IN - - /** The transaction status property is not considered sensitive and is now included in the non-anonymous event */ - let properties = { - chain_id: chainId, - referrer, - source, - status, - eip_1559_version: eip1559Version, - gas_edit_type: 'none', - gas_edit_attempted: 'none', - asset_type: assetType, - token_standard: tokenStandard, - transaction_type: transactionType, - transaction_speed_up: type === TransactionType.retry, - ui_customizations: uiCustomizations, - ///: BEGIN:ONLY_INCLUDE_IN(blockaid) - security_alert_response: - securityAlertResponse?.result_type ?? BlockaidResultType.NotApplicable, - security_alert_reason: - securityAlertResponse?.reason ?? BlockaidReason.notApplicable, - ///: END:ONLY_INCLUDE_IN - }; - - const snapAndHardwareInfo = await getSnapAndHardwareInfoForMetrics( - this.getSelectedAddress, - this.getAccountType, - this.getDeviceModel, - this.snapAndHardwareMessenger, - ); - - // merge the snapAndHardwareInfo into the properties - Object.assign(properties, snapAndHardwareInfo); - - if (transactionContractMethod === contractMethodNames.APPROVE) { - properties = { - ...properties, - transaction_approval_amount_type: transactionApprovalAmountType, - }; - } - - let sensitiveProperties = { - transaction_envelope_type: isEIP1559Transaction(txMeta) - ? TRANSACTION_ENVELOPE_TYPE_NAMES.FEE_MARKET - : TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY, - first_seen: time, - gas_limit: gasLimit, - transaction_contract_method: transactionContractMethod, - transaction_replaced: transactionReplaced, - ...extraParams, - ...gasParamsInGwei, - }; - - if (transactionContractMethod === contractMethodNames.APPROVE) { - sensitiveProperties = { - ...sensitiveProperties, - transaction_approval_amount_vs_balance_ratio: - transactionApprovalAmountVsBalanceRatio, - transaction_approval_amount_vs_proposed_ratio: - transactionApprovalAmountVsProposedRatio, - }; - } - - return { properties, sensitiveProperties }; - } - - /** - * Helper method that checks for the presence of an existing fragment by id - * appropriate for the type of event that triggered fragment creation. If the - * appropriate fragment exists, then nothing is done. If it does not exist a - * new event fragment is created with the appropriate payload. - * - * @param {TransactionMeta} txMeta - Transaction meta object - * @param {TransactionMetaMetricsEvent} event - The event type that - * triggered fragment creation - * @param {object} properties - properties to include in the fragment - * @param {object} [sensitiveProperties] - sensitive properties to include in - * @param {object} [actionId] - actionId passed from UI - * the fragment - */ - _createTransactionEventFragment( - txMeta, - event, - properties, - sensitiveProperties, - actionId, - ) { - const isSubmitted = [ - TransactionMetaMetricsEvent.finalized, - TransactionMetaMetricsEvent.submitted, - ].includes(event); - const uniqueIdentifier = `transaction-${ - isSubmitted ? 'submitted' : 'added' - }-${txMeta.id}`; - - const fragment = this.getEventFragmentById(uniqueIdentifier); - if (typeof fragment !== 'undefined') { - return; - } - - switch (event) { - // When a transaction is added to the controller, we know that the user - // will be presented with a confirmation screen. The user will then - // either confirm or reject that transaction. Each has an associated - // event we want to track. While we don't necessarily need an event - // fragment to model this, having one allows us to record additional - // properties onto the event from the UI. For example, when the user - // edits the transactions gas params we can record that property and - // then get analytics on the number of transactions in which gas edits - // occur. - case TransactionMetaMetricsEvent.added: - this.createEventFragment({ - category: MetaMetricsEventCategory.Transactions, - initialEvent: TransactionMetaMetricsEvent.added, - successEvent: TransactionMetaMetricsEvent.approved, - failureEvent: TransactionMetaMetricsEvent.rejected, - properties, - sensitiveProperties, - persist: true, - uniqueIdentifier, - actionId, - }); - break; - // If for some reason an approval or rejection occurs without the added - // fragment existing in memory, we create the added fragment but without - // the initialEvent firing. This is to prevent possible duplication of - // events. A good example why this might occur is if the user had - // unapproved transactions in memory when updating to the version that - // includes this change. A migration would have also helped here but this - // implementation hardens against other possible bugs where a fragment - // does not exist. - case TransactionMetaMetricsEvent.approved: - case TransactionMetaMetricsEvent.rejected: - this.createEventFragment({ - category: MetaMetricsEventCategory.Transactions, - successEvent: TransactionMetaMetricsEvent.approved, - failureEvent: TransactionMetaMetricsEvent.rejected, - properties, - sensitiveProperties, - persist: true, - uniqueIdentifier, - actionId, - }); - break; - // When a transaction is submitted it will always result in updating - // to a finalized state (dropped, failed, confirmed) -- eventually. - // However having a fragment started at this stage allows augmenting - // analytics data with user interactions such as speeding up and - // canceling the transactions. From this controllers perspective a new - // transaction with a new id is generated for speed up and cancel - // transactions, but from the UI we could augment the previous ID with - // supplemental data to show user intent. Such as when they open the - // cancel UI but don't submit. We can record that this happened and add - // properties to the transaction event. - case TransactionMetaMetricsEvent.submitted: - this.createEventFragment({ - category: MetaMetricsEventCategory.Transactions, - initialEvent: TransactionMetaMetricsEvent.submitted, - successEvent: TransactionMetaMetricsEvent.finalized, - properties, - sensitiveProperties, - persist: true, - uniqueIdentifier, - actionId, - }); - break; - // If for some reason a transaction is finalized without the submitted - // fragment existing in memory, we create the submitted fragment but - // without the initialEvent firing. This is to prevent possible - // duplication of events. A good example why this might occur is if th - // user had pending transactions in memory when updating to the version - // that includes this change. A migration would have also helped here but - // this implementation hardens against other possible bugs where a - // fragment does not exist. - case TransactionMetaMetricsEvent.finalized: - this.createEventFragment({ - category: MetaMetricsEventCategory.Transactions, - successEvent: TransactionMetaMetricsEvent.finalized, - properties, - sensitiveProperties, - persist: true, - uniqueIdentifier, - actionId, - }); - break; - default: - break; - } - } - - /** - * Extracts relevant properties from a transaction meta - * object and uses them to create and send metrics for various transaction - * events. - * - * @param {object} txMeta - the txMeta object - * @param {TransactionMetaMetricsEvent} event - the name of the transaction event - * @param {string} actionId - actionId passed from UI - * @param {object} extraParams - optional props and values to include in sensitiveProperties - */ - async _trackTransactionMetricsEvent( - txMeta, - event, - actionId, - extraParams = {}, - ) { - if (!txMeta) { - return; - } - const { properties, sensitiveProperties } = - await this._buildEventFragmentProperties(txMeta, extraParams); - - // Create event fragments for event types that spawn fragments, and ensure - // existence of fragments for event types that act upon them. - this._createTransactionEventFragment( - txMeta, - event, - properties, - sensitiveProperties, - actionId, - ); - - let id; - - switch (event) { - // If the user approves a transaction, finalize the transaction added - // event fragment. - case TransactionMetaMetricsEvent.approved: - id = `transaction-added-${txMeta.id}`; - this.updateEventFragment(id, { properties, sensitiveProperties }); - this.finalizeEventFragment(id); - break; - // If the user rejects a transaction, finalize the transaction added - // event fragment. with the abandoned flag set. - case TransactionMetaMetricsEvent.rejected: - id = `transaction-added-${txMeta.id}`; - this.updateEventFragment(id, { properties, sensitiveProperties }); - this.finalizeEventFragment(id, { - abandoned: true, - }); - break; - // When a transaction is finalized, also finalize the transaction - // submitted event fragment. - case TransactionMetaMetricsEvent.finalized: - id = `transaction-submitted-${txMeta.id}`; - this.updateEventFragment(id, { properties, sensitiveProperties }); - this.finalizeEventFragment(`transaction-submitted-${txMeta.id}`); - break; - default: - break; - } - } - - _getTransactionCompletionTime(submittedTime) { - return Math.round((Date.now() - submittedTime) / 1000).toString(); - } - _getGasValuesInGWEI(gasParams) { const gasValuesInGwei = {}; for (const param in gasParams) { @@ -2742,27 +2183,19 @@ export default class TransactionController extends EventEmitter { _failTransaction(txId, error, actionId) { this.txStateManager.setTxStatusFailed(txId, error); const txMeta = this.txStateManager.getTransaction(txId); - this._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.finalized, + this.emit('transaction-finalized', { actionId, - { - error: error.message, - }, - ); + error: error.message, + transactionMeta: txMeta, + }); } _dropTransaction(txId) { this.txStateManager.setTxStatusDropped(txId); const txMeta = this.txStateManager.getTransaction(txId); - this._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.finalized, - undefined, - { - dropped: true, - }, - ); + this.emit('transaction-dropped', { + transactionMeta: txMeta, + }); } /** @@ -2774,11 +2207,10 @@ export default class TransactionController extends EventEmitter { _addTransaction(txMeta) { this.txStateManager.addTransaction(txMeta); this.emit(`${txMeta.id}:unapproved`, txMeta); - this._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.added, - txMeta.actionId, - ); + this.emit('transaction-added', { + transactionMeta: txMeta, + actionId: txMeta.actionId, + }); } _onIncomingTransactions({ added: transactions }) { diff --git a/app/scripts/controllers/transactions/index.test.js b/app/scripts/controllers/transactions/index.test.js index e66ef838f399..e90bf6a19a32 100644 --- a/app/scripts/controllers/transactions/index.test.js +++ b/app/scripts/controllers/transactions/index.test.js @@ -8,26 +8,14 @@ import { ApprovalType } from '@metamask/controller-utils'; import sinon from 'sinon'; import { errorCodes, ethErrors } from 'eth-rpc-errors'; -import { - BlockaidReason, - BlockaidResultType, -} from '../../../../shared/constants/security-provider'; import { createTestProviderTools, getTestAccounts, } from '../../../../test/stub/provider'; -import mockEstimates from '../../../../test/data/mock-estimates.json'; -import { - MetaMetricsEventCategory, - MetaMetricsTransactionEventSource, -} from '../../../../shared/constants/metametrics'; import { TransactionStatus, TransactionType, TransactionEnvelopeType, - TransactionMetaMetricsEvent, - AssetType, - TokenStandard, } from '../../../../shared/constants/transaction'; import { @@ -36,7 +24,6 @@ import { } from '../../../../shared/constants/gas'; import { ORIGIN_METAMASK } from '../../../../shared/constants/app'; import { NetworkStatus } from '../../../../shared/constants/network'; -import { TRANSACTION_ENVELOPE_TYPE_NAMES } from '../../../../shared/lib/transactions-controller-utils'; import TxGasUtil from './tx-gas-utils'; import * as IncomingTransactionHelperClass from './IncomingTransactionHelper'; import TransactionController from '.'; @@ -48,7 +35,6 @@ const currentNetworkStatus = NetworkStatus.Available; const providerConfig = { type: 'goerli', }; -const actionId = 'DUMMY_ACTION_ID'; const VALID_ADDRESS = '0x0000000000000000000000000000000000000000'; const VALID_ADDRESS_TWO = '0x0000000000000000000000000000000000000001'; @@ -71,7 +57,6 @@ describe('Transaction Controller', function () { provider, providerResultStub, fromAccount, - fragmentExists, networkStatusStore, preferencesStore, getCurrentChainId, @@ -82,7 +67,6 @@ describe('Transaction Controller', function () { incomingTransactionHelperEventMock; beforeEach(function () { - fragmentExists = false; providerResultStub = { // 1 gwei eth_gasPrice: '0x0de0b6b3a7640000', @@ -146,12 +130,6 @@ describe('Transaction Controller', function () { getPermittedAccounts: () => undefined, getCurrentChainId, getParticipateInMetrics: () => false, - trackMetaMetricsEvent: () => undefined, - createEventFragment: () => undefined, - updateEventFragment: () => undefined, - finalizeEventFragment: () => undefined, - getEventFragmentById: () => - fragmentExists === false ? undefined : { id: 0 }, getEIP1559GasFeeEstimates: () => undefined, securityProviderRequest: () => undefined, preferencesStore, @@ -1839,7 +1817,7 @@ describe('Transaction Controller', function () { }); describe('_publishTransaction', function () { - let hash, txMeta, trackTransactionMetricsEventSpy; + let hash, txMeta; beforeEach(function () { hash = @@ -1855,14 +1833,6 @@ describe('Transaction Controller', function () { chainId: currentChainId, }; providerResultStub.eth_sendRawTransaction = hash; - trackTransactionMetricsEventSpy = sinon.spy( - txController, - '_trackTransactionMetricsEvent', - ); - }); - - afterEach(function () { - trackTransactionMetricsEventSpy.restore(); }); it('should publish a tx, updates the rawTx when provided a one', async function () { @@ -1890,22 +1860,6 @@ describe('Transaction Controller', function () { ); assert.equal(publishedTx.status, TransactionStatus.submitted); }); - - it('should call _trackTransactionMetricsEvent with the correct params', async function () { - const rawTx = - '0x477b2e6553c917af0db0388ae3da62965ff1a184558f61b749d1266b2e6d024c'; - txController.txStateManager.addTransaction(txMeta); - await txController._publishTransaction(txMeta.id, rawTx); - assert.equal(trackTransactionMetricsEventSpy.callCount, 1); - assert.deepEqual( - trackTransactionMetricsEventSpy.getCall(0).args[0], - txMeta, - ); - assert.equal( - trackTransactionMetricsEventSpy.getCall(0).args[1], - TransactionMetaMetricsEvent.submitted, - ); - }); }); describe('#_markNonceDuplicatesDropped', function () { @@ -2098,885 +2052,6 @@ describe('Transaction Controller', function () { }); }); - describe('#_trackTransactionMetricsEvent', function () { - let trackMetaMetricsEventSpy; - let createEventFragmentSpy; - let finalizeEventFragmentSpy; - - beforeEach(function () { - trackMetaMetricsEventSpy = sinon.spy( - txController, - '_trackMetaMetricsEvent', - ); - - createEventFragmentSpy = sinon.spy(txController, 'createEventFragment'); - - finalizeEventFragmentSpy = sinon.spy( - txController, - 'finalizeEventFragment', - ); - - sinon - .stub(txController, '_getEIP1559GasFeeEstimates') - .resolves(mockEstimates['fee-market']); - }); - - afterEach(function () { - trackMetaMetricsEventSpy.restore(); - createEventFragmentSpy.restore(); - finalizeEventFragmentSpy.restore(); - }); - - describe('On transaction created by the user', function () { - let txMeta; - - before(function () { - txMeta = { - id: 1, - status: TransactionStatus.unapproved, - txParams: { - from: fromAccount.address, - to: '0x1678a085c290ebd122dc42cba69373b5953b831d', - gasPrice: '0x77359400', - gas: '0x7b0d', - nonce: '0x4b', - }, - type: TransactionType.simpleSend, - origin: ORIGIN_METAMASK, - chainId: currentChainId, - time: 1624408066355, - defaultGasEstimates: { - gas: '0x7b0d', - gasPrice: '0x77359400', - }, - securityProviderResponse: { - flagAsDangerous: 0, - }, - }; - }); - - it('should create an event fragment when transaction added', async function () { - const expectedPayload = { - actionId, - initialEvent: 'Transaction Added', - successEvent: 'Transaction Approved', - failureEvent: 'Transaction Rejected', - uniqueIdentifier: 'transaction-added-1', - category: MetaMetricsEventCategory.Transactions, - persist: true, - properties: { - chain_id: '0x5', - eip_1559_version: '0', - gas_edit_attempted: 'none', - gas_edit_type: 'none', - - referrer: ORIGIN_METAMASK, - source: MetaMetricsTransactionEventSource.User, - transaction_type: TransactionType.simpleSend, - asset_type: AssetType.native, - token_standard: TokenStandard.none, - transaction_speed_up: false, - ui_customizations: null, - security_alert_reason: BlockaidReason.notApplicable, - security_alert_response: BlockaidResultType.NotApplicable, - status: 'unapproved', - }, - sensitiveProperties: { - default_gas: '0.000031501', - default_gas_price: '2', - gas_price: '2', - gas_limit: '0x7b0d', - transaction_contract_method: undefined, - transaction_replaced: undefined, - first_seen: 1624408066355, - transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY, - }, - }; - - await txController._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.added, - actionId, - ); - assert.equal(createEventFragmentSpy.callCount, 1); - assert.equal(finalizeEventFragmentSpy.callCount, 0); - assert.deepEqual( - createEventFragmentSpy.getCall(0).args[0], - expectedPayload, - ); - }); - - it('Should finalize the transaction added fragment as abandoned if user rejects transaction', async function () { - fragmentExists = true; - await txController._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.rejected, - actionId, - ); - assert.equal(createEventFragmentSpy.callCount, 0); - assert.equal(finalizeEventFragmentSpy.callCount, 1); - assert.deepEqual( - finalizeEventFragmentSpy.getCall(0).args[0], - 'transaction-added-1', - ); - assert.deepEqual(finalizeEventFragmentSpy.getCall(0).args[1], { - abandoned: true, - }); - }); - - it('Should finalize the transaction added fragment if user approves transaction', async function () { - fragmentExists = true; - await txController._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.approved, - actionId, - ); - assert.equal(createEventFragmentSpy.callCount, 0); - assert.equal(finalizeEventFragmentSpy.callCount, 1); - assert.deepEqual( - finalizeEventFragmentSpy.getCall(0).args[0], - 'transaction-added-1', - ); - assert.deepEqual( - finalizeEventFragmentSpy.getCall(0).args[1], - undefined, - ); - }); - - it('should create an event fragment when transaction is submitted', async function () { - const expectedPayload = { - actionId, - initialEvent: 'Transaction Submitted', - successEvent: 'Transaction Finalized', - uniqueIdentifier: 'transaction-submitted-1', - category: MetaMetricsEventCategory.Transactions, - persist: true, - properties: { - chain_id: '0x5', - eip_1559_version: '0', - gas_edit_attempted: 'none', - gas_edit_type: 'none', - - referrer: ORIGIN_METAMASK, - source: MetaMetricsTransactionEventSource.User, - transaction_type: TransactionType.simpleSend, - asset_type: AssetType.native, - token_standard: TokenStandard.none, - transaction_speed_up: false, - ui_customizations: null, - security_alert_reason: BlockaidReason.notApplicable, - security_alert_response: BlockaidResultType.NotApplicable, - status: 'unapproved', - }, - sensitiveProperties: { - default_gas: '0.000031501', - default_gas_price: '2', - gas_price: '2', - gas_limit: '0x7b0d', - transaction_contract_method: undefined, - transaction_replaced: undefined, - first_seen: 1624408066355, - transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY, - }, - }; - - await txController._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.submitted, - actionId, - ); - assert.equal(createEventFragmentSpy.callCount, 1); - assert.equal(finalizeEventFragmentSpy.callCount, 0); - assert.deepEqual( - createEventFragmentSpy.getCall(0).args[0], - expectedPayload, - ); - }); - - it('Should finalize the transaction submitted fragment when transaction finalizes', async function () { - fragmentExists = true; - await txController._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.finalized, - actionId, - ); - assert.equal(createEventFragmentSpy.callCount, 0); - assert.equal(finalizeEventFragmentSpy.callCount, 1); - assert.deepEqual( - finalizeEventFragmentSpy.getCall(0).args[0], - 'transaction-submitted-1', - ); - assert.deepEqual( - finalizeEventFragmentSpy.getCall(0).args[1], - undefined, - ); - }); - }); - - describe('On transaction suggested by dapp', function () { - let txMeta; - before(function () { - txMeta = { - id: 1, - status: TransactionStatus.unapproved, - txParams: { - from: fromAccount.address, - to: '0x1678a085c290ebd122dc42cba69373b5953b831d', - gasPrice: '0x77359400', - gas: '0x7b0d', - nonce: '0x4b', - }, - type: TransactionType.simpleSend, - origin: 'other', - chainId: currentChainId, - time: 1624408066355, - defaultGasEstimates: { - gas: '0x7b0d', - gasPrice: '0x77359400', - }, - securityProviderResponse: { - flagAsDangerous: 0, - }, - }; - }); - - it('should create an event fragment when transaction added', async function () { - const expectedPayload = { - actionId, - initialEvent: 'Transaction Added', - successEvent: 'Transaction Approved', - failureEvent: 'Transaction Rejected', - uniqueIdentifier: 'transaction-added-1', - category: MetaMetricsEventCategory.Transactions, - persist: true, - properties: { - chain_id: '0x5', - eip_1559_version: '0', - gas_edit_attempted: 'none', - gas_edit_type: 'none', - - referrer: 'other', - source: MetaMetricsTransactionEventSource.Dapp, - transaction_type: TransactionType.simpleSend, - asset_type: AssetType.native, - token_standard: TokenStandard.none, - transaction_speed_up: false, - ui_customizations: null, - security_alert_reason: BlockaidReason.notApplicable, - security_alert_response: BlockaidResultType.NotApplicable, - status: 'unapproved', - }, - sensitiveProperties: { - default_gas: '0.000031501', - default_gas_price: '2', - gas_price: '2', - gas_limit: '0x7b0d', - transaction_contract_method: undefined, - transaction_replaced: undefined, - first_seen: 1624408066355, - transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY, - }, - }; - - await txController._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.added, - actionId, - ); - assert.equal(createEventFragmentSpy.callCount, 1); - assert.equal(finalizeEventFragmentSpy.callCount, 0); - assert.deepEqual( - createEventFragmentSpy.getCall(0).args[0], - expectedPayload, - ); - }); - - it('Should finalize the transaction added fragment as abandoned if user rejects transaction', async function () { - fragmentExists = true; - - await txController._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.rejected, - actionId, - ); - assert.equal(createEventFragmentSpy.callCount, 0); - assert.equal(finalizeEventFragmentSpy.callCount, 1); - assert.deepEqual( - finalizeEventFragmentSpy.getCall(0).args[0], - 'transaction-added-1', - ); - assert.deepEqual(finalizeEventFragmentSpy.getCall(0).args[1], { - abandoned: true, - }); - }); - - it('Should finalize the transaction added fragment if user approves transaction', async function () { - fragmentExists = true; - - await txController._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.approved, - actionId, - ); - assert.equal(createEventFragmentSpy.callCount, 0); - assert.equal(finalizeEventFragmentSpy.callCount, 1); - assert.deepEqual( - finalizeEventFragmentSpy.getCall(0).args[0], - 'transaction-added-1', - ); - assert.deepEqual( - finalizeEventFragmentSpy.getCall(0).args[1], - undefined, - ); - }); - - it('should create an event fragment when transaction is submitted', async function () { - const expectedPayload = { - actionId, - initialEvent: 'Transaction Submitted', - successEvent: 'Transaction Finalized', - uniqueIdentifier: 'transaction-submitted-1', - category: MetaMetricsEventCategory.Transactions, - persist: true, - properties: { - chain_id: '0x5', - eip_1559_version: '0', - gas_edit_attempted: 'none', - gas_edit_type: 'none', - - referrer: 'other', - source: MetaMetricsTransactionEventSource.Dapp, - transaction_type: TransactionType.simpleSend, - asset_type: AssetType.native, - token_standard: TokenStandard.none, - transaction_speed_up: false, - ui_customizations: null, - security_alert_reason: BlockaidReason.notApplicable, - security_alert_response: BlockaidResultType.NotApplicable, - status: 'unapproved', - }, - sensitiveProperties: { - default_gas: '0.000031501', - default_gas_price: '2', - gas_price: '2', - gas_limit: '0x7b0d', - transaction_contract_method: undefined, - transaction_replaced: undefined, - first_seen: 1624408066355, - transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY, - }, - }; - - await txController._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.submitted, - actionId, - ); - assert.equal(createEventFragmentSpy.callCount, 1); - assert.equal(finalizeEventFragmentSpy.callCount, 0); - assert.deepEqual( - createEventFragmentSpy.getCall(0).args[0], - expectedPayload, - ); - }); - - it('Should finalize the transaction submitted fragment when transaction finalizes', async function () { - fragmentExists = true; - - await txController._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.finalized, - actionId, - ); - assert.equal(createEventFragmentSpy.callCount, 0); - assert.equal(finalizeEventFragmentSpy.callCount, 1); - assert.deepEqual( - finalizeEventFragmentSpy.getCall(0).args[0], - 'transaction-submitted-1', - ); - assert.deepEqual( - finalizeEventFragmentSpy.getCall(0).args[1], - undefined, - ); - }); - }); - - it('should create missing fragments when events happen out of order or are missing', async function () { - const txMeta = { - id: 1, - status: TransactionStatus.unapproved, - txParams: { - from: fromAccount.address, - to: '0x1678a085c290ebd122dc42cba69373b5953b831d', - gasPrice: '0x77359400', - gas: '0x7b0d', - nonce: '0x4b', - }, - type: TransactionType.simpleSend, - origin: 'other', - chainId: currentChainId, - time: 1624408066355, - securityProviderResponse: { - flagAsDangerous: 0, - }, - securityAlertResponse: { - security_alert_reason: BlockaidReason.notApplicable, - security_alert_response: BlockaidResultType.NotApplicable, - }, - }; - - const expectedPayload = { - actionId, - successEvent: 'Transaction Approved', - failureEvent: 'Transaction Rejected', - uniqueIdentifier: 'transaction-added-1', - category: MetaMetricsEventCategory.Transactions, - persist: true, - properties: { - chain_id: '0x5', - eip_1559_version: '0', - gas_edit_attempted: 'none', - gas_edit_type: 'none', - - referrer: 'other', - source: MetaMetricsTransactionEventSource.Dapp, - transaction_type: TransactionType.simpleSend, - asset_type: AssetType.native, - token_standard: TokenStandard.none, - transaction_speed_up: false, - ui_customizations: null, - security_alert_reason: BlockaidReason.notApplicable, - security_alert_response: BlockaidResultType.NotApplicable, - status: 'unapproved', - }, - sensitiveProperties: { - gas_price: '2', - gas_limit: '0x7b0d', - transaction_contract_method: undefined, - transaction_replaced: undefined, - first_seen: 1624408066355, - transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY, - }, - }; - await txController._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.approved, - actionId, - ); - assert.equal(createEventFragmentSpy.callCount, 1); - assert.deepEqual( - createEventFragmentSpy.getCall(0).args[0], - expectedPayload, - ); - assert.equal(finalizeEventFragmentSpy.callCount, 1); - assert.deepEqual( - finalizeEventFragmentSpy.getCall(0).args[0], - 'transaction-added-1', - ); - assert.deepEqual(finalizeEventFragmentSpy.getCall(0).args[1], undefined); - }); - - it('should call _trackMetaMetricsEvent with the correct payload (extra params)', async function () { - const txMeta = { - id: 1, - status: TransactionStatus.unapproved, - txParams: { - from: fromAccount.address, - to: '0x1678a085c290ebd122dc42cba69373b5953b831d', - gasPrice: '0x77359400', - gas: '0x7b0d', - nonce: '0x4b', - }, - type: TransactionType.simpleSend, - origin: 'other', - chainId: currentChainId, - time: 1624408066355, - securityProviderResponse: { - flagAsDangerous: 0, - }, - }; - const expectedPayload = { - actionId, - initialEvent: 'Transaction Added', - successEvent: 'Transaction Approved', - failureEvent: 'Transaction Rejected', - uniqueIdentifier: 'transaction-added-1', - persist: true, - category: MetaMetricsEventCategory.Transactions, - properties: { - referrer: 'other', - source: MetaMetricsTransactionEventSource.Dapp, - transaction_type: TransactionType.simpleSend, - chain_id: '0x5', - eip_1559_version: '0', - gas_edit_attempted: 'none', - gas_edit_type: 'none', - asset_type: AssetType.native, - token_standard: TokenStandard.none, - transaction_speed_up: false, - ui_customizations: null, - security_alert_reason: BlockaidReason.notApplicable, - security_alert_response: BlockaidResultType.NotApplicable, - status: 'unapproved', - }, - sensitiveProperties: { - baz: 3.0, - foo: 'bar', - gas_price: '2', - gas_limit: '0x7b0d', - transaction_contract_method: undefined, - transaction_replaced: undefined, - first_seen: 1624408066355, - transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY, - }, - }; - - await txController._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.added, - actionId, - { - baz: 3.0, - foo: 'bar', - }, - ); - assert.equal(createEventFragmentSpy.callCount, 1); - assert.equal(finalizeEventFragmentSpy.callCount, 0); - assert.deepEqual( - createEventFragmentSpy.getCall(0).args[0], - expectedPayload, - ); - }); - - it('should call _trackMetaMetricsEvent with the correct payload when blockaid verification fails', async function () { - const txMeta = { - id: 1, - status: TransactionStatus.unapproved, - txParams: { - from: fromAccount.address, - to: '0x1678a085c290ebd122dc42cba69373b5953b831d', - gasPrice: '0x77359400', - gas: '0x7b0d', - nonce: '0x4b', - }, - type: TransactionType.simpleSend, - origin: 'other', - chainId: currentChainId, - time: 1624408066355, - securityAlertResponse: { - result_type: BlockaidResultType.Failed, - reason: 'some error', - }, - }; - const expectedPayload = { - actionId, - initialEvent: 'Transaction Added', - successEvent: 'Transaction Approved', - failureEvent: 'Transaction Rejected', - uniqueIdentifier: 'transaction-added-1', - persist: true, - category: MetaMetricsEventCategory.Transactions, - properties: { - referrer: 'other', - source: MetaMetricsTransactionEventSource.Dapp, - status: 'unapproved', - transaction_type: TransactionType.simpleSend, - chain_id: '0x5', - eip_1559_version: '0', - gas_edit_attempted: 'none', - gas_edit_type: 'none', - asset_type: AssetType.native, - token_standard: TokenStandard.none, - transaction_speed_up: false, - ui_customizations: ['security_alert_failed'], - security_alert_reason: 'some error', - security_alert_response: BlockaidResultType.Failed, - }, - sensitiveProperties: { - baz: 3.0, - foo: 'bar', - gas_price: '2', - gas_limit: '0x7b0d', - transaction_contract_method: undefined, - transaction_replaced: undefined, - first_seen: 1624408066355, - transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY, - }, - }; - - await txController._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.added, - actionId, - { - baz: 3.0, - foo: 'bar', - }, - ); - assert.equal(createEventFragmentSpy.callCount, 1); - assert.equal(finalizeEventFragmentSpy.callCount, 0); - assert.deepEqual( - createEventFragmentSpy.getCall(0).args[0], - expectedPayload, - ); - }); - - it('should call _trackMetaMetricsEvent with the correct payload (extra params) when flagAsDangerous is malicious', async function () { - const txMeta = { - id: 1, - status: TransactionStatus.unapproved, - txParams: { - from: fromAccount.address, - to: '0x1678a085c290ebd122dc42cba69373b5953b831d', - gasPrice: '0x77359400', - gas: '0x7b0d', - nonce: '0x4b', - }, - type: TransactionType.simpleSend, - origin: 'other', - chainId: currentChainId, - time: 1624408066355, - securityProviderResponse: { - flagAsDangerous: 1, - }, - }; - const expectedPayload = { - actionId, - initialEvent: 'Transaction Added', - successEvent: 'Transaction Approved', - failureEvent: 'Transaction Rejected', - uniqueIdentifier: 'transaction-added-1', - persist: true, - category: MetaMetricsEventCategory.Transactions, - properties: { - referrer: 'other', - source: MetaMetricsTransactionEventSource.Dapp, - transaction_type: TransactionType.simpleSend, - chain_id: '0x5', - eip_1559_version: '0', - gas_edit_attempted: 'none', - gas_edit_type: 'none', - asset_type: AssetType.native, - token_standard: TokenStandard.none, - transaction_speed_up: false, - ui_customizations: ['flagged_as_malicious'], - security_alert_reason: BlockaidReason.notApplicable, - security_alert_response: BlockaidResultType.NotApplicable, - status: 'unapproved', - }, - sensitiveProperties: { - baz: 3.0, - foo: 'bar', - gas_price: '2', - gas_limit: '0x7b0d', - transaction_contract_method: undefined, - transaction_replaced: undefined, - first_seen: 1624408066355, - transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY, - }, - }; - - await txController._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.added, - actionId, - { - baz: 3.0, - foo: 'bar', - }, - ); - assert.equal(createEventFragmentSpy.callCount, 1); - assert.equal(finalizeEventFragmentSpy.callCount, 0); - assert.deepEqual( - createEventFragmentSpy.getCall(0).args[0], - expectedPayload, - ); - }); - - it('should call _trackMetaMetricsEvent with the correct payload (extra params) when flagAsDangerous is unknown', async function () { - const txMeta = { - id: 1, - status: TransactionStatus.unapproved, - txParams: { - from: fromAccount.address, - to: '0x1678a085c290ebd122dc42cba69373b5953b831d', - gasPrice: '0x77359400', - gas: '0x7b0d', - nonce: '0x4b', - }, - type: TransactionType.simpleSend, - origin: 'other', - chainId: currentChainId, - time: 1624408066355, - securityProviderResponse: { - flagAsDangerous: 2, - }, - }; - const expectedPayload = { - actionId, - initialEvent: 'Transaction Added', - successEvent: 'Transaction Approved', - failureEvent: 'Transaction Rejected', - uniqueIdentifier: 'transaction-added-1', - persist: true, - category: MetaMetricsEventCategory.Transactions, - properties: { - referrer: 'other', - source: MetaMetricsTransactionEventSource.Dapp, - transaction_type: TransactionType.simpleSend, - chain_id: '0x5', - eip_1559_version: '0', - gas_edit_attempted: 'none', - gas_edit_type: 'none', - asset_type: AssetType.native, - token_standard: TokenStandard.none, - transaction_speed_up: false, - ui_customizations: ['flagged_as_safety_unknown'], - security_alert_reason: BlockaidReason.notApplicable, - security_alert_response: BlockaidResultType.NotApplicable, - status: 'unapproved', - }, - sensitiveProperties: { - baz: 3.0, - foo: 'bar', - gas_price: '2', - gas_limit: '0x7b0d', - transaction_contract_method: undefined, - transaction_replaced: undefined, - first_seen: 1624408066355, - transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY, - }, - }; - - await txController._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.added, - actionId, - { - baz: 3.0, - foo: 'bar', - }, - ); - assert.equal(createEventFragmentSpy.callCount, 1); - assert.equal(finalizeEventFragmentSpy.callCount, 0); - assert.deepEqual( - createEventFragmentSpy.getCall(0).args[0], - expectedPayload, - ); - }); - - it('should call _trackMetaMetricsEvent with the correct payload (EIP-1559)', async function () { - const txMeta = { - id: 1, - status: TransactionStatus.unapproved, - txParams: { - from: fromAccount.address, - to: '0x1678a085c290ebd122dc42cba69373b5953b831d', - maxFeePerGas: '0x77359400', - maxPriorityFeePerGas: '0x77359400', - gas: '0x7b0d', - nonce: '0x4b', - estimateSuggested: GasRecommendations.medium, - estimateUsed: GasRecommendations.high, - }, - type: TransactionType.simpleSend, - origin: 'other', - chainId: currentChainId, - time: 1624408066355, - defaultGasEstimates: { - estimateType: 'medium', - maxFeePerGas: '0x77359400', - maxPriorityFeePerGas: '0x77359400', - }, - securityProviderResponse: { - flagAsDangerous: 0, - }, - }; - const expectedPayload = { - actionId, - initialEvent: 'Transaction Added', - successEvent: 'Transaction Approved', - failureEvent: 'Transaction Rejected', - uniqueIdentifier: 'transaction-added-1', - persist: true, - category: MetaMetricsEventCategory.Transactions, - properties: { - chain_id: '0x5', - eip_1559_version: '2', - gas_edit_attempted: 'none', - gas_edit_type: 'none', - - referrer: 'other', - source: MetaMetricsTransactionEventSource.Dapp, - transaction_type: TransactionType.simpleSend, - asset_type: AssetType.native, - token_standard: TokenStandard.none, - transaction_speed_up: false, - ui_customizations: null, - security_alert_reason: BlockaidReason.notApplicable, - security_alert_response: BlockaidResultType.NotApplicable, - status: 'unapproved', - }, - sensitiveProperties: { - baz: 3.0, - foo: 'bar', - max_fee_per_gas: '2', - max_priority_fee_per_gas: '2', - gas_limit: '0x7b0d', - transaction_contract_method: undefined, - transaction_replaced: undefined, - first_seen: 1624408066355, - transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.FEE_MARKET, - estimate_suggested: GasRecommendations.medium, - estimate_used: GasRecommendations.high, - default_estimate: 'medium', - default_max_fee_per_gas: '70', - default_max_priority_fee_per_gas: '7', - }, - }; - - await txController._trackTransactionMetricsEvent( - txMeta, - TransactionMetaMetricsEvent.added, - actionId, - { - baz: 3.0, - foo: 'bar', - }, - ); - assert.equal(createEventFragmentSpy.callCount, 1); - assert.equal(finalizeEventFragmentSpy.callCount, 0); - assert.deepEqual( - createEventFragmentSpy.getCall(0).args[0], - expectedPayload, - ); - }); - }); - - describe('#_getTransactionCompletionTime', function () { - let nowStub; - - beforeEach(function () { - nowStub = sinon.stub(Date, 'now').returns(1625782016341); - }); - - afterEach(function () { - nowStub.restore(); - }); - - it('calculates completion time (one)', function () { - const submittedTime = 1625781997397; - const result = txController._getTransactionCompletionTime(submittedTime); - assert.equal(result, '19'); - }); - - it('calculates completion time (two)', function () { - const submittedTime = 1625781995397; - const result = txController._getTransactionCompletionTime(submittedTime); - assert.equal(result, '21'); - }); - }); - describe('#_getGasValuesInGWEI', function () { it('converts gas values in hex GWEi to dec GWEI (EIP-1559)', function () { const params = { diff --git a/app/scripts/lib/createRPCMethodTrackingMiddleware.js b/app/scripts/lib/createRPCMethodTrackingMiddleware.js index 610b5ede2e6e..825994c391ec 100644 --- a/app/scripts/lib/createRPCMethodTrackingMiddleware.js +++ b/app/scripts/lib/createRPCMethodTrackingMiddleware.js @@ -17,7 +17,7 @@ import { } from '../../../shared/constants/security-provider'; ///: END:ONLY_INCLUDE_IN -import getSnapAndHardwareInfoForMetrics from './snap-keyring/metrics'; +import { getSnapAndHardwareInfoForMetrics } from './snap-keyring/metrics'; /** * These types determine how the method tracking middleware handles incoming diff --git a/app/scripts/lib/snap-keyring/metrics.ts b/app/scripts/lib/snap-keyring/metrics.ts index b22036f89f17..9036b8529435 100644 --- a/app/scripts/lib/snap-keyring/metrics.ts +++ b/app/scripts/lib/snap-keyring/metrics.ts @@ -13,7 +13,7 @@ export type SnapAndHardwareMessenger = RestrictedControllerMessenger< never >; -export default async function getSnapAndHardwareInfoForMetrics( +export async function getSnapAndHardwareInfoForMetrics( getSelectedAddress: () => string, getAccountType: (address: string) => Promise, getDeviceModel: (address: string) => Promise, diff --git a/app/scripts/lib/transaction-metrics.test.ts b/app/scripts/lib/transaction-metrics.test.ts new file mode 100644 index 000000000000..8634e3201465 --- /dev/null +++ b/app/scripts/lib/transaction-metrics.test.ts @@ -0,0 +1,673 @@ +import { Provider } from '@metamask/network-controller'; +import { + createTestProviderTools, + getTestAccounts, +} from '../../../test/stub/provider'; +import { ORIGIN_METAMASK } from '../../../shared/constants/app'; +import { + TransactionType, + TransactionStatus, + AssetType, + TokenStandard, + TransactionMetaMetricsEvent, +} from '../../../shared/constants/transaction'; +import { + MetaMetricsTransactionEventSource, + MetaMetricsEventCategory, +} from '../../../shared/constants/metametrics'; +import { TRANSACTION_ENVELOPE_TYPE_NAMES } from '../../../shared/lib/transactions-controller-utils'; +///: BEGIN:ONLY_INCLUDE_IN(blockaid) +import { BlockaidReason } from '../../../shared/constants/security-provider'; +///: END:ONLY_INCLUDE_IN(blockaid) +import { + handleTransactionAdded, + handleTransactionApproved, + handleTransactionDropped, + handleTransactionFinalized, + handleTransactionRejected, + handleTransactionSubmitted, + METRICS_STATUS_FAILED, +} from './transaction-metrics'; + +const providerResultStub = { + eth_getCode: '0x123', +}; +const { provider } = createTestProviderTools({ + scaffold: providerResultStub, + networkId: '5', + chainId: '5', +}); + +jest.mock('./snap-keyring/metrics', () => { + return { + getSnapAndHardwareInfoForMetrics: jest.fn().mockResolvedValue({ + account_snap_type: 'snaptype', + account_snap_version: 'snapversion', + }), + }; +}); + +const mockTransactionMetricsRequest = { + createEventFragment: jest.fn(), + finalizeEventFragment: jest.fn(), + getEventFragmentById: jest.fn(), + updateEventFragment: jest.fn(), + getAccountType: jest.fn(), + getDeviceModel: jest.fn(), + getEIP1559GasFeeEstimates: jest.fn(), + getSelectedAddress: jest.fn(), + getTokenStandardAndDetails: jest.fn(), + getTransaction: jest.fn(), + snapAndHardwareMessenger: jest.fn() as any, + provider: provider as Provider, +}; + +describe('Transaction metrics', () => { + let fromAccount, + mockChainId, + mockNetworkId, + mockTransactionMeta, + mockActionId; + + beforeEach(() => { + fromAccount = getTestAccounts()[0]; + mockChainId = '5'; + mockNetworkId = '5'; + mockActionId = '2'; + mockTransactionMeta = { + id: '1', + status: TransactionStatus.unapproved, + txParams: { + from: fromAccount.address, + to: '0x1678a085c290ebd122dc42cba69373b5953b831d', + gasPrice: '0x77359400', + gas: '0x7b0d', + nonce: '0x4b', + }, + type: TransactionType.simpleSend, + origin: ORIGIN_METAMASK, + chainId: mockChainId, + time: 1624408066355, + metamaskNetworkId: mockNetworkId, + defaultGasEstimates: { + gas: '0x7b0d', + gasPrice: '0x77359400', + }, + securityProviderResponse: { + flagAsDangerous: 0, + }, + }; + + jest.clearAllMocks(); + }); + + describe('handleTransactionAdded', () => { + it('should return if transaction meta is not defined', async () => { + await handleTransactionAdded(mockTransactionMetricsRequest, {} as any); + expect( + mockTransactionMetricsRequest.createEventFragment, + ).not.toBeCalled(); + }); + + it('should create event fragment', async () => { + await handleTransactionAdded(mockTransactionMetricsRequest, { + transactionMeta: mockTransactionMeta as any, + actionId: mockActionId, + }); + + expect(mockTransactionMetricsRequest.createEventFragment).toBeCalledTimes( + 1, + ); + expect(mockTransactionMetricsRequest.createEventFragment).toBeCalledWith({ + actionId: mockActionId, + category: MetaMetricsEventCategory.Transactions, + failureEvent: TransactionMetaMetricsEvent.rejected, + initialEvent: TransactionMetaMetricsEvent.added, + successEvent: TransactionMetaMetricsEvent.approved, + uniqueIdentifier: 'transaction-added-1', + persist: true, + properties: { + account_snap_type: 'snaptype', + account_snap_version: 'snapversion', + account_type: undefined, + asset_type: AssetType.native, + chain_id: mockChainId, + device_model: undefined, + eip_1559_version: '0', + gas_edit_attempted: 'none', + gas_edit_type: 'none', + network: mockNetworkId, + referrer: ORIGIN_METAMASK, + security_alert_reason: BlockaidReason.notApplicable, + security_alert_response: BlockaidReason.notApplicable, + source: MetaMetricsTransactionEventSource.User, + status: 'unapproved', + token_standard: TokenStandard.none, + transaction_speed_up: false, + transaction_type: TransactionType.simpleSend, + ui_customizations: null, + }, + sensitiveProperties: { + default_gas: '0.000031501', + default_gas_price: '2', + first_seen: 1624408066355, + gas_limit: '0x7b0d', + gas_price: '2', + transaction_contract_method: undefined, + transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY, + transaction_replaced: undefined, + }, + }); + }); + }); + + describe('handleTransactionApproved', () => { + it('should return if transaction meta is not defined', async () => { + await handleTransactionApproved(mockTransactionMetricsRequest, {} as any); + expect( + mockTransactionMetricsRequest.createEventFragment, + ).not.toBeCalled(); + expect( + mockTransactionMetricsRequest.updateEventFragment, + ).not.toBeCalled(); + expect( + mockTransactionMetricsRequest.finalizeEventFragment, + ).not.toBeCalled(); + }); + + it('should create, update, finalize event fragment', async () => { + await handleTransactionApproved(mockTransactionMetricsRequest, { + transactionMeta: mockTransactionMeta as any, + actionId: mockActionId, + }); + + const expectedUniqueId = 'transaction-added-1'; + const expectedProperties = { + account_snap_type: 'snaptype', + account_snap_version: 'snapversion', + account_type: undefined, + asset_type: AssetType.native, + chain_id: mockChainId, + device_model: undefined, + eip_1559_version: '0', + gas_edit_attempted: 'none', + gas_edit_type: 'none', + network: mockNetworkId, + referrer: ORIGIN_METAMASK, + security_alert_reason: BlockaidReason.notApplicable, + security_alert_response: BlockaidReason.notApplicable, + source: MetaMetricsTransactionEventSource.User, + status: 'unapproved', + token_standard: TokenStandard.none, + transaction_speed_up: false, + transaction_type: TransactionType.simpleSend, + ui_customizations: null, + }; + + const expectedSensitiveProperties = { + default_gas: '0.000031501', + default_gas_price: '2', + first_seen: 1624408066355, + gas_limit: '0x7b0d', + gas_price: '2', + transaction_contract_method: undefined, + transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY, + transaction_replaced: undefined, + }; + + expect(mockTransactionMetricsRequest.createEventFragment).toBeCalledTimes( + 1, + ); + expect(mockTransactionMetricsRequest.createEventFragment).toBeCalledWith({ + actionId: mockActionId, + category: MetaMetricsEventCategory.Transactions, + successEvent: TransactionMetaMetricsEvent.approved, + failureEvent: TransactionMetaMetricsEvent.rejected, + uniqueIdentifier: expectedUniqueId, + persist: true, + properties: expectedProperties, + sensitiveProperties: expectedSensitiveProperties, + }); + + expect(mockTransactionMetricsRequest.updateEventFragment).toBeCalledTimes( + 1, + ); + expect(mockTransactionMetricsRequest.updateEventFragment).toBeCalledWith( + expectedUniqueId, + { + properties: expectedProperties, + sensitiveProperties: expectedSensitiveProperties, + }, + ); + + expect( + mockTransactionMetricsRequest.finalizeEventFragment, + ).toBeCalledTimes(1); + expect( + mockTransactionMetricsRequest.finalizeEventFragment, + ).toBeCalledWith(expectedUniqueId); + }); + }); + + describe('handleTransactionFinalized', () => { + it('should return if transaction meta is not defined', async () => { + await handleTransactionFinalized( + mockTransactionMetricsRequest, + {} as any, + ); + expect( + mockTransactionMetricsRequest.createEventFragment, + ).not.toBeCalled(); + expect( + mockTransactionMetricsRequest.updateEventFragment, + ).not.toBeCalled(); + expect( + mockTransactionMetricsRequest.finalizeEventFragment, + ).not.toBeCalled(); + }); + + it('should create, update, finalize event fragment', async () => { + mockTransactionMeta.txReceipt = { + gasUsed: '0x123', + status: '0x0', + }; + mockTransactionMeta.submittedTime = 123; + + await handleTransactionFinalized(mockTransactionMetricsRequest, { + transactionMeta: mockTransactionMeta, + actionId: mockActionId, + } as any); + + const expectedUniqueId = 'transaction-submitted-1'; + const expectedProperties = { + account_snap_type: 'snaptype', + account_snap_version: 'snapversion', + account_type: undefined, + asset_type: AssetType.native, + chain_id: mockChainId, + device_model: undefined, + eip_1559_version: '0', + gas_edit_attempted: 'none', + gas_edit_type: 'none', + network: mockNetworkId, + referrer: ORIGIN_METAMASK, + security_alert_reason: BlockaidReason.notApplicable, + security_alert_response: BlockaidReason.notApplicable, + source: MetaMetricsTransactionEventSource.User, + status: 'unapproved', + token_standard: TokenStandard.none, + transaction_speed_up: false, + transaction_type: TransactionType.simpleSend, + ui_customizations: null, + }; + + const expectedSensitiveProperties = { + completion_time: expect.any(String), + default_gas: '0.000031501', + default_gas_price: '2', + first_seen: 1624408066355, + gas_limit: '0x7b0d', + gas_price: '2', + gas_used: '0.000000291', + transaction_contract_method: undefined, + transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY, + transaction_replaced: undefined, + status: METRICS_STATUS_FAILED, + }; + + expect(mockTransactionMetricsRequest.createEventFragment).toBeCalledTimes( + 1, + ); + expect(mockTransactionMetricsRequest.createEventFragment).toBeCalledWith({ + actionId: mockActionId, + category: MetaMetricsEventCategory.Transactions, + successEvent: TransactionMetaMetricsEvent.finalized, + uniqueIdentifier: expectedUniqueId, + persist: true, + properties: expectedProperties, + sensitiveProperties: expectedSensitiveProperties, + }); + + expect(mockTransactionMetricsRequest.updateEventFragment).toBeCalledTimes( + 1, + ); + expect(mockTransactionMetricsRequest.updateEventFragment).toBeCalledWith( + expectedUniqueId, + { + properties: expectedProperties, + sensitiveProperties: expectedSensitiveProperties, + }, + ); + + expect( + mockTransactionMetricsRequest.finalizeEventFragment, + ).toBeCalledTimes(1); + expect( + mockTransactionMetricsRequest.finalizeEventFragment, + ).toBeCalledWith(expectedUniqueId); + }); + + it('should append error to event properties', async () => { + const mockErrorMessage = 'Unexpected error'; + + await handleTransactionFinalized(mockTransactionMetricsRequest, { + transactionMeta: mockTransactionMeta, + actionId: mockActionId, + error: mockErrorMessage, + } as any); + + const expectedUniqueId = 'transaction-submitted-1'; + const expectedProperties = { + account_snap_type: 'snaptype', + account_snap_version: 'snapversion', + account_type: undefined, + asset_type: AssetType.native, + chain_id: mockChainId, + device_model: undefined, + eip_1559_version: '0', + gas_edit_attempted: 'none', + gas_edit_type: 'none', + network: mockNetworkId, + referrer: ORIGIN_METAMASK, + security_alert_reason: BlockaidReason.notApplicable, + security_alert_response: BlockaidReason.notApplicable, + source: MetaMetricsTransactionEventSource.User, + status: 'unapproved', + token_standard: TokenStandard.none, + transaction_speed_up: false, + transaction_type: TransactionType.simpleSend, + ui_customizations: null, + }; + + const expectedSensitiveProperties = { + default_gas: '0.000031501', + default_gas_price: '2', + error: mockErrorMessage, + first_seen: 1624408066355, + gas_limit: '0x7b0d', + gas_price: '2', + transaction_contract_method: undefined, + transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY, + transaction_replaced: undefined, + }; + + expect(mockTransactionMetricsRequest.createEventFragment).toBeCalledTimes( + 1, + ); + expect(mockTransactionMetricsRequest.createEventFragment).toBeCalledWith({ + actionId: mockActionId, + category: MetaMetricsEventCategory.Transactions, + successEvent: TransactionMetaMetricsEvent.finalized, + uniqueIdentifier: expectedUniqueId, + persist: true, + properties: expectedProperties, + sensitiveProperties: expectedSensitiveProperties, + }); + + expect(mockTransactionMetricsRequest.updateEventFragment).toBeCalledTimes( + 1, + ); + expect(mockTransactionMetricsRequest.updateEventFragment).toBeCalledWith( + expectedUniqueId, + { + properties: expectedProperties, + sensitiveProperties: expectedSensitiveProperties, + }, + ); + + expect( + mockTransactionMetricsRequest.finalizeEventFragment, + ).toBeCalledTimes(1); + expect( + mockTransactionMetricsRequest.finalizeEventFragment, + ).toBeCalledWith(expectedUniqueId); + }); + }); + + describe('handleTransactionDropped', () => { + it('should return if transaction meta is not defined', async () => { + await handleTransactionDropped(mockTransactionMetricsRequest, {} as any); + expect( + mockTransactionMetricsRequest.createEventFragment, + ).not.toBeCalled(); + expect( + mockTransactionMetricsRequest.updateEventFragment, + ).not.toBeCalled(); + expect( + mockTransactionMetricsRequest.finalizeEventFragment, + ).not.toBeCalled(); + }); + + it('should create, update, finalize event fragment', async () => { + await handleTransactionDropped(mockTransactionMetricsRequest, { + transactionMeta: mockTransactionMeta, + actionId: mockActionId, + } as any); + + const expectedUniqueId = 'transaction-submitted-1'; + const expectedProperties = { + account_snap_type: 'snaptype', + account_snap_version: 'snapversion', + account_type: undefined, + asset_type: AssetType.native, + chain_id: mockChainId, + device_model: undefined, + eip_1559_version: '0', + gas_edit_attempted: 'none', + gas_edit_type: 'none', + network: mockNetworkId, + referrer: ORIGIN_METAMASK, + security_alert_reason: BlockaidReason.notApplicable, + security_alert_response: BlockaidReason.notApplicable, + source: MetaMetricsTransactionEventSource.User, + status: 'unapproved', + token_standard: TokenStandard.none, + transaction_speed_up: false, + transaction_type: TransactionType.simpleSend, + ui_customizations: null, + }; + + const expectedSensitiveProperties = { + default_gas: '0.000031501', + default_gas_price: '2', + dropped: true, + first_seen: 1624408066355, + gas_limit: '0x7b0d', + gas_price: '2', + transaction_contract_method: undefined, + transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY, + transaction_replaced: 'other', + }; + + expect(mockTransactionMetricsRequest.createEventFragment).toBeCalledTimes( + 1, + ); + expect(mockTransactionMetricsRequest.createEventFragment).toBeCalledWith({ + actionId: mockActionId, + category: MetaMetricsEventCategory.Transactions, + successEvent: TransactionMetaMetricsEvent.finalized, + uniqueIdentifier: expectedUniqueId, + persist: true, + properties: expectedProperties, + sensitiveProperties: expectedSensitiveProperties, + }); + + expect(mockTransactionMetricsRequest.updateEventFragment).toBeCalledTimes( + 1, + ); + expect(mockTransactionMetricsRequest.updateEventFragment).toBeCalledWith( + expectedUniqueId, + { + properties: expectedProperties, + sensitiveProperties: expectedSensitiveProperties, + }, + ); + + expect( + mockTransactionMetricsRequest.finalizeEventFragment, + ).toBeCalledTimes(1); + expect( + mockTransactionMetricsRequest.finalizeEventFragment, + ).toBeCalledWith(expectedUniqueId); + }); + }); + + describe('handleTransactionRejected', () => { + it('should return if transaction meta is not defined', async () => { + await handleTransactionRejected(mockTransactionMetricsRequest, {} as any); + expect( + mockTransactionMetricsRequest.createEventFragment, + ).not.toBeCalled(); + expect( + mockTransactionMetricsRequest.updateEventFragment, + ).not.toBeCalled(); + expect( + mockTransactionMetricsRequest.finalizeEventFragment, + ).not.toBeCalled(); + }); + + it('should create, update, finalize event fragment', async () => { + await handleTransactionRejected(mockTransactionMetricsRequest, { + transactionMeta: mockTransactionMeta, + actionId: mockActionId, + } as any); + + const expectedUniqueId = 'transaction-added-1'; + const expectedProperties = { + account_snap_type: 'snaptype', + account_snap_version: 'snapversion', + account_type: undefined, + asset_type: AssetType.native, + chain_id: mockChainId, + device_model: undefined, + eip_1559_version: '0', + gas_edit_attempted: 'none', + gas_edit_type: 'none', + network: mockNetworkId, + referrer: ORIGIN_METAMASK, + security_alert_reason: BlockaidReason.notApplicable, + security_alert_response: BlockaidReason.notApplicable, + source: MetaMetricsTransactionEventSource.User, + status: 'unapproved', + token_standard: TokenStandard.none, + transaction_speed_up: false, + transaction_type: TransactionType.simpleSend, + ui_customizations: null, + }; + + const expectedSensitiveProperties = { + default_gas: '0.000031501', + default_gas_price: '2', + first_seen: 1624408066355, + gas_limit: '0x7b0d', + gas_price: '2', + transaction_contract_method: undefined, + transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY, + transaction_replaced: undefined, + }; + + expect(mockTransactionMetricsRequest.createEventFragment).toBeCalledTimes( + 1, + ); + expect(mockTransactionMetricsRequest.createEventFragment).toBeCalledWith({ + actionId: mockActionId, + category: MetaMetricsEventCategory.Transactions, + successEvent: TransactionMetaMetricsEvent.approved, + failureEvent: TransactionMetaMetricsEvent.rejected, + uniqueIdentifier: expectedUniqueId, + persist: true, + properties: expectedProperties, + sensitiveProperties: expectedSensitiveProperties, + }); + + expect(mockTransactionMetricsRequest.updateEventFragment).toBeCalledTimes( + 1, + ); + expect(mockTransactionMetricsRequest.updateEventFragment).toBeCalledWith( + expectedUniqueId, + { + properties: expectedProperties, + sensitiveProperties: expectedSensitiveProperties, + }, + ); + + expect( + mockTransactionMetricsRequest.finalizeEventFragment, + ).toBeCalledTimes(1); + expect( + mockTransactionMetricsRequest.finalizeEventFragment, + ).toBeCalledWith(expectedUniqueId, { + abandoned: true, + }); + }); + }); + + describe('handleTransactionSubmitted', () => { + it('should return if transaction meta is not defined', async () => { + await handleTransactionSubmitted( + mockTransactionMetricsRequest, + {} as any, + ); + expect( + mockTransactionMetricsRequest.createEventFragment, + ).not.toBeCalled(); + }); + + it('should only create event fragment', async () => { + await handleTransactionSubmitted(mockTransactionMetricsRequest, { + transactionMeta: mockTransactionMeta as any, + actionId: mockActionId, + }); + + expect(mockTransactionMetricsRequest.createEventFragment).toBeCalledTimes( + 1, + ); + expect(mockTransactionMetricsRequest.createEventFragment).toBeCalledWith({ + actionId: mockActionId, + category: MetaMetricsEventCategory.Transactions, + initialEvent: TransactionMetaMetricsEvent.submitted, + successEvent: TransactionMetaMetricsEvent.finalized, + uniqueIdentifier: 'transaction-submitted-1', + persist: true, + properties: { + account_snap_type: 'snaptype', + account_snap_version: 'snapversion', + account_type: undefined, + asset_type: AssetType.native, + chain_id: mockChainId, + device_model: undefined, + eip_1559_version: '0', + gas_edit_attempted: 'none', + gas_edit_type: 'none', + network: mockNetworkId, + referrer: ORIGIN_METAMASK, + security_alert_reason: BlockaidReason.notApplicable, + security_alert_response: BlockaidReason.notApplicable, + source: MetaMetricsTransactionEventSource.User, + status: 'unapproved', + token_standard: TokenStandard.none, + transaction_speed_up: false, + transaction_type: TransactionType.simpleSend, + ui_customizations: null, + }, + sensitiveProperties: { + default_gas: '0.000031501', + default_gas_price: '2', + first_seen: 1624408066355, + gas_limit: '0x7b0d', + gas_price: '2', + transaction_contract_method: undefined, + transaction_envelope_type: TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY, + transaction_replaced: undefined, + }, + }); + + expect( + mockTransactionMetricsRequest.updateEventFragment, + ).not.toBeCalled(); + expect( + mockTransactionMetricsRequest.finalizeEventFragment, + ).not.toBeCalled(); + }); + }); +}); diff --git a/app/scripts/lib/transaction-metrics.ts b/app/scripts/lib/transaction-metrics.ts new file mode 100644 index 000000000000..01e149bc45a6 --- /dev/null +++ b/app/scripts/lib/transaction-metrics.ts @@ -0,0 +1,912 @@ +import { isHexString } from 'ethereumjs-util'; +import EthQuery from 'eth-query'; +import { BigNumber } from 'bignumber.js'; +import type { Provider } from '@metamask/network-controller'; +import { FetchGasFeeEstimateOptions } from '@metamask/gas-fee-controller'; + +import { ORIGIN_METAMASK } from '../../../shared/constants/app'; +import { + determineTransactionAssetType, + isEIP1559Transaction, +} from '../../../shared/modules/transaction.utils'; +import { hexWEIToDecGWEI } from '../../../shared/modules/conversion.utils'; +import { + TransactionType, + TokenStandard, + TransactionApprovalAmountType, + TransactionMetaMetricsEvent, + TransactionMeta, +} from '../../../shared/constants/transaction'; +import { + MetaMetricsEventCategory, + MetaMetricsEventFragment, + MetaMetricsPageObject, + MetaMetricsReferrerObject, +} from '../../../shared/constants/metametrics'; +import { GasRecommendations } from '../../../shared/constants/gas'; +import { TRANSACTION_ENVELOPE_TYPE_NAMES } from '../../../shared/lib/transactions-controller-utils'; +///: BEGIN:ONLY_INCLUDE_IN(blockaid) +import { + BlockaidReason, + BlockaidResultType, +} from '../../../shared/constants/security-provider'; +///: END:ONLY_INCLUDE_IN +import { + getSnapAndHardwareInfoForMetrics, + type SnapAndHardwareMessenger, +} from './snap-keyring/metrics'; + +export const METRICS_STATUS_FAILED = 'failed on-chain'; + +export type TransactionMetricsRequest = { + createEventFragment: ( + options: MetaMetricsEventFragment, + ) => MetaMetricsEventFragment; + finalizeEventFragment: ( + fragmentId: string, + options?: { + abandoned?: boolean; + page?: MetaMetricsPageObject; + referrer?: MetaMetricsReferrerObject; + }, + ) => void; + getEventFragmentById: (fragmentId: string) => MetaMetricsEventFragment; + updateEventFragment: ( + fragmentId: string, + payload: Partial, + ) => void; + getAccountType: ( + address: string, + ) => Promise<'hardware' | 'imported' | 'MetaMask'>; + getDeviceModel: ( + address: string, + ) => Promise<'ledger' | 'lattice' | 'N/A' | string>; + // According to the type GasFeeState returned from getEIP1559GasFeeEstimates + // doesn't include some properties used in buildEventFragmentProperties, + // hence returning any here to avoid type errors. + getEIP1559GasFeeEstimates(options?: FetchGasFeeEstimateOptions): Promise; + getSelectedAddress: () => string; + getTokenStandardAndDetails: () => { + decimals?: string; + balance?: string; + symbol?: string; + standard?: TokenStandard; + }; + getTransaction: (transactionId: string) => TransactionMeta; + snapAndHardwareMessenger: SnapAndHardwareMessenger; + provider: Provider; +}; + +type TransactionEventPayload = { + transactionMeta: TransactionMeta; + actionId?: string; + error?: string; +}; + +/** + * This function is called when a transaction is added to the controller. + * + * @param transactionMetricsRequest - Contains controller actions needed to create/update/finalize event fragments + * @param transactionEventPayload - The event payload + * @param transactionEventPayload.transactionMeta - The transaction meta object + */ +export const handleTransactionAdded = async ( + transactionMetricsRequest: TransactionMetricsRequest, + transactionEventPayload: TransactionEventPayload, +) => { + if (!transactionEventPayload.transactionMeta) { + return; + } + const { properties, sensitiveProperties } = + await buildEventFragmentProperties({ + transactionEventPayload, + transactionMetricsRequest, + }); + + createTransactionEventFragment({ + eventName: TransactionMetaMetricsEvent.added, + transactionEventPayload, + transactionMetricsRequest, + payload: { + properties, + sensitiveProperties, + }, + }); +}; + +/** + * This function is called when a transaction is approved by the user. + * + * @param transactionMetricsRequest - Contains controller actions needed to create/update/finalize event fragments + * @param transactionEventPayload - The event payload + * @param transactionEventPayload.transactionMeta - The transaction meta object + */ +export const handleTransactionApproved = async ( + transactionMetricsRequest: TransactionMetricsRequest, + transactionEventPayload: TransactionEventPayload, +) => { + if (!transactionEventPayload.transactionMeta) { + return; + } + + await createUpdateFinalizeTransactionEventFragment({ + eventName: TransactionMetaMetricsEvent.approved, + transactionEventPayload, + transactionMetricsRequest, + }); +}; + +/** + * This function is called when a transaction is finalized. + * + * @param transactionMetricsRequest - Contains controller actions needed to create/update/finalize event fragments + * @param transactionEventPayload - The event payload + * @param transactionEventPayload.transactionMeta - The transaction meta object + * @param transactionEventPayload.error - The error message if the transaction failed + */ +export const handleTransactionFinalized = async ( + transactionMetricsRequest: TransactionMetricsRequest, + transactionEventPayload: TransactionEventPayload, +) => { + if (!transactionEventPayload.transactionMeta) { + return; + } + + const extraParams = {} as Record; + if (transactionEventPayload.error) { + // This is a failed transaction + extraParams.error = transactionEventPayload.error; + } else { + const { transactionMeta } = transactionEventPayload; + const { txReceipt } = transactionMeta; + + extraParams.gas_used = txReceipt.gasUsed; + + const { submittedTime } = transactionMeta; + + if (submittedTime) { + extraParams.completion_time = getTransactionCompletionTime(submittedTime); + } + + if (txReceipt.status === '0x0') { + extraParams.status = METRICS_STATUS_FAILED; + } + } + + await createUpdateFinalizeTransactionEventFragment({ + eventName: TransactionMetaMetricsEvent.finalized, + extraParams, + transactionEventPayload, + transactionMetricsRequest, + }); +}; + +/** + * This function is called when a transaction is dropped. + * + * @param transactionMetricsRequest - Contains controller actions needed to create/update/finalize event fragments + * @param transactionEventPayload - The event payload + * @param transactionEventPayload.transactionMeta - The transaction meta object + */ +export const handleTransactionDropped = async ( + transactionMetricsRequest: TransactionMetricsRequest, + transactionEventPayload: TransactionEventPayload, +) => { + if (!transactionEventPayload.transactionMeta) { + return; + } + + const extraParams = { + dropped: true, + }; + + await createUpdateFinalizeTransactionEventFragment({ + eventName: TransactionMetaMetricsEvent.finalized, + extraParams, + transactionEventPayload, + transactionMetricsRequest, + }); +}; + +/** + * This function is called when a transaction is rejected by the user. + * + * @param transactionMetricsRequest - Contains controller actions needed to create/update/finalize event fragments + * @param transactionEventPayload - The event payload + * @param transactionEventPayload.transactionMeta - The transaction meta object + */ +export const handleTransactionRejected = async ( + transactionMetricsRequest: TransactionMetricsRequest, + transactionEventPayload: TransactionEventPayload, +) => { + if (!transactionEventPayload.transactionMeta) { + return; + } + + await createUpdateFinalizeTransactionEventFragment({ + eventName: TransactionMetaMetricsEvent.rejected, + transactionEventPayload, + transactionMetricsRequest, + }); +}; + +/** + * This function is called when a transaction is submitted to the network. + * + * @param transactionMetricsRequest - Contains controller actions needed to create/update/finalize event fragments + * @param transactionEventPayload - The event payload + * @param transactionEventPayload.transactionMeta - The transaction meta object + */ +export const handleTransactionSubmitted = async ( + transactionMetricsRequest: TransactionMetricsRequest, + transactionEventPayload: TransactionEventPayload, +) => { + if (!transactionEventPayload.transactionMeta) { + return; + } + const { properties, sensitiveProperties } = + await buildEventFragmentProperties({ + transactionEventPayload, + transactionMetricsRequest, + }); + + createTransactionEventFragment({ + eventName: TransactionMetaMetricsEvent.submitted, + transactionEventPayload, + transactionMetricsRequest, + payload: { + properties, + sensitiveProperties, + }, + }); +}; + +/** + * UI needs this specific create function in order to be sure that event fragment exists when updating transaction gas values. + * + * @param transactionMetricsRequest - Contains controller actions needed to create/update/finalize event fragments + * @param eventPayload - The event payload + * @param eventPayload.actionId - The action id of the transaction + * @param eventPayload.transactionId - The transaction id + */ +export const createTransactionEventFragmentWithTxId = async ( + transactionMetricsRequest: TransactionMetricsRequest, + { + transactionId, + actionId, + }: { + transactionId: string; + actionId: string; + }, +) => { + const transactionMeta = + transactionMetricsRequest.getTransaction(transactionId); + + transactionMeta.actionId = actionId; + + const { properties, sensitiveProperties } = + await buildEventFragmentProperties({ + transactionEventPayload: { + transactionMeta, + }, + transactionMetricsRequest, + }); + createTransactionEventFragment({ + eventName: TransactionMetaMetricsEvent.approved, + transactionEventPayload: { + actionId: transactionMeta.actionId, + transactionMeta, + }, + transactionMetricsRequest, + payload: { + properties, + sensitiveProperties, + }, + }); +}; + +function createTransactionEventFragment({ + eventName, + transactionEventPayload: { transactionMeta, actionId }, + transactionMetricsRequest, + payload, +}: { + eventName: TransactionMetaMetricsEvent; + transactionEventPayload: TransactionEventPayload; + transactionMetricsRequest: TransactionMetricsRequest; + payload: any; +}) { + if ( + hasFragment( + transactionMetricsRequest.getEventFragmentById, + eventName, + transactionMeta, + ) + ) { + return; + } + + const uniqueIdentifier = getUniqueId(eventName, transactionMeta.id); + + switch (eventName) { + // When a transaction is added to the controller, we know that the user + // will be presented with a confirmation screen. The user will then + // either confirm or reject that transaction. Each has an associated + // event we want to track. While we don't necessarily need an event + // fragment to model this, having one allows us to record additional + // properties onto the event from the UI. For example, when the user + // edits the transactions gas params we can record that property and + // then get analytics on the number of transactions in which gas edits + // occur. + case TransactionMetaMetricsEvent.added: + transactionMetricsRequest.createEventFragment({ + category: MetaMetricsEventCategory.Transactions, + initialEvent: TransactionMetaMetricsEvent.added, + successEvent: TransactionMetaMetricsEvent.approved, + failureEvent: TransactionMetaMetricsEvent.rejected, + properties: payload.properties, + sensitiveProperties: payload.sensitiveProperties, + actionId, + uniqueIdentifier, + persist: true, + }); + break; + // If for some reason an approval or rejection occurs without the added + // fragment existing in memory, we create the added fragment but without + // the initialEvent firing. This is to prevent possible duplication of + // events. A good example why this might occur is if the user had + // unapproved transactions in memory when updating to the version that + // includes this change. A migration would have also helped here but this + // implementation hardens against other possible bugs where a fragment + // does not exist. + case TransactionMetaMetricsEvent.approved: + case TransactionMetaMetricsEvent.rejected: + transactionMetricsRequest.createEventFragment({ + category: MetaMetricsEventCategory.Transactions, + successEvent: TransactionMetaMetricsEvent.approved, + failureEvent: TransactionMetaMetricsEvent.rejected, + properties: payload.properties, + sensitiveProperties: payload.sensitiveProperties, + actionId, + uniqueIdentifier, + persist: true, + }); + break; + // When a transaction is submitted it will always result in updating + // to a finalized state (dropped, failed, confirmed) -- eventually. + // However having a fragment started at this stage allows augmenting + // analytics data with user interactions such as speeding up and + // canceling the transactions. From this controllers perspective a new + // transaction with a new id is generated for speed up and cancel + // transactions, but from the UI we could augment the previous ID with + // supplemental data to show user intent. Such as when they open the + // cancel UI but don't submit. We can record that this happened and add + // properties to the transaction event. + case TransactionMetaMetricsEvent.submitted: + transactionMetricsRequest.createEventFragment({ + category: MetaMetricsEventCategory.Transactions, + initialEvent: TransactionMetaMetricsEvent.submitted, + successEvent: TransactionMetaMetricsEvent.finalized, + properties: payload.properties, + sensitiveProperties: payload.sensitiveProperties, + actionId, + uniqueIdentifier, + persist: true, + }); + break; + // If for some reason a transaction is finalized without the submitted + // fragment existing in memory, we create the submitted fragment but + // without the initialEvent firing. This is to prevent possible + // duplication of events. A good example why this might occur is if th + // user had pending transactions in memory when updating to the version + // that includes this change. A migration would have also helped here but + // this implementation hardens against other possible bugs where a + // fragment does not exist. + case TransactionMetaMetricsEvent.finalized: + transactionMetricsRequest.createEventFragment({ + category: MetaMetricsEventCategory.Transactions, + successEvent: TransactionMetaMetricsEvent.finalized, + properties: payload.properties, + sensitiveProperties: payload.sensitiveProperties, + actionId, + uniqueIdentifier, + persist: true, + }); + break; + default: + break; + } +} + +function updateTransactionEventFragment({ + eventName, + transactionEventPayload: { transactionMeta }, + transactionMetricsRequest, + payload, +}: { + eventName: TransactionMetaMetricsEvent; + transactionEventPayload: TransactionEventPayload; + transactionMetricsRequest: TransactionMetricsRequest; + payload: any; +}) { + const uniqueId = getUniqueId(eventName, transactionMeta.id); + + switch (eventName) { + case TransactionMetaMetricsEvent.approved: + transactionMetricsRequest.updateEventFragment(uniqueId, { + properties: payload.properties, + sensitiveProperties: payload.sensitiveProperties, + }); + break; + + case TransactionMetaMetricsEvent.rejected: + transactionMetricsRequest.updateEventFragment(uniqueId, { + properties: payload.properties, + sensitiveProperties: payload.sensitiveProperties, + }); + break; + + case TransactionMetaMetricsEvent.finalized: + transactionMetricsRequest.updateEventFragment(uniqueId, { + properties: payload.properties, + sensitiveProperties: payload.sensitiveProperties, + }); + break; + default: + break; + } +} + +function finalizeTransactionEventFragment({ + eventName, + transactionMetricsRequest, + transactionEventPayload: { transactionMeta }, +}: { + eventName: TransactionMetaMetricsEvent; + transactionEventPayload: TransactionEventPayload; + transactionMetricsRequest: TransactionMetricsRequest; +}) { + const uniqueId = getUniqueId(eventName, transactionMeta.id); + + switch (eventName) { + case TransactionMetaMetricsEvent.approved: + transactionMetricsRequest.finalizeEventFragment(uniqueId); + break; + + case TransactionMetaMetricsEvent.rejected: + transactionMetricsRequest.finalizeEventFragment(uniqueId, { + abandoned: true, + }); + break; + + case TransactionMetaMetricsEvent.finalized: + transactionMetricsRequest.finalizeEventFragment(uniqueId); + break; + default: + break; + } +} + +async function createUpdateFinalizeTransactionEventFragment({ + eventName, + transactionEventPayload, + transactionMetricsRequest, + extraParams = {}, +}: { + eventName: TransactionMetaMetricsEvent; + transactionEventPayload: TransactionEventPayload; + transactionMetricsRequest: TransactionMetricsRequest; + extraParams?: Record; +}) { + const { properties, sensitiveProperties } = + await buildEventFragmentProperties({ + transactionEventPayload, + transactionMetricsRequest, + extraParams, + }); + + createTransactionEventFragment({ + eventName, + transactionEventPayload, + transactionMetricsRequest, + payload: { + properties, + sensitiveProperties, + }, + }); + + updateTransactionEventFragment({ + eventName, + transactionEventPayload, + transactionMetricsRequest, + payload: { + properties, + sensitiveProperties, + }, + }); + + finalizeTransactionEventFragment({ + eventName, + transactionEventPayload, + transactionMetricsRequest, + }); +} + +function hasFragment( + getEventFragmentById: (arg0: string) => any, + eventName: TransactionMetaMetricsEvent, + transactionMeta: TransactionMeta, +) { + const uniqueId = getUniqueId(eventName, transactionMeta.id); + const fragment = getEventFragmentById(uniqueId); + return typeof fragment !== 'undefined'; +} + +function getUniqueId( + eventName: TransactionMetaMetricsEvent, + transactionId: string, +) { + const isSubmitted = [ + TransactionMetaMetricsEvent.finalized, + TransactionMetaMetricsEvent.submitted, + ].includes(eventName); + const uniqueIdentifier = `transaction-${ + isSubmitted ? 'submitted' : 'added' + }-${transactionId}`; + + return uniqueIdentifier; +} + +async function buildEventFragmentProperties({ + transactionEventPayload: { transactionMeta }, + transactionMetricsRequest, + extraParams = {}, +}: { + extraParams?: Record; + transactionEventPayload: TransactionEventPayload; + transactionMetricsRequest: TransactionMetricsRequest; +}) { + const { + type, + time, + status, + chainId, + origin: referrer, + txParams: { + gasPrice, + gas: gasLimit, + maxFeePerGas, + maxPriorityFeePerGas, + estimateSuggested, + estimateUsed, + }, + defaultGasEstimates, + originalType, + replacedById, + metamaskNetworkId: network, + customTokenAmount, + dappProposedTokenAmount, + currentTokenBalance, + originalApprovalAmount, + finalApprovalAmount, + contractMethodName, + securityProviderResponse, + ///: BEGIN:ONLY_INCLUDE_IN(blockaid) + securityAlertResponse, + ///: END:ONLY_INCLUDE_IN + } = transactionMeta; + + const query = new EthQuery(transactionMetricsRequest.provider); + const source = referrer === ORIGIN_METAMASK ? 'user' : 'dapp'; + + const { assetType, tokenStandard } = await determineTransactionAssetType( + transactionMeta, + query, + transactionMetricsRequest.getTokenStandardAndDetails, + ); + + const gasParams = {} as Record; + + if (isEIP1559Transaction(transactionMeta)) { + gasParams.max_fee_per_gas = maxFeePerGas; + gasParams.max_priority_fee_per_gas = maxPriorityFeePerGas; + } else { + gasParams.gas_price = gasPrice; + } + + if (defaultGasEstimates) { + const { estimateType } = defaultGasEstimates; + if (estimateType) { + gasParams.default_estimate = estimateType; + let defaultMaxFeePerGas = + transactionMeta.defaultGasEstimates.maxFeePerGas; + let defaultMaxPriorityFeePerGas = + transactionMeta.defaultGasEstimates.maxPriorityFeePerGas; + + if ( + [ + GasRecommendations.low, + GasRecommendations.medium, + GasRecommendations.high, + ].includes(estimateType) + ) { + const { gasFeeEstimates } = + await transactionMetricsRequest.getEIP1559GasFeeEstimates(); + if (gasFeeEstimates?.[estimateType]?.suggestedMaxFeePerGas) { + defaultMaxFeePerGas = + gasFeeEstimates[estimateType]?.suggestedMaxFeePerGas; + gasParams.default_max_fee_per_gas = defaultMaxFeePerGas; + } + if (gasFeeEstimates?.[estimateType]?.suggestedMaxPriorityFeePerGas) { + defaultMaxPriorityFeePerGas = + gasFeeEstimates[estimateType]?.suggestedMaxPriorityFeePerGas; + gasParams.default_max_priority_fee_per_gas = + defaultMaxPriorityFeePerGas; + } + } + } + + if (transactionMeta.defaultGasEstimates.gas) { + gasParams.default_gas = transactionMeta.defaultGasEstimates.gas; + } + if (transactionMeta.defaultGasEstimates.gasPrice) { + gasParams.default_gas_price = + transactionMeta.defaultGasEstimates.gasPrice; + } + } + + if (estimateSuggested) { + gasParams.estimate_suggested = estimateSuggested; + } + + if (estimateUsed) { + gasParams.estimate_used = estimateUsed; + } + + if (extraParams?.gas_used) { + gasParams.gas_used = extraParams.gas_used; + } + + const gasParamsInGwei = getGasValuesInGWEI(gasParams); + + let eip1559Version = '0'; + if (transactionMeta.txParams.maxFeePerGas) { + eip1559Version = '2'; + } + + const contractInteractionTypes = [ + TransactionType.contractInteraction, + TransactionType.tokenMethodApprove, + TransactionType.tokenMethodSafeTransferFrom, + TransactionType.tokenMethodSetApprovalForAll, + TransactionType.tokenMethodTransfer, + TransactionType.tokenMethodTransferFrom, + TransactionType.smart, + TransactionType.swap, + TransactionType.swapApproval, + ].includes(type); + + const contractMethodNames = { + APPROVE: 'Approve', + }; + + let transactionApprovalAmountType; + let transactionContractMethod; + let transactionApprovalAmountVsProposedRatio; + let transactionApprovalAmountVsBalanceRatio; + let transactionType = TransactionType.simpleSend; + if (type === TransactionType.cancel) { + transactionType = TransactionType.cancel; + } else if (type === TransactionType.retry) { + transactionType = originalType; + } else if (type === TransactionType.deployContract) { + transactionType = TransactionType.deployContract; + } else if (contractInteractionTypes) { + transactionType = TransactionType.contractInteraction; + transactionContractMethod = contractMethodName; + if ( + transactionContractMethod === contractMethodNames.APPROVE && + tokenStandard === TokenStandard.ERC20 + ) { + if (dappProposedTokenAmount === '0' || customTokenAmount === '0') { + transactionApprovalAmountType = TransactionApprovalAmountType.revoke; + } else if ( + customTokenAmount && + customTokenAmount !== dappProposedTokenAmount + ) { + transactionApprovalAmountType = TransactionApprovalAmountType.custom; + } else if (dappProposedTokenAmount) { + transactionApprovalAmountType = + TransactionApprovalAmountType.dappProposed; + } + transactionApprovalAmountVsProposedRatio = + allowanceAmountInRelationToDappProposedValue( + transactionApprovalAmountType, + originalApprovalAmount, + finalApprovalAmount, + ); + transactionApprovalAmountVsBalanceRatio = + allowanceAmountInRelationToTokenBalance( + transactionApprovalAmountType, + dappProposedTokenAmount, + currentTokenBalance, + ); + } + } + + const replacedTransactionMeta = transactionMetricsRequest.getTransaction( + replacedById as string, + ); + + const TRANSACTION_REPLACEMENT_METHODS = { + RETRY: TransactionType.retry, + CANCEL: TransactionType.cancel, + SAME_NONCE: 'other', + }; + + let transactionReplaced; + if (extraParams?.dropped) { + transactionReplaced = TRANSACTION_REPLACEMENT_METHODS.SAME_NONCE; + if (replacedTransactionMeta?.type === TransactionType.cancel) { + transactionReplaced = TRANSACTION_REPLACEMENT_METHODS.CANCEL; + } else if (replacedTransactionMeta?.type === TransactionType.retry) { + transactionReplaced = TRANSACTION_REPLACEMENT_METHODS.RETRY; + } + } + + let uiCustomizations; + + ///: BEGIN:ONLY_INCLUDE_IN(blockaid) + if (securityAlertResponse?.result_type === BlockaidResultType.Failed) { + uiCustomizations = ['security_alert_failed']; + } else { + ///: END:ONLY_INCLUDE_IN + // eslint-disable-next-line no-lonely-if + if (securityProviderResponse?.flagAsDangerous === 1) { + uiCustomizations = ['flagged_as_malicious']; + } else if (securityProviderResponse?.flagAsDangerous === 2) { + uiCustomizations = ['flagged_as_safety_unknown']; + } else { + uiCustomizations = null; + } + ///: BEGIN:ONLY_INCLUDE_IN(blockaid) + } + ///: END:ONLY_INCLUDE_IN + + /** The transaction status property is not considered sensitive and is now included in the non-anonymous event */ + let properties = { + chain_id: chainId, + referrer, + source, + status, + network, + eip_1559_version: eip1559Version, + gas_edit_type: 'none', + gas_edit_attempted: 'none', + account_type: await transactionMetricsRequest.getAccountType( + transactionMetricsRequest.getSelectedAddress(), + ), + device_model: await transactionMetricsRequest.getDeviceModel( + transactionMetricsRequest.getSelectedAddress(), + ), + asset_type: assetType, + token_standard: tokenStandard, + transaction_type: transactionType, + transaction_speed_up: type === TransactionType.retry, + ui_customizations: uiCustomizations, + ///: BEGIN:ONLY_INCLUDE_IN(blockaid) + security_alert_response: + securityAlertResponse?.result_type ?? BlockaidResultType.NotApplicable, + security_alert_reason: + securityAlertResponse?.reason ?? BlockaidReason.notApplicable, + ///: END:ONLY_INCLUDE_IN + } as Record; + + const snapAndHardwareInfo = await getSnapAndHardwareInfoForMetrics( + transactionMetricsRequest.getSelectedAddress, + transactionMetricsRequest.getAccountType, + transactionMetricsRequest.getDeviceModel, + transactionMetricsRequest.snapAndHardwareMessenger, + ); + Object.assign(properties, snapAndHardwareInfo); + + if (transactionContractMethod === contractMethodNames.APPROVE) { + properties = { + ...properties, + transaction_approval_amount_type: transactionApprovalAmountType, + }; + } + + let sensitiveProperties = { + transaction_envelope_type: isEIP1559Transaction(transactionMeta) + ? TRANSACTION_ENVELOPE_TYPE_NAMES.FEE_MARKET + : TRANSACTION_ENVELOPE_TYPE_NAMES.LEGACY, + first_seen: time, + gas_limit: gasLimit, + transaction_contract_method: transactionContractMethod, + transaction_replaced: transactionReplaced, + ...extraParams, + ...gasParamsInGwei, + } as Record; + + if (transactionContractMethod === contractMethodNames.APPROVE) { + sensitiveProperties = { + ...sensitiveProperties, + transaction_approval_amount_vs_balance_ratio: + transactionApprovalAmountVsBalanceRatio, + transaction_approval_amount_vs_proposed_ratio: + transactionApprovalAmountVsProposedRatio, + }; + } + + return { properties, sensitiveProperties }; +} + +function getGasValuesInGWEI(gasParams: Record) { + const gasValuesInGwei = {} as Record; + for (const param in gasParams) { + if (isHexString(gasParams[param])) { + gasValuesInGwei[param] = hexWEIToDecGWEI(gasParams[param]); + } else { + gasValuesInGwei[param] = gasParams[param]; + } + } + return gasValuesInGwei; +} + +function getTransactionCompletionTime(submittedTime: number) { + return Math.round((Date.now() - submittedTime) / 1000).toString(); +} + +/** + * The allowance amount in relation to the dapp proposed amount for specific token + * + * @param transactionApprovalAmountType - The transaction approval amount type + * @param originalApprovalAmount - The original approval amount is the originally dapp proposed token amount + * @param finalApprovalAmount - The final approval amount is the chosen amount which will be the same as the + * originally dapp proposed token amount if the user does not edit the amount or will be a custom token amount set by the user + */ +function allowanceAmountInRelationToDappProposedValue( + transactionApprovalAmountType?: TransactionApprovalAmountType, + originalApprovalAmount?: string, + finalApprovalAmount?: string, +) { + if ( + transactionApprovalAmountType === TransactionApprovalAmountType.custom && + originalApprovalAmount && + finalApprovalAmount + ) { + return `${new BigNumber(originalApprovalAmount, 10) + .div(finalApprovalAmount, 10) + .times(100) + .round(2)}`; + } + return null; +} + +/** + * The allowance amount in relation to the balance for that specific token + * + * @param transactionApprovalAmountType - The transaction approval amount type + * @param dappProposedTokenAmount - The dapp proposed token amount + * @param currentTokenBalance - The balance of the token that is being send + */ +function allowanceAmountInRelationToTokenBalance( + transactionApprovalAmountType?: TransactionApprovalAmountType, + dappProposedTokenAmount?: string, + currentTokenBalance?: string, +) { + if ( + (transactionApprovalAmountType === TransactionApprovalAmountType.custom || + transactionApprovalAmountType === + TransactionApprovalAmountType.dappProposed) && + dappProposedTokenAmount && + currentTokenBalance + ) { + return `${new BigNumber(dappProposedTokenAmount, 16) + .div(currentTokenBalance, 10) + .times(100) + .round(2)}`; + } + return null; +} diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index dcea569934ce..9b925d79c98e 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -167,6 +167,15 @@ import { isManifestV3 } from '../../shared/modules/mv3.utils'; import { hexToDecimal } from '../../shared/modules/conversion.utils'; import { convertNetworkId } from '../../shared/modules/network.utils'; import { ACTION_QUEUE_METRICS_E2E_TEST } from '../../shared/constants/test-flags'; +import { + handleTransactionAdded, + handleTransactionApproved, + handleTransactionFinalized, + handleTransactionDropped, + handleTransactionRejected, + handleTransactionSubmitted, + createTransactionEventFragmentWithTxId, +} from './lib/transaction-metrics'; ///: BEGIN:ONLY_INCLUDE_IN(keyring-snaps) import { keyringSnapPermissionsBuilder } from './lib/keyring-snaps-permissions'; ///: END:ONLY_INCLUDE_IN @@ -1248,22 +1257,6 @@ export default class MetamaskController extends EventEmitter { ), }); - // This gets used as a ...spread parameter in two places: new TransactionController() and createRPCMethodTrackingMiddleware() - this.snapAndHardwareMetricsParams = { - getSelectedAddress: this.preferencesController.getSelectedAddress.bind( - this.preferencesController, - ), - getAccountType: this.getAccountType.bind(this), - getDeviceModel: this.getDeviceModel.bind(this), - snapAndHardwareMessenger: this.controllerMessenger.getRestricted({ - name: 'SnapAndHardwareMessenger', - allowedActions: [ - 'KeyringController:getKeyringForAccount', - 'SnapController:get', - ], - }), - }; - this.txController = new TransactionController({ initState: initState.TransactionController || initState.TransactionManager, @@ -1298,32 +1291,13 @@ export default class MetamaskController extends EventEmitter { ), provider: this.provider, blockTracker: this.blockTracker, - createEventFragment: this.metaMetricsController.createEventFragment.bind( - this.metaMetricsController, - ), - updateEventFragment: this.metaMetricsController.updateEventFragment.bind( - this.metaMetricsController, - ), - finalizeEventFragment: - this.metaMetricsController.finalizeEventFragment.bind( - this.metaMetricsController, - ), - getEventFragmentById: - this.metaMetricsController.getEventFragmentById.bind( - this.metaMetricsController, - ), - trackMetaMetricsEvent: this.metaMetricsController.trackEvent.bind( - this.metaMetricsController, - ), getParticipateInMetrics: () => this.metaMetricsController.state.participateInMetaMetrics, getEIP1559GasFeeEstimates: this.gasFeeController.fetchGasFeeEstimates.bind(this.gasFeeController), getExternalPendingTransactions: this.getExternalPendingTransactions.bind(this), - getTokenStandardAndDetails: this.getTokenStandardAndDetails.bind(this), securityProviderRequest: this.securityProviderRequest.bind(this), - ...this.snapAndHardwareMetricsParams, ///: BEGIN:ONLY_INCLUDE_IN(build-mmi) transactionUpdateController: this.transactionUpdateController, ///: END:ONLY_INCLUDE_IN @@ -1337,6 +1311,39 @@ export default class MetamaskController extends EventEmitter { }), }); + const transactionMetricsRequest = this.getTransactionMetricsRequest(); + + this.txController.on( + 'transaction-added', + handleTransactionAdded.bind(null, transactionMetricsRequest), + ); + this.txController.on( + 'transaction-approved', + handleTransactionApproved.bind(null, transactionMetricsRequest), + ); + this.txController.on( + 'transaction-dropped', + handleTransactionDropped.bind(null, transactionMetricsRequest), + ); + this.txController.on( + 'transaction-finalized', + handleTransactionFinalized.bind(null, transactionMetricsRequest), + ); + this.txController.on( + 'transaction-rejected', + handleTransactionRejected.bind(null, transactionMetricsRequest), + ); + this.txController.on( + 'transaction-submitted', + handleTransactionSubmitted.bind(null, transactionMetricsRequest), + ); + this.txController.on('transaction-swap-failed', (payload) => + this.metaMetricsController.trackEvent(payload), + ); + this.txController.on('transaction-swap-finalized', (payload) => + this.metaMetricsController.trackEvent(payload), + ); + this.txController.on(`tx:status-update`, async (txId, status) => { if ( status === TransactionStatus.confirmed || @@ -1946,6 +1953,48 @@ export default class MetamaskController extends EventEmitter { checkForMultipleVersionsRunning(); } + getTransactionMetricsRequest() { + const controllerActions = { + // Metametrics Actions + createEventFragment: this.metaMetricsController.createEventFragment.bind( + this.metaMetricsController, + ), + finalizeEventFragment: + this.metaMetricsController.finalizeEventFragment.bind( + this.metaMetricsController, + ), + getEventFragmentById: + this.metaMetricsController.getEventFragmentById.bind( + this.metaMetricsController, + ), + updateEventFragment: this.metaMetricsController.updateEventFragment.bind( + this.metaMetricsController, + ), + // Other dependencies + getAccountType: this.getAccountType.bind(this), + getDeviceModel: this.getDeviceModel.bind(this), + getEIP1559GasFeeEstimates: + this.gasFeeController.fetchGasFeeEstimates.bind(this.gasFeeController), + getSelectedAddress: () => + this.preferencesController.store.getState().selectedAddress, + getTokenStandardAndDetails: this.getTokenStandardAndDetails.bind(this), + getTransaction: this.txController.txStateManager.getTransaction.bind( + this.txController, + ), + }; + return { + ...controllerActions, + snapAndHardwareMessenger: this.controllerMessenger.getRestricted({ + name: 'SnapAndHardwareMessenger', + allowedActions: [ + 'KeyringController:getKeyringForAccount', + 'SnapController:get', + ], + }), + provider: this.provider, + }; + } + triggerNetworkrequests() { this.accountTracker.start(); this.txController.startIncomingTransactionPolling(); @@ -2741,7 +2790,10 @@ export default class MetamaskController extends EventEmitter { addTransactionAndWaitForPublish: this.addTransactionAndWaitForPublish.bind(this), createTransactionEventFragment: - txController.createTransactionEventFragment.bind(txController), + createTransactionEventFragmentWithTxId.bind( + null, + this.getTransactionMetricsRequest(), + ), getTransactions: txController.getTransactions.bind(txController), updateEditableParams: @@ -4331,7 +4383,18 @@ export default class MetamaskController extends EventEmitter { this.metaMetricsController.store, ), securityProviderRequest: this.securityProviderRequest.bind(this), - ...this.snapAndHardwareMetricsParams, + getSelectedAddress: this.preferencesController.getSelectedAddress.bind( + this.preferencesController, + ), + getAccountType: this.getAccountType.bind(this), + getDeviceModel: this.getDeviceModel.bind(this), + snapAndHardwareMessenger: this.controllerMessenger.getRestricted({ + name: 'SnapAndHardwareMessenger', + allowedActions: [ + 'KeyringController:getKeyringForAccount', + 'SnapController:get', + ], + }), }), ); diff --git a/shared/constants/metametrics.ts b/shared/constants/metametrics.ts index 165a36d36290..b6bbbd561e95 100644 --- a/shared/constants/metametrics.ts +++ b/shared/constants/metametrics.ts @@ -149,6 +149,10 @@ export type MetaMetricsEventOptions = { }; export type MetaMetricsEventFragment = { + /** + * The action ID of transaction metadata object. + */ + actionId?: string; /** * The event name to fire when the fragment is closed in an affirmative action. */ diff --git a/shared/constants/transaction.ts b/shared/constants/transaction.ts index 07d0525ab714..056d9ec06f50 100644 --- a/shared/constants/transaction.ts +++ b/shared/constants/transaction.ts @@ -285,12 +285,16 @@ export interface TxParams { accessList?: AccessList; maxFeePerGas?: string; maxPriorityFeePerGas?: string; + estimateSuggested?: string; + estimateUsed?: string; } export interface TxReceipt { blockHash?: string; blockNumber?: string; transactionIndex?: string; + gasUsed?: string; + status?: string; } export interface TxError { @@ -321,6 +325,8 @@ interface DappSuggestedGasFees { * An object representing a transaction, in whatever state it is in. */ export interface TransactionMeta { + /** Unique ID to prevent duplicate requests.*/ + actionId?: string; ///: BEGIN:ONLY_INCLUDE_IN(build-mmi) custodyStatus: string; custodyId?: string; @@ -343,6 +349,7 @@ export interface TransactionMeta { dappProposedTokenAmount: string; /** The original gas fees suggested by the dapp that proposed this transaction */ dappSuggestedGasFees?: DappSuggestedGasFees; + defaultGasEstimates?: any; /** The balance of the token that is being sent */ currentTokenBalance: string; /** The original dapp proposed token approval amount before edit by user */ @@ -390,6 +397,7 @@ export interface TransactionMeta { * network. */ rawTx: string; + replacedById?: string; /** * A hex string of the transaction hash, used to identify the transaction * on the network. @@ -409,6 +417,8 @@ export interface TransactionMeta { * Whether the transaction is verified on the blockchain. */ verifiedOnBlockchain?: boolean; + securityProviderResponse?: Record; + securityAlertResponse?: any; } /** diff --git a/ui/hooks/useTransactionEventFragment.js b/ui/hooks/useTransactionEventFragment.js index 1a10a7157584..f304e5ebdeec 100644 --- a/ui/hooks/useTransactionEventFragment.js +++ b/ui/hooks/useTransactionEventFragment.js @@ -7,7 +7,6 @@ import { updateEventFragment, } from '../store/actions'; import { selectMatchingFragment } from '../selectors'; -import { TransactionMetaMetricsEvent } from '../../shared/constants/transaction'; export const useTransactionEventFragment = () => { const { transaction } = useGasFeeContext(); @@ -24,10 +23,7 @@ export const useTransactionEventFragment = () => { return; } if (!fragment) { - await createTransactionEventFragment( - transaction.id, - TransactionMetaMetricsEvent.approved, - ); + await createTransactionEventFragment(transaction.id); } updateEventFragment(`transaction-added-${transaction.id}`, params); }, diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 057027c2f15a..ba05ef55d9ed 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -87,7 +87,6 @@ import { decimalToHex } from '../../shared/modules/conversion.utils'; import { TxGasFees, PriorityLevels } from '../../shared/constants/gas'; import { TransactionMeta, - TransactionMetaMetricsEvent, TransactionType, } from '../../shared/constants/transaction'; import { NetworkType, RPCDefinition } from '../../shared/constants/network'; @@ -4139,13 +4138,13 @@ export function createEventFragment( export function createTransactionEventFragment( transactionId: string, - event: TransactionMetaMetricsEvent, ): Promise { const actionId = generateActionId(); return submitRequestToBackground('createTransactionEventFragment', [ - transactionId, - event, - actionId, + { + transactionId, + actionId, + }, ]); }