From f92835fd05cfad29d55704d6aa95f304a45e270a Mon Sep 17 00:00:00 2001 From: ffmcgee <51971598+ffmcgee725@users.noreply.github.com> Date: Fri, 12 Sep 2025 09:40:14 +0200 Subject: [PATCH 1/9] refactor: import 5792 middleware from monorepo package (#19320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/WAPI-691 ## **Manual testing steps** 1. No user facing changes, but for extra confirmation, we can make sure using EIP 5792 methods via https://metamask.github.io/test-dapp/ still works! ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../RPCMethods/createEip5792Middleware.ts | 41 ++++++++++++++----- package.json | 4 +- yarn.lock | 27 ++++++++++-- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/app/core/RPCMethods/createEip5792Middleware.ts b/app/core/RPCMethods/createEip5792Middleware.ts index 2445e92ce7b..da2af5350b5 100644 --- a/app/core/RPCMethods/createEip5792Middleware.ts +++ b/app/core/RPCMethods/createEip5792Middleware.ts @@ -1,10 +1,16 @@ import { - createWalletMiddleware, - ProcessSendCallsHook, - GetCapabilitiesHook, - GetCallsStatusHook, -} from '@metamask/eth-json-rpc-middleware'; -import { JsonRpcRequest } from '@metamask/utils'; + type ProcessSendCallsHook, + type GetCapabilitiesHook, + type GetCallsStatusHook, + walletGetCallsStatus, + walletGetCapabilities, + walletSendCalls, +} from '@metamask/eip-5792-middleware'; +import { + createAsyncMiddleware, + createScaffoldMiddleware, +} from '@metamask/json-rpc-engine'; +import type { JsonRpcRequest } from '@metamask/utils'; export function createEip5792Middleware({ getAccounts, @@ -17,10 +23,23 @@ export function createEip5792Middleware({ getCapabilities: GetCapabilitiesHook; processSendCalls: ProcessSendCallsHook; }) { - return createWalletMiddleware({ - getAccounts, - getCallsStatus, - getCapabilities, - processSendCalls, + return createScaffoldMiddleware({ + wallet_getCapabilities: createAsyncMiddleware(async (req, res) => + walletGetCapabilities(req, res, { + getAccounts, + getCapabilities, + }), + ), + wallet_sendCalls: createAsyncMiddleware(async (req, res) => + walletSendCalls(req, res, { + getAccounts, + processSendCalls, + }), + ), + wallet_getCallsStatus: createAsyncMiddleware(async (req, res) => + walletGetCallsStatus(req, res, { + getCallsStatus, + }), + ), }); } diff --git a/package.json b/package.json index 5ba54488979..e37e16dd68b 100644 --- a/package.json +++ b/package.json @@ -233,12 +233,12 @@ "@metamask/design-system-twrnc-preset": "^0.2.1", "@metamask/design-tokens": "^8.1.1", "@metamask/earn-controller": "^7.0.0", - "@metamask/eip-5792-middleware": "^1.0.0", + "@metamask/eip-5792-middleware": "^1.1.0", "@metamask/eip1193-permission-middleware": "^1.0.0", "@metamask/error-reporting-service": "^2.0.0", "@metamask/eth-hd-keyring": "^12.1.0", "@metamask/eth-json-rpc-filters": "^9.0.0", - "@metamask/eth-json-rpc-middleware": "^17.0.1", + "@metamask/eth-json-rpc-middleware": "^18.0.0", "@metamask/eth-ledger-bridge-keyring": "11.1.0", "@metamask/eth-query": "^4.0.0", "@metamask/eth-sig-util": "^8.0.0", diff --git a/yarn.lock b/yarn.lock index 4a3a2931be5..68175e55d6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5129,12 +5129,13 @@ "@metamask/stake-sdk" "^3.2.1" reselect "^5.1.1" -"@metamask/eip-5792-middleware@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@metamask/eip-5792-middleware/-/eip-5792-middleware-1.0.0.tgz#6df7cb47999661585e592e7758b80c4652f4cd7a" - integrity sha512-NL98QBjE9CEVpfQskdoqBYhZmz3pIyujjTt4Pyp2Vlxz7PYzm1qrkSj0MrENaXkOMy0IClj8ZxoIJpMJuoAcRQ== +"@metamask/eip-5792-middleware@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@metamask/eip-5792-middleware/-/eip-5792-middleware-1.1.0.tgz#3dc25e31711f0045766da9d473ae9306de75d2df" + integrity sha512-zgI9uLSwi+U2x2fkIFMViI/RFJQFGp2B38YoWSgIQ1L7kshy0p0eNYrkOXnNX5Y+oDfbZLTSJeeAYBQ+suSNcA== dependencies: "@metamask/eth-json-rpc-middleware" "^17.0.1" + "@metamask/superstruct" "^3.1.0" "@metamask/transaction-controller" "^60.2.0" "@metamask/utils" "^11.4.2" uuid "^8.3.2" @@ -5230,6 +5231,24 @@ pify "^5.0.0" safe-stable-stringify "^2.4.3" +"@metamask/eth-json-rpc-middleware@^18.0.0": + version "18.0.0" + resolved "https://registry.yarnpkg.com/@metamask/eth-json-rpc-middleware/-/eth-json-rpc-middleware-18.0.0.tgz#7a6fdf83dd7400d7e9dd45bfd145a5e5b2c0109e" + integrity sha512-5Cje8BW8IEshkJYV/I6QPazv9xMVJWGFYjD48K70oYty2qEKGCHpUbbuinEyLmFFqi4YzpvK5PfRBMmf93hTYQ== + dependencies: + "@metamask/eth-block-tracker" "^12.0.0" + "@metamask/eth-json-rpc-provider" "^4.1.7" + "@metamask/eth-sig-util" "^8.1.2" + "@metamask/json-rpc-engine" "^10.0.2" + "@metamask/rpc-errors" "^7.0.2" + "@metamask/superstruct" "^3.1.0" + "@metamask/utils" "^11.7.0" + "@types/bn.js" "^5.1.5" + bn.js "^5.2.1" + klona "^2.0.6" + pify "^5.0.0" + safe-stable-stringify "^2.4.3" + "@metamask/eth-json-rpc-provider@^4.1.5", "@metamask/eth-json-rpc-provider@^4.1.6", "@metamask/eth-json-rpc-provider@^4.1.7", "@metamask/eth-json-rpc-provider@^4.1.8": version "4.1.8" resolved "https://registry.yarnpkg.com/@metamask/eth-json-rpc-provider/-/eth-json-rpc-provider-4.1.8.tgz#1ff84270b240b75d14064bcc2ecb3bb14e718401" From 17ccd7e9d3458a8e79395e6e1d3f019fcf4c0263 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 12 Sep 2025 10:28:32 +0100 Subject: [PATCH 2/9] feat: metamask pay metrics (#19602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Add initial metrics for MetaMask pay and Perps deposit confirmation. Bump transaction controller to fix Perps deposit using swaps. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [#5606](https://github.com/MetaMask/MetaMask-planning/issues/5606) [#5774](https://github.com/MetaMask/MetaMask-planning/issues/5774) ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../metrics/useConfirmationAlertMetrics.ts | 8 +- .../useAutomaticTransactionPayToken.test.ts | 39 ++ .../pay/useAutomaticTransactionPayToken.ts | 20 +- .../hooks/pay/useTransactionBridgeQuotes.ts | 3 + .../pay/useTransactionPayMetrics.test.ts | 252 +++++++++++++ .../hooks/pay/useTransactionPayMetrics.ts | 91 +++++ .../Views/confirmations/utils/bridge.test.ts | 18 + .../Views/confirmations/utils/bridge.ts | 30 +- .../event-handlers/metrics.ts | 49 ++- .../event_properties/metamask-pay.test.ts | 349 ++++++++++++++++++ .../event_properties/metamask-pay.ts | 101 +++++ .../transaction-controller/types.ts | 8 + .../transaction-controller/utils.test.ts | 4 + .../transaction-controller/utils.ts | 15 +- package.json | 2 +- ...tamask+transaction-controller+58.1.1.patch | 15 - ...tamask+transaction-controller+60.3.0.patch | 34 ++ yarn.lock | 10 +- 18 files changed, 1009 insertions(+), 39 deletions(-) create mode 100644 app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts create mode 100644 app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts create mode 100644 app/core/Engine/controllers/transaction-controller/event_properties/metamask-pay.test.ts create mode 100644 app/core/Engine/controllers/transaction-controller/event_properties/metamask-pay.ts delete mode 100644 patches/@metamask+transaction-controller+58.1.1.patch create mode 100644 patches/@metamask+transaction-controller+60.3.0.patch diff --git a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts index 7329dc995e1..3a8926285e5 100644 --- a/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts +++ b/app/components/Views/confirmations/hooks/metrics/useConfirmationAlertMetrics.ts @@ -110,11 +110,11 @@ const ALERTS_NAME_METRICS: AlertNameMetrics = { [AlertKeys.Blockaid]: 'blockaid', [AlertKeys.DomainMismatch]: 'domain_mismatch', [AlertKeys.InsufficientBalance]: 'insufficient_balance', - [AlertKeys.InsufficientPayTokenBalance]: 'insufficient_pay_token_balance', - [AlertKeys.InsufficientPayTokenNative]: 'insufficient_pay_token_native', - [AlertKeys.NoPayTokenQuotes]: 'no_pay_token_quotes', + [AlertKeys.InsufficientPayTokenBalance]: 'insufficient_funds', + [AlertKeys.InsufficientPayTokenNative]: 'insufficient_funds_for_gas', + [AlertKeys.NoPayTokenQuotes]: 'no_payment_route_available', [AlertKeys.PendingTransaction]: 'pending_transaction', - [AlertKeys.PerpsDepositMinimum]: 'perps_deposit_minimum', + [AlertKeys.PerpsDepositMinimum]: 'minimum_deposit', [AlertKeys.PerpsHardwareAccount]: 'perps_hardware_account', [AlertKeys.SignedOrSubmitted]: 'signed_or_submitted', }; diff --git a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts index b10b45a403a..99e851bdb43 100644 --- a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.ts @@ -374,4 +374,43 @@ describe('useAutomaticTransactionPayToken', () => { chainId: CHAIN_ID_1_MOCK, }); }); + + it('returns number of tokens with sufficient balance', () => { + useTokensWithBalanceMock.mockReturnValue([ + { + address: TOKEN_ADDRESS_1_MOCK, + chainId: CHAIN_ID_1_MOCK, + tokenFiatAmount: TOTAL_FIAT_MOCK - 1, + }, + { + address: TOKEN_ADDRESS_2_MOCK, + chainId: CHAIN_ID_1_MOCK, + tokenFiatAmount: TOTAL_FIAT_MOCK - 2, + }, + { + address: TOKEN_ADDRESS_1_MOCK, + chainId: CHAIN_ID_2_MOCK, + tokenFiatAmount: TOTAL_FIAT_MOCK + 10, + }, + { + address: TOKEN_ADDRESS_3_MOCK, + chainId: CHAIN_ID_2_MOCK, + tokenFiatAmount: TOTAL_FIAT_MOCK + 20, + }, + { + address: NATIVE_TOKEN_ADDRESS, + chainId: CHAIN_ID_1_MOCK, + tokenFiatAmount: 1, + }, + { + address: NATIVE_TOKEN_ADDRESS, + chainId: CHAIN_ID_2_MOCK, + tokenFiatAmount: 1, + }, + ] as unknown as ReturnType); + + const { result } = runHook(); + + expect(result.current.count).toBe(2); + }); }); diff --git a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts index 52c5facb5ab..de783cbf32c 100644 --- a/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts +++ b/app/components/Views/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts @@ -23,8 +23,10 @@ export interface BalanceOverride { export function useAutomaticTransactionPayToken({ balanceOverrides, + countOnly = false, }: { balanceOverrides?: BalanceOverride[]; + countOnly?: boolean; } = {}) { const isUpdated = useRef(false); const supportedChains = useSelector(selectEnabledSourceChains); @@ -43,11 +45,12 @@ export function useAutomaticTransactionPayToken({ ); const tokens = useTokensWithBalance({ chainIds }); - const isHardwareWallet = isHardwareAccount(from ?? ''); + let automaticToken: { address: string; chainId?: string } | undefined; + let count = 0; - if (!isUpdated.current) { + if (!isUpdated.current || countOnly) { const targetToken = requiredTokens.find((token) => token.address !== NATIVE_TOKEN_ADDRESS) ?? requiredTokens[0]; @@ -68,6 +71,8 @@ export function useAutomaticTransactionPayToken({ 'desc', ); + count = sufficientBalanceTokens.length; + const requiredToken = sufficientBalanceTokens.find( (token) => token.address === targetToken?.address && token.chainId === chainId, @@ -100,7 +105,12 @@ export function useAutomaticTransactionPayToken({ } useEffect(() => { - if (isUpdated.current || !automaticToken || !requiredTokens?.length) { + if ( + isUpdated.current || + !automaticToken || + !requiredTokens?.length || + countOnly + ) { return; } @@ -112,7 +122,9 @@ export function useAutomaticTransactionPayToken({ isUpdated.current = true; log('Automatically selected pay token', automaticToken); - }, [automaticToken, isUpdated, requiredTokens, setPayToken]); + }, [automaticToken, countOnly, isUpdated, requiredTokens, setPayToken]); + + return { count }; } function isTokenSupported( diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionBridgeQuotes.ts b/app/components/Views/confirmations/hooks/pay/useTransactionBridgeQuotes.ts index 03771c3a65e..ab0a422e7b4 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionBridgeQuotes.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionBridgeQuotes.ts @@ -19,6 +19,7 @@ import { isQuoteExpired, } from '../../../../UI/Bridge/utils/quoteUtils'; import { selectBridgeFeatureFlags } from '../../../../../core/redux/slices/bridge'; +import { useTransactionPayMetrics } from './useTransactionPayMetrics'; const EXCLUDED_ALERTS = [ AlertKeys.NoPayTokenQuotes, @@ -42,6 +43,8 @@ export function useTransactionBridgeQuotes() { const isExpired = useRef(false); const [refreshIndex, setRefreshIndex] = useState(0); + useTransactionPayMetrics(); + useEffect(() => { if (interval.current) { clearInterval(interval.current as unknown as number); diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts new file mode 100644 index 00000000000..2daba96526e --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts @@ -0,0 +1,252 @@ +import { merge, noop } from 'lodash'; +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { + simpleSendTransactionControllerMock, + transactionIdMock, +} from '../../__mocks__/controllers/transaction-controller-mock'; +import { useTransactionPayMetrics } from './useTransactionPayMetrics'; +import { transactionApprovalControllerMock } from '../../__mocks__/controllers/approval-controller-mock'; +import { + otherControllersMock, + tokenAddress1Mock, +} from '../../__mocks__/controllers/other-controllers-mock'; +import { useTransactionPayToken } from './useTransactionPayToken'; +import { useTokenAmount } from '../useTokenAmount'; +import { act } from '@testing-library/react-native'; +import { + selectTransactionBridgeQuotesById, + updateConfirmationMetric, +} from '../../../../../core/redux/slices/confirmationMetrics'; +import { TransactionType } from '@metamask/transaction-controller'; +import { NATIVE_TOKEN_ADDRESS } from '../../constants/tokens'; +import { useAutomaticTransactionPayToken } from './useAutomaticTransactionPayToken'; + +jest.mock('./useTransactionPayToken'); +jest.mock('./useAutomaticTransactionPayToken'); +jest.mock('../useTokenAmount'); + +jest.mock('../../../../../core/redux/slices/confirmationMetrics', () => ({ + ...jest.requireActual('../../../../../core/redux/slices/confirmationMetrics'), + updateConfirmationMetric: jest.fn(), + selectTransactionBridgeQuotesById: jest.fn(), +})); + +const CHAIN_ID_MOCK = '0x1'; +const TOKEN_AMOUNT_MOCK = '1.23'; + +const PAY_TOKEN_MOCK = { + address: tokenAddress1Mock, + chainId: CHAIN_ID_MOCK, + symbol: 'TST', +}; + +function runHook() { + const state = merge( + {}, + simpleSendTransactionControllerMock, + transactionApprovalControllerMock, + otherControllersMock, + ); + + state.engine.backgroundState.TransactionController.transactions[0].type = + TransactionType.perpsDeposit; + + return renderHookWithProvider(useTransactionPayMetrics, { + state, + }); +} + +describe('useTransactionPayMetrics', () => { + const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken); + const useTokenAmountMock = jest.mocked(useTokenAmount); + const updateConfirmationMetricMock = jest.mocked(updateConfirmationMetric); + + const useAutomaticTransactionPayTokenMock = jest.mocked( + useAutomaticTransactionPayToken, + ); + + const selectTransactionBridgeQuotesByIdMock = jest.mocked( + selectTransactionBridgeQuotesById, + ); + + beforeEach(() => { + jest.resetAllMocks(); + + useTransactionPayTokenMock.mockReturnValue({ + payToken: undefined, + setPayToken: noop, + }); + + useTokenAmountMock.mockReturnValue({ + amountPrecise: TOKEN_AMOUNT_MOCK, + } as ReturnType); + + updateConfirmationMetricMock.mockReturnValue({ + type: 'test', + } as never); + + selectTransactionBridgeQuotesByIdMock.mockReturnValue([] as never[]); + + useAutomaticTransactionPayTokenMock.mockReturnValue({ + count: 5, + }); + }); + + it('does not update metrics if no pay token selected', async () => { + runHook(); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: {}, + sensitiveProperties: {}, + }, + }); + }); + + it('includes pay token properties if pay token selected', async () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: PAY_TOKEN_MOCK, + setPayToken: noop, + } as ReturnType); + + runHook(); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay: true, + mm_pay_token_selected: PAY_TOKEN_MOCK.symbol, + mm_pay_chain_selected: CHAIN_ID_MOCK, + mm_pay_token_presented: PAY_TOKEN_MOCK.symbol, + mm_pay_chain_presented: PAY_TOKEN_MOCK.chainId, + }), + sensitiveProperties: {}, + }, + }); + }); + + it('includes step properties based on number of quotes', async () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: PAY_TOKEN_MOCK, + setPayToken: noop, + } as ReturnType); + + selectTransactionBridgeQuotesByIdMock.mockReturnValue([ + {} as never, + {} as never, + {} as never, + ] as never[]); + + runHook(); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_transaction_step_total: 4, + mm_pay_transaction_step: 4, + }), + sensitiveProperties: {}, + }, + }); + }); + + it('includes perps deposit properties', async () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: PAY_TOKEN_MOCK, + setPayToken: noop, + } as ReturnType); + + selectTransactionBridgeQuotesByIdMock.mockReturnValue([ + {} as never, + {} as never, + {} as never, + ] as never[]); + + runHook(); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_use_case: 'perps_deposit', + simulation_sending_assets_total_value: TOKEN_AMOUNT_MOCK, + }), + sensitiveProperties: {}, + }, + }); + }); + + it('includes dust property for non-native quote', async () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: PAY_TOKEN_MOCK, + setPayToken: noop, + } as ReturnType); + + selectTransactionBridgeQuotesByIdMock.mockReturnValue([ + { + quote: { + minDestTokenAmount: '2000000', + }, + request: { + targetAmountMinimum: '1500000', + targetTokenAddress: NATIVE_TOKEN_ADDRESS, + }, + }, + { + quote: { + minDestTokenAmount: '3000000', + }, + request: { + targetAmountMinimum: '2500000', + targetTokenAddress: tokenAddress1Mock, + }, + }, + ] as never[]); + + runHook(); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_dust_usd: '0.5', + }), + sensitiveProperties: {}, + }, + }); + }); + + it('includes token size property', async () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: PAY_TOKEN_MOCK, + setPayToken: noop, + } as ReturnType); + + runHook(); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + mm_pay_payment_token_list_size: 5, + }), + sensitiveProperties: {}, + }, + }); + }); +}); diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts new file mode 100644 index 00000000000..34c873138b2 --- /dev/null +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts @@ -0,0 +1,91 @@ +import { useEffect, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + selectTransactionBridgeQuotesById, + updateConfirmationMetric, +} from '../../../../../core/redux/slices/confirmationMetrics'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { useDeepMemo } from '../useDeepMemo'; +import { Json } from '@metamask/utils'; +import { TransactionType } from '@metamask/transaction-controller'; +import { RootState } from '../../../../../reducers'; +import { useTransactionPayToken } from './useTransactionPayToken'; +import { BridgeToken } from '../../../../UI/Bridge/types'; +import { NATIVE_TOKEN_ADDRESS } from '../../constants/tokens'; +import { BigNumber } from 'bignumber.js'; +import { useTokenAmount } from '../useTokenAmount'; +import { useAutomaticTransactionPayToken } from './useAutomaticTransactionPayToken'; + +export function useTransactionPayMetrics() { + const dispatch = useDispatch(); + const transactionMeta = useTransactionMetadataRequest(); + const { payToken } = useTransactionPayToken(); + const { amountPrecise } = useTokenAmount(); + const automaticPayToken = useRef(); + + const { count: availableTokenCount } = useAutomaticTransactionPayToken({ + countOnly: true, + }); + + const transactionId = transactionMeta?.id ?? ''; + const { type } = transactionMeta ?? {}; + + const quotes = useSelector((state: RootState) => + selectTransactionBridgeQuotesById(state, transactionId), + ); + + if (!automaticPayToken.current && payToken) { + automaticPayToken.current = payToken; + } + + const properties: Json = {}; + const sensitiveProperties: Json = {}; + + if (payToken) { + properties.mm_pay = true; + properties.mm_pay_token_selected = payToken.symbol; + properties.mm_pay_chain_selected = payToken.chainId; + properties.mm_pay_transaction_step_total = (quotes?.length ?? 0) + 1; + + properties.mm_pay_transaction_step = + properties.mm_pay_transaction_step_total; + + properties.mm_pay_token_presented = + automaticPayToken.current?.symbol ?? null; + + properties.mm_pay_chain_presented = + automaticPayToken.current?.chainId ?? null; + + properties.mm_pay_payment_token_list_size = availableTokenCount; + } + + if (payToken && type === TransactionType.perpsDeposit) { + properties.mm_pay_use_case = 'perps_deposit'; + properties.simulation_sending_assets_total_value = amountPrecise ?? null; + } + + const nonGasQuote = quotes?.find( + (q) => q.request?.targetTokenAddress !== NATIVE_TOKEN_ADDRESS, + ); + + if (nonGasQuote) { + properties.mm_pay_dust_usd = new BigNumber( + nonGasQuote.quote?.minDestTokenAmount, + ) + .minus(nonGasQuote.request?.targetAmountMinimum) + .shiftedBy(-6) + .toString(10); + } + + const params = useDeepMemo( + () => ({ + properties, + sensitiveProperties, + }), + [properties, sensitiveProperties], + ); + + useEffect(() => { + dispatch(updateConfirmationMetric({ id: transactionId, params })); + }, [dispatch, transactionId, params]); +} diff --git a/app/components/Views/confirmations/utils/bridge.test.ts b/app/components/Views/confirmations/utils/bridge.test.ts index 0b641c15749..8d13a752a20 100644 --- a/app/components/Views/confirmations/utils/bridge.test.ts +++ b/app/components/Views/confirmations/utils/bridge.test.ts @@ -135,6 +135,24 @@ describe('Confirmations Bridge Utils', () => { ]); }); + it('returns metrics', async () => { + const quotesPromise = getBridgeQuotes([ + QUOTE_REQUEST_1_MOCK, + QUOTE_REQUEST_2_MOCK, + ]); + + const quotes = await quotesPromise; + + expect(quotes).toStrictEqual([ + expect.objectContaining({ + metrics: { attempts: 1, buffer: 1, latency: 0 }, + }), + expect.objectContaining({ + metrics: { attempts: 1, buffer: 2, latency: 0 }, + }), + ]); + }); + it('requests quotes', async () => { await getBridgeQuotes([QUOTE_REQUEST_1_MOCK, QUOTE_REQUEST_2_MOCK]); diff --git a/app/components/Views/confirmations/utils/bridge.ts b/app/components/Views/confirmations/utils/bridge.ts index d074f6cbf2b..eef1b9d5105 100644 --- a/app/components/Views/confirmations/utils/bridge.ts +++ b/app/components/Views/confirmations/utils/bridge.ts @@ -16,8 +16,17 @@ import { BigNumber } from 'bignumber.js'; const ERROR_MESSAGE_NO_QUOTES = 'No quotes found'; const ERROR_MESSAGE_ALL_QUOTES_UNDER_MINIMUM = 'All quotes under minimum'; +export interface QuoteMetrics { + attempts: number; + buffer: number; + latency: number; +} + export type TransactionBridgeQuote = QuoteResponse & - QuoteMetadata & { request: BridgeQuoteRequest }; + QuoteMetadata & { + metrics?: QuoteMetrics; + request: BridgeQuoteRequest; + }; const log = createProjectLogger('confirmation-bridge-utils'); @@ -42,6 +51,8 @@ export async function getBridgeQuotes( ): Promise { log('Fetching bridge quotes', requests); + const startTime = Date.now(); + if (!requests?.length) { return []; } @@ -59,7 +70,13 @@ export async function getBridgeQuotes( ), ); - return result; + return result.map((quote) => ({ + ...quote, + metrics: { + ...(quote.metrics as QuoteMetrics), + latency: Date.now() - startTime, + }, + })); } catch (error) { log('Error fetching bridge quotes', error); return undefined; @@ -132,7 +149,14 @@ async function getSufficientSingleBridgeQuote( quote: result, }); - return result; + return { + ...result, + metrics: { + attempts: i + 1, + buffer: buffer + bufferStep * i, + latency: 0, + }, + }; } catch (error) { const errorMessage = (error as { message: string }).message; diff --git a/app/core/Engine/controllers/transaction-controller/event-handlers/metrics.ts b/app/core/Engine/controllers/transaction-controller/event-handlers/metrics.ts index 363f83d2018..9c16cf136b8 100644 --- a/app/core/Engine/controllers/transaction-controller/event-handlers/metrics.ts +++ b/app/core/Engine/controllers/transaction-controller/event-handlers/metrics.ts @@ -10,9 +10,18 @@ import { generateDefaultTransactionMetrics, generateEvent, generateRPCProperties, + getConfirmationMetricProperties, } from '../utils'; -import type { TransactionEventHandlerRequest } from '../types'; +import type { + TransactionEventHandlerRequest, + TransactionMetricsBuilder, +} from '../types'; import Logger from '../../../../../util/Logger'; +import { getMetaMaskPayProperties } from '../event_properties/metamask-pay'; + +const METRICS_BUILDERS: TransactionMetricsBuilder[] = [ + getMetaMaskPayProperties, +]; // Generic handler for simple transaction events const createTransactionEventHandler = @@ -28,10 +37,46 @@ const createTransactionEventHandler = transactionEventHandlerRequest, ); - const event = generateEvent(defaultTransactionMetricProperties); + const metrics = { + properties: defaultTransactionMetricProperties.properties, + sensitiveProperties: + defaultTransactionMetricProperties.sensitiveProperties, + }; + + const allTransactions = + transactionEventHandlerRequest.getState()?.engine?.backgroundState + ?.TransactionController?.transactions ?? []; + + const getUIMetrics = getConfirmationMetricProperties.bind( + null, + transactionEventHandlerRequest.getState, + ); + + const getState = transactionEventHandlerRequest.getState; + + for (const builder of METRICS_BUILDERS) { + try { + const currentMetrics = builder({ + transactionMeta, + allTransactions, + getUIMetrics, + getState, + }); + + merge(metrics, currentMetrics); + } catch (error) { + // Intentionally empty + } + } + + const event = generateEvent({ + ...defaultTransactionMetricProperties, + ...metrics, + }); MetaMetrics.getInstance().trackEvent(event); }; + /** * Handles metrics tracking when a transaction is added to the transaction controller * @param transactionMeta - The transaction metadata diff --git a/app/core/Engine/controllers/transaction-controller/event_properties/metamask-pay.test.ts b/app/core/Engine/controllers/transaction-controller/event_properties/metamask-pay.test.ts new file mode 100644 index 00000000000..7651a617029 --- /dev/null +++ b/app/core/Engine/controllers/transaction-controller/event_properties/metamask-pay.test.ts @@ -0,0 +1,349 @@ +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; +import { getMetaMaskPayProperties } from './metamask-pay'; +import { TransactionMetricsBuilder } from '../types'; +import { Hex } from '@metamask/utils'; +import { RootState } from '../../../../../reducers'; +import { NATIVE_TOKEN_ADDRESS } from '../../../../../components/Views/confirmations/constants/tokens'; + +const BATCH_ID_MOCK = '0x1234' as Hex; + +describe('Metamask Pay Metrics', () => { + const getStateMock: jest.MockedFn< + Parameters[0]['getState'] + > = jest.fn(); + + const getUIMetricsMock: jest.MockedFn< + Parameters[0]['getUIMetrics'] + > = jest.fn(); + + let request: Parameters[0]; + + beforeEach(() => { + jest.resetAllMocks(); + + request = { + transactionMeta: { + id: 'child-1', + txParams: { nonce: '0x1' }, + } as TransactionMeta, + allTransactions: [], + getUIMetrics: getUIMetricsMock, + getState: getStateMock, + }; + }); + + it('returns nothing if perps_deposit', () => { + request.transactionMeta.type = TransactionType.perpsDeposit; + + const result = getMetaMaskPayProperties(request); + + expect(result).toStrictEqual({ + properties: {}, + sensitiveProperties: {}, + }); + }); + + it('copies properties from parent transaction if bridge', () => { + getUIMetricsMock.mockReturnValue({ + properties: { + mm_pay: true, + mm_pay_use_case: 'test_use_case', + mm_pay_transaction_step_total: 3, + }, + sensitiveProperties: {}, + }); + + request.allTransactions = [ + { + id: 'parent-1', + type: TransactionType.perpsDeposit, + requiredTransactionIds: ['child-1'], + } as TransactionMeta, + ]; + + const result = getMetaMaskPayProperties(request); + + expect(result).toStrictEqual({ + properties: expect.objectContaining({ + mm_pay: true, + mm_pay_use_case: 'test_use_case', + mm_pay_transaction_step_total: 3, + }), + sensitiveProperties: {}, + }); + }); + + it('copies properties from parent transaction if swap', () => { + getUIMetricsMock.mockReturnValue({ + properties: { + mm_pay: true, + mm_pay_use_case: 'test_use_case', + mm_pay_transaction_step_total: 3, + }, + sensitiveProperties: {}, + }); + + request.transactionMeta.batchId = BATCH_ID_MOCK; + + request.allTransactions = [ + { + id: 'parent-1', + batchId: BATCH_ID_MOCK, + txParams: { nonce: '0x2' }, + type: TransactionType.perpsDeposit, + } as TransactionMeta, + ]; + + const result = getMetaMaskPayProperties(request); + + expect(result).toStrictEqual({ + properties: expect.objectContaining({ + mm_pay: true, + mm_pay_use_case: 'test_use_case', + mm_pay_transaction_step_total: 3, + }), + sensitiveProperties: {}, + }); + }); + + it('adds step property if bridge', () => { + request.allTransactions = [ + { + id: 'parent-1', + type: TransactionType.perpsDeposit, + requiredTransactionIds: ['child-0', 'child-1'], + } as TransactionMeta, + ]; + + const result = getMetaMaskPayProperties(request); + + expect(result).toStrictEqual({ + properties: expect.objectContaining({ + mm_pay_transaction_step: 2, + }), + sensitiveProperties: {}, + }); + }); + + it('adds step property if swap', () => { + request.transactionMeta.batchId = BATCH_ID_MOCK; + + request.allTransactions = [ + { + id: 'child-0', + batchId: BATCH_ID_MOCK, + txParams: { nonce: '0x0' }, + } as TransactionMeta, + { + id: 'parent-1', + batchId: BATCH_ID_MOCK, + type: TransactionType.perpsDeposit, + txParams: { nonce: '0x2' }, + } as TransactionMeta, + request.transactionMeta, + ]; + + const result = getMetaMaskPayProperties(request); + + expect(result).toStrictEqual({ + properties: expect.objectContaining({ + mm_pay_transaction_step: 2, + }), + sensitiveProperties: {}, + }); + }); + + it('adds quote properties if bridge', () => { + request.transactionMeta.type = TransactionType.bridge; + + request.allTransactions = [ + { + id: 'child-0', + type: TransactionType.bridge, + } as TransactionMeta, + { + id: 'parent-1', + type: TransactionType.perpsDeposit, + requiredTransactionIds: ['child-0', 'child-1'], + } as TransactionMeta, + request.transactionMeta, + ]; + + getStateMock.mockReturnValue({ + confirmationMetrics: { + transactionBridgeQuotesById: { + 'parent-1': [ + {}, + { + metrics: { attempts: 3, buffer: 0.123, latency: 1234 }, + quote: { bridgeId: 'testBridge' }, + request: { + targetTokenAddress: '0x123', + }, + }, + ], + }, + }, + } as unknown as RootState); + + const result = getMetaMaskPayProperties(request); + + expect(result).toStrictEqual({ + properties: expect.objectContaining({ + mm_pay_bridge_provider: 'testBridge', + mm_pay_quotes_attempts: 3, + mm_pay_quotes_buffer_size: 0.123, + mm_pay_quotes_latency: 1234, + }), + sensitiveProperties: {}, + }); + }); + + it('adds quote properties if swap', () => { + request.transactionMeta.batchId = BATCH_ID_MOCK; + request.transactionMeta.type = TransactionType.swap; + + request.allTransactions = [ + { + id: 'child-0', + batchId: BATCH_ID_MOCK, + type: TransactionType.swap, + txParams: { nonce: '0x0' }, + } as TransactionMeta, + { + id: 'parent-1', + batchId: BATCH_ID_MOCK, + type: TransactionType.perpsDeposit, + txParams: { nonce: '0x2' }, + } as TransactionMeta, + request.transactionMeta, + ]; + + getStateMock.mockReturnValue({ + confirmationMetrics: { + transactionBridgeQuotesById: { + 'parent-1': [ + {}, + { + metrics: { attempts: 3, buffer: 0.123, latency: 1234 }, + quote: { bridgeId: 'testBridge' }, + request: { + targetTokenAddress: '0x123', + }, + }, + ], + }, + }, + } as unknown as RootState); + + const result = getMetaMaskPayProperties(request); + + expect(result).toStrictEqual({ + properties: expect.objectContaining({ + mm_pay_bridge_provider: 'testBridge', + mm_pay_quotes_attempts: 3, + mm_pay_quotes_buffer_size: 0.123, + mm_pay_quotes_latency: 1234, + }), + sensitiveProperties: {}, + }); + }); + + it('adds dust property', () => { + request.transactionMeta.type = TransactionType.bridge; + + getUIMetricsMock.mockReturnValue({ + properties: { + mm_pay_dust_usd: '1.23', + }, + sensitiveProperties: {}, + }); + + request.allTransactions = [ + { + id: 'child-0', + type: TransactionType.bridge, + } as TransactionMeta, + { + id: 'parent-1', + type: TransactionType.perpsDeposit, + requiredTransactionIds: ['child-0', 'child-1'], + } as TransactionMeta, + request.transactionMeta, + ]; + + getStateMock.mockReturnValue({ + confirmationMetrics: { + transactionBridgeQuotesById: { + 'parent-1': [ + {}, + { + metrics: { attempts: 3, buffer: 0.123, latency: 1234 }, + quote: { bridgeId: 'testBridge' }, + request: { + targetTokenAddress: '0x123', + }, + }, + ], + }, + }, + } as unknown as RootState); + + const result = getMetaMaskPayProperties(request); + + expect(result).toStrictEqual({ + properties: expect.objectContaining({ + mm_pay_dust_usd: '1.23', + }), + sensitiveProperties: {}, + }); + }); + + it('does not add dust property if native bridge', () => { + request.transactionMeta.type = TransactionType.bridge; + + getUIMetricsMock.mockReturnValue({ + properties: { + mm_pay_dust_usd: '1.23', + }, + sensitiveProperties: {}, + }); + + request.allTransactions = [ + { + id: 'child-0', + type: TransactionType.bridge, + } as TransactionMeta, + { + id: 'parent-1', + type: TransactionType.perpsDeposit, + requiredTransactionIds: ['child-0', 'child-1'], + } as TransactionMeta, + request.transactionMeta, + ]; + + getStateMock.mockReturnValue({ + confirmationMetrics: { + transactionBridgeQuotesById: { + 'parent-1': [ + {}, + { + metrics: { attempts: 3, buffer: 0.123, latency: 1234 }, + quote: { bridgeId: 'testBridge' }, + request: { + targetTokenAddress: NATIVE_TOKEN_ADDRESS, + }, + }, + ], + }, + }, + } as unknown as RootState); + + const result = getMetaMaskPayProperties(request); + + expect(result.properties.mm_pay_dust_usd).toBeUndefined(); + }); +}); diff --git a/app/core/Engine/controllers/transaction-controller/event_properties/metamask-pay.ts b/app/core/Engine/controllers/transaction-controller/event_properties/metamask-pay.ts new file mode 100644 index 00000000000..d9239e26069 --- /dev/null +++ b/app/core/Engine/controllers/transaction-controller/event_properties/metamask-pay.ts @@ -0,0 +1,101 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import { TransactionMetricsBuilder } from '../types'; +import { JsonMap } from '../../../../Analytics/MetaMetrics.types'; +import { orderBy } from 'lodash'; +import { NATIVE_TOKEN_ADDRESS } from '../../../../../components/Views/confirmations/constants/tokens'; + +const COPY_METRICS = [ + 'mm_pay', + 'mm_pay_use_case', + 'mm_pay_transaction_step_total', +] as const; + +export const getMetaMaskPayProperties: TransactionMetricsBuilder = ({ + transactionMeta, + allTransactions, + getUIMetrics, + getState, +}) => { + const properties: JsonMap = {}; + const sensitiveProperties: JsonMap = {}; + const { batchId, id: transactionId, type } = transactionMeta; + + const parentTransaction = allTransactions.find( + (tx) => + tx.requiredTransactionIds?.includes(transactionId) || + (batchId && + tx.type === TransactionType.perpsDeposit && + tx.batchId === batchId), + ); + + if (type === TransactionType.perpsDeposit || !parentTransaction) { + return { + properties, + sensitiveProperties, + }; + } + + const parentMetrics = getUIMetrics(parentTransaction.id); + + for (const key of COPY_METRICS) { + if (parentMetrics?.properties?.[key] !== undefined) { + properties[key] = parentMetrics.properties[key]; + } + } + + const batchTransactionIds = parentTransaction.batchId + ? orderBy( + allTransactions.filter( + (tx) => tx.batchId === parentTransaction.batchId, + ), + (t) => parseInt(t.txParams.nonce ?? '0x0', 16), + 'asc', + ).map((t) => t.id) + : undefined; + + const relatedTransactionIds = + parentTransaction.requiredTransactionIds ?? batchTransactionIds ?? []; + + properties.mm_pay_transaction_step = + relatedTransactionIds.indexOf(transactionId) + 1; + + if ( + [TransactionType.bridge, TransactionType.swap].includes( + type as TransactionType, + ) + ) { + const quotes = + getState().confirmationMetrics.transactionBridgeQuotesById[ + parentTransaction.id + ] ?? []; + + const quoteTransactionIds = relatedTransactionIds.filter((id) => + allTransactions.some( + (tx) => + tx.id === id && + [TransactionType.bridge, TransactionType.swap].includes( + tx.type as TransactionType, + ), + ), + ); + + const quoteIndex = quoteTransactionIds.indexOf(transactionMeta.id); + const quote = quotes[quoteIndex]; + + if (quote) { + properties.mm_pay_quotes_attempts = quote.metrics?.attempts; + properties.mm_pay_quotes_buffer_size = quote.metrics?.buffer; + properties.mm_pay_quotes_latency = quote.metrics?.latency; + properties.mm_pay_bridge_provider = quote.quote.bridgeId; + } + + if (quote && quote.request.targetTokenAddress !== NATIVE_TOKEN_ADDRESS) { + properties.mm_pay_dust_usd = parentMetrics?.properties?.mm_pay_dust_usd; + } + } + + return { + properties, + sensitiveProperties, + }; +}; diff --git a/app/core/Engine/controllers/transaction-controller/types.ts b/app/core/Engine/controllers/transaction-controller/types.ts index 91584b35473..74495359d9b 100644 --- a/app/core/Engine/controllers/transaction-controller/types.ts +++ b/app/core/Engine/controllers/transaction-controller/types.ts @@ -2,6 +2,7 @@ import { JsonMap } from '../../../Analytics/MetaMetrics.types'; import SmartTransactionsController from '@metamask/smart-transactions-controller'; import type { RootState } from '../../../../reducers'; import { TransactionControllerInitMessenger } from '../../messengers/transaction-controller-messenger'; +import { TransactionMeta } from '@metamask/transaction-controller'; // In order to not import from redux slice, type is defined here export interface TransactionMetrics { @@ -14,3 +15,10 @@ export interface TransactionEventHandlerRequest { initMessenger: TransactionControllerInitMessenger; smartTransactionsController: SmartTransactionsController; } + +export type TransactionMetricsBuilder = (request: { + transactionMeta: TransactionMeta; + allTransactions: TransactionMeta[]; + getUIMetrics: (transactionId: string) => TransactionMetrics; + getState: () => RootState; +}) => TransactionMetrics; diff --git a/app/core/Engine/controllers/transaction-controller/utils.test.ts b/app/core/Engine/controllers/transaction-controller/utils.test.ts index 91dad8566f4..7ee5d47d3b3 100644 --- a/app/core/Engine/controllers/transaction-controller/utils.test.ts +++ b/app/core/Engine/controllers/transaction-controller/utils.test.ts @@ -539,6 +539,7 @@ describe('generateDefaultTransactionMetrics', () => { chain_id: '0xaa36a7', dapp_host_name: 'metamask.github.io', eip7702_upgrade_transaction: true, + error: undefined, gas_estimation_failed: true, gas_fee_presented: ['custom'], gas_fee_selected: undefined, @@ -571,6 +572,7 @@ describe('generateDefaultTransactionMetrics', () => { chain_id: '0xaa36a7', dapp_host_name: 'metamask', eip7702_upgrade_transaction: true, + error: undefined, gas_estimation_failed: true, gas_fee_presented: ['custom'], gas_fee_selected: undefined, @@ -609,6 +611,7 @@ describe('generateDefaultTransactionMetrics', () => { dapp_host_name: 'metamask', eip7702_upgrade_rejection: true, eip7702_upgrade_transaction: true, + error: undefined, gas_estimation_failed: true, gas_fee_presented: ['custom'], gas_fee_selected: undefined, @@ -643,6 +646,7 @@ describe('generateDefaultTransactionMetrics', () => { chain_id: '0x1', dapp_host_name: 'jumper123.exchange', eip7702_upgrade_transaction: false, + error: undefined, gas_estimation_failed: false, gas_fee_presented: ['custom', 'low', 'medium', 'high'], gas_fee_selected: 'medium', diff --git a/app/core/Engine/controllers/transaction-controller/utils.ts b/app/core/Engine/controllers/transaction-controller/utils.ts index 2279cf65116..98671a0b856 100644 --- a/app/core/Engine/controllers/transaction-controller/utils.ts +++ b/app/core/Engine/controllers/transaction-controller/utils.ts @@ -47,6 +47,8 @@ export function getTransactionTypeValue( return 'deploy_contract'; case TransactionType.ethGetEncryptionPublicKey: return 'eth_get_encryption_public_key'; + case TransactionType.perpsDeposit: + return 'perps_deposit'; case TransactionType.signTypedData: return 'eth_sign_typed_data'; case TransactionType.simpleSend: @@ -90,7 +92,7 @@ export function getTransactionTypeValue( } } -const getConfirmationMetricProperties = ( +export const getConfirmationMetricProperties = ( getState: () => RootState, transactionId: string, ): TransactionMetrics => { @@ -168,7 +170,9 @@ export async function generateDefaultTransactionMetrics( transactionMeta: TransactionMeta, transactionEventHandlerRequest: TransactionEventHandlerRequest, ) { - const { chainId, status, type, id, origin, txParams } = transactionMeta || {}; + const { chainId, error, status, type, id, origin, txParams } = + transactionMeta || {}; + const { from } = txParams || {}; const accountType = isValidHexAddress(from) @@ -183,8 +187,11 @@ export async function generateDefaultTransactionMetrics( metametricsEvent, properties: { ...batchProperties, - chain_id: chainId, ...gasFeeProperties, + account_type: accountType, + chain_id: chainId, + dapp_host_name: origin ?? 'N/A', + error: error?.message, status, source: 'MetaMask Mobile', transaction_contract_method: await getTransactionContractMethod( @@ -193,8 +200,6 @@ export async function generateDefaultTransactionMetrics( transaction_envelope_type: transactionMeta.txParams.type, transaction_internal_id: id, transaction_type: getTransactionTypeValue(type), - account_type: accountType, - dapp_host_name: origin ?? 'N/A', }, sensitiveProperties: { from_address: transactionMeta.txParams.from, diff --git a/package.json b/package.json index e37e16dd68b..83f447504a4 100644 --- a/package.json +++ b/package.json @@ -302,7 +302,7 @@ "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/swaps-controller": "^13.3.0", "@metamask/token-search-discovery-controller": "^3.1.0", - "@metamask/transaction-controller": "^60.2.0", + "@metamask/transaction-controller": "^60.3.0", "@metamask/utils": "^11.4.2", "@ngraveio/bc-ur": "^1.1.6", "@notifee/react-native": "^9.0.0", diff --git a/patches/@metamask+transaction-controller+58.1.1.patch b/patches/@metamask+transaction-controller+58.1.1.patch deleted file mode 100644 index a947683b270..00000000000 --- a/patches/@metamask+transaction-controller+58.1.1.patch +++ /dev/null @@ -1,15 +0,0 @@ -diff --git a/node_modules/@metamask/transaction-controller/dist/TransactionController.cjs b/node_modules/@metamask/transaction-controller/dist/TransactionController.cjs -index 9bc3d74..af0c49c 100644 ---- a/node_modules/@metamask/transaction-controller/dist/TransactionController.cjs -+++ b/node_modules/@metamask/transaction-controller/dist/TransactionController.cjs -@@ -87,6 +87,10 @@ const metadata = { - persist: true, - anonymous: false, - }, -+ swapsTransactions: { -+ persist: true, -+ anonymous: false, -+ }, - }; - const SUBMIT_HISTORY_LIMIT = 100; - /** diff --git a/patches/@metamask+transaction-controller+60.3.0.patch b/patches/@metamask+transaction-controller+60.3.0.patch new file mode 100644 index 00000000000..670d288f6ea --- /dev/null +++ b/patches/@metamask+transaction-controller+60.3.0.patch @@ -0,0 +1,34 @@ +diff --git a/node_modules/@metamask/transaction-controller/dist/TransactionController.cjs b/node_modules/@metamask/transaction-controller/dist/TransactionController.cjs +index 986bdf1..2ae9d76 100644 +--- a/node_modules/@metamask/transaction-controller/dist/TransactionController.cjs ++++ b/node_modules/@metamask/transaction-controller/dist/TransactionController.cjs +@@ -97,6 +97,12 @@ const metadata = { + anonymous: false, + usedInUi: false, + }, ++ swapsTransactions: { ++ includeInStateLogs: true, ++ persist: true, ++ anonymous: false, ++ usedInUi: true, ++ }, + }; + const SUBMIT_HISTORY_LIMIT = 100; + /** +diff --git a/node_modules/@metamask/transaction-controller/dist/TransactionController.mjs b/node_modules/@metamask/transaction-controller/dist/TransactionController.mjs +index c331c07..31a620c 100644 +--- a/node_modules/@metamask/transaction-controller/dist/TransactionController.mjs ++++ b/node_modules/@metamask/transaction-controller/dist/TransactionController.mjs +@@ -99,6 +99,12 @@ const metadata = { + anonymous: false, + usedInUi: false, + }, ++ swapsTransactions: { ++ includeInStateLogs: true, ++ persist: true, ++ anonymous: false, ++ usedInUi: true, ++ }, + }; + const SUBMIT_HISTORY_LIMIT = 100; + /** diff --git a/yarn.lock b/yarn.lock index 68175e55d6f..de7a104e50d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6269,10 +6269,10 @@ "@toruslabs/http-helpers" "^8.1.1" bn.js "^5.2.1" -"@metamask/transaction-controller@^60.2.0": - version "60.2.0" - resolved "https://registry.yarnpkg.com/@metamask/transaction-controller/-/transaction-controller-60.2.0.tgz#ba2ebb76b082019c738678a3777585d8949029c5" - integrity sha512-Oee4LE2tu/yRO55ecER8zZxBh1BpXcKIbNX59l8zQ2de9HxT9rYjs4zM0YEDvuu00LC4MfJ+mDg+QU1xISHFSA== +"@metamask/transaction-controller@^60.2.0", "@metamask/transaction-controller@^60.3.0": + version "60.3.0" + resolved "https://registry.yarnpkg.com/@metamask/transaction-controller/-/transaction-controller-60.3.0.tgz#acb1f8bfae954e484c494bd631cb93db1e4dc067" + integrity sha512-dFgK72ckx98PkmxWQx+j3ODCB06uY0lj4hZxtW1Hx3szW0mmDb+tuqdY0Z+vbv8vyf0S1ZSFDD5CDxz7CMM69A== dependencies: "@ethereumjs/common" "^4.4.0" "@ethereumjs/tx" "^5.4.0" @@ -6281,7 +6281,7 @@ "@ethersproject/contracts" "^5.7.0" "@ethersproject/providers" "^5.7.0" "@ethersproject/wallet" "^5.7.0" - "@metamask/base-controller" "^8.2.0" + "@metamask/base-controller" "^8.3.0" "@metamask/controller-utils" "^11.12.0" "@metamask/eth-query" "^4.0.0" "@metamask/metamask-eth-abis" "^3.1.1" From ad9e635e2b25ff1c3d514d50d03f59afad72d71d Mon Sep 17 00:00:00 2001 From: Priya Date: Fri, 12 Sep 2025 11:52:14 +0200 Subject: [PATCH 3/9] test: fix failing confirmations e2e (#19648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- e2e/specs/confirmations/batch-transfer-erc1155.spec.ts | 6 ++++++ e2e/specs/confirmations/increase-allowance-erc20.spec.ts | 6 ++++++ e2e/specs/confirmations/send-erc721.spec.ts | 6 ++++++ e2e/specs/confirmations/set-approve-for-all-erc721.spec.ts | 6 ++++++ 4 files changed, 24 insertions(+) diff --git a/e2e/specs/confirmations/batch-transfer-erc1155.spec.ts b/e2e/specs/confirmations/batch-transfer-erc1155.spec.ts index 45a335bc021..cc80d0a9dbe 100644 --- a/e2e/specs/confirmations/batch-transfer-erc1155.spec.ts +++ b/e2e/specs/confirmations/batch-transfer-erc1155.spec.ts @@ -14,6 +14,8 @@ import { buildPermissions } from '../../framework/fixtures/FixtureUtils'; import { Mockttp } from 'mockttp'; import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; import { oldConfirmationsRemoteFeatureFlags } from '../../api-mocking/mock-responses/feature-flags-mocks'; +import WalletView from '../../pages/wallet/WalletView'; +import NetworkListModal from '../../pages/Network/NetworkListModal'; describe(SmokeConfirmations('ERC1155 token'), () => { const ERC1155_CONTRACT = SMART_CONTRACTS.ERC1155; @@ -67,6 +69,10 @@ describe(SmokeConfirmations('ERC1155 token'), () => { // Navigate to the activity screen await TabBarComponent.tapActivity(); + await WalletView.tapTokenNetworkFilter(); + await NetworkListModal.tapOnCustomTab(); + await NetworkListModal.changeNetworkTo('Localhost'); + // Assert that the ERC1155 activity is an smart contract interaction and it is confirmed await Assertions.expectTextDisplayed( ActivitiesViewSelectorsText.SMART_CONTRACT_INTERACTION, diff --git a/e2e/specs/confirmations/increase-allowance-erc20.spec.ts b/e2e/specs/confirmations/increase-allowance-erc20.spec.ts index 35f182e1f3c..fd0ee3edda8 100644 --- a/e2e/specs/confirmations/increase-allowance-erc20.spec.ts +++ b/e2e/specs/confirmations/increase-allowance-erc20.spec.ts @@ -13,6 +13,8 @@ import { DappVariants } from '../../framework/Constants'; import { Mockttp } from 'mockttp'; import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; import { oldConfirmationsRemoteFeatureFlags } from '../../api-mocking/mock-responses/feature-flags-mocks'; +import NetworkListModal from '../../pages/Network/NetworkListModal'; +import WalletView from '../../pages/wallet/WalletView'; const HST_CONTRACT = SMART_CONTRACTS.HST; @@ -76,6 +78,10 @@ describe(SmokeConfirmations('ERC20 - Increase Allowance'), () => { // Navigate to the activity screen await TabBarComponent.tapActivity(); + await WalletView.tapTokenNetworkFilter(); + await NetworkListModal.tapOnCustomTab(); + await NetworkListModal.changeNetworkTo('Localhost'); + // Assert that the ERC20 activity is an increase allowance and it is confirmed await Assertions.expectTextDisplayed( ActivitiesViewSelectorsText.INCREASE_ALLOWANCE_METHOD, diff --git a/e2e/specs/confirmations/send-erc721.spec.ts b/e2e/specs/confirmations/send-erc721.spec.ts index 8d384471aed..68d34d043cb 100644 --- a/e2e/specs/confirmations/send-erc721.spec.ts +++ b/e2e/specs/confirmations/send-erc721.spec.ts @@ -12,6 +12,8 @@ import { DappVariants } from '../../framework/Constants'; import { Mockttp } from 'mockttp'; import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; import { oldConfirmationsRemoteFeatureFlags } from '../../api-mocking/mock-responses/feature-flags-mocks'; +import NetworkListModal from '../../pages/Network/NetworkListModal'; +import WalletView from '../../pages/wallet/WalletView'; describe(SmokeConfirmations('ERC721 tokens'), () => { const NFT_CONTRACT = SMART_CONTRACTS.NFTS; @@ -61,6 +63,10 @@ describe(SmokeConfirmations('ERC721 tokens'), () => { // Navigate to the activity screen await TabBarComponent.tapActivity(); + await WalletView.tapTokenNetworkFilter(); + await NetworkListModal.tapOnCustomTab(); + await NetworkListModal.changeNetworkTo('Localhost'); + // Assert collectible is sent await Assertions.expectTextDisplayed( ActivitiesViewSelectorsText.SENT_COLLECTIBLE_MESSAGE_TEXT, diff --git a/e2e/specs/confirmations/set-approve-for-all-erc721.spec.ts b/e2e/specs/confirmations/set-approve-for-all-erc721.spec.ts index 01b8ef5f527..4434be72cbd 100644 --- a/e2e/specs/confirmations/set-approve-for-all-erc721.spec.ts +++ b/e2e/specs/confirmations/set-approve-for-all-erc721.spec.ts @@ -14,6 +14,8 @@ import { DappVariants } from '../../framework/Constants'; import { Mockttp } from 'mockttp'; import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; import { oldConfirmationsRemoteFeatureFlags } from '../../api-mocking/mock-responses/feature-flags-mocks'; +import NetworkListModal from '../../pages/Network/NetworkListModal'; +import WalletView from '../../pages/wallet/WalletView'; describe(SmokeConfirmations('ERC721 token'), () => { const NFT_CONTRACT = SMART_CONTRACTS.NFTS; @@ -67,6 +69,10 @@ describe(SmokeConfirmations('ERC721 token'), () => { // Navigate to the activity screen await TabBarComponent.tapActivity(); + await WalletView.tapTokenNetworkFilter(); + await NetworkListModal.tapOnCustomTab(); + await NetworkListModal.changeNetworkTo('Localhost'); + // Assert that the ERC721 activity is an set approve for all and it is confirmed await Assertions.expectTextDisplayed( ActivitiesViewSelectorsText.SET_APPROVAL_FOR_ALL_METHOD, From b23c08bbb2c3858738ea75a8da345eb7464ec625 Mon Sep 17 00:00:00 2001 From: Jorge Carrasco Date: Fri, 12 Sep 2025 12:19:09 +0200 Subject: [PATCH 4/9] fix(INFRA-2932): retry yarn packages installation (#19491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR addresses frequent CI/CD pipeline failures caused by transient network errors (502 Bad Gateway) when fetching yarn packages from the npm registry. The solution implements automatic retry logic for all yarn installation commands across GitHub Actions workflows, significantly improving CI reliability and reducing the need for manual job re-runs. Additionally, this PR fixes an incorrect parameter name in the E2E workflow setup that was causing warning messages. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [INFRA-2932](https://consensyssoftware.atlassian.net/browse/INFRA-2932), [INFRA-2940](https://consensyssoftware.atlassian.net/browse/INFRA-2940) ## **Manual testing steps** Job running with the new config: - [1](https://github.com/MetaMask/metamask-mobile/actions/runs/17634468514/job/50108043261?pr=19491) - [2](https://github.com/MetaMask/metamask-mobile/actions/runs/17634468465/job/50108042985?pr=19491) ## **Screenshots/Recordings** ### **Before** - Frequent CI failures with error: `error Error: https://registry.yarnpkg.com/@types/teen_process/-/teen_process-2.0.4.tgz: Request failed "502 Bad Gateway"` - Warning messages: `Warning: Unexpected input(s) 'sd-card-size', valid inputs are [..., 'android-sdcard-size', ...]` - Manual job re-runs required to recover from transient failures ### **After** - Automatic retry mechanism handles transient network failures - No warning messages about incorrect parameter names - Improved CI reliability with reduced manual intervention ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable (N/A - Infrastructure change) - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable (N/A - Workflow files) - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Labels: `team-dev-ops`, `No QA Needed`, `size-M` ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [INFRA-2932]: https://consensyssoftware.atlassian.net/browse/INFRA-2932?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [INFRA-2940]: https://consensyssoftware.atlassian.net/browse/INFRA-2940?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: João --- .github/workflows/add-release-label.yml | 10 ++- .github/workflows/bitrise-e2e-gate.yml | 10 ++- .github/workflows/build-android-e2e.yml | 13 +++- .github/workflows/build-ios-e2e.yml | 15 +++- .github/workflows/check-attributions.yml | 9 ++- .github/workflows/check-pr-labels.yml | 10 ++- .../check-template-and-add-labels.yml | 10 ++- .github/workflows/ci.yml | 75 ++++++++++++++++--- .github/workflows/close-bug-report.yml | 10 ++- .github/workflows/create-bug-report.yml | 10 ++- .github/workflows/fitness-functions.yml | 10 ++- .github/workflows/run-bitrise-e2e-check.yml | 10 ++- .../workflows/run-bitrise-flask-e2e-check.yml | 10 ++- .github/workflows/run-e2e-api-specs.yml | 18 ++++- .github/workflows/run-e2e-workflow.yml | 65 ++++++++-------- .github/workflows/run-performance-e2e.yml | 53 ++++++++----- .github/workflows/test-android-build-app.yml | 17 ++++- .github/workflows/test-ios-build-app.yml | 17 ++++- .github/workflows/trigger-performance-e2e.yml | 18 ++++- .github/workflows/update-attributions.yml | 18 ++++- 20 files changed, 291 insertions(+), 117 deletions(-) diff --git a/.github/workflows/add-release-label.yml b/.github/workflows/add-release-label.yml index 80941c64536..42a21bfa4c0 100644 --- a/.github/workflows/add-release-label.yml +++ b/.github/workflows/add-release-label.yml @@ -22,9 +22,13 @@ jobs: with: node-version-file: '.nvmrc' - - name: Install dependencies - run: yarn --immutable - working-directory: '.github/scripts' + - name: Install dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: cd .github/scripts && yarn --immutable - name: Get the next semver version id: get-next-semver-version diff --git a/.github/workflows/bitrise-e2e-gate.yml b/.github/workflows/bitrise-e2e-gate.yml index 63eeeb8c0ec..17e87539a3c 100644 --- a/.github/workflows/bitrise-e2e-gate.yml +++ b/.github/workflows/bitrise-e2e-gate.yml @@ -27,9 +27,13 @@ jobs: with: node-version-file: '.nvmrc' - - name: Install dependencies - run: yarn --immutable - working-directory: '.github/scripts' + - name: Install dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: cd .github/scripts && yarn --immutable - name: Check Bitrise E2E Gate env: diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index ad87c62628c..d8a6bb72cce 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -50,11 +50,18 @@ jobs: restore-keys: | gradle-${{ runner.os }}- + - name: Setup project dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: | + echo "🚀 Setting up project..." + yarn setup:github-ci --no-build-ios + - name: Build Android E2E APKs run: | - echo "🚀 Setting up project..." - yarn setup:github-ci --no-build-ios - echo "🏗 Building Android E2E APKs..." export NODE_OPTIONS="--max-old-space-size=8192" cp android/gradle.properties.github android/gradle.properties diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index 992e93c0501..7633081b962 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -93,11 +93,20 @@ jobs: - name: Clean iOS plist files run: find ios -name "*.plist" -exec xattr -c {} \; - # Run project setup and build the iOS E2E app for simulator + # Run project setup with retry for better resilience + - name: Setup project dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: | + echo "🚀 Setting up project..." + yarn setup:github-ci --build-ios --no-build-android + + # Build the iOS E2E app for simulator - name: Build iOS E2E App run: | - echo "🚀 Setting up project..." - yarn setup:github-ci --build-ios --no-build-android echo "🏗 Building iOS E2E App..." export NODE_OPTIONS="--max-old-space-size=8192" yarn build:ios:main:e2e diff --git a/.github/workflows/check-attributions.yml b/.github/workflows/check-attributions.yml index 22d6fa63e9e..2cabcfe8dbe 100644 --- a/.github/workflows/check-attributions.yml +++ b/.github/workflows/check-attributions.yml @@ -15,7 +15,12 @@ jobs: with: node-version-file: '.nvmrc' cache: 'yarn' - - name: Install dependencies from cache - run: yarn --immutable + - name: Install dependencies from cache with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn --immutable - name: Check attributions changes run: yarn test:attribution-check diff --git a/.github/workflows/check-pr-labels.yml b/.github/workflows/check-pr-labels.yml index e17447c2711..31f8aa4c967 100644 --- a/.github/workflows/check-pr-labels.yml +++ b/.github/workflows/check-pr-labels.yml @@ -27,9 +27,13 @@ jobs: with: node-version-file: '.nvmrc' - - name: Install dependencies - run: yarn --immutable - working-directory: '.github/scripts' + - name: Install dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: cd .github/scripts && yarn --immutable - name: Check PR has required labels id: check-pr-has-required-labels diff --git a/.github/workflows/check-template-and-add-labels.yml b/.github/workflows/check-template-and-add-labels.yml index 2846cebf223..07a8e7d99d7 100644 --- a/.github/workflows/check-template-and-add-labels.yml +++ b/.github/workflows/check-template-and-add-labels.yml @@ -20,9 +20,13 @@ jobs: with: node-version-file: '.nvmrc' - - name: Install dependencies - run: yarn --immutable - working-directory: '.github/scripts' + - name: Install dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: cd .github/scripts && yarn --immutable - name: Check template and add labels id: check-template-and-add-labels diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8c56c0a299..df7c09bc679 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,13 @@ jobs: ruby-version: '3.1.6' env: BUNDLE_GEMFILE: ios/Gemfile - - run: yarn setup + - name: Install Yarn dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn setup - name: Require clean working directory shell: bash run: | @@ -46,9 +52,20 @@ jobs: with: node-version-file: '.nvmrc' cache: yarn - - run: yarn setup --node - - name: Deduplicate dependencies - run: yarn deduplicate + - name: Install Yarn dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn setup --node + - name: Deduplicate dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn deduplicate - name: Print error if duplicates found shell: bash run: | @@ -64,9 +81,20 @@ jobs: with: node-version-file: '.nvmrc' cache: yarn - - run: yarn setup --node - - name: Run @lavamoat/git-safe-dependencies - run: yarn git-safe-dependencies + - name: Install Yarn dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn setup --node + - name: Run @lavamoat/git-safe-dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn git-safe-dependencies scripts: runs-on: ubuntu-latest strategy: @@ -86,7 +114,13 @@ jobs: with: node-version-file: '.nvmrc' cache: yarn - - run: yarn setup --node + - name: Install Yarn dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn setup --node - run: yarn ${{ matrix['scripts'] }} - name: Require clean working directory shell: bash @@ -108,7 +142,13 @@ jobs: with: node-version-file: '.nvmrc' cache: yarn - - run: yarn setup --node + - name: Install Yarn dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn setup --node # The "10" in this command is the total number of shards. It must be kept # in sync with the length of matrix.shard - run: yarn test:unit --shard=${{ matrix.shard }}/10 --forceExit --silent --coverageReporters=json @@ -141,7 +181,13 @@ jobs: with: node-version-file: '.nvmrc' cache: yarn - - run: yarn setup --node + - name: Install Yarn dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn setup --node - uses: actions/download-artifact@v4 with: path: tests/coverage/ @@ -197,8 +243,13 @@ jobs: with: node-version-file: '.nvmrc' cache: yarn - - name: Install dependencies - run: yarn setup --no-build-android + - name: Install Yarn dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn setup --no-build-android - name: Generate iOS bundle run: yarn gen-bundle:ios diff --git a/.github/workflows/close-bug-report.yml b/.github/workflows/close-bug-report.yml index 9907a836386..5804a3a392e 100644 --- a/.github/workflows/close-bug-report.yml +++ b/.github/workflows/close-bug-report.yml @@ -22,9 +22,13 @@ jobs: with: node-version-file: '.nvmrc' - - name: Install dependencies - run: yarn --immutable - working-directory: '.github/scripts' + - name: Install dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: cd .github/scripts && yarn --immutable - name: Close release bug report issue id: close-release-bug-report-issue diff --git a/.github/workflows/create-bug-report.yml b/.github/workflows/create-bug-report.yml index e989e37bffc..3b264e82759 100644 --- a/.github/workflows/create-bug-report.yml +++ b/.github/workflows/create-bug-report.yml @@ -27,10 +27,14 @@ jobs: with: node-version-file: '.nvmrc' - - name: Install dependencies + - name: Install dependencies with retry if: steps.extract_version.outputs.version - run: yarn --immutable - working-directory: '.github/scripts' + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: cd .github/scripts && yarn --immutable - name: Create bug report issue on planning repo if: steps.extract_version.outputs.version diff --git a/.github/workflows/fitness-functions.yml b/.github/workflows/fitness-functions.yml index 6e621537271..7a44c97b5cc 100644 --- a/.github/workflows/fitness-functions.yml +++ b/.github/workflows/fitness-functions.yml @@ -19,9 +19,13 @@ jobs: with: node-version-file: '.nvmrc' - - name: Install dependencies - run: yarn --immutable - working-directory: '.github/scripts' + - name: Install dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: cd .github/scripts && yarn --immutable - name: Run fitness functions env: diff --git a/.github/workflows/run-bitrise-e2e-check.yml b/.github/workflows/run-bitrise-e2e-check.yml index 4828d651ba2..56405aca350 100644 --- a/.github/workflows/run-bitrise-e2e-check.yml +++ b/.github/workflows/run-bitrise-e2e-check.yml @@ -29,9 +29,13 @@ jobs: with: node-version-file: '.nvmrc' - - name: Install dependencies - run: yarn --immutable - working-directory: '.github/scripts' + - name: Install dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: cd .github/scripts && yarn --immutable - name: Check Bitrise E2E Status env: diff --git a/.github/workflows/run-bitrise-flask-e2e-check.yml b/.github/workflows/run-bitrise-flask-e2e-check.yml index 7d58790aa9d..812842200d4 100644 --- a/.github/workflows/run-bitrise-flask-e2e-check.yml +++ b/.github/workflows/run-bitrise-flask-e2e-check.yml @@ -28,9 +28,13 @@ jobs: with: node-version-file: '.nvmrc' - - name: Install dependencies - run: yarn --immutable - working-directory: '.github/scripts' + - name: Install dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: cd .github/scripts && yarn --immutable - name: Check Bitrise Flask E2E Status env: diff --git a/.github/workflows/run-e2e-api-specs.yml b/.github/workflows/run-e2e-api-specs.yml index 53d0d392389..812e40a1e4c 100644 --- a/.github/workflows/run-e2e-api-specs.yml +++ b/.github/workflows/run-e2e-api-specs.yml @@ -48,13 +48,23 @@ jobs: corepack enable corepack prepare yarn@1.22.22 --activate - - name: Install JavaScript dependencies - run: yarn install --frozen-lockfile + - name: Install JavaScript dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 env: NODE_OPTIONS: --max-old-space-size=4096 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn install --frozen-lockfile - - name: Install Detox CLI - run: yarn global add detox-cli + - name: Install Detox CLI with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn global add detox-cli - name: Setup Xcode run: sudo xcode-select -s /Applications/Xcode_16.2.app diff --git a/.github/workflows/run-e2e-workflow.yml b/.github/workflows/run-e2e-workflow.yml index 1e8c9c0dfdd..c635b241d0b 100644 --- a/.github/workflows/run-e2e-workflow.yml +++ b/.github/workflows/run-e2e-workflow.yml @@ -92,7 +92,6 @@ jobs: setup-simulator: ${{ inputs.platform == 'ios' }} android-avd-name: emulator configure-keystores: false - sd-card-size: 8192 - name: Build Detox framework cache (iOS) if: ${{ inputs.platform == 'ios' }} @@ -171,36 +170,40 @@ jobs: echo "✅ iOS environment cleaned" - name: Run E2E tests - timeout-minutes: ${{ inputs.test-timeout-minutes }} - run: | - platform="${{ inputs.platform }}" - test_suite_tag="${{ inputs.test_suite_tag }}" - - echo "🚀 Running ${{ inputs.test-suite-name }} tests on $platform" - - # Validate required test suite tag - if [[ -z "$test_suite_tag" ]]; then - echo "❌ Error: test_suite_tag is required for non-api-specs tests" - exit 1 - fi - - export TEST_SUITE_TAG="$test_suite_tag" - echo "Using TEST_SUITE_TAG: $TEST_SUITE_TAG" - - # Run tests (Detox/Jest handle retries internally) - echo "🚀 Starting E2E tests..." - if [[ "$platform" == "ios" ]]; then - export BITRISE_TRIGGERED_WORKFLOW_ID="ios_workflow" - else - export BITRISE_TRIGGERED_WORKFLOW_ID="android_workflow" - fi - - # Always use the splitting script (handles both split and non-split cases) - echo "Running split ${{ inputs.split_number }} of ${{ inputs.total_splits }}" - - ./scripts/run-e2e-tags-gha.sh - - echo "✅ Test execution completed" + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: ${{ inputs.test-timeout-minutes }} + max_attempts: 3 + retry_wait_seconds: 30 + command: | + platform="${{ inputs.platform }}" + test_suite_tag="${{ inputs.test_suite_tag }}" + + echo "🚀 Running ${{ inputs.test-suite-name }} tests on $platform" + + # Validate required test suite tag + if [[ -z "$test_suite_tag" ]]; then + echo "❌ Error: test_suite_tag is required for non-api-specs tests" + exit 1 + fi + + export TEST_SUITE_TAG="$test_suite_tag" + echo "Using TEST_SUITE_TAG: $TEST_SUITE_TAG" + + # Run tests (Detox/Jest handle retries internally) + echo "🚀 Starting E2E tests..." + if [[ "$platform" == "ios" ]]; then + export BITRISE_TRIGGERED_WORKFLOW_ID="ios_workflow" + else + export BITRISE_TRIGGERED_WORKFLOW_ID="android_workflow" + fi + + # Always use the splitting script (handles both split and non-split cases) + echo "Running split ${{ inputs.split_number }} of ${{ inputs.total_splits }}" + + ./scripts/run-e2e-tags-gha.sh + + echo "✅ Test execution completed" env: JOB_NAME: ${{ inputs.test-suite-name }} RUN_ID: ${{ github.run_id }} diff --git a/.github/workflows/run-performance-e2e.yml b/.github/workflows/run-performance-e2e.yml index e95664724cd..4a3b8a4f01f 100644 --- a/.github/workflows/run-performance-e2e.yml +++ b/.github/workflows/run-performance-e2e.yml @@ -91,8 +91,13 @@ jobs: node-version-file: '.nvmrc' cache: 'yarn' - - name: Install dependencies - run: yarn setup + - name: Install dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn setup - name: Cache node_modules uses: actions/cache@v4 @@ -374,9 +379,14 @@ jobs: node-version-file: '.nvmrc' cache: 'yarn' - - name: Install dependencies (if cache miss) + - name: Install dependencies with retry (if cache miss) if: steps.cache.outputs.cache-hit != 'true' - run: yarn setup + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn setup - name: BrowserStack Env Setup uses: browserstack/github-actions/setup-env@4478e16186f38e5be07721931642e65a028713c3 @@ -417,13 +427,13 @@ jobs: echo "Either provide browserstack_app_url_android as input or ensure trigger-qa-builds-and-upload job runs successfully" exit 1 fi - + # Use app version from trigger job if available, otherwise default APP_VERSION="${{ needs.trigger-qa-builds-and-upload.outputs.android-version }}" if [ -z "$APP_VERSION" ]; then APP_VERSION="Manual-Input" fi - + { echo "BROWSERSTACK_DEVICE=${{ matrix.device.name }}" echo "BROWSERSTACK_OS_VERSION=${{ matrix.device.os_version }}" @@ -432,7 +442,7 @@ jobs: echo "QA_APP_VERSION=$APP_VERSION" echo "BROWSERSTACK_BUILD_NAME=Android-Performance-${{ github.ref_name }}-Branch" } >> "$GITHUB_ENV" - + - name: Run Android Tests on ${{ matrix.device.name }} env: BROWSERSTACK_LOCAL: true @@ -444,9 +454,9 @@ jobs: echo "Branch: ${{ github.ref_name }}" echo "QA App Version: $QA_APP_VERSION" echo "BrowserStack Android App URL: $BROWSERSTACK_ANDROID_APP_URL" - + yarn run-appwright:android-bs - + - name: Upload Android Test Results uses: actions/upload-artifact@v4 if: always() @@ -472,7 +482,7 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Restore node_modules cache id: cache uses: actions/cache@v4 @@ -484,41 +494,46 @@ jobs: key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} restore-keys: | ${{ runner.os }}-yarn- - + - name: Set up Node.js uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' cache: 'yarn' - - - name: Install dependencies (if cache miss) + + - name: Install dependencies with retry (if cache miss) if: steps.cache.outputs.cache-hit != 'true' - run: yarn setup - + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn setup + - name: BrowserStack Env Setup uses: browserstack/github-actions/setup-env@4478e16186f38e5be07721931642e65a028713c3 with: username: ${{ env.BROWSERSTACK_USERNAME }} access-key: ${{ env.BROWSERSTACK_ACCESS_KEY }} project-name: ${{ github.repository }} - + - name: Setup BrowserStack Local uses: browserstack/github-actions/setup-local@4478e16186f38e5be07721931642e65a028713c3 with: local-testing: start local-identifier: ${{ github.run_id }} local-args: --force-local --verbose - + - name: Wait for BrowserStack Local run: | echo "Waiting for BrowserStack Local to be ready..." sleep 30 echo "BrowserStack Local should be ready now" - + - name: Set iOS Test Environment run: | echo "Setting test environment for device: ${{ matrix.device.name }}" - + # Use BrowserStack URL from trigger job if it ran, otherwise from input IOS_APP_URL="${{ needs.trigger-qa-builds-and-upload.outputs.browserstack-ios-url }}" if [ -z "$IOS_APP_URL" ]; then diff --git a/.github/workflows/test-android-build-app.yml b/.github/workflows/test-android-build-app.yml index dd7749d1ddf..1af96ba89bc 100644 --- a/.github/workflows/test-android-build-app.yml +++ b/.github/workflows/test-android-build-app.yml @@ -90,11 +90,20 @@ jobs: - # Run project setup and build the Android QA app (APK and AAB) - - name: Setup and Build Android App + # Run project setup with retry for better resilience + - name: Setup project dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: | + echo "🚀 Finishing Android Setup..." + yarn setup:github-ci --no-build-ios + + # Build the Android QA app (APK and AAB) + - name: Build Android App run: | - echo "🚀 Finishing Android Setup..." - yarn setup:github-ci --no-build-ios echo "🏗 Building Android APP..." export NODE_OPTIONS="--max-old-space-size=8192" cp android/gradle.properties.github android/gradle.properties diff --git a/.github/workflows/test-ios-build-app.yml b/.github/workflows/test-ios-build-app.yml index be1e1023759..48c92800f57 100644 --- a/.github/workflows/test-ios-build-app.yml +++ b/.github/workflows/test-ios-build-app.yml @@ -71,11 +71,20 @@ jobs: xcrun simctl list | grep Booted || echo "No booted simulators found" shell: bash - # Run project setup and build the iOS QA app for simulator - - name: Setup iOS Environment + # Run project setup with retry for better resilience + - name: Setup project dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: | + echo "🚀 Finishing iOS Setup..." + yarn setup:github-ci --build-ios --no-build-android + + # Build the iOS QA app for simulator + - name: Build iOS App run: | - echo "🚀 Finishing iOS Setup..." - yarn setup:github-ci --build-ios --no-build-android echo "🏗 Building iOS APP..." yarn build:ios:qa shell: bash diff --git a/.github/workflows/trigger-performance-e2e.yml b/.github/workflows/trigger-performance-e2e.yml index 9d0d5192f36..c2fd610374b 100644 --- a/.github/workflows/trigger-performance-e2e.yml +++ b/.github/workflows/trigger-performance-e2e.yml @@ -124,8 +124,13 @@ jobs: node-version-file: '.nvmrc' cache: 'yarn' - - name: Install dependencies - run: yarn setup + - name: Install dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn setup - name: BrowserStack Env Setup uses: browserstack/github-actions/setup-env@4478e16186f38e5be07721931642e65a028713c3 @@ -199,8 +204,13 @@ jobs: node-version-file: '.nvmrc' cache: 'yarn' - - name: Install dependencies - run: yarn setup + - name: Install dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn setup - name: BrowserStack Env Setup uses: browserstack/github-actions/setup-env@4478e16186f38e5be07721931642e65a028713c3 diff --git a/.github/workflows/update-attributions.yml b/.github/workflows/update-attributions.yml index 54f02610689..28aa4642b2e 100644 --- a/.github/workflows/update-attributions.yml +++ b/.github/workflows/update-attributions.yml @@ -63,8 +63,13 @@ jobs: with: node-version-file: '.nvmrc' cache: 'yarn' - - name: Install Yarn dependencies - run: yarn --immutable + - name: Install Yarn dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn --immutable - name: Get commit SHA id: commit-sha run: echo "COMMIT_SHA=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" @@ -90,8 +95,13 @@ jobs: with: node-version-file: '.nvmrc' cache: 'yarn' - - name: Install dependencies from cache - run: yarn --immutable --immutable-cache + - name: Install dependencies from cache with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn --immutable --immutable-cache - name: Generate Attributions run: yarn build:attribution - name: Cache attributions file From 91c02873f3b677f212caea6fbd44751364adee00 Mon Sep 17 00:00:00 2001 From: tommasini <46944231+tommasini@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:23:31 +0100 Subject: [PATCH 5/9] chore: add sourcemaps to bitrise artifacts (#19543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Build: https://app.bitrise.io/build/c9256a8d-ff8c-421b-a537-585c93fef17b command to download the json profile document (please edit the sourcemap path): `npx react-native-release-profiler --fromDownload --appId io.metamask --sourcemap-path android/app/build/generated/sourcemaps/react/prodRelease/index.android.bundle.map` ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- bitrise.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bitrise.yml b/bitrise.yml index 800cc41a343..42af2e5607b 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -2445,6 +2445,15 @@ workflows: - pipeline_intermediate_files: $PROJECT_LOCATION/app/build/outputs/bundle/$OUTPUT_PATH/$RENAMED_AAB_FILE:BITRISE_PLAY_STORE_ABB_PATH - deploy_path: $PROJECT_LOCATION/app/build/outputs/bundle/$OUTPUT_PATH/$RENAMED_AAB_FILE title: Bitrise Deploy AAB + - deploy-to-bitrise-io@2.2.3: + is_always_run: false + is_skippable: true + run_if: '{{not (enveq "IS_DEV_BUILD" "true")}}' + inputs: + - deploy_path: $PROJECT_LOCATION/app/build/generated/sourcemaps/react/$OUTPUT_PATH + - is_compress: true + - zip_name: Android_Sourcemaps_$OUTPUT_PATH + title: Deploy Android Sourcemaps - script@1: title: Prepare Android build outputs for caching run_if: '{{and (getenv "ANDROID_PR_BUILD_CACHE_KEY" | ne "") (getenv "SHARE_WITH_DETOX" | eq "true")}}' From 907693a01409ddea4b5d2bfa1e66b65041e0a394 Mon Sep 17 00:00:00 2001 From: cmd-ob Date: Fri, 12 Sep 2025 12:03:25 +0100 Subject: [PATCH 6/9] chore: cp-7.54.2 remove card smoke tests from Android and iOS workflows (#19655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../workflows/run-e2e-smoke-tests-android.yml | 30 ------------------- .github/workflows/run-e2e-smoke-tests-ios.yml | 18 ----------- bitrise.yml | 2 -- e2e/specs/card/card-button.spec.ts | 2 +- e2e/specs/card/card-home-add-funds.spec.ts | 2 +- e2e/specs/card/card-home-manage-card.spec.ts | 2 +- 6 files changed, 3 insertions(+), 53 deletions(-) diff --git a/.github/workflows/run-e2e-smoke-tests-android.yml b/.github/workflows/run-e2e-smoke-tests-android.yml index ae739d809b2..b504e9631c3 100644 --- a/.github/workflows/run-e2e-smoke-tests-android.yml +++ b/.github/workflows/run-e2e-smoke-tests-android.yml @@ -106,34 +106,6 @@ jobs: total_splits: 2 secrets: inherit - # performance-android-smoke: - # strategy: - # matrix: - # split: [1, 2] - # fail-fast: false - # uses: ./.github/workflows/run-e2e-workflow.yml - # with: - # test-suite-name: performance-android-smoke-${{ matrix.split }} - # platform: android - # test_suite_tag: "SmokePerformance" - # split_number: ${{ matrix.split }} - # total_splits: 2 - # secrets: inherit - - card-android-smoke: - strategy: - matrix: - split: [1] - fail-fast: false - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: card-android-smoke-${{ matrix.split }} - platform: android - test_suite_tag: "SmokeCard" - split_number: ${{ matrix.split }} - total_splits: 1 - secrets: inherit - confirmations-redesigned-android-smoke: strategy: matrix: @@ -160,8 +132,6 @@ jobs: - accounts-android-smoke - network-abstraction-android-smoke - network-expansion-android-smoke - # - performance-android-smoke - - card-android-smoke - confirmations-redesigned-android-smoke steps: diff --git a/.github/workflows/run-e2e-smoke-tests-ios.yml b/.github/workflows/run-e2e-smoke-tests-ios.yml index b43b2ee04c6..781ff329d07 100644 --- a/.github/workflows/run-e2e-smoke-tests-ios.yml +++ b/.github/workflows/run-e2e-smoke-tests-ios.yml @@ -120,22 +120,6 @@ jobs: total_splits: 2 secrets: inherit - # performance-ios-smoke: - # uses: ./.github/workflows/run-e2e-workflow.yml - # with: - # test-suite-name: performance-ios-smoke - # platform: ios - # test_suite_tag: "SmokePerformance" - # secrets: inherit - - card-ios-smoke: - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: card-ios-smoke - platform: ios - test_suite_tag: "SmokeCard" - secrets: inherit - report-ios-smoke-tests: name: Report iOS Smoke Tests runs-on: ubuntu-latest @@ -149,8 +133,6 @@ jobs: - accounts-ios-smoke - network-abstraction-ios-smoke - network-expansion-ios-smoke - # - performance-ios-smoke - - card-ios-smoke steps: - name: Checkout diff --git a/bitrise.yml b/bitrise.yml index 42af2e5607b..dbaee490ffc 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -359,8 +359,6 @@ stages: - run_tag_flask_build_tests_android: {} - run_tag_smoke_accounts_ios: {} - run_tag_smoke_accounts_android: {} - - run_tag_smoke_card_ios: {} - - run_tag_smoke_card_android: {} run_single_e2e_ios_android_stage: workflows: - run_single_ios_e2e_test: {} diff --git a/e2e/specs/card/card-button.spec.ts b/e2e/specs/card/card-button.spec.ts index 8327a884739..3deb87e0f24 100644 --- a/e2e/specs/card/card-button.spec.ts +++ b/e2e/specs/card/card-button.spec.ts @@ -10,7 +10,7 @@ import CardHomeView from '../../pages/Card/CardHomeView'; import SoftAssert from '../../utils/SoftAssert'; import { CustomNetworks } from '../../resources/networks.e2e'; -describe(SmokeCard('Card NavBar Button'), () => { +describe.skip(SmokeCard('Card NavBar Button'), () => { const eventsToCheck: EventPayload[] = []; const setupCardTest = async (testFunction: () => Promise) => { diff --git a/e2e/specs/card/card-home-add-funds.spec.ts b/e2e/specs/card/card-home-add-funds.spec.ts index f6ac52976d3..e39975bcd6f 100644 --- a/e2e/specs/card/card-home-add-funds.spec.ts +++ b/e2e/specs/card/card-home-add-funds.spec.ts @@ -10,7 +10,7 @@ import CardHomeView from '../../pages/Card/CardHomeView'; import SoftAssert from '../../utils/SoftAssert'; import { CustomNetworks } from '../../resources/networks.e2e'; -describe(SmokeCard('CardHome - Add Funds'), () => { +describe.skip(SmokeCard('CardHome - Add Funds'), () => { const eventsToCheck: EventPayload[] = []; const setupCardTest = async (testFunction: () => Promise) => { diff --git a/e2e/specs/card/card-home-manage-card.spec.ts b/e2e/specs/card/card-home-manage-card.spec.ts index c694b31093f..991de945289 100644 --- a/e2e/specs/card/card-home-manage-card.spec.ts +++ b/e2e/specs/card/card-home-manage-card.spec.ts @@ -10,7 +10,7 @@ import CardHomeView from '../../pages/Card/CardHomeView'; import SoftAssert from '../../utils/SoftAssert'; import { CustomNetworks } from '../../resources/networks.e2e'; -describe(SmokeCard('CardHome - Manage Card'), () => { +describe.skip(SmokeCard('CardHome - Manage Card'), () => { const eventsToCheck: EventPayload[] = []; const setupCardTest = async (testFunction: () => Promise) => { From f45314fdc1c049e32934beb4cd8bc673d3c278af Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Fri, 12 Sep 2025 16:36:16 +0530 Subject: [PATCH 7/9] fix: cp-7.55.0 token name display in token hero component (#19647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Token hero component to display token name correctly ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/19151 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** TODO ## **Pre-merge author checklist** - [X] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [X] I've completed the PR template to the best of my ability - [X] I’ve included tests if applicable - [X] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [X] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../avatar-token-with-network-badge.tsx | 4 +- .../confirmations/hooks/useTokenAsset.test.ts | 2 +- .../confirmations/hooks/useTokenAsset.ts | 68 +++++++------------ 3 files changed, 26 insertions(+), 48 deletions(-) diff --git a/app/components/Views/confirmations/components/hero-token/avatar-token-with-network-badge/avatar-token-with-network-badge.tsx b/app/components/Views/confirmations/components/hero-token/avatar-token-with-network-badge/avatar-token-with-network-badge.tsx index aa3fdc012c1..4381e19e488 100644 --- a/app/components/Views/confirmations/components/hero-token/avatar-token-with-network-badge/avatar-token-with-network-badge.tsx +++ b/app/components/Views/confirmations/components/hero-token/avatar-token-with-network-badge/avatar-token-with-network-badge.tsx @@ -33,7 +33,6 @@ const AvatarTokenOrNetworkAssetLogo = ({ const { styles } = useStyles(styleSheet, {}); const { image, isNative } = asset; const isUnknownToken = displayName === strings('token.unknown'); - return isNative ? ( ) : ( { } > diff --git a/app/components/Views/confirmations/hooks/useTokenAsset.test.ts b/app/components/Views/confirmations/hooks/useTokenAsset.test.ts index 08d6723b4c2..2a636489583 100644 --- a/app/components/Views/confirmations/hooks/useTokenAsset.test.ts +++ b/app/components/Views/confirmations/hooks/useTokenAsset.test.ts @@ -40,7 +40,7 @@ describe('useTokenAsset', () => { expect(result.current.asset).toMatchObject({ name: 'Ethereum', - symbol: 'ETH', + symbol: 'Ethereum', }); expect(result.current.displayName).toEqual('ETH'); }); diff --git a/app/components/Views/confirmations/hooks/useTokenAsset.ts b/app/components/Views/confirmations/hooks/useTokenAsset.ts index 498ffb6d3c2..c12e9d60355 100644 --- a/app/components/Views/confirmations/hooks/useTokenAsset.ts +++ b/app/components/Views/confirmations/hooks/useTokenAsset.ts @@ -1,17 +1,20 @@ -import { useSelector } from 'react-redux'; -import { TransactionType } from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; -import { getNativeAssetForChainId } from '@metamask/bridge-controller'; +import { TransactionType } from '@metamask/transaction-controller'; +import { useSelector } from 'react-redux'; import { strings } from '../../../../../locales/i18n'; -import { TokenI } from '../../../UI/Tokens/types'; -import { RootState } from '../../../../reducers'; -import { makeSelectAssetByAddressAndChainId } from '../../../../selectors/multichain'; +import { selectAccountTokensAcrossChains } from '../../../../selectors/multichain'; import { safeToChecksumAddress } from '../../../../util/address'; +import { TokenI } from '../../../UI/Tokens/types'; import { getNativeTokenAddress } from '../utils/asset'; import { useTransactionMetadataRequest } from './transactions/useTransactionMetadataRequest'; -const selectEvmAsset = makeSelectAssetByAddressAndChainId(); +const TypesForNativeToken = [ + TransactionType.simpleSend, + TransactionType.stakingClaim, + TransactionType.stakingDeposit, + TransactionType.stakingUnstake, +]; export const useTokenAsset = () => { const { @@ -21,48 +24,23 @@ export const useTokenAsset = () => { } = useTransactionMetadataRequest() ?? {}; const nativeTokenAddress = getNativeTokenAddress(chainId as Hex); - const tokenAddress = - safeToChecksumAddress(txParams?.to)?.toLowerCase() || nativeTokenAddress; - - const evmAsset = useSelector((state: RootState) => - selectEvmAsset(state, { - address: tokenAddress, - chainId: chainId as Hex, - }), - ); + const tokens = useSelector(selectAccountTokensAcrossChains); - let nativeEvmAsset = useSelector((state: RootState) => - selectEvmAsset(state, { - address: nativeTokenAddress, - chainId: chainId as Hex, - }), - ); - if ( - transactionType === TransactionType.simpleSend && - !nativeEvmAsset && - chainId - ) { - nativeEvmAsset = getNativeAssetForChainId(chainId) as unknown as TokenI; + if (!chainId) { + return { displayName: strings('token.unknown') }; } - let asset = {} as TokenI; + const tokenAddress = + transactionType && TypesForNativeToken.includes(transactionType) + ? nativeTokenAddress + : safeToChecksumAddress(txParams?.to)?.toLowerCase(); + + const asset = tokens[chainId]?.find( + ({ address }) => address.toLowerCase() === tokenAddress, + ) as TokenI; - switch (transactionType) { - case TransactionType.simpleSend: - case TransactionType.stakingClaim: - case TransactionType.stakingDeposit: - case TransactionType.stakingUnstake: { - // Native - asset = nativeEvmAsset ?? ({} as TokenI); - break; - } - case TransactionType.contractInteraction: - case TransactionType.tokenMethodTransfer: - default: { - // ERC20 - asset = evmAsset ?? ({} as TokenI); - break; - } + if (!asset) { + return { asset: {}, displayName: strings('token.unknown') }; } const { name, symbol, ticker } = asset; From 29c4376e66f9fff0ffb7e75ac8a773e6a3f30d15 Mon Sep 17 00:00:00 2001 From: tommasini <46944231+tommasini@users.noreply.github.com> Date: Fri, 12 Sep 2025 12:11:14 +0100 Subject: [PATCH 8/9] chore: Enable sentry by default for devs (#19170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Enable sentry by default for developers. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: Daniel Rocha <68558152+danroc@users.noreply.github.com> --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 0e1a98d1ffe..5f57f2f2625 100644 --- a/index.js +++ b/index.js @@ -29,7 +29,7 @@ if (__DEV__) { enableFreeze(true); // Setup Sentry -setupSentry(); +setupSentry(__DEV__); // Setup Performance observers Performance.setupPerformanceObservers(); From 7051a0bad1b91fd293726c3878b0589396975827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= Date: Fri, 12 Sep 2025 13:14:19 +0200 Subject: [PATCH 9/9] feat: add multichain accounts intro modal and related actions (#19594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR adds educational modal for Multichain Accounts. ## **Changelog** CHANGELOG entry: Added multichain accounts intro modal for user onboarding. ## **Related issues** Fixes: Jira ticket: https://consensyssoftware.atlassian.net/browse/MUL-532 ## **Manual testing steps** ```gherkin Feature: Show educational modal for Multichain Accounts Scenario: user opens app after update Given feature flag for multichain account state 2 is enabled When user unlocks application Then new educational modal for Multichain Accounts is displayed ``` ## **Screenshots/Recordings** Screenshot 2025-09-11 at 14 22 33 Screenshot 2025-09-11 at 14 22 41 ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/actions/user/index.ts | 13 + app/actions/user/types.ts | 9 +- app/components/Nav/App/App.tsx | 12 + .../IntroModal/LearnMoreBottomSheet.styles.ts | 39 +++ .../IntroModal/LearnMoreBottomSheet.test.tsx | 281 ++++++++++++++++++ .../LearnMoreBottomSheet.testIds.ts | 21 ++ .../IntroModal/LearnMoreBottomSheet.tsx | 127 ++++++++ .../MultichainAccountsIntroModal.styles.ts | 65 ++++ .../MultichainAccountsIntroModal.test.tsx | 173 +++++++++++ .../MultichainAccountsIntroModal.testIds.ts | 29 ++ .../MultichainAccountsIntroModal.tsx | 143 +++++++++ .../MultichainAccounts/IntroModal/index.ts | 1 + .../IntroModal/shared.testIds.ts | 18 ++ .../MultichainAccounts/IntroModal/testIds.ts | 8 + app/components/Views/Wallet/index.tsx | 6 + .../useMultichainAccountsIntroModal.test.ts | 157 ++++++++++ .../hooks/useMultichainAccountsIntroModal.ts | 35 +++ app/constants/navigation/Routes.ts | 2 + app/reducers/user/index.ts | 6 + app/reducers/user/selectors.ts | 6 + app/reducers/user/types.ts | 1 + locales/languages/en.json | 15 + 22 files changed, 1166 insertions(+), 1 deletion(-) create mode 100644 app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.styles.ts create mode 100644 app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx create mode 100644 app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.testIds.ts create mode 100644 app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx create mode 100644 app/components/Views/MultichainAccounts/IntroModal/MultichainAccountsIntroModal.styles.ts create mode 100644 app/components/Views/MultichainAccounts/IntroModal/MultichainAccountsIntroModal.test.tsx create mode 100644 app/components/Views/MultichainAccounts/IntroModal/MultichainAccountsIntroModal.testIds.ts create mode 100644 app/components/Views/MultichainAccounts/IntroModal/MultichainAccountsIntroModal.tsx create mode 100644 app/components/Views/MultichainAccounts/IntroModal/index.ts create mode 100644 app/components/Views/MultichainAccounts/IntroModal/shared.testIds.ts create mode 100644 app/components/Views/MultichainAccounts/IntroModal/testIds.ts create mode 100644 app/components/hooks/useMultichainAccountsIntroModal.test.ts create mode 100644 app/components/hooks/useMultichainAccountsIntroModal.ts diff --git a/app/actions/user/index.ts b/app/actions/user/index.ts index 3ceade56e5a..5620eda34c5 100644 --- a/app/actions/user/index.ts +++ b/app/actions/user/index.ts @@ -24,6 +24,7 @@ import { type SetAppServicesReadyAction, type SetExistingUserAction, type SetIsConnectionRemovedAction, + type SetMultichainAccountsIntroModalSeenAction, UserActionType, } from './types'; @@ -200,3 +201,15 @@ export function setIsConnectionRemoved( payload: { isConnectionRemoved }, }; } + +/** + * Action to set multichain accounts intro modal as seen + */ +export function setMultichainAccountsIntroModalSeen( + seen: boolean, +): SetMultichainAccountsIntroModalSeenAction { + return { + type: UserActionType.SET_MULTICHAIN_ACCOUNTS_INTRO_MODAL_SEEN, + payload: { seen }, + }; +} diff --git a/app/actions/user/types.ts b/app/actions/user/types.ts index f98c55ad1a2..4af31fa01b6 100644 --- a/app/actions/user/types.ts +++ b/app/actions/user/types.ts @@ -27,6 +27,7 @@ export enum UserActionType { SET_APP_SERVICES_READY = 'SET_APP_SERVICES_READY', SET_EXISTING_USER = 'SET_EXISTING_USER', SET_IS_CONNECTION_REMOVED = 'SET_IS_CONNECTION_REMOVED', + SET_MULTICHAIN_ACCOUNTS_INTRO_MODAL_SEEN = 'SET_MULTICHAIN_ACCOUNTS_INTRO_MODAL_SEEN', } // User actions @@ -103,6 +104,11 @@ export type SetIsConnectionRemovedAction = payload: { isConnectionRemoved: boolean }; }; +export type SetMultichainAccountsIntroModalSeenAction = + Action & { + payload: { seen: boolean }; + }; + /** * User actions union type */ @@ -130,4 +136,5 @@ export type UserAction = | CheckedAuthAction | SetAppServicesReadyAction | SetExistingUserAction - | SetIsConnectionRemovedAction; + | SetIsConnectionRemovedAction + | SetMultichainAccountsIntroModalSeenAction; diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index c7c152b2da8..b3c34c719c0 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -140,6 +140,8 @@ import DeleteAccount from '../../Views/MultichainAccounts/sheets/DeleteAccount'; import RevealPrivateKey from '../../Views/MultichainAccounts/sheets/RevealPrivateKey'; import RevealSRP from '../../Views/MultichainAccounts/sheets/RevealSRP'; import { DeepLinkModal } from '../../UI/DeepLinkModal'; +import MultichainAccountsIntroModal from '../../Views/MultichainAccounts/IntroModal'; +import LearnMoreBottomSheet from '../../Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet'; import { WalletDetails } from '../../Views/MultichainAccounts/WalletDetails/WalletDetails'; import { AddressList as MultichainAccountAddressList } from '../../Views/MultichainAccounts/AddressList'; import { PrivateKeyList as MultichainAccountPrivateKeyList } from '../../Views/MultichainAccounts/PrivateKeyList'; @@ -532,6 +534,16 @@ const RootModalFlow = (props: RootModalFlowProps) => ( name={Routes.MODAL.DEEP_LINK_MODAL} component={DeepLinkModal} /> + + ); diff --git a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.styles.ts b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.styles.ts new file mode 100644 index 00000000000..d10f7d6584b --- /dev/null +++ b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.styles.ts @@ -0,0 +1,39 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + container: { + backgroundColor: colors.background.default, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 8, + }, + title: { + flex: 1, + textAlign: 'center', + marginHorizontal: 8, + }, + content: { + paddingHorizontal: 16, + paddingVertical: 24, + }, + description: { + marginBottom: 24, + lineHeight: 24, + }, + footer: { + paddingHorizontal: 16, + paddingVertical: 24, + paddingTop: 0, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx new file mode 100644 index 00000000000..d5b070245c7 --- /dev/null +++ b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx @@ -0,0 +1,281 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import LearnMoreBottomSheet from './LearnMoreBottomSheet'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import { strings } from '../../../../../locales/i18n'; +import { LEARN_MORE_BOTTOM_SHEET_TEST_IDS } from './testIds'; + +const mockOnClose = jest.fn(); +const mockNavigation = { + goBack: jest.fn(), + navigate: jest.fn(), +}; +const mockDispatch = jest.fn(); + +// Mock the BottomSheet component +const mockOnCloseBottomSheet = jest.fn(); +// eslint-disable-next-line import/no-commonjs +jest.mock( + '../../../../component-library/components/BottomSheets/BottomSheet', + () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, import/no-commonjs, @typescript-eslint/no-var-requires + const ReactMock = require('react'); + return { + __esModule: true, + default: ReactMock.forwardRef( + ( + { children }: { children: React.ReactNode }, + ref: React.Ref<{ onCloseBottomSheet: () => void }>, + ) => { + ReactMock.useImperativeHandle(ref, () => ({ + onCloseBottomSheet: mockOnCloseBottomSheet, + })); + return ReactMock.createElement( + 'View', + { testID: 'bottom-sheet' }, + children, + ); + }, + ), + }; + }, +); + +// Mock React Navigation +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => mockNavigation, + useTheme: () => ({}), + }; +}); + +// Mock Redux hooks +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockDispatch, + useSelector: jest.fn(), +})); + +describe('LearnMoreBottomSheet', () => { + const { useSelector } = jest.requireMock('react-redux'); + + beforeEach(() => { + jest.clearAllMocks(); + // Mock useSelector to return basic functionality disabled by default + useSelector.mockImplementation((selector: (state: unknown) => unknown) => { + const mockState = { + settings: { + basicFunctionalityEnabled: false, + }, + }; + return selector(mockState); + }); + }); + + it('renders correctly with all elements', () => { + const { getByTestId } = renderWithProvider( + , + ); + + expect( + getByTestId(LEARN_MORE_BOTTOM_SHEET_TEST_IDS.BOTTOM_SHEET), + ).toBeOnTheScreen(); + expect( + getByTestId(LEARN_MORE_BOTTOM_SHEET_TEST_IDS.TITLE), + ).toBeOnTheScreen(); + expect( + getByTestId(LEARN_MORE_BOTTOM_SHEET_TEST_IDS.BACK_BUTTON), + ).toBeOnTheScreen(); + expect( + getByTestId(LEARN_MORE_BOTTOM_SHEET_TEST_IDS.CLOSE_BUTTON), + ).toBeOnTheScreen(); + expect( + getByTestId(LEARN_MORE_BOTTOM_SHEET_TEST_IDS.DESCRIPTION), + ).toBeOnTheScreen(); + expect( + getByTestId(LEARN_MORE_BOTTOM_SHEET_TEST_IDS.CHECKBOX), + ).toBeOnTheScreen(); + expect( + getByTestId(LEARN_MORE_BOTTOM_SHEET_TEST_IDS.CONFIRM_BUTTON), + ).toBeOnTheScreen(); + }); + + it('displays correct title', () => { + const { getByTestId } = renderWithProvider( + , + ); + + expect( + getByTestId(LEARN_MORE_BOTTOM_SHEET_TEST_IDS.TITLE), + ).toHaveTextContent(strings('multichain_accounts.learn_more.title')); + }); + + it('displays correct description', () => { + const { getByTestId } = renderWithProvider( + , + ); + + expect( + getByTestId(LEARN_MORE_BOTTOM_SHEET_TEST_IDS.DESCRIPTION), + ).toHaveTextContent(strings('multichain_accounts.learn_more.description')); + }); + + it('displays correct checkbox label', () => { + const { getByTestId } = renderWithProvider( + , + ); + + expect( + getByTestId(LEARN_MORE_BOTTOM_SHEET_TEST_IDS.CHECKBOX), + ).toHaveTextContent( + strings('multichain_accounts.learn_more.checkbox_label'), + ); + }); + + it('displays correct confirm button label', () => { + const { getByTestId } = renderWithProvider( + , + ); + + expect( + getByTestId(LEARN_MORE_BOTTOM_SHEET_TEST_IDS.CONFIRM_BUTTON), + ).toHaveTextContent( + strings('multichain_accounts.learn_more.confirm_button'), + ); + }); + + it('renders confirm button', () => { + const { getByTestId } = renderWithProvider( + , + ); + + const confirmButton = getByTestId( + LEARN_MORE_BOTTOM_SHEET_TEST_IDS.CONFIRM_BUTTON, + ); + expect(confirmButton).toBeOnTheScreen(); + }); + + it('handles back button press', () => { + const { getByTestId } = renderWithProvider( + , + ); + + const backButton = getByTestId( + LEARN_MORE_BOTTOM_SHEET_TEST_IDS.BACK_BUTTON, + ); + fireEvent.press(backButton); + + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + }); + + it('handles close button press', () => { + const { getByTestId } = renderWithProvider( + , + ); + + const closeButton = getByTestId( + LEARN_MORE_BOTTOM_SHEET_TEST_IDS.CLOSE_BUTTON, + ); + fireEvent.press(closeButton); + + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + }); + + it('handles checkbox press and toggles state', () => { + const { getByTestId } = renderWithProvider( + , + ); + + const checkbox = getByTestId(LEARN_MORE_BOTTOM_SHEET_TEST_IDS.CHECKBOX); + const confirmButton = getByTestId( + LEARN_MORE_BOTTOM_SHEET_TEST_IDS.CONFIRM_BUTTON, + ); + + // Initially checkbox should be unchecked and confirm button disabled + expect(confirmButton).toHaveProp('disabled', true); + + // Press checkbox to check it + fireEvent.press(checkbox); + + // Confirm button should now be enabled + expect(confirmButton).toHaveProp('disabled', false); + }); + + it('handles confirm button press when checkbox is checked', () => { + const { getByTestId } = renderWithProvider( + , + ); + + const checkbox = getByTestId(LEARN_MORE_BOTTOM_SHEET_TEST_IDS.CHECKBOX); + const confirmButton = getByTestId( + LEARN_MORE_BOTTOM_SHEET_TEST_IDS.CONFIRM_BUTTON, + ); + + // First check the checkbox + fireEvent.press(checkbox); + + // Then press confirm button + fireEvent.press(confirmButton); + + // Should call navigation.goBack twice (close bottom sheet and modal) + expect(mockNavigation.goBack).toHaveBeenCalledTimes(2); + }); + + it('does not navigate when confirm button pressed without checkbox checked', () => { + const { getByTestId } = renderWithProvider( + , + ); + + const confirmButton = getByTestId( + LEARN_MORE_BOTTOM_SHEET_TEST_IDS.CONFIRM_BUTTON, + ); + + // Press confirm button without checking checkbox + fireEvent.press(confirmButton); + + // Should not call navigation methods + expect(mockNavigation.goBack).not.toHaveBeenCalled(); + expect(mockNavigation.navigate).not.toHaveBeenCalled(); + }); + + it('navigates to basic functionality when enabled and checkbox is checked', () => { + // Mock useSelector to return basic functionality enabled + useSelector.mockImplementation((selector: (state: unknown) => unknown) => { + const mockState = { + settings: { + basicFunctionalityEnabled: true, + }, + }; + return selector(mockState); + }); + + const { getByTestId } = renderWithProvider( + , + ); + + const checkbox = getByTestId(LEARN_MORE_BOTTOM_SHEET_TEST_IDS.CHECKBOX); + const confirmButton = getByTestId( + LEARN_MORE_BOTTOM_SHEET_TEST_IDS.CONFIRM_BUTTON, + ); + + // Check the checkbox + fireEvent.press(checkbox); + + // Press confirm button + fireEvent.press(confirmButton); + + // Should call navigation.goBack twice and navigate to basic functionality + expect(mockNavigation.goBack).toHaveBeenCalledTimes(2); + expect(mockNavigation.navigate).toHaveBeenCalledWith('RootModalFlow', { + screen: 'BasicFunctionality', + }); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'SET_MULTICHAIN_ACCOUNTS_INTRO_MODAL_SEEN', + payload: { seen: true }, + }), + ); + }); +}); diff --git a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.testIds.ts b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.testIds.ts new file mode 100644 index 00000000000..5f93c00c1ef --- /dev/null +++ b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.testIds.ts @@ -0,0 +1,21 @@ +/** + * Test IDs for LearnMoreBottomSheet component + * Centralized test identifiers to avoid hardcoded values in tests + */ +export const LEARN_MORE_BOTTOM_SHEET_TEST_IDS = { + // Main container + BOTTOM_SHEET: 'bottom-sheet', + + // Header elements + TITLE: 'learn-more-title', + BACK_BUTTON: 'learn-more-back-button', + CLOSE_BUTTON: 'learn-more-close-button', + + // Content elements + DESCRIPTION: 'learn-more-description', + CHECKBOX: 'learn-more-checkbox', + CHECKBOX_LABEL: 'learn-more-checkbox-label', + + // Action buttons + CONFIRM_BUTTON: 'learn-more-confirm-button', +} as const; diff --git a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx new file mode 100644 index 00000000000..b4e597c2760 --- /dev/null +++ b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx @@ -0,0 +1,127 @@ +import React, { useState, useCallback, useRef } from 'react'; +import { View } from 'react-native'; +import { + Text, + ButtonIcon, + TextVariant, + IconName, + TextColor, +} from '@metamask/design-system-react-native'; +import Button, { + ButtonVariants, + ButtonWidthTypes, + ButtonSize, +} from '../../../../component-library/components/Buttons/Button'; +import Checkbox from '../../../../component-library/components/Checkbox'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../component-library/components/BottomSheets/BottomSheet'; +import { useNavigation, useTheme } from '@react-navigation/native'; +import { strings } from '../../../../../locales/i18n'; +import { useStyles } from '../../../../component-library/hooks'; +import styleSheet from './LearnMoreBottomSheet.styles'; +import Routes from '../../../../constants/navigation/Routes'; +import { RootState } from '../../../../reducers'; +import { useDispatch, useSelector } from 'react-redux'; +import { setMultichainAccountsIntroModalSeen } from '../../../../actions/user'; + +interface LearnMoreBottomSheetProps { + onClose: () => void; +} + +const LearnMoreBottomSheet: React.FC = ({ + onClose, +}) => { + const { styles } = useStyles(styleSheet, { theme: useTheme() }); + const [isCheckboxChecked, setIsCheckboxChecked] = useState(false); + const sheetRef = useRef(null); + const navigation = useNavigation(); + const dispatch = useDispatch(); + + const isBasicFunctionalityEnabled = useSelector( + (state: RootState) => state?.settings?.basicFunctionalityEnabled, + ); + + const handleBack = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + }, []); + + const handleClose = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + }, []); + + const handleCheckboxToggle = useCallback(() => { + setIsCheckboxChecked(!isCheckboxChecked); + }, [isCheckboxChecked]); + + const handleConfirm = useCallback(() => { + if (isCheckboxChecked) { + navigation.goBack(); // close bottom sheet + navigation.goBack(); // close modal + if (isBasicFunctionalityEnabled) { + dispatch(setMultichainAccountsIntroModalSeen(true)); + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.BASIC_FUNCTIONALITY, + }); + } + } + }, [isCheckboxChecked, navigation, isBasicFunctionalityEnabled, dispatch]); + + return ( + + + + + + {strings('multichain_accounts.learn_more.title')} + + + + + + + {strings('multichain_accounts.learn_more.description')} + + + + + + +