From 44756679c4fe8b323b3d57c67c8d7e4a7963a2d9 Mon Sep 17 00:00:00 2001 From: MetaMask Bot <37885440+metamaskbot@users.noreply.github.com> Date: Fri, 14 Nov 2025 02:05:50 -0330 Subject: [PATCH 001/154] release: Bump main version to 13.11.0 (#37827) ## Version Bump After Release This PR bumps the main branch version from 13.10.0 to 13.11.0 after cutting the release branch. ### Why this is needed: - **Nightly builds**: Each nightly build needs to be one minor version ahead of the current release candidate - **Version conflicts**: Prevents conflicts between nightlies and release candidates - **Platform alignment**: Maintains version alignment between MetaMask mobile and extension - **Update systems**: Ensures nightlies are accepted by app stores and browser update systems ### What changed: - Version bumped from `13.10.0` to `13.11.0` - Platform: `extension` - Files updated by `set-semvar-version.sh` script ### Next steps: This PR should be **manually reviewed and merged by the release manager** to maintain proper version flow. ### Related: - Release version: 13.10.0 - Release branch: release/13.10.0 - Platform: extension - Test mode: false --- *This PR was automatically created by the `create-platform-release-pr.sh` script.* --- > [!NOTE] > Bumps version in `package.json` from `13.10.0` to `13.11.0`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9fa8cd34e11835b2569ab2feb543e3a774f40baf. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). Co-authored-by: metamaskbot --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8a2e0a62afbc..bf429bec7770 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask-crx", - "version": "13.10.0", + "version": "13.11.0", "private": true, "repository": { "type": "git", From 9793ca4b40c5abc0e6b8f3d343cd673741772e6c Mon Sep 17 00:00:00 2001 From: Nidhi Kumari Date: Fri, 14 Nov 2025 11:06:01 +0530 Subject: [PATCH 002/154] feat: added sidepanel icon (#37777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR is to add sidepanel/popup icon to the DS component library. It also fixes an edge case with the Advanced Settings `Show extension in full-size view` button where users who have turned sidepanel on after enabling this will see sidepanel by default. ## **Changelog** CHANGELOG entry:null ## **Related issues** Fixes: [https://consensyssoftware.atlassian.net/browse/CEUX-684](https://consensyssoftware.atlassian.net/browse/CEUX-684) ) ## **Manual testing steps** 1. In storybook, check sidepanel icon 2. Go to popup view or sidepanel and check the icon in global menu ## **Screenshots/Recordings** ### **Before** NA ### **After** ![Screenshot 2025-11-13 at 4 36 06 PM](https://github.com/user-attachments/assets/127b771a-a5e8-4521-a508-ffa0afbf71d2) ![Screenshot 2025-11-13 at 10 10 04 PM](https://github.com/user-attachments/assets/d214efc2-2817-42ab-acf9-5e4ba1e307d7) ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/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. --- > [!NOTE] > Adds popup/sidepanel icons to the design system, uses them for the view toggle in the global menu, and enables the full-size opening behavior from sidepanel too. > > - **Design System / Icons**: > - Add `app/images/icons/popup.svg` and `app/images/icons/sidepanel.svg`. > - Expose `IconName.Popup` and `IconName.Sidepanel` in `ui/components/component-library/icon/icon.types.ts`. > - **Global Menu** (`ui/components/multichain/global-menu/global-menu.tsx`): > - Replace viewport toggle to use `IconName.Popup`/`IconName.Sidepanel` and `isSidePanelDefault` logic; update metrics payload and close behavior. > - Add divider after notifications section. > - **Routing / Behavior** (`ui/pages/routes/routes.component.tsx`): > - Extend "Show extension in full-size view" to trigger when in `ENVIRONMENT_TYPE_SIDEPANEL` as well as popup. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 592283c41ac429ddba72c00d07dcfbaba1c0bfe1. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: ameliejyc --- app/images/icons/popup.svg | 3 + app/images/icons/sidepanel.svg | 10 ++++ .../component-library/icon/icon.types.ts | 2 + .../multichain/global-menu/global-menu.tsx | 60 ++++++++++--------- ui/pages/routes/routes.component.tsx | 6 +- 5 files changed, 51 insertions(+), 30 deletions(-) create mode 100644 app/images/icons/popup.svg create mode 100644 app/images/icons/sidepanel.svg diff --git a/app/images/icons/popup.svg b/app/images/icons/popup.svg new file mode 100644 index 000000000000..c1e143480f7a --- /dev/null +++ b/app/images/icons/popup.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/images/icons/sidepanel.svg b/app/images/icons/sidepanel.svg new file mode 100644 index 000000000000..8a55962cb4dd --- /dev/null +++ b/app/images/icons/sidepanel.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ui/components/component-library/icon/icon.types.ts b/ui/components/component-library/icon/icon.types.ts index 652b1f35f871..7469a187309e 100644 --- a/ui/components/component-library/icon/icon.types.ts +++ b/ui/components/component-library/icon/icon.types.ts @@ -201,6 +201,7 @@ export enum IconName { Plug = 'plug', PlusAndMinus = 'plus-and-minus', PolicyAlert = 'policy-alert', + Popup = 'popup', Print = 'print', PriorityHigh = 'priority-high', PrivacyTip = 'privacy-tip', @@ -241,6 +242,7 @@ export enum IconName { ShieldLock = 'shield-lock', ShoppingBag = 'shopping-bag', ShoppingCart = 'shopping-cart', + Sidepanel = 'sidepanel', SignalCellular = 'signal-cellular', Slash = 'slash', Sms = 'sms', diff --git a/ui/components/multichain/global-menu/global-menu.tsx b/ui/components/multichain/global-menu/global-menu.tsx index d58431f4e086..93ddf0be5e32 100644 --- a/ui/components/multichain/global-menu/global-menu.tsx +++ b/ui/components/multichain/global-menu/global-menu.tsx @@ -336,6 +336,11 @@ export const GlobalMenu = ({ + )} {rewardsEnabled && ( @@ -394,6 +399,32 @@ export const GlobalMenu = ({ width={BlockSize.Full} style={{ height: '1px', borderBottomWidth: 0 }} > + {/* Toggle between popup and sidepanel - only for Chrome when sidepanel is enabled */} + {getBrowserName() !== PLATFORM_FIREFOX && + isSidePanelEnabled && + (getEnvironmentType() === ENVIRONMENT_TYPE_POPUP || + getEnvironmentType() === ENVIRONMENT_TYPE_SIDEPANEL) && ( + { + await toggleDefaultView(); + trackEvent({ + event: MetaMetricsEventName.ViewportSwitched, + category: MetaMetricsEventCategory.Navigation, + properties: { + location: METRICS_LOCATION, + to: isSidePanelDefault + ? ENVIRONMENT_TYPE_POPUP + : ENVIRONMENT_TYPE_SIDEPANEL, + }, + }); + closeMenu(); + }} + data-testid="global-menu-toggle-view" + > + {isSidePanelDefault ? t('switchToPopup') : t('switchToSidePanel')} + + )} {t('allPermissions')} - {/* Toggle between popup and sidepanel - only for Chrome when sidepanel is enabled */} - {getBrowserName() !== PLATFORM_FIREFOX && - isSidePanelEnabled && - (getEnvironmentType() === ENVIRONMENT_TYPE_POPUP || - getEnvironmentType() === ENVIRONMENT_TYPE_SIDEPANEL) && ( - { - await toggleDefaultView(); - trackEvent({ - event: MetaMetricsEventName.ViewportSwitched, - category: MetaMetricsEventCategory.Navigation, - properties: { - location: METRICS_LOCATION, - to: - getEnvironmentType() === ENVIRONMENT_TYPE_SIDEPANEL - ? ENVIRONMENT_TYPE_POPUP - : ENVIRONMENT_TYPE_SIDEPANEL, - }, - }); - closeMenu(); - }} - data-testid="global-menu-toggle-view" - > - {getEnvironmentType() === ENVIRONMENT_TYPE_SIDEPANEL - ? t('switchToPopup') - : t('switchToSidePanel')} - - )} { const windowType = getEnvironmentType(); - if (showExtensionInFullSizeView && windowType === ENVIRONMENT_TYPE_POPUP) { + if ( + showExtensionInFullSizeView && + (windowType === ENVIRONMENT_TYPE_POPUP || + windowType === ENVIRONMENT_TYPE_SIDEPANEL) + ) { global.platform?.openExtensionInBrowser?.(); } }, [showExtensionInFullSizeView]); From ac3f905eb37a0bdc135fa42df8c577b503bcfedc Mon Sep 17 00:00:00 2001 From: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com> Date: Fri, 14 Nov 2025 09:26:22 +0000 Subject: [PATCH 003/154] fix: enhance metrics for Transaction/Signatures events (Shield) cp-13.10.0 (#37804) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR aims to add metrics for transaction shield. - `shield_result_response_latency_ms`: latency to receive response from Shield Upgrading `@metamask/shield-controller` from `^1.2.0` to ` ^2.1.0`, to support tracking latency of the requests. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/37804?quickstart=1) ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/6136 ## **Manual testing steps** 1. Start the app 2. Switch to Sepolia network 3. Mint some USDC on sepolia on [this smart contract](https://sepolia.etherscan.io/token/0xaa6e4b8e84a2c0957c8a8efb12a64d2f06700142#writeContract) (at least 80 for annual plan, 96 for monthly). 4. Import the token manually in the assets list 5. Navigate to Settings > Transaction Shield 6. Select pay with crypto, and Continue 7. The confirmation screen should appear and new shield properties should be added to the transaction/signature events 8. Check Segment ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/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. --- > [!NOTE] > Adds shield_result_response_latency_ms to transaction/signature event fragments, introduces getCoverageMetrics selector, updates tests, and bumps @metamask/shield-controller to ^2.1.0. > > - **Alerts/Telemetry**: > - `useShieldCoverageAlert` now records `shield_result_response_latency_ms` (from `metrics.latency` or `"N/A"`) in event fragments. > - Pulls metrics via new selector `getCoverageMetrics`; refines `getShieldResult` default handling and effect dependencies. > - **Selectors**: > - Add types `CoverageStatusResult`, `CoverageMetrics` and helper `getFirstCoverageResult`. > - Implement `getCoverageMetrics` and refactor `getCoverageStatus` to use shared helper. > - **Tests**: > - Extend `useShieldCoverageAlert.test` to assert latency propagation for tx/signature paths and various statuses. > - Add comprehensive tests for `getCoverageMetrics` and edge cases in `getCoverageStatus`. > - **Dependencies**: > - Upgrade `@metamask/shield-controller` to `^2.1.0`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit bab9a95f2003d3a0c03aa64849cad0b66613ddcd. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- package.json | 2 +- .../alerts/useShieldCoverageAlert.test.ts | 18 +- .../hooks/alerts/useShieldCoverageAlert.ts | 24 ++- ui/selectors/shield/coverage.test.ts | 188 ++++++++++++++---- ui/selectors/shield/coverage.ts | 40 +++- yarn.lock | 12 +- 6 files changed, 224 insertions(+), 60 deletions(-) diff --git a/package.json b/package.json index bf429bec7770..d3418abad443 100644 --- a/package.json +++ b/package.json @@ -352,7 +352,7 @@ "@metamask/scure-bip39": "^2.0.3", "@metamask/seedless-onboarding-controller": "^5.0.0", "@metamask/selected-network-controller": "^25.0.0", - "@metamask/shield-controller": "^1.2.0", + "@metamask/shield-controller": "^2.1.0", "@metamask/signature-controller": "^35.0.0", "@metamask/smart-transactions-controller": "^20.0.0", "@metamask/snaps-controllers": "^16.1.1", diff --git a/ui/pages/confirmations/hooks/alerts/useShieldCoverageAlert.test.ts b/ui/pages/confirmations/hooks/alerts/useShieldCoverageAlert.test.ts index 1bc4caf5b10c..ea03fa5bd02f 100644 --- a/ui/pages/confirmations/hooks/alerts/useShieldCoverageAlert.test.ts +++ b/ui/pages/confirmations/hooks/alerts/useShieldCoverageAlert.test.ts @@ -54,6 +54,7 @@ describe('useShieldCoverageAlert', () => { status?: string, reasonCode = 'E104', isTransaction: boolean = true, + latency: number | string = 'N/A', ): Record => { const mockId = '123'; const baseState = isTransaction @@ -84,6 +85,9 @@ describe('useShieldCoverageAlert', () => { { status, reasonCode, + metrics: { + latency, + }, }, ], }, @@ -157,7 +161,7 @@ describe('useShieldCoverageAlert', () => { }); it('updates transaction event fragment with covered status', () => { - const state = getStateWithCoverage('covered', 'E104'); + const state = getStateWithCoverage('covered', 'E104', true, 150); renderHookWithConfirmContextProvider(() => useShieldCoverageAlert(), state); expect(updateTransactionEventFragmentMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -166,6 +170,8 @@ describe('useShieldCoverageAlert', () => { shield_result: 'covered', // eslint-disable-next-line @typescript-eslint/naming-convention shield_reason: 'shieldCoverageAlertCovered', + // eslint-disable-next-line @typescript-eslint/naming-convention + shield_result_response_latency_ms: 150, }, }), expect.anything(), @@ -182,6 +188,8 @@ describe('useShieldCoverageAlert', () => { shield_result: 'not_covered_malicious', // eslint-disable-next-line @typescript-eslint/naming-convention shield_reason: 'shieldCoverageAlertMessagePotentialRisks', + // eslint-disable-next-line @typescript-eslint/naming-convention + shield_result_response_latency_ms: 'N/A', }, }), expect.anything(), @@ -198,6 +206,8 @@ describe('useShieldCoverageAlert', () => { shield_result: 'not_covered', // eslint-disable-next-line @typescript-eslint/naming-convention shield_reason: 'shieldCoverageAlertMessagePotentialRisks', + // eslint-disable-next-line @typescript-eslint/naming-convention + shield_result_response_latency_ms: 'N/A', }, }), expect.anything(), @@ -214,6 +224,8 @@ describe('useShieldCoverageAlert', () => { shield_result: 'loading', // eslint-disable-next-line @typescript-eslint/naming-convention shield_reason: 'shieldCoverageAlertMessagePotentialRisks', + // eslint-disable-next-line @typescript-eslint/naming-convention + shield_result_response_latency_ms: 'N/A', }, }), expect.anything(), @@ -221,7 +233,7 @@ describe('useShieldCoverageAlert', () => { }); it('updates signature event fragment with correct metrics', () => { - const state = getStateWithCoverage('covered', 'E104', false); + const state = getStateWithCoverage('covered', 'E104', false, 200); renderHookWithConfirmContextProvider(() => useShieldCoverageAlert(), state); expect(updateSignatureEventFragmentMock).toHaveBeenCalledWith( @@ -231,6 +243,8 @@ describe('useShieldCoverageAlert', () => { shield_result: 'covered', // eslint-disable-next-line @typescript-eslint/naming-convention shield_reason: 'shieldCoverageAlertCovered', + // eslint-disable-next-line @typescript-eslint/naming-convention + shield_result_response_latency_ms: 200, }, }), ); diff --git a/ui/pages/confirmations/hooks/alerts/useShieldCoverageAlert.ts b/ui/pages/confirmations/hooks/alerts/useShieldCoverageAlert.ts index 15a85a35532d..337d0c0b7700 100644 --- a/ui/pages/confirmations/hooks/alerts/useShieldCoverageAlert.ts +++ b/ui/pages/confirmations/hooks/alerts/useShieldCoverageAlert.ts @@ -13,6 +13,7 @@ import { } from '../../../../helpers/constants/design-system'; import { useI18nContext } from '../../../../hooks/useI18nContext'; import { + getCoverageMetrics, getCoverageStatus, ShieldState, } from '../../../../selectors/shield/coverage'; @@ -25,6 +26,8 @@ import { useSignatureEventFragment } from '../useSignatureEventFragment'; import { useTransactionEventFragment } from '../useTransactionEventFragment'; import { ShieldCoverageAlertMessage } from './transactions/ShieldCoverageAlertMessage'; +const N_A = 'N/A'; + const getModalBodyStr = (reasonCode: string | undefined) => { // grouping codes with a fallthrough pattern is not allowed by the linter let modalBodyStr: string; @@ -169,10 +172,11 @@ const getShieldResult = ( return 'not_covered_malicious'; case 'unknown': return 'not_covered'; - case undefined: - case 'not_shown': - return 'loading'; default: + // Returns 'loading' for: + // - undefined: coverage check not yet initiated or in progress + // - 'not_shown': coverage didn't load before user confirmed + // - any unexpected values: fail safe to loading state return 'loading'; } }; @@ -190,6 +194,9 @@ export function useShieldCoverageAlert(): Alert[] { const { reasonCode, status } = useSelector((state) => getCoverageStatus(state as ShieldState, id), ); + const metrics = useSelector((state) => + getCoverageMetrics(state as ShieldState, id), + ); const { isEnabled, isPaused } = useEnableShieldCoverageChecks(); @@ -221,6 +228,8 @@ export function useShieldCoverageAlert(): Alert[] { shield_result: getShieldResult(status), // eslint-disable-next-line @typescript-eslint/naming-convention shield_reason: modalBodyStr, + // eslint-disable-next-line @typescript-eslint/naming-convention + shield_result_response_latency_ms: metrics?.latency ?? N_A, }; if (isSignatureTransactionType(currentConfirmation)) { @@ -237,12 +246,13 @@ export function useShieldCoverageAlert(): Alert[] { } } }, [ - status, - modalBodyStr, - isEnabled, - isPaused, currentConfirmation, id, + isEnabled, + isPaused, + metrics?.latency, + modalBodyStr, + status, updateSignatureEventFragment, updateTransactionEventFragment, ]); diff --git a/ui/selectors/shield/coverage.test.ts b/ui/selectors/shield/coverage.test.ts index 152757180e44..dee4e6ca6d55 100644 --- a/ui/selectors/shield/coverage.test.ts +++ b/ui/selectors/shield/coverage.test.ts @@ -1,54 +1,172 @@ import type { CoverageStatus } from '@metamask/shield-controller'; -import { getCoverageStatus, ShieldState } from './coverage'; +import { + CoverageMetrics, + getCoverageMetrics, + getCoverageStatus, + ShieldState, +} from './coverage'; describe('shield coverage selectors', () => { const confirmationId = 'abc123'; - it('returns undefined when there are no coverage results', () => { - const state = { + const createStateWithResult = (result: { + status?: CoverageStatus; + reasonCode?: string; + metrics?: { latency?: number }; + }): ShieldState => + ({ metamask: { - coverageResults: {}, + coverageResults: { + [confirmationId]: { + results: [result], + }, + }, }, - } as unknown as ShieldState; + }) as unknown as ShieldState; + + describe('getCoverageStatus', () => { + it('returns undefined when there are no coverage results', () => { + const state = { + metamask: { + coverageResults: {}, + }, + } as unknown as ShieldState; + + const result = getCoverageStatus(state, confirmationId); + expect(result).toEqual({ status: undefined, reasonCode: undefined }); + }); + + it('returns undefined when results array is empty', () => { + const state = { + metamask: { + coverageResults: { + [confirmationId]: { results: [] }, + }, + }, + } as unknown as ShieldState; + + const result = getCoverageStatus(state, confirmationId); + expect(result).toEqual({ status: undefined, reasonCode: undefined }); + }); + + it('returns status and reasonCode from the first result', () => { + const status: CoverageStatus = 'covered'; + const reasonCode = 'ok'; + const state = { + metamask: { + coverageResults: { + [confirmationId]: { + results: [ + { + status, + reasonCode, + }, + { status: 'other', reasonCode: 'ignored' }, + ], + }, + }, + }, + } as unknown as ShieldState; - const result = getCoverageStatus(state, confirmationId); - expect(result).toEqual({ status: undefined, reasonCode: undefined }); + const result = getCoverageStatus(state, confirmationId); + expect(result.status).toBe(status); + expect(result.reasonCode).toBe(reasonCode); + }); }); - it('returns undefined when results array is empty', () => { - const state = { - metamask: { - coverageResults: { - [confirmationId]: { results: [] }, + describe('getCoverageMetrics', () => { + it('returns undefined when there are no coverage results', () => { + const state = { + metamask: { + coverageResults: {}, }, + } as unknown as ShieldState; + + const result = getCoverageMetrics(state, confirmationId); + expect(result).toBeUndefined(); + }); + + const metricsTestCases = [ + { + description: 'metrics with latency', + metrics: { latency: 123 }, + expectedLatency: 123, + }, + { + description: 'metrics with latency value of 0', + metrics: { latency: 0 }, + expectedLatency: 0, + }, + { + description: 'empty metrics object', + metrics: {}, + expectedLatency: undefined, }, - } as unknown as ShieldState; + { + description: 'large latency values', + metrics: { latency: 999999 }, + expectedLatency: 999999, + }, + ]; + // @ts-expect-error This function is missing from the Mocha type definitions + it.each(metricsTestCases)( + 'returns $description', + ({ + metrics, + expectedLatency, + }: { + metrics: CoverageMetrics; + expectedLatency?: number; + }) => { + const state = createStateWithResult({ + status: 'covered', + metrics, + }); - const result = getCoverageStatus(state, confirmationId); - expect(result).toEqual({ status: undefined, reasonCode: undefined }); - }); + const result = getCoverageMetrics(state, confirmationId); - it('returns status and reasonCode from the first result', () => { - const status: CoverageStatus = 'covered'; - const reasonCode = 'ok'; - const state = { - metamask: { - coverageResults: { - [confirmationId]: { - results: [ - { - status, - reasonCode, - }, - { status: 'other', reasonCode: 'ignored' }, - ], + expect(result).toEqual(metrics); + expect(result?.latency).toBe(expectedLatency); + }, + ); + + it('returns undefined when metrics property is missing', () => { + const state = createStateWithResult({ + status: 'covered', + reasonCode: 'ok', + }); + + const result = getCoverageMetrics(state, confirmationId); + + expect(result).toBeUndefined(); + }); + + it('returns metrics from the first result only', () => { + const metrics = { latency: 123 }; + const state = { + metamask: { + coverageResults: { + [confirmationId]: { + results: [ + { + status: 'covered', + reasonCode: 'ok', + metrics, + }, + { + status: 'unknown', + metrics: { latency: 456 }, + }, + ], + }, }, }, - }, - } as unknown as ShieldState; + } as unknown as ShieldState; + + const result = getCoverageMetrics(state, confirmationId); - const result = getCoverageStatus(state, confirmationId); - expect(result.status).toBe(status); - expect(result.reasonCode).toBe(reasonCode); + expect(result).toEqual(metrics); + expect(result?.latency).toBe(123); + }); }); }); diff --git a/ui/selectors/shield/coverage.ts b/ui/selectors/shield/coverage.ts index e8d9e75674b7..3043d906c4fb 100644 --- a/ui/selectors/shield/coverage.ts +++ b/ui/selectors/shield/coverage.ts @@ -7,19 +7,41 @@ export type ShieldState = { metamask: ShieldControllerState; }; +export type CoverageStatusResult = { + status: CoverageStatus | undefined; + reasonCode: string | undefined; +}; + +export type CoverageMetrics = { + latency?: number; +}; + +function getFirstCoverageResult(state: ShieldState, confirmationId: string) { + const coverageResults = state.metamask.coverageResults?.[confirmationId]; + + if (!coverageResults?.results?.length) { + return undefined; + } + + return coverageResults.results[0]; +} + export function getCoverageStatus( state: ShieldState, confirmationId: string, -): { status: CoverageStatus | undefined; reasonCode: string | undefined } { - const coverageResults = state.metamask.coverageResults[confirmationId]; - if (!coverageResults || coverageResults.results.length === 0) { - return { status: undefined, reasonCode: undefined }; - } - - const result = coverageResults.results[0]; +): CoverageStatusResult { + const result = getFirstCoverageResult(state, confirmationId); return { - status: result.status, - reasonCode: result.reasonCode, + status: result?.status, + reasonCode: result?.reasonCode, }; } + +export function getCoverageMetrics( + state: ShieldState, + confirmationId: string, +): CoverageMetrics | undefined { + const result = getFirstCoverageResult(state, confirmationId); + return result?.metrics; +} diff --git a/yarn.lock b/yarn.lock index ff8b852e596b..2f3b8a989598 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7837,9 +7837,9 @@ __metadata: languageName: node linkType: hard -"@metamask/shield-controller@npm:^1.2.0": - version: 1.2.0 - resolution: "@metamask/shield-controller@npm:1.2.0" +"@metamask/shield-controller@npm:^2.1.0": + version: 2.1.0 + resolution: "@metamask/shield-controller@npm:2.1.0" dependencies: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.15.0" @@ -7847,9 +7847,9 @@ __metadata: "@metamask/utils": "npm:^11.8.1" cockatiel: "npm:^3.1.2" peerDependencies: - "@metamask/signature-controller": ^35.0.0 + "@metamask/signature-controller": ^36.0.0 "@metamask/transaction-controller": ^61.0.0 - checksum: 10/ed58b67ddcfa51a1d375bdc43503d0da6e4445625aa0742972f06da151616469aae3eedde83f8af063da56f6181991d0da956536f1c736c22c38d629c6bd4d1e + checksum: 10/175a9eda1ed873e85fdf9e05383604854d137a6278497158b2b6862a60593bb49f8aecb84c767d9f901687f67827ccc65507031c9bf8cf7d233d9a9eccf42868 languageName: node linkType: hard @@ -32602,7 +32602,7 @@ __metadata: "@metamask/scure-bip39": "npm:^2.0.3" "@metamask/seedless-onboarding-controller": "npm:^5.0.0" "@metamask/selected-network-controller": "npm:^25.0.0" - "@metamask/shield-controller": "npm:^1.2.0" + "@metamask/shield-controller": "npm:^2.1.0" "@metamask/signature-controller": "npm:^35.0.0" "@metamask/smart-transactions-controller": "npm:^20.0.0" "@metamask/snap-account-abstraction-keyring-site": "npm:^1.0.0" From b99a0591fa9f58696e8643bbe01baa497773a5e9 Mon Sep 17 00:00:00 2001 From: Lwin <147362763+lwin-kyaw@users.noreply.github.com> Date: Fri, 14 Nov 2025 17:52:10 +0800 Subject: [PATCH 004/154] feat: shield cohort and priority support events cp-13.10.0 (#37822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR adds new events tracking for the segment ~ - `Shield Eligibility Cohort Assigned` - `Shield Eligibility Cohort Timeout` - `Shield Priority Support Clicked` [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/37822?quickstart=1) ## **Changelog** CHANGELOG entry: added new events for shield eligibility and priority support ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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. --- > [!NOTE] > Adds new Shield metrics events (cohort assigned/timeout, priority support clicked, error state clicked), integrates tracking in UI flows and cohort logic, and updates tests/mocks accordingly. > > - **Metrics/Events**: > - Add `MetaMetricsEventName` entries: `ShieldPrioritySupportClicked`, `ShieldEligibilityCohortAssigned`, `ShieldEligibilityCohortTimeout`, `ShieldErrorStateClicked` in `shared/constants/metametrics.ts`. > - Introduce `ShieldErrorStateActionClickedEnum`, `ShieldErrorStateLocationEnum`, `ShieldErrorStateViewEnum` in `shared/constants/subscriptions.ts`. > - **Hooks/Utils**: > - Extend `useSubscriptionMetrics` with `captureShieldEligibilityCohortEvent`, `captureCommonExistingShieldSubscriptionEvents`, `captureShieldErrorStateClickedEvent`; add supporting types and formatter `formatCaptureShieldEligibilityCohortEventsProps`. > - Refactor usages to common capture method for billing history opened and payment method updated in `useSubscription.ts`. > - **Cohort Flow**: > - In `ui/contexts/shield/shield-subscription.tsx`, include `modalType` in cohort assignment; emit `ShieldEligibilityCohortAssigned` and `ShieldEligibilityCohortTimeout` events. > - **UI Integrations**: > - `ui/components/multichain/global-menu/global-menu.tsx`: track `ShieldPrioritySupportClicked` when support is clicked with priority tag. > - `ui/components/app/toast-master/toast-master.js`: `ShieldPausedToast` logs `ShieldErrorStateClicked` on CTA/dismiss with payment metadata. > - `ui/pages/settings/transaction-shield-tab/transaction-shield.tsx`: on membership error actions, log `ShieldErrorStateClicked` (banner/Settings context). > - **Tests/Mocks**: > - Update E2E mocks and tests to include `modalType` and structured eligibility payload in `mock-e2e.js` and `shield-entry-modal.spec.ts`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 33278b305a5291215dbdfed9c258305b1abe2013. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- shared/constants/metametrics.ts | 4 + shared/constants/subscriptions.ts | 15 +++ test/e2e/mock-e2e.js | 14 +- .../tests/shield/shield-entry-modal.spec.ts | 1 + .../app/toast-master/toast-master.js | 48 ++++++- .../multichain/global-menu/global-menu.tsx | 61 ++++++--- ui/contexts/shield/shield-subscription.tsx | 39 +++++- ui/hooks/shield/metrics/types.ts | 47 ++++--- .../shield/metrics/useSubscriptionMetrics.ts | 122 ++++++++++-------- ui/hooks/shield/metrics/utils.ts | 39 ++++++ ui/hooks/subscription/useSubscription.ts | 46 ++++--- .../transaction-shield.tsx | 20 ++- 12 files changed, 339 insertions(+), 117 deletions(-) diff --git a/shared/constants/metametrics.ts b/shared/constants/metametrics.ts index 95930894ee37..3fe7eacfcb1f 100644 --- a/shared/constants/metametrics.ts +++ b/shared/constants/metametrics.ts @@ -955,6 +955,10 @@ export enum MetaMetricsEventName { ShieldCtaClicked = 'Shield CTA Clicked', ShieldClaimSubmission = 'Shield Claim Submission', ShieldSubscriptionCryptoConfirmation = 'Shield Subscription Crypto Confirmation', + ShieldPrioritySupportClicked = 'Shield Priority Support Clicked', + ShieldEligibilityCohortAssigned = 'Shield Eligibility Cohort Assigned', + ShieldEligibilityCohortTimeout = 'Shield Eligibility Cohort Timeout', + ShieldErrorStateClicked = 'Shield Error State Clicked', } export enum MetaMetricsEventAccountType { diff --git a/shared/constants/subscriptions.ts b/shared/constants/subscriptions.ts index 8ee0c3c3d338..cbaf49555f98 100644 --- a/shared/constants/subscriptions.ts +++ b/shared/constants/subscriptions.ts @@ -58,3 +58,18 @@ export enum ShieldCtaActionClickedEnum { FindingTxHash = 'finding_tx_hash', Dismiss = 'dismiss', } + +export enum ShieldErrorStateActionClickedEnum { + Cta = 'cta', + Dismiss = 'dismiss', +} + +export enum ShieldErrorStateLocationEnum { + Homepage = 'homepage', + Settings = 'settings', +} + +export enum ShieldErrorStateViewEnum { + Banner = 'banner', + Toast = 'toast', +} diff --git a/test/e2e/mock-e2e.js b/test/e2e/mock-e2e.js index c65170b2d6c4..c4366297abb8 100644 --- a/test/e2e/mock-e2e.js +++ b/test/e2e/mock-e2e.js @@ -198,6 +198,7 @@ async function setupMocking( }; }); + // Subscriptions Eligibility await server .forGet( 'https://subscription.dev-api.cx.metamask.io/v1/subscriptions/eligibility', @@ -205,7 +206,18 @@ async function setupMocking( .thenCallback(() => { return { statusCode: 200, - json: [], + json: [ + { + canSubscribe: false, + canViewEntryModal: false, + minBalanceUSD: 1000, + product: 'shield', + modalType: 'A', + cohorts: [], + assignedCohort: null, + hasAssignedCohortExpired: null, + }, + ], }; }); diff --git a/test/e2e/tests/shield/shield-entry-modal.spec.ts b/test/e2e/tests/shield/shield-entry-modal.spec.ts index 3e6a20494268..8039691e734a 100644 --- a/test/e2e/tests/shield/shield-entry-modal.spec.ts +++ b/test/e2e/tests/shield/shield-entry-modal.spec.ts @@ -63,6 +63,7 @@ async function mockSubscriptionApiCalls( canViewEntryModal: true, minBalanceUSD: 1000, product: 'shield', + modalType: 'A', cohorts: [ { cohort: 'wallet_home', diff --git a/ui/components/app/toast-master/toast-master.js b/ui/components/app/toast-master/toast-master.js index a93cd3ec33bd..0ee25ed5d542 100644 --- a/ui/components/app/toast-master/toast-master.js +++ b/ui/components/app/toast-master/toast-master.js @@ -70,7 +70,14 @@ import { getShortDateFormatterV2 } from '../../../pages/asset/util'; import { getIsShieldSubscriptionEndingSoon, getIsShieldSubscriptionPaused, + getSubscriptionPaymentData, } from '../../../../shared/lib/shield'; +import { useSubscriptionMetrics } from '../../../hooks/shield/metrics/useSubscriptionMetrics'; +import { + ShieldErrorStateActionClickedEnum, + ShieldErrorStateLocationEnum, + ShieldErrorStateViewEnum, +} from '../../../../shared/constants/subscriptions'; import { selectNftDetectionEnablementToast, selectShowConnectAccountToast, @@ -612,7 +619,7 @@ function ShieldPausedToast() { const navigate = useNavigate(); const showShieldPausedToast = useSelector(selectShowShieldPausedToast); - + const { captureShieldErrorStateClickedEvent } = useSubscriptionMetrics(); const { subscriptions } = useUserSubscriptions(); const shieldSubscription = useUserSubscriptionByProduct( @@ -622,6 +629,38 @@ function ShieldPausedToast() { const isPaused = getIsShieldSubscriptionPaused(shieldSubscription); + const trackShieldErrorStateClickedEvent = (actionClicked) => { + const { cryptoPaymentChain, cryptoPaymentCurrency } = + getSubscriptionPaymentData(shieldSubscription); + // capture error state clicked event + captureShieldErrorStateClickedEvent({ + subscriptionStatus: shieldSubscription.status, + paymentType: shieldSubscription.paymentMethod.type, + billingInterval: shieldSubscription.interval, + cryptoPaymentChain, + cryptoPaymentCurrency, + errorCause: 'payment_error', + actionClicked, + location: ShieldErrorStateLocationEnum.Homepage, + view: ShieldErrorStateViewEnum.Toast, + }); + }; + + const handleActionClick = async () => { + // capture error state clicked event + trackShieldErrorStateClickedEvent(ShieldErrorStateActionClickedEnum.Cta); + setShieldPausedToastLastClickedOrClosed(Date.now()); + navigate(TRANSACTION_SHIELD_ROUTE); + }; + + const handleToastClose = () => { + // capture error state clicked event + trackShieldErrorStateClickedEvent( + ShieldErrorStateActionClickedEnum.Dismiss, + ); + setShieldPausedToastLastClickedOrClosed(Date.now()); + }; + return ( Boolean(isPaused) && showShieldPausedToast && ( @@ -630,10 +669,7 @@ function ShieldPausedToast() { text={t('shieldPaymentDeclined')} description={t('shieldPaymentDeclinedDescription')} actionText={t('shieldPaymentDeclinedAction')} - onActionClick={async () => { - setShieldPausedToastLastClickedOrClosed(Date.now()); - navigate(TRANSACTION_SHIELD_ROUTE); - }} + onActionClick={handleActionClick} startAdornment={ } - onClose={() => setShieldPausedToastLastClickedOrClosed(Date.now())} + onClose={handleToastClose} /> ) ); diff --git a/ui/components/multichain/global-menu/global-menu.tsx b/ui/components/multichain/global-menu/global-menu.tsx index 93ddf0be5e32..6b1cb8e37d6c 100644 --- a/ui/components/multichain/global-menu/global-menu.tsx +++ b/ui/components/multichain/global-menu/global-menu.tsx @@ -94,8 +94,11 @@ import { useUserSubscriptions } from '../../../hooks/subscription/useSubscriptio import { getIsShieldSubscriptionActive, getIsShieldSubscriptionPaused, + getShieldSubscription, + getSubscriptionPaymentData, } from '../../../../shared/lib/shield'; import { useRewardsContext } from '../../../contexts/rewards'; +import { useSubscriptionMetrics } from '../../../hooks/shield/metrics/useSubscriptionMetrics'; const METRICS_LOCATION = 'Global Menu'; @@ -113,6 +116,8 @@ export const GlobalMenu = ({ const t = useI18nContext(); const dispatch = useDispatch(); const trackEvent = useContext(MetaMetricsContext); + const { captureCommonExistingShieldSubscriptionEvents } = + useSubscriptionMetrics(); const basicFunctionality = useSelector(getUseExternalServices); const { rewardsEnabled } = useRewardsContext(); @@ -302,6 +307,42 @@ export const GlobalMenu = ({ closeMenu(); }; + const handleSupportMenuClick = () => { + dispatch(setShowSupportDataConsentModal(true)); + trackEvent( + { + category: MetaMetricsEventCategory.Home, + event: MetaMetricsEventName.SupportLinkClicked, + properties: { + url: supportLink, + location: METRICS_LOCATION, + }, + }, + { + contextPropsIntoEventProperties: [MetaMetricsContextProp.PageTitle], + }, + ); + if (showPriorityTag) { + // track priority support clicked event + const shieldSubscription = getShieldSubscription(subscriptions); + const { cryptoPaymentChain, cryptoPaymentCurrency } = + getSubscriptionPaymentData(shieldSubscription); + if (shieldSubscription) { + captureCommonExistingShieldSubscriptionEvents( + { + subscriptionStatus: shieldSubscription.status, + paymentType: shieldSubscription.paymentMethod.type, + billingInterval: shieldSubscription.interval, + cryptoPaymentChain, + cryptoPaymentCurrency, + }, + MetaMetricsEventName.ShieldPrioritySupportClicked, + ); + } + } + closeMenu(); + }; + return ( { - dispatch(setShowSupportDataConsentModal(true)); - trackEvent( - { - category: MetaMetricsEventCategory.Home, - event: MetaMetricsEventName.SupportLinkClicked, - properties: { - url: supportLink, - location: METRICS_LOCATION, - }, - }, - { - contextPropsIntoEventProperties: [ - MetaMetricsContextProp.PageTitle, - ], - }, - ); - closeMenu(); - }} + onClick={handleSupportMenuClick} data-testid="global-menu-support" > Promise; @@ -64,13 +68,14 @@ export const ShieldSubscriptionProvider: React.FC = ({ children }) => { false, true, // use USD conversion rate instead of the current currency ); + const { captureShieldEligibilityCohortEvent } = useSubscriptionMetrics(); /** * Assigns a user to a cohort based on eligibility rate (80/20 split). * Returns the selected cohort or null. */ const assignToCohort = useCallback( - async (cohorts: Cohort[]): Promise => { + async (cohorts: Cohort[], modalType: ModalType): Promise => { if (cohorts.length === 0) { return null; } @@ -104,6 +109,14 @@ export const ShieldSubscriptionProvider: React.FC = ({ children }) => { if (selectedCohort) { try { await dispatch(assignUserToCohort({ cohort: selectedCohort.cohort })); + await captureShieldEligibilityCohortEvent( + { + cohort: selectedCohort.cohort as CohortName, + modalType, + numberOfEligibleCohorts: cohorts.length, + }, + MetaMetricsEventName.ShieldEligibilityCohortAssigned, + ); return selectedCohort; } catch (error) { log.error('[ShieldSubscription] Failed to assign cohort', error); @@ -113,7 +126,7 @@ export const ShieldSubscriptionProvider: React.FC = ({ children }) => { return null; }, - [dispatch], + [dispatch, captureShieldEligibilityCohortEvent], ); /** @@ -182,6 +195,17 @@ export const ShieldSubscriptionProvider: React.FC = ({ children }) => { if (entrypointCohort !== COHORT_NAMES.POST_TX && !hasExpired) { return; } + + // User has an assigned cohort but it has expired + // track `shield_eligibility_cohort_timeout` event + await captureShieldEligibilityCohortEvent( + { + cohort: assignedCohortName as CohortName, + numberOfEligibleCohorts: eligibleCohorts.length, + }, + MetaMetricsEventName.ShieldEligibilityCohortTimeout, + ); + const cohort = eligibleCohorts.find( (c) => c.cohort === entrypointCohort, ); @@ -204,11 +228,15 @@ export const ShieldSubscriptionProvider: React.FC = ({ children }) => { // New user - only assign from wallet_home entrypoint if ( entrypointCohort === COHORT_NAMES.WALLET_HOME && - eligibleCohorts.length > 0 + eligibleCohorts.length > 0 && + modalType ) { - const selectedCohort = await assignToCohort(eligibleCohorts); + const selectedCohort = await assignToCohort( + eligibleCohorts, + modalType, + ); if (selectedCohort?.cohort === COHORT_NAMES.WALLET_HOME) { - const shouldSubmitUserEvents = true; // submits `shield_entry_modal_viewed` event + const shouldSubmitUserEvents = true; // submits `shield_entry_modal_viewed` event to subscription backend dispatch( setShowShieldEntryModalOnce( true, @@ -235,6 +263,7 @@ export const ShieldSubscriptionProvider: React.FC = ({ children }) => { totalFiatBalance, getShieldSubscriptionEligibility, assignToCohort, + captureShieldEligibilityCohortEvent, ], ); diff --git a/ui/hooks/shield/metrics/types.ts b/ui/hooks/shield/metrics/types.ts index 84463766a771..8df5d317925d 100644 --- a/ui/hooks/shield/metrics/types.ts +++ b/ui/hooks/shield/metrics/types.ts @@ -3,12 +3,16 @@ import { PaymentType, RecurringInterval, ModalType, + CohortName, } from '@metamask/subscription-controller'; import { TransactionType } from '@metamask/transaction-controller'; import { EntryModalSourceEnum, ShieldCtaActionClickedEnum, ShieldCtaSourceEnum, + ShieldErrorStateActionClickedEnum, + ShieldErrorStateLocationEnum, + ShieldErrorStateViewEnum, } from '../../../../shared/constants/subscriptions'; import { DefaultSubscriptionPaymentOptions } from '../../../../shared/types'; @@ -110,21 +114,6 @@ export type CaptureShieldPaymentMethodChangeEventParams = errorMessage?: string; }; -/** - * Capture the event when the payment method is retried after unsuccessful deduction attempt. - */ -export type CaptureShieldPaymentMethodRetriedEventParams = - ExistingSubscriptionEventParams; - -/** - * Capture the event when payment failed due to insufficient allowance or users want to renew subscription that is ending soon. - */ -export type CaptureShieldPaymentMethodUpdatedEventParams = - ExistingSubscriptionEventParams; - -export type CaptureShieldBillingHistoryOpenedEventParams = - ExistingSubscriptionEventParams; - /** * Triggered when the user has opened the crypto confirmation screen for a subscription or rejected the approval transaction. */ @@ -173,3 +162,31 @@ export type CaptureShieldClaimSubmissionEventParams = { errorMessage?: string; }; + +/** + * Capture the event when the user is assigned to a cohort based on eligibility rate. + */ +export type CaptureShieldEligibilityCohortAssignedEventParams = { + cohort: CohortName; + modalType: ModalType; + numberOfEligibleCohorts: number; +}; + +/** + * Capture the event when the user is timed out from a cohort. + */ +export type CaptureShieldEligibilityCohortTimeoutEventParams = { + cohort: CohortName; + numberOfEligibleCohorts: number; +}; + +/** + * Capture the event when the user clicks on the error state. + */ +export type CaptureShieldErrorStateClickedEventParams = + ExistingSubscriptionEventParams & { + errorCause: string; + actionClicked: ShieldErrorStateActionClickedEnum; + location: ShieldErrorStateLocationEnum; + view: ShieldErrorStateViewEnum; + }; diff --git a/ui/hooks/shield/metrics/useSubscriptionMetrics.ts b/ui/hooks/shield/metrics/useSubscriptionMetrics.ts index 13564a8a9597..bd8a7e2ac0b0 100644 --- a/ui/hooks/shield/metrics/useSubscriptionMetrics.ts +++ b/ui/hooks/shield/metrics/useSubscriptionMetrics.ts @@ -15,19 +15,21 @@ import { MetaMaskReduxDispatch } from '../../../store/store'; import { setShieldSubscriptionMetricsProps } from '../../../store/actions'; import { EntryModalSourceEnum } from '../../../../shared/constants/subscriptions'; import { - CaptureShieldBillingHistoryOpenedEventParams, CaptureShieldClaimSubmissionEventParams, CaptureShieldCryptoConfirmationEventParams, CaptureShieldCtaClickedEventParams, + CaptureShieldEligibilityCohortAssignedEventParams, + CaptureShieldEligibilityCohortTimeoutEventParams, CaptureShieldEntryModalEventParams, + CaptureShieldErrorStateClickedEventParams, CaptureShieldMembershipCancelledEventParams, CaptureShieldPaymentMethodChangeEventParams, - CaptureShieldPaymentMethodRetriedEventParams, - CaptureShieldPaymentMethodUpdatedEventParams, CaptureShieldSubscriptionRequestParams, + ExistingSubscriptionEventParams, } from './types'; import { formatCaptureShieldCtaClickedEventProps, + formatCaptureShieldEligibilityCohortEventsProps, formatCaptureShieldPaymentMethodChangeEventProps, formatDefaultShieldSubscriptionRequestEventProps, formatExistingSubscriptionEventProps, @@ -69,6 +71,33 @@ export const useSubscriptionMetrics = () => { [dispatch, totalFiatBalance], ); + const captureShieldEligibilityCohortEvent = useCallback( + async ( + params: + | CaptureShieldEligibilityCohortAssignedEventParams + | CaptureShieldEligibilityCohortTimeoutEventParams, + event: MetaMetricsEventName, + ) => { + const userAccountTypeAndCategory = getUserAccountTypeAndCategory( + selectedAccount, + hdKeyingsMetadata, + ); + const formattedParams = formatCaptureShieldEligibilityCohortEventsProps( + params, + Number(totalFiatBalance), + ); + trackEvent({ + event, + category: MetaMetricsEventCategory.Shield, + properties: { + ...userAccountTypeAndCategory, + ...formattedParams, + }, + }); + }, + [trackEvent, selectedAccount, hdKeyingsMetadata, totalFiatBalance], + ); + /** * Capture the event when the Shield entry modal is viewed and the user clicks CTA actions. */ @@ -137,28 +166,6 @@ export const useSubscriptionMetrics = () => { [trackEvent, selectedAccount, hdKeyingsMetadata, totalFiatBalance], ); - /** - * Capture the event when the payment method is retried after unsuccessful deduction attempt. - */ - const captureShieldPaymentMethodRetriedEvent = useCallback( - (params: CaptureShieldPaymentMethodRetriedEventParams) => { - const userAccountTypeAndCategory = getUserAccountTypeAndCategory( - selectedAccount, - hdKeyingsMetadata, - ); - const formattedParams = formatExistingSubscriptionEventProps(params); - trackEvent({ - event: MetaMetricsEventName.ShieldPaymentMethodRetried, - category: MetaMetricsEventCategory.Shield, - properties: { - ...userAccountTypeAndCategory, - ...formattedParams, - }, - }); - }, - [trackEvent, selectedAccount, hdKeyingsMetadata], - ); - const captureShieldMembershipCancelledEvent = useCallback( (params: CaptureShieldMembershipCancelledEventParams) => { const userAccountTypeAndCategory = getUserAccountTypeAndCategory( @@ -217,39 +224,20 @@ export const useSubscriptionMetrics = () => { ); /** - * Capture the event when payment failed due to insufficient allowance or users want to renew subscription that is ending soon. - */ - const captureShieldPaymentMethodUpdatedEvent = useCallback( - (params: CaptureShieldPaymentMethodUpdatedEventParams) => { - const userAccountTypeAndCategory = getUserAccountTypeAndCategory( - selectedAccount, - hdKeyingsMetadata, - ); - const formattedParams = formatExistingSubscriptionEventProps(params); - trackEvent({ - event: MetaMetricsEventName.ShieldPaymentMethodUpdated, - category: MetaMetricsEventCategory.Shield, - properties: { - ...userAccountTypeAndCategory, - ...formattedParams, - }, - }); - }, - [trackEvent, selectedAccount, hdKeyingsMetadata], - ); - - /** - * Capture the event when the billing history is opened. + * Capture the various events when the shield membership is active. + * + * @param params - The parameters for the event. + * @param event - The name of the event to capture. */ - const captureShieldBillingHistoryOpenedEvent = useCallback( - (params: CaptureShieldBillingHistoryOpenedEventParams) => { + const captureCommonExistingShieldSubscriptionEvents = useCallback( + (params: ExistingSubscriptionEventParams, event: MetaMetricsEventName) => { const userAccountTypeAndCategory = getUserAccountTypeAndCategory( selectedAccount, hdKeyingsMetadata, ); const formattedParams = formatExistingSubscriptionEventProps(params); trackEvent({ - event: MetaMetricsEventName.ShieldBillingHistoryOpened, + event, category: MetaMetricsEventCategory.Shield, properties: { ...userAccountTypeAndCategory, @@ -333,17 +321,43 @@ export const useSubscriptionMetrics = () => { [trackEvent, selectedAccount, hdKeyingsMetadata], ); + /** + * Capture the event when the user clicks on the error state. + */ + const captureShieldErrorStateClickedEvent = useCallback( + (params: CaptureShieldErrorStateClickedEventParams) => { + const userAccountTypeAndCategory = getUserAccountTypeAndCategory( + selectedAccount, + hdKeyingsMetadata, + ); + const formattedParams = formatExistingSubscriptionEventProps(params); + trackEvent({ + event: MetaMetricsEventName.ShieldErrorStateClicked, + category: MetaMetricsEventCategory.Shield, + properties: { + ...userAccountTypeAndCategory, + ...formattedParams, + type: params.errorCause, + action: params.actionClicked, + location: params.location, + view: params.view, + }, + }); + }, + [trackEvent, selectedAccount, hdKeyingsMetadata], + ); + return { setShieldSubscriptionMetricsPropsToBackground, captureShieldEntryModalEvent, captureShieldSubscriptionRequestEvent, - captureShieldBillingHistoryOpenedEvent, captureShieldMembershipCancelledEvent, captureShieldPaymentMethodChangeEvent, - captureShieldPaymentMethodRetriedEvent, - captureShieldPaymentMethodUpdatedEvent, captureShieldCtaClickedEvent, captureShieldClaimSubmissionEvent, captureShieldCryptoConfirmationEvent, + captureShieldEligibilityCohortEvent, + captureCommonExistingShieldSubscriptionEvents, + captureShieldErrorStateClickedEvent, }; }; diff --git a/ui/hooks/shield/metrics/utils.ts b/ui/hooks/shield/metrics/utils.ts index aaa94dfbbf58..a57ae93bad78 100644 --- a/ui/hooks/shield/metrics/utils.ts +++ b/ui/hooks/shield/metrics/utils.ts @@ -1,9 +1,12 @@ import { getBillingCyclesForMetrics, getBillingIntervalForMetrics, + getUserBalanceCategory, } from '../../../../shared/modules/shield'; import { CaptureShieldCtaClickedEventParams, + CaptureShieldEligibilityCohortAssignedEventParams, + CaptureShieldEligibilityCohortTimeoutEventParams, CaptureShieldPaymentMethodChangeEventParams, CaptureShieldSubscriptionRequestParams, ExistingSubscriptionEventParams, @@ -141,3 +144,39 @@ export function formatCaptureShieldCtaClickedEventProps( marketing_utm_id: params.marketingUtmId, }; } + +/** + * Format the properties for the Shield eligibility cohort assigned and timeout events. + * + * @param params - The parameters for the Shield eligibility cohort assigned and timeout events. + * @param totalFiatBalance - The total fiat balance of the user. + * @returns The formatted properties. + */ +export function formatCaptureShieldEligibilityCohortEventsProps( + params: + | CaptureShieldEligibilityCohortAssignedEventParams + | CaptureShieldEligibilityCohortTimeoutEventParams, + totalFiatBalance: number, +) { + const props: Record = { + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + multi_chain_balance_category: getUserBalanceCategory( + Number(totalFiatBalance), + ), + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + number_of_eligible_cohorts: params.numberOfEligibleCohorts, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + assigned_cohort: params.cohort, + }; + + if ('modalType' in params) { + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + props.modal_type = params.modalType; + } + + return props; +} diff --git a/ui/hooks/subscription/useSubscription.ts b/ui/hooks/subscription/useSubscription.ts index 46bfd908cfbe..a9c186e92d45 100644 --- a/ui/hooks/subscription/useSubscription.ts +++ b/ui/hooks/subscription/useSubscription.ts @@ -55,6 +55,7 @@ import { CaptureShieldSubscriptionRequestParams } from '../shield/metrics/types' import { EntryModalSourceEnum } from '../../../shared/constants/subscriptions'; import { DefaultSubscriptionPaymentOptions } from '../../../shared/types'; import { getLatestSubscriptionStatus } from '../../../shared/modules/shield'; +import { MetaMetricsEventName } from '../../../shared/constants/metametrics'; import { TokenWithApprovalAmount, useSubscriptionPricing, @@ -200,7 +201,8 @@ export const useOpenGetSubscriptionBillingPortal = ( subscription?: Subscription, ) => { const dispatch = useDispatch(); - const { captureShieldBillingHistoryOpenedEvent } = useSubscriptionMetrics(); + const { captureCommonExistingShieldSubscriptionEvents } = + useSubscriptionMetrics(); const trackBillingHistoryOpenedEvent = useCallback(() => { if (!subscription) { @@ -210,14 +212,17 @@ export const useOpenGetSubscriptionBillingPortal = ( getSubscriptionPaymentData(subscription); // capture the event when the billing history is opened - captureShieldBillingHistoryOpenedEvent({ - subscriptionStatus: subscription.status, - paymentType: subscription.paymentMethod.type, - billingInterval: subscription.interval, - cryptoPaymentChain, - cryptoPaymentCurrency, - }); - }, [captureShieldBillingHistoryOpenedEvent, subscription]); + captureCommonExistingShieldSubscriptionEvents( + { + subscriptionStatus: subscription.status, + paymentType: subscription.paymentMethod.type, + billingInterval: subscription.interval, + cryptoPaymentChain, + cryptoPaymentCurrency, + }, + MetaMetricsEventName.ShieldBillingHistoryOpened, + ); + }, [captureCommonExistingShieldSubscriptionEvents, subscription]); return useAsyncCallback(async () => { const { url } = await dispatch(getSubscriptionBillingPortalUrl()); @@ -234,7 +239,8 @@ export const useUpdateSubscriptionCardPaymentMethod = ({ newRecurringInterval?: RecurringInterval; }) => { const dispatch = useDispatch(); - const { captureShieldPaymentMethodUpdatedEvent } = useSubscriptionMetrics(); + const { captureCommonExistingShieldSubscriptionEvents } = + useSubscriptionMetrics(); return useAsyncCallback(async () => { if (!subscription || !newRecurringInterval) { @@ -252,12 +258,20 @@ export const useUpdateSubscriptionCardPaymentMethod = ({ ); // capture the event when the payment method is updated - captureShieldPaymentMethodUpdatedEvent({ - subscriptionStatus: subscription.status, - paymentType: subscription.paymentMethod.type, - billingInterval: newRecurringInterval, - }); - }, [dispatch, subscription, newRecurringInterval]); + captureCommonExistingShieldSubscriptionEvents( + { + subscriptionStatus: subscription.status, + paymentType: subscription.paymentMethod.type, + billingInterval: newRecurringInterval, + }, + MetaMetricsEventName.ShieldPaymentMethodUpdated, + ); + }, [ + dispatch, + subscription, + newRecurringInterval, + captureCommonExistingShieldSubscriptionEvents, + ]); }; export const useSubscriptionCryptoApprovalTransaction = ( diff --git a/ui/pages/settings/transaction-shield-tab/transaction-shield.tsx b/ui/pages/settings/transaction-shield-tab/transaction-shield.tsx index 6db74dc6b61a..902355c33639 100644 --- a/ui/pages/settings/transaction-shield-tab/transaction-shield.tsx +++ b/ui/pages/settings/transaction-shield-tab/transaction-shield.tsx @@ -80,6 +80,9 @@ import { useSubscriptionMetrics } from '../../../hooks/shield/metrics/useSubscri import { ShieldCtaActionClickedEnum, ShieldCtaSourceEnum, + ShieldErrorStateActionClickedEnum, + ShieldErrorStateLocationEnum, + ShieldErrorStateViewEnum, } from '../../../../shared/constants/subscriptions'; import { ThemeType } from '../../../../shared/constants/preferences'; import CancelMembershipModal from './cancel-membership-modal'; @@ -89,7 +92,8 @@ const TransactionShield = () => { const t = useI18nContext(); const navigate = useNavigate(); const { search } = useLocation(); - const { captureShieldCtaClickedEvent } = useSubscriptionMetrics(); + const { captureShieldCtaClickedEvent, captureShieldErrorStateClickedEvent } = + useSubscriptionMetrics(); const shouldWaitForSubscriptionCreation = useMemo(() => { const searchParams = new URLSearchParams(search); // param to wait for subscription creation happen in the background @@ -413,6 +417,19 @@ const TransactionShield = () => { useSubscriptionCryptoApprovalTransaction(paymentToken); const handlePaymentError = useCallback(async () => { + if (currentShieldSubscription) { + // capture error state clicked event + captureShieldErrorStateClickedEvent({ + subscriptionStatus: currentShieldSubscription.status, + paymentType: currentShieldSubscription.paymentMethod.type, + billingInterval: currentShieldSubscription.interval, + errorCause: 'payment_error', + actionClicked: ShieldErrorStateActionClickedEnum.Cta, + location: ShieldErrorStateLocationEnum.Settings, + view: ShieldErrorStateViewEnum.Banner, + }); + } + if (isCancelled) { // go to shield plan page to renew subscription for cancelled subscription navigate(SHIELD_PLAN_ROUTE); @@ -445,6 +462,7 @@ const TransactionShield = () => { executeUpdateSubscriptionCardPaymentMethod, setIsAddFundsModalOpen, executeSubscriptionCryptoApprovalTransaction, + captureShieldErrorStateClickedEvent, ]); const membershipErrorBanner = useMemo(() => { From d6d768be18bff88d1e57e568702f27e5712a6748 Mon Sep 17 00:00:00 2001 From: seaona <54408225+seaona@users.noreply.github.com> Date: Fri, 14 Nov 2025 11:07:49 +0100 Subject: [PATCH 005/154] fix: flaky tests ` Sentry errors before initialization, after opting into metrics should...` (#37834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** There are 2 Sentry requests happening: one transaction type and one event type, that match with our mock. The test is flaky as sometimes we get the transaction instead the event one (those can happen in any order), and it doesn't contain the expected properties (ie exception). With this change, we make sure we are always grabbing the event type one, so then the properties we assert is always there [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/37834?quickstart=1) ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** 1. Check ci ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/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. --- > [!NOTE] > Filter mocked Sentry requests to event envelopes ("{"type":"event"}") to stabilize flaky Sentry error tests. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c5a6372898c490ecbed31d9599f06529ec799cdf. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- test/e2e/tests/metrics/errors.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/e2e/tests/metrics/errors.spec.ts b/test/e2e/tests/metrics/errors.spec.ts index c82739de6a1d..7056c140c727 100644 --- a/test/e2e/tests/metrics/errors.spec.ts +++ b/test/e2e/tests/metrics/errors.spec.ts @@ -211,6 +211,7 @@ describe('Sentry errors', function () { async function mockSentryMigratorError(mockServer: Mockttp) { return await mockServer .forPost(sentryRegEx) + .withBodyIncluding('{"type":"event"') .withBodyIncluding(migrationError) .thenCallback(() => { return { From 77d5e389802676c49fab84ff9cb3925c46ed2760 Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Fri, 14 Nov 2025 11:55:02 +0100 Subject: [PATCH 006/154] fix: Change to available fiat value text when fiat mode is enabled (#37749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ode is enabled ## **Description** [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/37749?quickstart=1) This PR adds the total available fiat value when user switch to fiat mode. ## **Changelog** CHANGELOG entry: Change available value text to total fiat value when fiat mode is enabled ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/6203 ## **Manual testing steps** 1. Go to send 2. Switching from fiat to token should also change the available balance text ## **Screenshots/Recordings** ### **Before** Screenshot 2025-11-12 at 15 21 29 ### **After** Screenshot 2025-11-12 at 15 16 11 ## **Pre-merge author checklist** - [X] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/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. --- > [!NOTE] > Displays available balance in fiat when fiat mode is on (and token otherwise), updating tests accordingly. > > - **Send Amount UI (`ui/pages/.../amount.tsx`)**: > - Add memoized `balanceDisplayValue` to render available balance in fiat (`getFiatDisplayValue(...) available`) when in fiat mode, and token balance otherwise. > - Replace inline balance text with `balanceDisplayValue`; utilizes `t('available')`. > - **Tests (`amount.test.tsx`)**: > - Update assertions to expect `"$ 20.00 available"` in fiat mode and `"10.023 NEU available"` by default. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit dd0b4b5ebb328c027889eff5f1ec4cc6acc6fed5. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../confirmations/components/send/amount/amount.test.tsx | 1 + ui/pages/confirmations/components/send/amount/amount.tsx | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ui/pages/confirmations/components/send/amount/amount.test.tsx b/ui/pages/confirmations/components/send/amount/amount.test.tsx index ef6c9a25bbb5..effbdc24b02d 100644 --- a/ui/pages/confirmations/components/send/amount/amount.test.tsx +++ b/ui/pages/confirmations/components/send/amount/amount.test.tsx @@ -120,6 +120,7 @@ describe('Amount', () => { fireEvent.change(getByRole('textbox'), { target: { value: 100 } }); expect(getByText('$ 20.00')).toBeInTheDocument(); fireEvent.click(getByTestId('toggle-fiat-mode')); + expect(getByText('$ 20.00 available')).toBeInTheDocument(); expect(getByRole('textbox')).toHaveValue('20'); expect(getByText('USD')).toBeInTheDocument(); fireEvent.change(getByRole('textbox'), { target: { value: 100 } }); diff --git a/ui/pages/confirmations/components/send/amount/amount.tsx b/ui/pages/confirmations/components/send/amount/amount.tsx index 8ee95d21f885..425706842bfd 100644 --- a/ui/pages/confirmations/components/send/amount/amount.tsx +++ b/ui/pages/confirmations/components/send/amount/amount.tsx @@ -121,6 +121,12 @@ export const Amount = ({ updateValue, ]); + const balanceDisplayValue = useMemo(() => { + return fiatMode + ? `${getFiatDisplayValue(String(balance))} ${t('available')}` + : `${balance} ${asset?.symbol} ${t('available')}`; + }, [fiatMode, getFiatDisplayValue, balance, asset?.symbol, t]); + if (asset?.standard === ERC721) { return null; } @@ -173,7 +179,7 @@ export const Amount = ({ - {balance} {asset?.symbol} {t('available')} + {balanceDisplayValue} {!isNonEvmNativeSendType && ( Date: Fri, 14 Nov 2025 17:34:11 +0530 Subject: [PATCH 007/154] fix: JIRA-759, 760, 765, 754, 756 cp-13.10.0 (#37848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** In this PR, Updated Transaction Shield UI Bugs. Jira Link: - https://consensyssoftware.atlassian.net/browse/SUBS-759 - https://consensyssoftware.atlassian.net/browse/SUBS-760 - https://consensyssoftware.atlassian.net/browse/SUBS-765 - https://consensyssoftware.atlassian.net/browse/SUBS-754 - https://consensyssoftware.atlassian.net/browse/SUBS-756 Figma Link: - https://www.figma.com/design/HTAO1SrmixV4ppv7qIvLoa/Metamask-Transaction-Shield?node-id=14740-209891&m=dev - https://www.figma.com/design/agblIyQvyxSoqGMjQDAPBK/Transaction-Shield?node-id=277-14&p=f&t=apuyU6XgEuw694p7-0 [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/37722?quickstart=1) ## **Changelog** CHANGELOG entry: Used feature flag to only show this change when sidepanel flag is enabled for chrome. Updated button on wallet creation successful page from 'Done' to 'Open wallet'. ## **Related issues** Fixes: ## **Manual testing steps** 1. Open extension 2. Create wallet and validate Transaction Shield from settings. ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2025-11-14 at 3 58 50 PM ![Uploading Screenshot 2025-11-14 at 4.45.54 PM.png…]() ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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. --- > [!NOTE] > Updates Transaction Shield copy and i18n, improves responsive layouts and theming, replaces the entry modal animation with a static image, and renames the billing action to “Manage billing.” > > - **Frontend UI/Styles**: > - **Shield Entry Modal (`ui/components/app/shield-entry-modal/`)**: Replace animation with static image (`transaction-shield-modal.png`), add responsive title/spacing, full-height body, and new `shield-entry-modal-sheild-image` styles. > - **Settings Layout (`ui/pages/settings/index.scss`)**: On small screens, modules use flex column with scroll; similar adjustments in sidepanel selected view. > - **Transaction Shield Tab (`ui/pages/settings/transaction-shield-tab/`)**: Add light-theme inactive background (`--shield-membership-inactive-light`) with new modifier class; remove forced dark theme; minor styling. > - **Claims Form (`claims-form.tsx`)**: Add padding/margins, keep only section headers (remove descriptive subtext), retain dividers. > - **Shield Plan (`ui/pages/shield-plan/`)**: Tweak plan card padding/gaps and responsive price font; move spacing from props to CSS. > - **Colors (`ui/css/utilities/colors.scss`)**: Add `--shield-membership-inactive-light`. > - **i18n (`app/_locales/en*/messages.json`)**: > - Update copy: `shieldTxDetails1Title` to "Up to $10,000 transaction protection"; billing action to `"Manage billing"`. > - Remove unused: `shieldClaimIncidentDetailsDescription`, `shieldClaimPersonalDetailsDescription`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1b81792a46fc4a393b31617229b8332f514d8473. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/_locales/en/messages.json | 10 ++------ app/_locales/en_GB/messages.json | 10 ++------ app/images/transaction-shield-modal.png | Bin 0 -> 60166 bytes .../app/shield-entry-modal/index.scss | 14 +++++++++++ .../shield-entry-modal/shield-entry-modal.tsx | 13 ++++------- ui/css/utilities/colors.scss | 1 + ui/pages/settings/index.scss | 7 ++++-- .../claims-form/claims-form.tsx | 22 +++--------------- .../transaction-shield-tab/index.scss | 4 ++++ .../transaction-shield.tsx | 7 +++++- ui/pages/shield-plan/index.scss | 15 ++++++++++++ ui/pages/shield-plan/shield-plan.tsx | 10 ++++---- 12 files changed, 63 insertions(+), 50 deletions(-) create mode 100644 app/images/transaction-shield-modal.png diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 6f6b0cba3f7d..b7fc68ea3163 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -5928,9 +5928,6 @@ "shieldClaimIncidentDetails": { "message": "Incident details" }, - "shieldClaimIncidentDetailsDescription": { - "message": "Help us understand more about what happened." - }, "shieldClaimInvalidChainId": { "message": "Please enter a valid chain ID" }, @@ -5955,9 +5952,6 @@ "shieldClaimPersonalDetails": { "message": "Personal details" }, - "shieldClaimPersonalDetailsDescription": { - "message": "We'll use this to communicate with you when investigating and resolving." - }, "shieldClaimReimbursementWalletAddress": { "message": "Wallet address for reimbursement" }, @@ -6221,7 +6215,7 @@ "message": "Secures your assets from risky transactions" }, "shieldTxDetails1Title": { - "message": "Covers $10,000 in transaction protection" + "message": "Up to $10,000 transaction protection" }, "shieldTxDetails2Description": { "message": "Get faster, dedicated support anytime" @@ -6248,7 +6242,7 @@ "message": "Payment method" }, "shieldTxMembershipBillingDetailsViewBillingHistory": { - "message": "View billing history" + "message": "Manage billing" }, "shieldTxMembershipCancel": { "message": "Cancel membership" diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index 6f6b0cba3f7d..b7fc68ea3163 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -5928,9 +5928,6 @@ "shieldClaimIncidentDetails": { "message": "Incident details" }, - "shieldClaimIncidentDetailsDescription": { - "message": "Help us understand more about what happened." - }, "shieldClaimInvalidChainId": { "message": "Please enter a valid chain ID" }, @@ -5955,9 +5952,6 @@ "shieldClaimPersonalDetails": { "message": "Personal details" }, - "shieldClaimPersonalDetailsDescription": { - "message": "We'll use this to communicate with you when investigating and resolving." - }, "shieldClaimReimbursementWalletAddress": { "message": "Wallet address for reimbursement" }, @@ -6221,7 +6215,7 @@ "message": "Secures your assets from risky transactions" }, "shieldTxDetails1Title": { - "message": "Covers $10,000 in transaction protection" + "message": "Up to $10,000 transaction protection" }, "shieldTxDetails2Description": { "message": "Get faster, dedicated support anytime" @@ -6248,7 +6242,7 @@ "message": "Payment method" }, "shieldTxMembershipBillingDetailsViewBillingHistory": { - "message": "View billing history" + "message": "Manage billing" }, "shieldTxMembershipCancel": { "message": "Cancel membership" diff --git a/app/images/transaction-shield-modal.png b/app/images/transaction-shield-modal.png new file mode 100644 index 0000000000000000000000000000000000000000..54d0e4d2ac487119a2a6752acffcdf0a982d1428 GIT binary patch literal 60166 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 z%0A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 literal 0 HcmV?d00001 diff --git a/ui/components/app/shield-entry-modal/index.scss b/ui/components/app/shield-entry-modal/index.scss index d6464c74faf9..6c7ba29c5d8a 100644 --- a/ui/components/app/shield-entry-modal/index.scss +++ b/ui/components/app/shield-entry-modal/index.scss @@ -4,6 +4,11 @@ .shield-entry-modal__title { line-height: 1em; font-size: 2.25rem; + + @include design-system.screen-sm-max { + font-size: 2rem; + margin-top: 16px; + } } .shield-entry-modal__content { @@ -43,4 +48,13 @@ width: 100%; } } + + &-sheild-image { + width: 346px; + height: 252px; + + @include design-system.screen-sm-max { + width: 322px; + } + } } diff --git a/ui/components/app/shield-entry-modal/shield-entry-modal.tsx b/ui/components/app/shield-entry-modal/shield-entry-modal.tsx index f84edb115a69..273892ae8e62 100644 --- a/ui/components/app/shield-entry-modal/shield-entry-modal.tsx +++ b/ui/components/app/shield-entry-modal/shield-entry-modal.tsx @@ -47,12 +47,12 @@ import { } from '../../../../shared/constants/subscriptions'; import { AlignItems, + BlockSize, Display, FlexDirection, } from '../../../helpers/constants/design-system'; import { TRANSACTION_SHIELD_LINK } from '../../../helpers/constants/common'; import { ThemeType } from '../../../../shared/constants/preferences'; -import ShieldIllustrationAnimation from './shield-illustration-animation'; const ShieldEntryModal = ({ skipEventSubmission = false, @@ -197,6 +197,7 @@ const ShieldEntryModal = ({ flexDirection={FlexDirection.Column} gap={3} paddingTop={4} + height={BlockSize.Full} > - + Shield Entry Illustration - diff --git a/ui/css/utilities/colors.scss b/ui/css/utilities/colors.scss index 3c7b3cc2221d..a93af1a7bdc4 100644 --- a/ui/css/utilities/colors.scss +++ b/ui/css/utilities/colors.scss @@ -24,4 +24,5 @@ Before adding a color here make sure that there isn't a design token available. --color-network-linea-mainnet-default: #121212; --color-network-linea-mainnet-inverse: #fcfcfc; --welcome-bg-light: #fff2eb; + --shield-membership-inactive-light: #dadce5; } diff --git a/ui/pages/settings/index.scss b/ui/pages/settings/index.scss index 2fb1e725d99c..1e1b16411ac4 100644 --- a/ui/pages/settings/index.scss +++ b/ui/pages/settings/index.scss @@ -397,7 +397,9 @@ &__modules { @include design-system.screen-sm-max { - display: block; + display: flex; + flex-flow: column; + overflow-y: auto; } } } @@ -547,7 +549,8 @@ } .settings-page__content__modules { - display: block; + display: flex; + flex-flow: column; } } } diff --git a/ui/pages/settings/transaction-shield-tab/claims-form/claims-form.tsx b/ui/pages/settings/transaction-shield-tab/claims-form/claims-form.tsx index e814e125e2b3..c2aaceaab70d 100644 --- a/ui/pages/settings/transaction-shield-tab/claims-form/claims-form.tsx +++ b/ui/pages/settings/transaction-shield-tab/claims-form/claims-form.tsx @@ -386,9 +386,8 @@ const ClaimsForm = ({ isView = false }: { isView?: boolean }) => { return ( {!isView && pendingClaims.length > 0 && ( @@ -430,16 +429,9 @@ const ClaimsForm = ({ isView = false }: { isView?: boolean }) => { {/* Personal details */} - + {t('shieldClaimPersonalDetails')} - - {t('shieldClaimPersonalDetailsDescription')} - { /> {/* Incident details */} - + {t('shieldClaimIncidentDetails')} - - {t('shieldClaimIncidentDetailsDescription')} - - { }, [search]); const { formatCurrency } = useFormatters(); + const theme = useSelector(getTheme); + const isLightTheme = theme === ThemeType.light; const { customerId, @@ -645,7 +649,6 @@ const TransactionShield = () => { {membershipErrorBanner} { isMembershipInactive && !showSkeletonLoader, 'transaction-shield-page__membership--active': !isMembershipInactive && !showSkeletonLoader, + 'transaction-shield-page__membership--inactive-light': + isLightTheme && isMembershipInactive && !showSkeletonLoader, }, )} {...rowsStyleProps} diff --git a/ui/pages/shield-plan/index.scss b/ui/pages/shield-plan/index.scss index 1df8dbecc8ab..4eb936c15370 100644 --- a/ui/pages/shield-plan/index.scss +++ b/ui/pages/shield-plan/index.scss @@ -10,6 +10,15 @@ .shield-plan-page__plan { border: 1px solid transparent; position: relative; + padding-top: 16px; + padding-bottom: 16px; + column-gap: 16px; + + @include design-system.screen-sm-max { + padding-top: 8px; + padding-bottom: 8px; + column-gap: 8px; + } .shield-plan-page__radio { display: flex; @@ -32,6 +41,12 @@ height: 20px; background-color: var(--color-primary-default); } + + .shield-plan-page__plan-price { + @include design-system.screen-sm-max { + font-size: 16px; + } + } } .shield-plan-page__plan--selected { diff --git a/ui/pages/shield-plan/shield-plan.tsx b/ui/pages/shield-plan/shield-plan.tsx index 76c86ee79263..d0564e24acba 100644 --- a/ui/pages/shield-plan/shield-plan.tsx +++ b/ui/pages/shield-plan/shield-plan.tsx @@ -342,9 +342,6 @@ const ShieldPlan = () => { key={plan.id} {...rowsStyleProps} borderRadius={BorderRadius.LG} - paddingTop={2} - paddingBottom={2} - gap={4} className={classnames('shield-plan-page__plan', { 'shield-plan-page__plan--selected': plan.id === selectedPlan, @@ -358,7 +355,12 @@ const ShieldPlan = () => { className="shield-plan-page__radio-label" > {plan.label} - {plan.price} + + {plan.price} + {plan.id === RECURRING_INTERVALS.year && ( Date: Fri, 14 Nov 2025 21:12:01 +0800 Subject: [PATCH 008/154] chore: update Shield copywriting and error msgs cp-13.10.0 (#37829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR updates - Shield confirmation msgs, copywriting - add marketing deep link for shield - Fixes confirmation release blocker introduced by side panel changes [here](https://github.com/MetaMask/metamask-extension/pull/37778) - Enables shield flag in all builds - Fixes paused state subscription release blocker [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/37829?quickstart=1) ## **Changelog** CHANGELOG entry: update Shield confirmation msgs ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** Screenshot 2025-11-14 at 2 10 26 PM ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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. --- > [!NOTE] > Updates Shield copy and paused-state UX, refactors metrics with common props and new restart tracking, adds deep link to show entry modal, classifies approve tx, and enables the Shield feature flag. > > - **Shield UX copy & i18n**: > - Replace “membership” with “plan” across Shield strings; refine coverage/renewal/tooltips; split paused-state messages/actions by payment type; minor text tweaks (e.g., Save 17%). > - **Metrics**: > - Add `getShieldCommonTrackingProps`; update all Shield capture hooks to use it. > - Replace `ShieldErrorStateClicked` with `ShieldMembershipErrorStateClicked` and adjust event emissions. > - Move subscription restart request tracking from background to UI (`captureShieldSubscriptionRestartRequestEvent`). > - **Deep links & Settings**: > - Add `SHIELD_QUERY_PARAMS.showShieldEntryModal`; when true, route to `SETTINGS_ROUTE` and show entry modal or redirect to Shield tab if subscribed. > - Export `SETTINGS_ROUTE` via deep-links. > - **Toasts & Banners**: > - Paused toast now shows type-specific descriptions/actions (card/crypto/unexpected). > - Settings Shield banner uses new paused/ending-soon copy and actions; adds "Contact support" flow with user identifiers. > - **Transactions**: > - Classify `TransactionType.shieldSubscriptionApprove` in metrics. > - Crypto approval flow now navigates to confirmation after approval creation is detected. > - **Builds**: > - Enable `METAMASK_SHIELD_ENABLED: 'true'` in config. > - **Misc UI**: > - Use `color={TextColor.textAlternative}` for billing label; small refactors/imports. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8069be41eaf61c07e94a19c0b8121a1500ee9e27. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: hieu-w Co-authored-by: Nguyen Anh Tu Co-authored-by: Chaitanya Potti --- app/_locales/en/messages.json | 89 +++++++----- app/_locales/en_GB/messages.json | 89 +++++++----- app/_locales/ga/messages.json | 13 -- app/scripts/lib/transaction/metrics.ts | 1 + .../subscription/subscription-service.ts | 21 --- builds.yml | 3 +- shared/constants/metametrics.ts | 1 - shared/lib/deep-links/routes/route.ts | 1 + shared/lib/deep-links/routes/shield.ts | 17 ++- shared/modules/shield/metrics.ts | 81 +++-------- .../app/toast-master/toast-master.js | 30 +++- ui/hooks/shield/metrics/types.ts | 6 + .../shield/metrics/useSubscriptionMetrics.ts | 113 ++++++++++------ ui/hooks/subscription/useSubscription.ts | 110 +++++++++++++-- .../billing-details.tsx | 3 +- ui/pages/settings/settings.component.js | 15 ++ ui/pages/settings/settings.container.js | 9 +- .../transaction-shield.tsx | 128 ++++++++++-------- 18 files changed, 451 insertions(+), 279 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index b7fc68ea3163..52ceb7e8a55f 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -5141,7 +5141,7 @@ "message": "Your plan is now active." }, "pushNotificationShieldSubscriptionPaymentFailedDescriptionShort": { - "message": "Your membership has been paused due to payment failure." + "message": "Your plan has been paused due to payment failure." }, "pushNotificationShieldSubscriptionTitle": { "message": "MetaMask Transaction Shield" @@ -6024,7 +6024,7 @@ "message": "PENDING CLAIMS" }, "shieldConfirmMembership": { - "message": "Confirm membership" + "message": "Confirm plan" }, "shieldCoverageAlertCovered": { "message": "You're protected up to $2 with Metamask Transaction Shield. $1." @@ -6036,10 +6036,10 @@ "message": "This chain is not supported, so it isn't protected by Transaction Shield. $1." }, "shieldCoverageAlertMessageLearnHowCoverageWorks": { - "message": "Learn how coverage works" + "message": "See What's Covered" }, "shieldCoverageAlertMessagePaused": { - "message": "There was an issue with your Transaction Shield membership payment. Please update your payment method to resume coverage." + "message": "There was an issue with your Transaction Shield plan payment. Please update your payment method to resume coverage." }, "shieldCoverageAlertMessagePausedAcknowledgeButton": { "message": "Update payment method" @@ -6081,7 +6081,7 @@ "message": "Renew" }, "shieldCoverageEndingDescription": { - "message": "Membership ends on $1.", + "message": "Plan ends on $1.", "description": "The $1 is the date" }, "shieldCovered": { @@ -6105,7 +6105,7 @@ "message": "Introducing Transaction Shield" }, "shieldEstimatedChangesMonthlyTooltipText": { - "message": "Authorize up to $1 for the full year. You'll be billed $2 each month, not the full amount now." + "message": "Authorize $1/month for 12 months ($2 total). You'll be billed monthly, not the full amount now." }, "shieldFooterAgreement": { "message": "By continuing, I agree to Transaction Shield $1." @@ -6116,14 +6116,26 @@ "shieldPaused": { "message": "Paused" }, - "shieldPaymentDeclined": { - "message": "Shield payment declined" + "shieldPaymentPaused": { + "message": "Transaction Shield paused" }, - "shieldPaymentDeclinedAction": { - "message": "Add funds" + "shieldPaymentPausedActionCardPayment": { + "message": "Update" + }, + "shieldPaymentPausedActionCryptoPayment": { + "message": "Update" + }, + "shieldPaymentPausedActionUnexpectedError": { + "message": "View" + }, + "shieldPaymentPausedDescriptionCardPayment": { + "message": "Card payment failed." + }, + "shieldPaymentPausedDescriptionCryptoPayment": { + "message": "Insufficient token balance." }, - "shieldPaymentDeclinedDescription": { - "message": "Insufficient token balance. Please try again to resume coverage." + "shieldPaymentPausedDescriptionUnexpectedError": { + "message": "An unexpected error occurred." }, "shieldPlanAnnual": { "message": "Annual" @@ -6139,7 +6151,7 @@ "message": "Card" }, "shieldPlanCryptoMonthlyNote": { - "message": "Total monthly fees pre-approved for a year for crypto payments, so your plan stays active" + "message": "Total monthly fees pre-approved for a year" }, "shieldPlanDetails": { "message": "What you get" @@ -6190,7 +6202,7 @@ "message": "Change payment method" }, "shieldPlanSave": { - "message": "Save 16%" + "message": "Save 17%" }, "shieldPlanSelectToken": { "message": "Select a token" @@ -6224,7 +6236,7 @@ "message": "Priority support" }, "shieldTxMembershipActive": { - "message": "Active membership" + "message": "Active plan" }, "shieldTxMembershipBillingDetails": { "message": "Billing details" @@ -6245,55 +6257,64 @@ "message": "Manage billing" }, "shieldTxMembershipCancel": { - "message": "Cancel membership" + "message": "Cancel plan" }, "shieldTxMembershipCancelNotification": { - "message": "Your membership will be cancelled on $1.", + "message": "Your plan will be cancelled on $1.", "description": "The $1 is the date" }, - "shieldTxMembershipErrorAddFunds": { - "message": "Add funds" - }, "shieldTxMembershipErrorInsufficientFunds": { - "message": "Insufficient funds for your $1 renewal. Keep access active by adding funds.", - "description": "The $1 is the date" + "message": "Your plan ends on $1. Renew now to continue your benefits.", + "description": "The $1 is the date subscription ends" }, "shieldTxMembershipErrorInsufficientToken": { - "message": "Insufficient $1", - "description": "The $1 is the token symbol" + "message": "Retry payment" + }, + "shieldTxMembershipErrorPausedCard": { + "message": "Your plan has been paused due to a failed card payment." }, - "shieldTxMembershipErrorPaused": { - "message": "Membership paused due to insufficient funds. Coverage will resume after payment update." + "shieldTxMembershipErrorPausedCardAction": { + "message": "Update card details" }, "shieldTxMembershipErrorPausedCardTooltip": { "message": "Your payment was declined. Please update your payment method to continue your coverage." }, + "shieldTxMembershipErrorPausedCryptoInsufficientFunds": { + "message": "Plan paused due to insufficient funds. Payment updates may take up to $1 hours.", + "description": "The $1 is the number of hours" + }, + "shieldTxMembershipErrorPausedCryptoInsufficientFundsAction": { + "message": "Add funds" + }, "shieldTxMembershipErrorPausedCryptoTooltip": { - "message": "Insufficient token balance in your wallet. Top up to resume coverage." + "message": "Insufficient token balance in your wallet. Click retry after funding your wallet." + }, + "shieldTxMembershipErrorPausedUnexpected": { + "message": "Plan paused due to an unexpected error." + }, + "shieldTxMembershipErrorPausedUnexpectedAction": { + "message": "Contact support" }, "shieldTxMembershipErrorUpdateCard": { "message": "Update card details" }, - "shieldTxMembershipErrorUpdatePayment": { - "message": "Update payment" - }, "shieldTxMembershipFreeTrial": { "message": "Free trial" }, "shieldTxMembershipId": { - "message": "Member ID" + "message": "Plan ID" }, "shieldTxMembershipInactive": { - "message": "Inactive membership" + "message": "Inactive plan" }, "shieldTxMembershipPaused": { "message": "Paused" }, "shieldTxMembershipRenew": { - "message": "Renew membership" + "message": "Renew plan" }, "shieldTxMembershipResubscribe": { - "message": "Restart membership" + "message": "Restart plan" }, "shieldTxMembershipSubmitCase": { "message": "Submit a claim" diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index b7fc68ea3163..52ceb7e8a55f 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -5141,7 +5141,7 @@ "message": "Your plan is now active." }, "pushNotificationShieldSubscriptionPaymentFailedDescriptionShort": { - "message": "Your membership has been paused due to payment failure." + "message": "Your plan has been paused due to payment failure." }, "pushNotificationShieldSubscriptionTitle": { "message": "MetaMask Transaction Shield" @@ -6024,7 +6024,7 @@ "message": "PENDING CLAIMS" }, "shieldConfirmMembership": { - "message": "Confirm membership" + "message": "Confirm plan" }, "shieldCoverageAlertCovered": { "message": "You're protected up to $2 with Metamask Transaction Shield. $1." @@ -6036,10 +6036,10 @@ "message": "This chain is not supported, so it isn't protected by Transaction Shield. $1." }, "shieldCoverageAlertMessageLearnHowCoverageWorks": { - "message": "Learn how coverage works" + "message": "See What's Covered" }, "shieldCoverageAlertMessagePaused": { - "message": "There was an issue with your Transaction Shield membership payment. Please update your payment method to resume coverage." + "message": "There was an issue with your Transaction Shield plan payment. Please update your payment method to resume coverage." }, "shieldCoverageAlertMessagePausedAcknowledgeButton": { "message": "Update payment method" @@ -6081,7 +6081,7 @@ "message": "Renew" }, "shieldCoverageEndingDescription": { - "message": "Membership ends on $1.", + "message": "Plan ends on $1.", "description": "The $1 is the date" }, "shieldCovered": { @@ -6105,7 +6105,7 @@ "message": "Introducing Transaction Shield" }, "shieldEstimatedChangesMonthlyTooltipText": { - "message": "Authorize up to $1 for the full year. You'll be billed $2 each month, not the full amount now." + "message": "Authorize $1/month for 12 months ($2 total). You'll be billed monthly, not the full amount now." }, "shieldFooterAgreement": { "message": "By continuing, I agree to Transaction Shield $1." @@ -6116,14 +6116,26 @@ "shieldPaused": { "message": "Paused" }, - "shieldPaymentDeclined": { - "message": "Shield payment declined" + "shieldPaymentPaused": { + "message": "Transaction Shield paused" }, - "shieldPaymentDeclinedAction": { - "message": "Add funds" + "shieldPaymentPausedActionCardPayment": { + "message": "Update" + }, + "shieldPaymentPausedActionCryptoPayment": { + "message": "Update" + }, + "shieldPaymentPausedActionUnexpectedError": { + "message": "View" + }, + "shieldPaymentPausedDescriptionCardPayment": { + "message": "Card payment failed." + }, + "shieldPaymentPausedDescriptionCryptoPayment": { + "message": "Insufficient token balance." }, - "shieldPaymentDeclinedDescription": { - "message": "Insufficient token balance. Please try again to resume coverage." + "shieldPaymentPausedDescriptionUnexpectedError": { + "message": "An unexpected error occurred." }, "shieldPlanAnnual": { "message": "Annual" @@ -6139,7 +6151,7 @@ "message": "Card" }, "shieldPlanCryptoMonthlyNote": { - "message": "Total monthly fees pre-approved for a year for crypto payments, so your plan stays active" + "message": "Total monthly fees pre-approved for a year" }, "shieldPlanDetails": { "message": "What you get" @@ -6190,7 +6202,7 @@ "message": "Change payment method" }, "shieldPlanSave": { - "message": "Save 16%" + "message": "Save 17%" }, "shieldPlanSelectToken": { "message": "Select a token" @@ -6224,7 +6236,7 @@ "message": "Priority support" }, "shieldTxMembershipActive": { - "message": "Active membership" + "message": "Active plan" }, "shieldTxMembershipBillingDetails": { "message": "Billing details" @@ -6245,55 +6257,64 @@ "message": "Manage billing" }, "shieldTxMembershipCancel": { - "message": "Cancel membership" + "message": "Cancel plan" }, "shieldTxMembershipCancelNotification": { - "message": "Your membership will be cancelled on $1.", + "message": "Your plan will be cancelled on $1.", "description": "The $1 is the date" }, - "shieldTxMembershipErrorAddFunds": { - "message": "Add funds" - }, "shieldTxMembershipErrorInsufficientFunds": { - "message": "Insufficient funds for your $1 renewal. Keep access active by adding funds.", - "description": "The $1 is the date" + "message": "Your plan ends on $1. Renew now to continue your benefits.", + "description": "The $1 is the date subscription ends" }, "shieldTxMembershipErrorInsufficientToken": { - "message": "Insufficient $1", - "description": "The $1 is the token symbol" + "message": "Retry payment" + }, + "shieldTxMembershipErrorPausedCard": { + "message": "Your plan has been paused due to a failed card payment." }, - "shieldTxMembershipErrorPaused": { - "message": "Membership paused due to insufficient funds. Coverage will resume after payment update." + "shieldTxMembershipErrorPausedCardAction": { + "message": "Update card details" }, "shieldTxMembershipErrorPausedCardTooltip": { "message": "Your payment was declined. Please update your payment method to continue your coverage." }, + "shieldTxMembershipErrorPausedCryptoInsufficientFunds": { + "message": "Plan paused due to insufficient funds. Payment updates may take up to $1 hours.", + "description": "The $1 is the number of hours" + }, + "shieldTxMembershipErrorPausedCryptoInsufficientFundsAction": { + "message": "Add funds" + }, "shieldTxMembershipErrorPausedCryptoTooltip": { - "message": "Insufficient token balance in your wallet. Top up to resume coverage." + "message": "Insufficient token balance in your wallet. Click retry after funding your wallet." + }, + "shieldTxMembershipErrorPausedUnexpected": { + "message": "Plan paused due to an unexpected error." + }, + "shieldTxMembershipErrorPausedUnexpectedAction": { + "message": "Contact support" }, "shieldTxMembershipErrorUpdateCard": { "message": "Update card details" }, - "shieldTxMembershipErrorUpdatePayment": { - "message": "Update payment" - }, "shieldTxMembershipFreeTrial": { "message": "Free trial" }, "shieldTxMembershipId": { - "message": "Member ID" + "message": "Plan ID" }, "shieldTxMembershipInactive": { - "message": "Inactive membership" + "message": "Inactive plan" }, "shieldTxMembershipPaused": { "message": "Paused" }, "shieldTxMembershipRenew": { - "message": "Renew membership" + "message": "Renew plan" }, "shieldTxMembershipResubscribe": { - "message": "Restart membership" + "message": "Restart plan" }, "shieldTxMembershipSubmitCase": { "message": "Submit a claim" diff --git a/app/_locales/ga/messages.json b/app/_locales/ga/messages.json index 72ecad14e298..a6a5b29b2356 100644 --- a/app/_locales/ga/messages.json +++ b/app/_locales/ga/messages.json @@ -5742,20 +5742,10 @@ "message": "Cuirfear do bhallra�ocht ar ceal ar $1.", "description": "The $1 is the date" }, - "shieldTxMembershipErrorAddFunds": { - "message": "Cuir cistí leis" - }, - "shieldTxMembershipErrorInsufficientFunds": { - "message": "Gan dóthain airgid le haghaidh d'athnuachana an 18 Aibreán. Coinnigh rochtain gníomhach trí airgead a chur leis.", - "description": "The $1 is the date" - }, "shieldTxMembershipErrorInsufficientToken": { "message": "$1 neamhleor", "description": "The $1 is the token symbol" }, - "shieldTxMembershipErrorPaused": { - "message": "Ballraíocht curtha ar sos mar gheall ar easpa cistí. Atosófar an clúdach tar éis nuashonrú íocaíochta." - }, "shieldTxMembershipErrorPausedCardTooltip": { "message": "Diúltaíodh do d’íocaíocht. Nuashonraigh do mhodh íocaíochta le go leanfaidh do chlúdach ar aghaidh." }, @@ -5765,9 +5755,6 @@ "shieldTxMembershipErrorUpdateCard": { "message": "Nuashonraigh sonraí cárta" }, - "shieldTxMembershipErrorUpdatePayment": { - "message": "Nuashonraigh an íocaíocht" - }, "shieldTxMembershipFreeTrial": { "message": "Triail saor in aisce" }, diff --git a/app/scripts/lib/transaction/metrics.ts b/app/scripts/lib/transaction/metrics.ts index dc712dc03387..9c93e35241a4 100644 --- a/app/scripts/lib/transaction/metrics.ts +++ b/app/scripts/lib/transaction/metrics.ts @@ -1369,6 +1369,7 @@ function determineTransactionTypeAndContractInteraction( TransactionType.deployContract, TransactionType.gasPayment, TransactionType.batch, + TransactionType.shieldSubscriptionApprove, ]; if (directTypeMappings.includes(type)) { diff --git a/app/scripts/services/subscription/subscription-service.ts b/app/scripts/services/subscription/subscription-service.ts index c3ef201d58d5..fb9b1c6c5ad1 100644 --- a/app/scripts/services/subscription/subscription-service.ts +++ b/app/scripts/services/subscription/subscription-service.ts @@ -22,7 +22,6 @@ import { fetchSwapsFeatureFlags } from '../../../../ui/pages/swaps/swaps.util'; import { SwapsControllerState } from '../../controllers/swaps/swaps.types'; import { getSubscriptionRequestTrackingProps, - getSubscriptionRestartRequestTrackingProps, getUserAccountTypeAndCategory, } from '../../../../shared/modules/shield/metrics'; import { @@ -244,26 +243,6 @@ export class SubscriptionService { transactionMeta, ); - const isRenewal = trackingProps.subscription_state !== 'none'; - - if (isRenewal) { - const renewalTrackingProps = getSubscriptionRestartRequestTrackingProps( - subscriptionControllerState, - requestStatus, - shieldSubscriptionMetricsProps, - extrasProps?.error_message as string | undefined, - ); - - this.#messenger.call('MetaMetricsController:trackEvent', { - event: MetaMetricsEventName.ShieldMembershipRestartRequest, - category: MetaMetricsEventCategory.Shield, - properties: { - ...accountTypeAndCategory, - ...renewalTrackingProps, - }, - }); - } - this.#messenger.call('MetaMetricsController:trackEvent', { event: MetaMetricsEventName.ShieldSubscriptionRequest, category: MetaMetricsEventCategory.Shield, diff --git a/builds.yml b/builds.yml index 3f68e11ccd77..8cffae81dba0 100644 --- a/builds.yml +++ b/builds.yml @@ -99,7 +99,6 @@ buildTypes: - SEGMENT_WRITE_KEY_REF: SEGMENT_EXPERIMENTAL_WRITE_KEY - ALLOW_LOCAL_SNAPS: false - IS_SIDEPANEL: true - - METAMASK_SHIELD_ENABLED: true - REQUIRE_SNAPS_ALLOWLIST: true - REJECT_INVALID_SNAPS_PLATFORM_VERSION: true - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/10.2.3/index.html @@ -453,7 +452,7 @@ env: - APPLE_CLIENT_ID_UAT: '' # Metamask Shield - - METAMASK_SHIELD_ENABLED: 'false' + - METAMASK_SHIELD_ENABLED: 'true' # Snaps - AUTO_UPDATE_PREINSTALLED_SNAPS: 'true' diff --git a/shared/constants/metametrics.ts b/shared/constants/metametrics.ts index 3fe7eacfcb1f..08aaf889e2a5 100644 --- a/shared/constants/metametrics.ts +++ b/shared/constants/metametrics.ts @@ -958,7 +958,6 @@ export enum MetaMetricsEventName { ShieldPrioritySupportClicked = 'Shield Priority Support Clicked', ShieldEligibilityCohortAssigned = 'Shield Eligibility Cohort Assigned', ShieldEligibilityCohortTimeout = 'Shield Eligibility Cohort Timeout', - ShieldErrorStateClicked = 'Shield Error State Clicked', } export enum MetaMetricsEventAccountType { diff --git a/shared/lib/deep-links/routes/route.ts b/shared/lib/deep-links/routes/route.ts index 611a7a39b398..b9fe96b1d600 100644 --- a/shared/lib/deep-links/routes/route.ts +++ b/shared/lib/deep-links/routes/route.ts @@ -10,6 +10,7 @@ export { DEEP_LINK_ROUTE, NOTIFICATIONS_ROUTE, SHIELD_PLAN_ROUTE, + SETTINGS_ROUTE, } from '../../../../ui/helpers/constants/routes'; /** diff --git a/shared/lib/deep-links/routes/shield.ts b/shared/lib/deep-links/routes/shield.ts index e0b143620a01..bc8108623fd0 100644 --- a/shared/lib/deep-links/routes/shield.ts +++ b/shared/lib/deep-links/routes/shield.ts @@ -1,9 +1,24 @@ -import { Route, SHIELD_PLAN_ROUTE } from './route'; +import { Route, SETTINGS_ROUTE, SHIELD_PLAN_ROUTE } from './route'; + +export const SHIELD_QUERY_PARAMS = { + showShieldEntryModal: 'showShieldEntryModal', +}; export default new Route({ pathname: '/shield', getTitle: (_: URLSearchParams) => 'deepLink_theTransactionShieldPage', handler: function handler(params: URLSearchParams) { + const shouldShowShieldEntryModal = + params.get(SHIELD_QUERY_PARAMS.showShieldEntryModal) === 'true'; + + if (shouldShowShieldEntryModal) { + // link to settings page and show the shield entry modal + return { + path: SETTINGS_ROUTE, + query: params, + }; + } + return { path: SHIELD_PLAN_ROUTE, query: params, diff --git a/shared/modules/shield/metrics.ts b/shared/modules/shield/metrics.ts index ff79c8c81b5d..4468429c6114 100644 --- a/shared/modules/shield/metrics.ts +++ b/shared/modules/shield/metrics.ts @@ -17,10 +17,7 @@ import { ShieldUserAccountCategoryEnum, ShieldUserAccountTypeEnum, } from '../../constants/subscriptions'; -import { - getShieldSubscription, - getSubscriptionPaymentData, -} from '../../lib/shield'; +import { getShieldSubscription } from '../../lib/shield'; import { KeyringType } from '../../constants/keyring'; import { DefaultSubscriptionPaymentOptions, @@ -123,6 +120,27 @@ export function getUserAccountTypeAndCategory( }; } +/** + * Get the common tracking props for the Shield metrics. + * + * @param account - The account. + * @param keyringsMetadata - The keyrings metadata. + * @param balanceInUSD - The balance in USD. + * @returns The common tracking props. + */ +export function getShieldCommonTrackingProps( + account: InternalAccount, + keyringsMetadata: KeyringObject[], + balanceInUSD: number, +) { + return { + ...getUserAccountTypeAndCategory(account, keyringsMetadata), + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + multi_chain_balance_category: getUserBalanceCategory(balanceInUSD), + }; +} + /** * Get the tracking props for the subscription request for the Shield metrics. * @@ -223,58 +241,3 @@ export function getSubscriptionRequestTrackingProps( is_trial: !isTrialed, }; } - -/** - * Get the tracking props for the subscription restart request for the Shield metrics. - * - * @param subscriptionControllerState - The subscription controller state. - * @param requestStatus - The request status. - * @param shieldSubscriptionMetricsProps - The Shield subscription metrics properties. - * @param errorMessage - The error message. - * @returns The tracking props. - */ -export function getSubscriptionRestartRequestTrackingProps( - subscriptionControllerState: SubscriptionControllerState, - requestStatus: 'started' | 'completed' | 'failed', - shieldSubscriptionMetricsProps?: ShieldSubscriptionMetricsPropsFromUI, - errorMessage?: string, -): Record { - const { subscriptions, lastSubscription } = subscriptionControllerState; - const latestSubscriptionStatus = - getLatestSubscriptionStatus(subscriptions, lastSubscription) || 'none'; - const lastSubscriptionData = getSubscriptionPaymentData(lastSubscription); - const billingInterval = getBillingIntervalForMetrics( - lastSubscriptionData.billingInterval, - ); - return { - source: - shieldSubscriptionMetricsProps?.source || EntryModalSourceEnum.Settings, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - marketing_utm_id: shieldSubscriptionMetricsProps?.marketingUtmId || null, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - multi_chain_balance_category: getUserBalanceCategory( - shieldSubscriptionMetricsProps?.userBalanceInUSD ?? 0, - ), - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - subscription_status: latestSubscriptionStatus, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - payment_type: lastSubscriptionData.paymentType, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - crypto_payment_chain: lastSubscriptionData.cryptoPaymentChain || null, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - crypto_payment_currency: lastSubscriptionData.cryptoPaymentCurrency || null, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - billing_interval: billingInterval, - status: requestStatus, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - error_message: errorMessage || null, - }; -} diff --git a/ui/components/app/toast-master/toast-master.js b/ui/components/app/toast-master/toast-master.js index 0ee25ed5d542..268b90621069 100644 --- a/ui/components/app/toast-master/toast-master.js +++ b/ui/components/app/toast-master/toast-master.js @@ -72,6 +72,10 @@ import { getIsShieldSubscriptionPaused, getSubscriptionPaymentData, } from '../../../../shared/lib/shield'; +import { + isCardPaymentMethod, + isCryptoPaymentMethod, +} from '../../../pages/settings/transaction-shield-tab/types'; import { useSubscriptionMetrics } from '../../../hooks/shield/metrics/useSubscriptionMetrics'; import { ShieldErrorStateActionClickedEnum, @@ -629,6 +633,26 @@ function ShieldPausedToast() { const isPaused = getIsShieldSubscriptionPaused(shieldSubscription); + const isCardPayment = + shieldSubscription && + isCardPaymentMethod(shieldSubscription?.paymentMethod); + const isCryptoPaymentWithError = + shieldSubscription && + isCryptoPaymentMethod(shieldSubscription.paymentMethod) && + Boolean(shieldSubscription.paymentMethod.crypto.error); + + // default text to unexpected error case + let descriptionText = 'shieldPaymentPausedDescriptionUnexpectedError'; + let actionText = 'shieldPaymentPausedActionUnexpectedError'; + if (isCardPayment) { + descriptionText = 'shieldPaymentPausedDescriptionCardPayment'; + actionText = 'shieldPaymentPausedActionCardPayment'; + } + if (isCryptoPaymentWithError) { + descriptionText = 'shieldPaymentPausedDescriptionCryptoPayment'; + actionText = 'shieldPaymentPausedActionCryptoPayment'; + } + const trackShieldErrorStateClickedEvent = (actionClicked) => { const { cryptoPaymentChain, cryptoPaymentCurrency } = getSubscriptionPaymentData(shieldSubscription); @@ -666,9 +690,9 @@ function ShieldPausedToast() { showShieldPausedToast && ( { | CaptureShieldEligibilityCohortTimeoutEventParams, event: MetaMetricsEventName, ) => { - const userAccountTypeAndCategory = getUserAccountTypeAndCategory( + const commonTrackingProps = getShieldCommonTrackingProps( selectedAccount, hdKeyingsMetadata, + Number(totalFiatBalance), ); const formattedParams = formatCaptureShieldEligibilityCohortEventsProps( params, @@ -90,7 +89,7 @@ export const useSubscriptionMetrics = () => { event, category: MetaMetricsEventCategory.Shield, properties: { - ...userAccountTypeAndCategory, + ...commonTrackingProps, ...formattedParams, }, }); @@ -103,21 +102,17 @@ export const useSubscriptionMetrics = () => { */ const captureShieldEntryModalEvent = useCallback( (params: CaptureShieldEntryModalEventParams) => { - const userAccountTypeAndCategory = getUserAccountTypeAndCategory( + const commonTrackingProps = getShieldCommonTrackingProps( selectedAccount, hdKeyingsMetadata, + Number(totalFiatBalance), ); trackEvent({ event: MetaMetricsEventName.ShieldEntryModal, category: MetaMetricsEventCategory.Shield, properties: { - ...userAccountTypeAndCategory, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - multi_chain_balance_category: getUserBalanceCategory( - Number(totalFiatBalance), - ), + ...commonTrackingProps, source: params.source, // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention @@ -142,9 +137,10 @@ export const useSubscriptionMetrics = () => { */ const captureShieldSubscriptionRequestEvent = useCallback( (params: CaptureShieldSubscriptionRequestParams) => { - const userAccountTypeAndCategory = getUserAccountTypeAndCategory( + const commonTrackingProps = getShieldCommonTrackingProps( selectedAccount, hdKeyingsMetadata, + Number(totalFiatBalance), ); const formattedParams = formatDefaultShieldSubscriptionRequestEventProps(params); @@ -153,31 +149,59 @@ export const useSubscriptionMetrics = () => { event: MetaMetricsEventName.ShieldSubscriptionRequest, category: MetaMetricsEventCategory.Shield, properties: { - ...userAccountTypeAndCategory, + ...commonTrackingProps, + ...formattedParams, + }, + }); + }, + [trackEvent, selectedAccount, hdKeyingsMetadata, totalFiatBalance], + ); + + /** + * Capture the event when the subscription restart request is triggered. + */ + const captureShieldSubscriptionRestartRequestEvent = useCallback( + (params: CaptureShieldSubscriptionRestartRequestEventParams) => { + const commonTrackingProps = getShieldCommonTrackingProps( + selectedAccount, + hdKeyingsMetadata, + Number(totalFiatBalance), + ); + const formattedParams = formatExistingSubscriptionEventProps(params); + trackEvent({ + event: MetaMetricsEventName.ShieldMembershipRestartRequest, + category: MetaMetricsEventCategory.Shield, + properties: { + ...commonTrackingProps, + ...formattedParams, // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention - multi_chain_balance_category: getUserBalanceCategory( - Number(totalFiatBalance), - ), - ...formattedParams, + status: params.requestStatus, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + error_message: params.errorMessage, }, }); }, [trackEvent, selectedAccount, hdKeyingsMetadata, totalFiatBalance], ); + /** + * Capture the event when the Shield membership is cancelled. + */ const captureShieldMembershipCancelledEvent = useCallback( (params: CaptureShieldMembershipCancelledEventParams) => { - const userAccountTypeAndCategory = getUserAccountTypeAndCategory( + const commonTrackingProps = getShieldCommonTrackingProps( selectedAccount, hdKeyingsMetadata, + Number(totalFiatBalance), ); const formattedParams = formatExistingSubscriptionEventProps(params); trackEvent({ event: MetaMetricsEventName.ShieldMembershipCancelled, category: MetaMetricsEventCategory.Shield, properties: { - ...userAccountTypeAndCategory, + ...commonTrackingProps, ...formattedParams, // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention @@ -191,7 +215,7 @@ export const useSubscriptionMetrics = () => { }, }); }, - [trackEvent, selectedAccount, hdKeyingsMetadata], + [trackEvent, selectedAccount, hdKeyingsMetadata, totalFiatBalance], ); /** @@ -199,9 +223,10 @@ export const useSubscriptionMetrics = () => { */ const captureShieldPaymentMethodChangeEvent = useCallback( (params: CaptureShieldPaymentMethodChangeEventParams) => { - const userAccountTypeAndCategory = getUserAccountTypeAndCategory( + const commonTrackingProps = getShieldCommonTrackingProps( selectedAccount, hdKeyingsMetadata, + Number(totalFiatBalance), ); const formattedParams = formatCaptureShieldPaymentMethodChangeEventProps(params); @@ -209,7 +234,7 @@ export const useSubscriptionMetrics = () => { event: MetaMetricsEventName.ShieldPaymentMethodChange, category: MetaMetricsEventCategory.Shield, properties: { - ...userAccountTypeAndCategory, + ...commonTrackingProps, ...formattedParams, // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention @@ -220,7 +245,7 @@ export const useSubscriptionMetrics = () => { }, }); }, - [trackEvent, selectedAccount, hdKeyingsMetadata], + [selectedAccount, hdKeyingsMetadata, totalFiatBalance, trackEvent], ); /** @@ -231,28 +256,30 @@ export const useSubscriptionMetrics = () => { */ const captureCommonExistingShieldSubscriptionEvents = useCallback( (params: ExistingSubscriptionEventParams, event: MetaMetricsEventName) => { - const userAccountTypeAndCategory = getUserAccountTypeAndCategory( + const commonTrackingProps = getShieldCommonTrackingProps( selectedAccount, hdKeyingsMetadata, + Number(totalFiatBalance), ); const formattedParams = formatExistingSubscriptionEventProps(params); trackEvent({ event, category: MetaMetricsEventCategory.Shield, properties: { - ...userAccountTypeAndCategory, + ...commonTrackingProps, ...formattedParams, }, }); }, - [trackEvent, selectedAccount, hdKeyingsMetadata], + [trackEvent, selectedAccount, hdKeyingsMetadata, totalFiatBalance], ); const captureShieldCryptoConfirmationEvent = useCallback( (params: CaptureShieldCryptoConfirmationEventParams) => { - const userAccountTypeAndCategory = getUserAccountTypeAndCategory( + const commonTrackingProps = getShieldCommonTrackingProps( selectedAccount, hdKeyingsMetadata, + Number(totalFiatBalance), ); const formattedParams = @@ -262,7 +289,7 @@ export const useSubscriptionMetrics = () => { event: MetaMetricsEventName.ShieldSubscriptionCryptoConfirmation, category: MetaMetricsEventCategory.Shield, properties: { - ...userAccountTypeAndCategory, + ...commonTrackingProps, ...formattedParams, // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention @@ -270,39 +297,41 @@ export const useSubscriptionMetrics = () => { }, }); }, - [trackEvent, selectedAccount, hdKeyingsMetadata], + [trackEvent, selectedAccount, hdKeyingsMetadata, totalFiatBalance], ); const captureShieldCtaClickedEvent = useCallback( (params: CaptureShieldCtaClickedEventParams) => { - const userAccountTypeAndCategory = getUserAccountTypeAndCategory( + const commonTrackingProps = getShieldCommonTrackingProps( selectedAccount, hdKeyingsMetadata, + Number(totalFiatBalance), ); const formattedParams = formatCaptureShieldCtaClickedEventProps(params); trackEvent({ event: MetaMetricsEventName.ShieldCtaClicked, category: MetaMetricsEventCategory.Shield, properties: { - ...userAccountTypeAndCategory, + ...commonTrackingProps, ...formattedParams, }, }); }, - [trackEvent, selectedAccount, hdKeyingsMetadata], + [trackEvent, selectedAccount, hdKeyingsMetadata, totalFiatBalance], ); const captureShieldClaimSubmissionEvent = useCallback( (params: CaptureShieldClaimSubmissionEventParams) => { - const userAccountTypeAndCategory = getUserAccountTypeAndCategory( + const commonTrackingProps = getShieldCommonTrackingProps( selectedAccount, hdKeyingsMetadata, + Number(totalFiatBalance), ); trackEvent({ event: MetaMetricsEventName.ShieldClaimSubmission, category: MetaMetricsEventCategory.Shield, properties: { - ...userAccountTypeAndCategory, + ...commonTrackingProps, // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention subscription_status: params.subscriptionStatus, @@ -318,7 +347,7 @@ export const useSubscriptionMetrics = () => { }, }); }, - [trackEvent, selectedAccount, hdKeyingsMetadata], + [trackEvent, selectedAccount, hdKeyingsMetadata, totalFiatBalance], ); /** @@ -326,16 +355,17 @@ export const useSubscriptionMetrics = () => { */ const captureShieldErrorStateClickedEvent = useCallback( (params: CaptureShieldErrorStateClickedEventParams) => { - const userAccountTypeAndCategory = getUserAccountTypeAndCategory( + const commonTrackingProps = getShieldCommonTrackingProps( selectedAccount, hdKeyingsMetadata, + Number(totalFiatBalance), ); const formattedParams = formatExistingSubscriptionEventProps(params); trackEvent({ - event: MetaMetricsEventName.ShieldErrorStateClicked, + event: MetaMetricsEventName.ShieldMembershipErrorStateClicked, category: MetaMetricsEventCategory.Shield, properties: { - ...userAccountTypeAndCategory, + ...commonTrackingProps, ...formattedParams, type: params.errorCause, action: params.actionClicked, @@ -344,7 +374,7 @@ export const useSubscriptionMetrics = () => { }, }); }, - [trackEvent, selectedAccount, hdKeyingsMetadata], + [trackEvent, selectedAccount, hdKeyingsMetadata, totalFiatBalance], ); return { @@ -359,5 +389,6 @@ export const useSubscriptionMetrics = () => { captureShieldEligibilityCohortEvent, captureCommonExistingShieldSubscriptionEvents, captureShieldErrorStateClickedEvent, + captureShieldSubscriptionRestartRequestEvent, }; }; diff --git a/ui/hooks/subscription/useSubscription.ts b/ui/hooks/subscription/useSubscription.ts index a9c186e92d45..4ba2ad208dc0 100644 --- a/ui/hooks/subscription/useSubscription.ts +++ b/ui/hooks/subscription/useSubscription.ts @@ -1,5 +1,5 @@ import { useDispatch, useSelector } from 'react-redux'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { PAYMENT_TYPES, PaymentType, @@ -35,7 +35,10 @@ import { } from '../../store/actions'; import { useAsyncCallback, useAsyncResult } from '../useAsync'; import { MetaMaskReduxDispatch } from '../../store/store'; -import { selectIsSignedIn } from '../../selectors/identity/authentication'; +import { + selectIsSignedIn, + selectSessionData, +} from '../../selectors/identity/authentication'; import { getIsUnlocked } from '../../ducks/metamask/metamask'; import { getIsShieldSubscriptionActive, @@ -47,7 +50,9 @@ import { decimalToHex } from '../../../shared/modules/conversion.utils'; import { CONFIRM_TRANSACTION_ROUTE } from '../../helpers/constants/routes'; import { getInternalAccountBySelectedAccountGroupAndCaip } from '../../selectors/multichain-accounts/account-tree'; import { + getMetaMetricsId, getModalTypeForShieldEntryModal, + getUnapprovedConfirmations, selectNetworkConfigurationByChainId, } from '../../selectors'; import { useSubscriptionMetrics } from '../shield/metrics/useSubscriptionMetrics'; @@ -55,6 +60,8 @@ import { CaptureShieldSubscriptionRequestParams } from '../shield/metrics/types' import { EntryModalSourceEnum } from '../../../shared/constants/subscriptions'; import { DefaultSubscriptionPaymentOptions } from '../../../shared/types'; import { getLatestSubscriptionStatus } from '../../../shared/modules/shield'; +import { openWindow } from '../../helpers/utils/window'; +import { SUPPORT_LINK } from '../../../shared/lib/ui-utils'; import { MetaMetricsEventName } from '../../../shared/constants/metametrics'; import { TokenWithApprovalAmount, @@ -183,18 +190,48 @@ export const useCancelSubscription = (subscription?: Subscription) => { }, [dispatch, subscription, captureShieldMembershipCancelledEvent]); }; -export const useUnCancelSubscription = ({ - subscriptionId, -}: { - subscriptionId?: string; -}) => { +export const useUnCancelSubscription = (subscription?: Subscription) => { const dispatch = useDispatch(); + const { captureShieldSubscriptionRestartRequestEvent } = + useSubscriptionMetrics(); + + const trackSubscriptionUncancelRequestEvent = useCallback( + (status: 'completed' | 'failed', errorMessage?: string) => { + if (!subscription) { + return; + } + const { cryptoPaymentChain, cryptoPaymentCurrency } = + getSubscriptionPaymentData(subscription); + + // capture the event when the subscription restart request is triggered + captureShieldSubscriptionRestartRequestEvent({ + subscriptionStatus: subscription.status, + paymentType: subscription.paymentMethod.type, + billingInterval: subscription.interval, + cryptoPaymentChain, + cryptoPaymentCurrency, + requestStatus: status, + errorMessage, + }); + }, + [captureShieldSubscriptionRestartRequestEvent, subscription], + ); + return useAsyncCallback(async () => { - if (!subscriptionId) { - return; + try { + const subscriptionId = subscription?.id; + if (!subscriptionId) { + return; + } + await dispatch(unCancelSubscription({ subscriptionId })); + trackSubscriptionUncancelRequestEvent('completed'); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + trackSubscriptionUncancelRequestEvent('failed', errorMessage); + throw error; } - await dispatch(unCancelSubscription({ subscriptionId })); - }, [dispatch, subscriptionId]); + }, [dispatch, subscription, trackSubscriptionUncancelRequestEvent]); }; export const useOpenGetSubscriptionBillingPortal = ( @@ -294,6 +331,19 @@ export const useSubscriptionCryptoApprovalTransaction = ( networkConfiguration.defaultRpcEndpointIndex ?? 0 ]?.networkClientId; + const hasPendingApprovals = + useSelector(getUnapprovedConfirmations).length > 0; + const [shieldTransactionDispatched, setShieldTransactionDispatched] = + useState(false); + + useEffect(() => { + // navigate to confirmation page if there are pending approvals and shield transaction is dispatched + // need to handle here instead of right after `await addTransaction` because approval is not created right after `addTransaction` completed + if (hasPendingApprovals && shieldTransactionDispatched) { + navigate(CONFIRM_TRANSACTION_ROUTE); + } + }, [hasPendingApprovals, shieldTransactionDispatched, navigate]); + const handler = useCallback(async () => { if (!subscriptionPricing) { throw new Error('Subscription pricing not found'); @@ -324,9 +374,9 @@ export const useSubscriptionCryptoApprovalTransaction = ( networkClientId: networkClientId as string, }; await addTransaction(transactionParams, transactionOptions); - navigate(CONFIRM_TRANSACTION_ROUTE); + setShieldTransactionDispatched(true); }, [ - navigate, + setShieldTransactionDispatched, subscriptionPricing, evmInternalAccount, selectedToken, @@ -546,3 +596,37 @@ export const useHandleSubscription = ({ subscriptionResult, }; }; + +export const useHandleSubscriptionSupportAction = () => { + const version = process.env.METAMASK_VERSION as string; + const sessionData = useSelector(selectSessionData); + const profileId = sessionData?.profile?.profileId; + const metaMetricsId = useSelector(getMetaMetricsId); + const { customerId: shieldCustomerId } = useUserSubscriptions(); + + const handleClickContactSupport = useCallback(() => { + let supportLinkWithUserId = SUPPORT_LINK as string; + const queryParams = new URLSearchParams(); + queryParams.append('metamask_version', version); + if (profileId) { + queryParams.append('metamask_profile_id', profileId); + } + if (metaMetricsId) { + queryParams.append('metamask_metametrics_id', metaMetricsId); + } + if (shieldCustomerId) { + queryParams.append('shield_id', shieldCustomerId); + } + + const queryString = queryParams.toString(); + if (queryString) { + supportLinkWithUserId += `?${queryString}`; + } + + openWindow(supportLinkWithUserId); + }, [version, profileId, metaMetricsId, shieldCustomerId]); + + return { + handleClickContactSupport, + }; +}; diff --git a/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/billing-details.tsx b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/billing-details.tsx index 5392037e4acf..ecae5b277a5a 100644 --- a/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/billing-details.tsx +++ b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/billing-details.tsx @@ -7,6 +7,7 @@ import { import { ConfirmInfoSection } from '../../../../../../components/app/confirm/info/row/section'; import { useI18nContext } from '../../../../../../hooks/useI18nContext'; import { getShortDateFormatterV2 } from '../../../../../asset/util'; +import { TextColor } from '../../../../../../helpers/constants/design-system'; const BillingDetails = ({ productPrice, @@ -32,7 +33,7 @@ const BillingDetails = ({ { + this.props.navigate(TRANSACTION_SHIELD_ROUTE); + }); + } else { + this.setState({ showShieldEntryModal: true }); + } + } } componentDidUpdate() { diff --git a/ui/pages/settings/settings.container.js b/ui/pages/settings/settings.container.js index 00290ec69960..9a9ef5ac826c 100644 --- a/ui/pages/settings/settings.container.js +++ b/ui/pages/settings/settings.container.js @@ -50,6 +50,7 @@ import { decodeSnapIdFromPathname } from '../../helpers/utils/snaps'; import { getIsSeedlessPasswordOutdated } from '../../ducks/metamask/metamask'; import { getIsMetaMaskShieldFeatureEnabled } from '../../../shared/modules/environment'; import { getHasSubscribedToShield } from '../../selectors/subscription/subscription'; +import { SHIELD_QUERY_PARAMS } from '../../../shared/lib/deep-links/routes/shield'; import Settings from './settings.component'; const ROUTES_TO_I18N_KEYS = { @@ -77,7 +78,7 @@ const ROUTES_TO_I18N_KEYS = { const mapStateToProps = (state, ownProps) => { const { location } = ownProps; - const { pathname } = location; + const { pathname, search } = location; const { ticker } = getProviderConfig(state); const { metamask: { currencyRates, socialLoginEmail }, @@ -86,6 +87,11 @@ const mapStateToProps = (state, ownProps) => { const snapsMetadata = getSnapsMetadata(state); const conversionDate = currencyRates[ticker]?.conversionDate; + const searchParams = new URLSearchParams(search); + // param to check and show shield entry modal at start + const shouldShowShieldEntryModal = + searchParams.get(SHIELD_QUERY_PARAMS.showShieldEntryModal) === 'true'; + const pathNameTail = pathname.match(/[^/]+$/u)?.[0] || ''; const isAddressEntryPage = pathNameTail.includes('0x'); const isAddContactPage = Boolean(pathname.match(CONTACT_ADD_ROUTE)); @@ -188,6 +194,7 @@ const mapStateToProps = (state, ownProps) => { mostRecentOverviewPage: getMostRecentOverviewPage(state), pathnameI18nKey, settingsPageSnaps, + shouldShowShieldEntryModal, snapSettingsTitle, useExternalServices, }; diff --git a/ui/pages/settings/transaction-shield-tab/transaction-shield.tsx b/ui/pages/settings/transaction-shield-tab/transaction-shield.tsx index c8c3fbb57069..70910a40ff0c 100644 --- a/ui/pages/settings/transaction-shield-tab/transaction-shield.tsx +++ b/ui/pages/settings/transaction-shield-tab/transaction-shield.tsx @@ -42,6 +42,7 @@ import { Skeleton } from '../../../components/component-library/skeleton'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { useCancelSubscription, + useHandleSubscriptionSupportAction, useOpenGetSubscriptionBillingPortal, useSubscriptionCryptoApprovalTransaction, useUnCancelSubscription, @@ -88,7 +89,7 @@ import { import { ThemeType } from '../../../../shared/constants/preferences'; import { getTheme } from '../../../selectors'; import CancelMembershipModal from './cancel-membership-modal'; -import { isCryptoPaymentMethod } from './types'; +import { isCardPaymentMethod, isCryptoPaymentMethod } from './types'; const TransactionShield = () => { const t = useI18nContext(); @@ -198,9 +199,7 @@ const TransactionShield = () => { useCancelSubscription(currentShieldSubscription); const [executeUnCancelSubscription, unCancelSubscriptionResult] = - useUnCancelSubscription({ - subscriptionId: currentShieldSubscription?.id, - }); + useUnCancelSubscription(currentShieldSubscription); const [ executeOpenGetSubscriptionBillingPortal, @@ -351,22 +350,6 @@ const TransactionShield = () => { ); }; - const isInsufficientFundsCrypto = - currentShieldSubscription && - isCryptoPaymentMethod(currentShieldSubscription.paymentMethod) && - currentShieldSubscription.paymentMethod.crypto.error === - 'insufficient_balance'; - const isAllowanceNeededCrypto = - currentShieldSubscription && - isCryptoPaymentMethod(currentShieldSubscription.paymentMethod) && - (currentShieldSubscription.paymentMethod.crypto.error === - CRYPTO_PAYMENT_METHOD_ERRORS.INSUFFICIENT_ALLOWANCE || - currentShieldSubscription?.paymentMethod.crypto.error === - CRYPTO_PAYMENT_METHOD_ERRORS.APPROVAL_TRANSACTION_TOO_OLD || - currentShieldSubscription?.paymentMethod.crypto.error === - CRYPTO_PAYMENT_METHOD_ERRORS.APPROVAL_TRANSACTION_REVERTED || - currentShieldSubscription?.paymentMethod.crypto.error === - CRYPTO_PAYMENT_METHOD_ERRORS.APPROVAL_TRANSACTION_MAX_VERIFICATION_ATTEMPTS_REACHED); const { value: subscriptionCryptoApprovalAmount } = useAsyncResult(async () => { @@ -420,6 +403,33 @@ const TransactionShield = () => { const { execute: executeSubscriptionCryptoApprovalTransaction } = useSubscriptionCryptoApprovalTransaction(paymentToken); + const isCardPayment = + currentShieldSubscription && + isCardPaymentMethod(currentShieldSubscription.paymentMethod); + const isUnexpectedErrorCryptoPayment = + currentShieldSubscription && + isPaused && + isCryptoPaymentMethod(currentShieldSubscription.paymentMethod) && + !currentShieldSubscription.paymentMethod.crypto.error; + const isInsufficientFundsCrypto = + currentShieldSubscription && + isCryptoPaymentMethod(currentShieldSubscription.paymentMethod) && + currentShieldSubscription.paymentMethod.crypto.error === + CRYPTO_PAYMENT_METHOD_ERRORS.INSUFFICIENT_BALANCE; + const isAllowanceNeededCrypto = + currentShieldSubscription && + isCryptoPaymentMethod(currentShieldSubscription.paymentMethod) && + (currentShieldSubscription.paymentMethod.crypto.error === + CRYPTO_PAYMENT_METHOD_ERRORS.INSUFFICIENT_ALLOWANCE || + currentShieldSubscription?.paymentMethod.crypto.error === + CRYPTO_PAYMENT_METHOD_ERRORS.APPROVAL_TRANSACTION_TOO_OLD || + currentShieldSubscription?.paymentMethod.crypto.error === + CRYPTO_PAYMENT_METHOD_ERRORS.APPROVAL_TRANSACTION_REVERTED || + currentShieldSubscription?.paymentMethod.crypto.error === + CRYPTO_PAYMENT_METHOD_ERRORS.APPROVAL_TRANSACTION_MAX_VERIFICATION_ATTEMPTS_REACHED); + + const { handleClickContactSupport } = useHandleSubscriptionSupportAction(); + const handlePaymentError = useCallback(async () => { if (currentShieldSubscription) { // capture error state clicked event @@ -437,6 +447,9 @@ const TransactionShield = () => { if (isCancelled) { // go to shield plan page to renew subscription for cancelled subscription navigate(SHIELD_PLAN_ROUTE); + } else if (isUnexpectedErrorCryptoPayment) { + // handle support action + handleClickContactSupport(); } else if ( currentShieldSubscription && isCryptoPaymentMethod(currentShieldSubscription.paymentMethod) @@ -458,6 +471,8 @@ const TransactionShield = () => { await executeUpdateSubscriptionCardPaymentMethod(); } }, [ + handleClickContactSupport, + isUnexpectedErrorCryptoPayment, isCancelled, navigate, currentShieldSubscription, @@ -470,22 +485,32 @@ const TransactionShield = () => { ]); const membershipErrorBanner = useMemo(() => { + // This is the number of hours it might takes for the payment to be updated + const PAYMENT_UPDATE_HOURS = 24; if (isPaused) { + // default text to unexpected error case + let descriptionText = 'shieldTxMembershipErrorPausedUnexpected'; + let actionButtonLabel = 'shieldTxMembershipErrorPausedUnexpectedAction'; + if (isCryptoPayment) { + descriptionText = + 'shieldTxMembershipErrorPausedCryptoInsufficientFunds'; + actionButtonLabel = + 'shieldTxMembershipErrorPausedCryptoInsufficientFundsAction'; + } else if (isCardPayment) { + descriptionText = 'shieldTxMembershipErrorPausedCard'; + actionButtonLabel = 'shieldTxMembershipErrorPausedCardAction'; + } return ( ); } - if (isSubscriptionEndingSoon && currentShieldSubscription) { + if (currentShieldSubscription && isSubscriptionEndingSoon) { return ( { ])} severity={BannerAlertSeverity.Warning} marginBottom={4} - actionButtonLabel={ - isAllowanceNeededCrypto - ? t('shieldTxMembershipRenew') - : t('shieldTxMembershipErrorAddFunds') - } + actionButtonLabel={t('shieldTxMembershipRenew')} actionButtonOnClick={handlePaymentError} /> ); @@ -511,9 +532,8 @@ const TransactionShield = () => { isSubscriptionEndingSoon, currentShieldSubscription, t, + isCardPayment, isCryptoPayment, - isInsufficientFundsCrypto, - isAllowanceNeededCrypto, handlePaymentError, ]); @@ -521,16 +541,20 @@ const TransactionShield = () => { if (!displayedShieldSubscription) { return ''; } - if (isPaused) { + if (isPaused && !isUnexpectedErrorCryptoPayment) { + let tooltipText = ''; + let buttonText = ''; + if (isCryptoPayment) { + tooltipText = 'shieldTxMembershipErrorPausedCryptoTooltip'; + buttonText = 'shieldTxMembershipErrorInsufficientToken'; + } else { + // card payment error case + tooltipText = 'shieldTxMembershipErrorPausedCardTooltip'; + buttonText = 'shieldTxMembershipErrorUpdateCard'; + } + return ( - + { onClick={handlePaymentError} danger > - {t( - isCryptoPayment - ? 'shieldTxMembershipErrorInsufficientToken' - : 'shieldTxMembershipErrorUpdateCard', - [ - isCryptoPaymentMethod( - displayedShieldSubscription?.paymentMethod, - ) - ? displayedShieldSubscription.paymentMethod.crypto.tokenSymbol - : '', - ], - )} + {t(buttonText, [ + isCryptoPaymentMethod(displayedShieldSubscription?.paymentMethod) + ? displayedShieldSubscription.paymentMethod.crypto.tokenSymbol + : '', + ])} ); @@ -599,6 +616,7 @@ const TransactionShield = () => { return `${displayedShieldSubscription.paymentMethod.card.brand.charAt(0).toUpperCase() + displayedShieldSubscription.paymentMethod.card.brand.slice(1)} - ${displayedShieldSubscription.paymentMethod.card.last4}`; // display card info for card payment method; }, [ isPaused, + isUnexpectedErrorCryptoPayment, displayedShieldSubscription, isCryptoPayment, isSubscriptionEndingSoon, From 257a63dc4f426b4dc417d97e263e5f7e06a27597 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 14 Nov 2025 14:41:57 +0100 Subject: [PATCH 009/154] fix: add specific error message for duplicate SRP imports (#37743) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/37743?quickstart=1) ## **Changelog** CHANGELOG entry: fix error message when trying to import an SRP with an account that is already imported via private key ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MUL-491 ## **Manual testing steps** 1. Import private key account 2. Import SRP that includes this private key account 3. Verify that the error message "The account you are trying to import is a duplicate." is shown ## **Screenshots/Recordings** ### **Before** ### **After** image ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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. --- > [!NOTE] > Show a specific error when an imported SRP contains a duplicate account, with new i18n strings and tests validating behavior. > > - **Import SRP UI (`ui/pages/multi-srp/import-srp/import-srp.tsx`)**: > - Handle duplicate-account import errors explicitly by mapping controller error to `t('srpImportDuplicateAccountError')`; fall back to `srpAlreadyImportedError`. > - **i18n (`app/_locales/en/messages.json`, `app/_locales/en_GB/messages.json`)**: > - Add `srpImportDuplicateAccountError` message: "The account you are trying to import is a duplicate." > - **Tests (`ui/pages/multi-srp/import-srp/import-srp.test.tsx`)**: > - Add error-handling tests covering duplicate-account vs generic import errors; ensure button disables, no navigation, and no toast on error. > - Minor import updates to support test setup. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 7adca3241972e7b8bc5fe89bd6a9bdd28d6b0ce5. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/_locales/en/messages.json | 3 + app/_locales/en_GB/messages.json | 3 + .../multi-srp/import-srp/import-srp.test.tsx | 71 +++++++++++++++++-- ui/pages/multi-srp/import-srp/import-srp.tsx | 9 ++- 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 52ceb7e8a55f..791ebdc90a7c 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -6899,6 +6899,9 @@ "srpDetailsTitle": { "message": "What’s a Secret Recovery Phrase?" }, + "srpImportDuplicateAccountError": { + "message": "The account you are trying to import is a duplicate." + }, "srpInputNumberOfWords": { "message": "I have a $1-word phrase", "description": "This is the text for each option in the dropdown where a user selects how many words their secret recovery phrase has during import. The $1 is the number of words (either 12, 15, 18, 21, or 24)." diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index 52ceb7e8a55f..791ebdc90a7c 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -6899,6 +6899,9 @@ "srpDetailsTitle": { "message": "What’s a Secret Recovery Phrase?" }, + "srpImportDuplicateAccountError": { + "message": "The account you are trying to import is a duplicate." + }, "srpInputNumberOfWords": { "message": "I have a $1-word phrase", "description": "This is the text for each option in the dropdown where a user selects how many words their secret recovery phrase has during import. The $1 is the number of words (either 12, 15, 18, 21, or 24)." diff --git a/ui/pages/multi-srp/import-srp/import-srp.test.tsx b/ui/pages/multi-srp/import-srp/import-srp.test.tsx index cfa579a24a37..2eadb7e00e5e 100644 --- a/ui/pages/multi-srp/import-srp/import-srp.test.tsx +++ b/ui/pages/multi-srp/import-srp/import-srp.test.tsx @@ -1,13 +1,15 @@ import React from 'react'; import { fireEvent, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; -import { userEvent } from '@testing-library/user-event'; -import { renderWithProvider } from '../../../../test/lib/render-helpers-navigate'; +// eslint-disable-next-line import/no-restricted-paths +import messages from '../../../../app/_locales/en/messages.json'; import mockState from '../../../../test/data/mock-state.json'; -import { importMnemonicToVault } from '../../../store/actions'; -import { DEFAULT_ROUTE } from '../../../helpers/constants/routes'; +import { renderWithProvider } from '../../../../test/lib/render-helpers-navigate'; import { setShowNewSrpAddedToast } from '../../../components/app/toast-master/utils'; +import { DEFAULT_ROUTE } from '../../../helpers/constants/routes'; +import { importMnemonicToVault } from '../../../store/actions'; import { ImportSrp } from './import-srp'; jest.mock('../../../store/actions', () => ({ @@ -95,4 +97,65 @@ describe('ImportSrp', () => { expect(setShowNewSrpAddedToast).toHaveBeenCalledWith(true); }); }); + + describe('error handling', () => { + const testImportError = async ( + errorMessage: string, + expectedErrorMessage: string, + ) => { + const mockStore = configureMockStore([thunk])(mockState); + + // Mock importMnemonicToVault to reject with the specified error + (importMnemonicToVault as jest.Mock).mockReturnValueOnce( + jest.fn().mockRejectedValue(new Error(errorMessage)), + ); + + const { queryByTestId, getByText } = renderWithProvider( + , + mockStore, + ); + + const srpNote = queryByTestId('srp-input-import__srp-note'); + expect(srpNote).toBeInTheDocument(); + + srpNote?.focus(); + + if (srpNote) { + await userEvent.type(srpNote, TEST_SEED); + } + + const confirmSrpButton = queryByTestId('import-srp-confirm'); + expect(confirmSrpButton).not.toBeDisabled(); + + if (confirmSrpButton) { + fireEvent.click(confirmSrpButton); + } + + // Wait for error to be displayed + await waitFor(() => { + expect(getByText(expectedErrorMessage)).toBeInTheDocument(); + }); + + // Verify the button is now disabled due to error + expect(confirmSrpButton).toBeDisabled(); + + // Verify navigation did not happen + expect(mockNavigate).not.toHaveBeenCalledWith(DEFAULT_ROUTE); + expect(setShowNewSrpAddedToast).not.toHaveBeenCalled(); + }; + + it('should display duplicate account error when trying to import a duplicate account', async () => { + await testImportError( + 'KeyringController - The account you are trying to import is a duplicate', + messages.srpImportDuplicateAccountError.message, + ); + }); + + it('should display already imported error for any other import error', async () => { + await testImportError( + 'Some other error', + messages.srpAlreadyImportedError.message, + ); + }); + }); }); diff --git a/ui/pages/multi-srp/import-srp/import-srp.tsx b/ui/pages/multi-srp/import-srp/import-srp.tsx index 004b95ea60e3..a260344a97fb 100644 --- a/ui/pages/multi-srp/import-srp/import-srp.tsx +++ b/ui/pages/multi-srp/import-srp/import-srp.tsx @@ -81,7 +81,14 @@ export const ImportSrp = () => { navigate(DEFAULT_ROUTE); dispatch(setShowNewSrpAddedToast(true)); } catch (error) { - setSrpError(t('srpAlreadyImportedError')); + switch ((error as Error)?.message) { + case 'KeyringController - The account you are trying to import is a duplicate': + setSrpError(t('srpImportDuplicateAccountError')); + break; + default: + setSrpError(t('srpAlreadyImportedError')); + break; + } } } From a48fbf7691e00c2837e752d42a252c0f50da98e0 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Fri, 14 Nov 2025 10:43:10 -0330 Subject: [PATCH 010/154] test: Remove feature flags from onboarding fixture (#37857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Feature flags were accidentally added to the onboarding fixture recently. This wasn't a problem for E2E tests because we refresh feature flags as part of initialization (which itself was a bug), but when we tried to fix this in #37552 it revealed this fixture problem. The onboarding fixture has been updated to not include any feature flags. Individual E2E tests can set the features they need using a manifest override or a feature flag API mock. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/37857?quickstart=1) ## **Changelog** CHANGELOG entry: null ## **Related issues** Unblocks #37552 ## **Manual testing steps** N/A ## **Screenshots/Recordings** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/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. --- > [!NOTE] > Strips `RemoteFeatureFlagController` data from `test/e2e/fixtures/onboarding-fixture.json`, setting `cacheTimestamp` to `0` and `remoteFeatureFlags` to `{}`. > > - **E2E Fixture** (`test/e2e/fixtures/onboarding-fixture.json`): > - Remove all `RemoteFeatureFlagController.remoteFeatureFlags` content. > - Set `RemoteFeatureFlagController.cacheTimestamp` to `0`. > - Keep other onboarding defaults unchanged. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f70ce8188b559b28449b77a208f0ebd29627de79. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- test/e2e/fixtures/onboarding-fixture.json | 1323 +-------------------- 1 file changed, 2 insertions(+), 1321 deletions(-) diff --git a/test/e2e/fixtures/onboarding-fixture.json b/test/e2e/fixtures/onboarding-fixture.json index 7e2e6dbfbd95..2829b570656e 100644 --- a/test/e2e/fixtures/onboarding-fixture.json +++ b/test/e2e/fixtures/onboarding-fixture.json @@ -1638,1327 +1638,8 @@ "watchEthereumAccountEnabled": false }, "RemoteFeatureFlagController": { - "cacheTimestamp": 1760509394187, - "remoteFeatureFlags": { - "addBitcoinAccount": false, - "addBitcoinAccountDummyFlag": false, - "addSolanaAccount": true, - "addTronAccount": false, - "assetsAccountApiBalances": [], - "assetsDefiPositionsEnabled": true, - "assetsEnableNotificationsByDefault": true, - "assetsEnableNotificationsByDefaultV2": { - "name": "feature is ON", - "value": true - }, - "bitcoinAccounts": { - "enabled": true, - "minimumVersion": "13.7.0" - }, - "bitcoinTestnetsEnabled": false, - "bridgeConfig": { - "bip44DefaultPairs": { - "bip122": { - "other": {}, - "standard": { - "bip122:000000000019d6689c085ae165831e93/slip44:0": "eip155:1/slip44:60" - } - }, - "eip155": { - "other": {}, - "standard": { - "eip155:1/slip44:60": "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" - } - }, - "solana": { - "other": {}, - "standard": { - "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" - } - } - }, - "chains": { - "1": { - "isActiveDest": true, - "isActiveSrc": true, - "isGaslessSwapEnabled": true, - "isSingleSwapBridgeButtonEnabled": true, - "noFeeAssets": ["0xaca92e438df0b2401ff60da7e4337b687a2435da"], - "stablecoins": [ - "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", - "0xdac17f958d2ee523a2206206994597c13d831ec7" - ], - "topAssets": ["0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"] - }, - "10": { - "isActiveDest": true, - "isActiveSrc": true, - "isSingleSwapBridgeButtonEnabled": true, - "stablecoins": [ - "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", - "0x7F5c764cBc14f9669B88837ca1490cCa17c31607", - "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58" - ] - }, - "56": { - "isActiveDest": true, - "isActiveSrc": true, - "isGaslessSwapEnabled": true, - "isSingleSwapBridgeButtonEnabled": true, - "stablecoins": [ - "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", - "0x55d398326f99059ff775485246999027b3197955" - ] - }, - "137": { - "isActiveDest": true, - "isActiveSrc": true, - "isSingleSwapBridgeButtonEnabled": true, - "stablecoins": [ - "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", - "0x2791bca1f2de4661ed88a30c99a7a9449aa84174", - "0xc2132d05d31c914a87c6611c10748aeb04b58e8f" - ] - }, - "324": { - "isActiveDest": true, - "isActiveSrc": true, - "isSingleSwapBridgeButtonEnabled": true, - "stablecoins": [ - "0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4", - "0x3355df6D4c9C3035724Fd0e3914dE96A5a83aaf4", - "0x493257fD37EDB34451f62EDf8D2a0C418852bA4C" - ] - }, - "1329": { - "isActiveDest": true, - "isActiveSrc": true, - "isSingleSwapBridgeButtonEnabled": true, - "stablecoins": ["0xe15fC38F6D8c56aF07bbCBe3BAf5708A2Bf42392"] - }, - "8453": { - "isActiveDest": true, - "isActiveSrc": true, - "isGaslessSwapEnabled": true, - "isSingleSwapBridgeButtonEnabled": true, - "stablecoins": ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"] - }, - "42161": { - "isActiveDest": true, - "isActiveSrc": true, - "isSingleSwapBridgeButtonEnabled": true, - "stablecoins": [ - "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", - "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8", - "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9" - ] - }, - "43114": { - "isActiveDest": true, - "isActiveSrc": true, - "isSingleSwapBridgeButtonEnabled": true, - "stablecoins": [ - "0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e", - "0xa7d7079b0fead91f3e65f86e8915cb59c1a4c664", - "0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7", - "0xc7198437980c041c805a1edcba50c1ce5db95118" - ] - }, - "59144": { - "isActiveDest": true, - "isActiveSrc": true, - "isGaslessSwapEnabled": true, - "isSingleSwapBridgeButtonEnabled": true, - "noFeeAssets": ["0xaca92e438df0b2401ff60da7e4337b687a2435da"], - "stablecoins": [ - "0x176211869cA2b568f2A7D4EE941E073a821EE1ff", - "0xA219439258ca9da29E9Cc4cE5596924745e12B93" - ], - "topAssets": ["0x176211869ca2b568f2a7d4ee941e073a821ee1ff"] - }, - "1151111081099710": { - "isActiveDest": true, - "isActiveSrc": true, - "isSingleSwapBridgeButtonEnabled": true, - "isSnapConfirmationEnabled": true, - "refreshRate": 10000, - "topAssets": [ - "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", - "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN", - "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxsDx8F8k8k3uYw1PDC", - "3iQL8BFS2vE7mww4ehAqQHAsbmRNCrPxizWAT2Zfyr9y", - "9zNQRsGLjNKwCUU5Gq5LR8beUCPzQMVMqKAi3SSZh54u", - "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263", - "rndrizKT3MK1iimdxRdWabcF7Zg7AR5T4nud4EkHBof", - "21AErpiB8uSb94oQKRcwuHqyHF93njAxBSbdUrpupump", - "pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn" - ] - } - }, - "maxRefreshCount": 5, - "minimumVersion": "0.0.0", - "priceImpactThreshold": { - "gasless": 0.2, - "normal": 0.05 - }, - "refreshRate": 30000, - "sseEnabled": true, - "support": true - }, - "carouselBanners": true, - "confirmations_eip_7702": { - "contracts": { - "0x1": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "Mainnet", - "signature": "0xffb37facfedf12f1e98b56203de1c855391b791a20ee361234c546f4b50eb11853283cfc311419049f0325ad0a806ec232cc519073e3b5d4ad59ff331964d2e71b" - } - ], - "0x13882": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "Polygon Amoy Testnet", - "signature": "0x472bb78ebb6686ddf0bb2e75265e1f4266cd050f8b498e88f97e9380afd8bfbd169c4d3221ec8845cb81ba7e9ddb7de9b819a15617803e20aee2aaa07664b6c81b" - } - ], - "0x138c5": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "Berachain Testnet", - "signature": "0x66940bcb2c4b95ec2c1c1024fee1e3a8e51c8f072a52a9f0252a793604c8a6ba58ac3153d4dd041873d33eec349450c4a9acd51ddaed117bee448ed7a388208c1b" - } - ], - "0x138de": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "Berachain", - "signature": "0x2c2037ddedcdfb9b7d8ea7c546259eef371a86b0e3610192eb15ece0114c59d86134791cd9e9df4208bbbdc83776d80b30b1fea6bf1a05bb072575217492497a1b" - } - ], - "0x13fb": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "Citrea Testnet", - "signature": "0xf9e4aa35fc098468212352c2b9662022f9565bd713ca66e634c804f9820b5e0c266d710afba58aed00e5b7e24134dd9b52e2e331076de745137531a6d245a7521b" - } - ], - "0x14a34": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "Base Sepolia", - "signature": "0xaed94ac035e745629423c547200eb2411fd7194d832a6b4cf459d3e3d34a6b62124e88640a0bf623146bdef63b0ce1c8797bd2a6c8357fab86c8be466744f55d1c" - } - ], - "0x18c6": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "MegaEth Testnet", - "signature": "0x6743135a8dfc8f58133d827b4997bc5316c8eb92883d2704a30b1d8a7bf494ce226b523e5f85a681eb5de8349c9564e62d389876d0e5fe5cc06fb9412d9d1cb61b" - } - ], - "0x2105": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "Base", - "signature": "0xbdddd2e925cf2cc7e148d3c11b02c917995fba8f3a3dc0b73c0059d029feca88014e723b8a32b2310a60c5b1cc17dfb3ae180b5a39f1d3264f985314b9168e0a1c" - } - ], - "0x27d8": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "Chiado", - "signature": "0x0ff531d6afcc191c3b3bdffc1596d9ce8d1d52fa500ea2097c0823820a66f97963b88b646d4d4edbc0f781127d7985b87132d89c62c3cb4ad42848ce289645fa1b" - } - ], - "0x38": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "BNB", - "signature": "0x28ae371904b3ba71344e426c8de0e2cee0b8529a9510c059b412671655881ad646b8cf544342a5f8e0753eda83221e14e3c9dae5435417401f5fee8ee1d63dce1b" - } - ], - "0x515": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "Unichain Sepolia", - "signature": "0x64487330691a05700a2321ee1db4092adce9590e7aded6e489df024838ecec734c935d182f74883818cb7659d5c784163573afdf8221252fa68d960cbe1c312f1b" - } - ], - "0x531": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "Sei", - "signature": "0xde089fc9af662bc4b0f873e4dc79760f6c3539f6f1cf32d9bc46baccf86ebae070a9062436f29ee86d04cc55699b27579f657922a2292ec2f1c5170d587917401b" - } - ], - "0x61": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "BNB Testnet", - "signature": "0x80aaf42c70b0b9efdf26e38ced69fce70f6b4f5496e7e59888819c14fb16290301ad049299d99e3650fa1a616a87bb80eb52ae9f02ddd8b53dd6b983275d0eb61b" - } - ], - "0x64": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "Gnosis", - "signature": "0xd0cfc2959c866e5218faf675f852e0c7021a454064e509d40256c5bec395e300381c19dcbec2e921b2f6d7d9a925a39dee8ea2e8dd8f595633b8dc333d91f1af1b" - } - ], - "0x66eee": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "Arbitrum Sepolia", - "signature": "0x6fdb53ecf8f575b85ff9895277b1f8e11349970fbb42225fe41587a072bbcef43e8d54303c4e1aa38d44cae9ba2c8bf825e9e138176d6b09a729cd82a14356cf1b" - } - ], - "0x82": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "Unichain Mainnet", - "signature": "0x54c423b1af4abbd1fb226e260dddba757acbcd8881e6b55b842c6b839874fa3f0e2f77685389ad5c28e096f12ef22557cebf6a77f6064baa071453a445a4c7d51c" - } - ], - "0x89": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "Polygon", - "signature": "0x302aa2d59940e88f35d2fa140fe6a1e9dc682218a444a7fb2d88f007fbe7792b2b8d615f5ae1e4f184533a02c47d8ac0f6ba3f591679295dff93c65095c0f03d1b" - } - ], - "0xa": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "Optimism", - "signature": "0x60e12ffc04e098bd26a897ed2a974e4e255fc6db3b052fe3a2647372bfbac76f096bf5236510ddc217e12b802e08617cc27292d69ca51b0467ba91c6df74cd7b1c" - } - ], - "0xa4b1": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "Arbitrum One", - "signature": "0xc3be82057efec197d92b0cbb7cef9d50dba0345646524687a3ae7235a8fcb1706ba79f197d45fcf4c6cfb5808ef70258c5f6bb29b7e3553a4b9660692eb5e81d1b" - } - ], - "0xa4ba": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "Arbitrum Nova", - "signature": "0x818898e7f90f2f1f47dc7bec74dd683dfcc11efc7025d81f57644d366a3d9e442edb789731045ccb5ba89ee0d84bb517194bb9a097b152922bbd39ffd022ff421c" - } - ], - "0xaa36a7": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "Sepolia - Official", - "signature": "0x1aba1c0dafadab6663efdd6086764a9b9fa5ab5c002e88ebae85edea162fbc425c398b2b93afdc036503f12361c05a7ff0b409ee523d5277e0b4d0a840679e591c" - }, - { - "address": "0xCd8D6C5554e209Fbb0deC797C6293cf7eAE13454", - "name": "Sepolia - Testing", - "signature": "0x016cf109489c415ba28e695eb3cb06ac46689c5c49e2aba101d7ec2f68c890282563b324f5c8df5e0536994451825aa235438b7346e8c18b4e64161d990781891c" - } - ], - "0xaa37dc": [ - { - "address": "0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B", - "name": "Optimism Sepolia", - "signature": "0xa60cab833af6a8aa2dcc80d5e12d9e1566edb6cdf51c38e7cf43d441dac561007f05643e73e6b00107e18dbf15de98aae14192306276e92d654f62bd7c3023241c" - } - ] - }, - "dev": true, - "supportedChains": [ - "0x1", - "0x13882", - "0x138c5", - "0x138de", - "0x13fb", - "0x14a34", - "0x18c6", - "0x2105", - "0x27d8", - "0x38", - "0x515", - "0x531", - "0x61", - "0x64", - "0x66eee", - "0x82", - "0x89", - "0xa", - "0xa4b1", - "0xa4ba", - "0xaa36a7", - "0xaa37dc" - ] - }, - "confirmations_gas_buffer": { - "default": 1, - "dev": true, - "included": 1.5, - "perChainConfig": { - "0x18c6": { - "base": 1.3, - "name": "megaeth" - }, - "0x2105": { - "eip7702": 1.3, - "name": "base" - }, - "0x38": { - "eip7702": 1.3, - "name": "bnb" - }, - "0xa": { - "eip7702": 1.3, - "name": "optimism" - }, - "0xa4b1": { - "base": 1.2, - "name": "arbitrum" - } - } - }, - "confirmations_incoming_transactions": { - "pollingIntervalMs": 60000 - }, - "confirmations_transactions": { - "acceleratedPolling": { - "defaultCountMax": 10, - "defaultIntervalMs": 3000, - "perChainConfig": { - "0x1": { - "blockTime": 12000, - "chainId": "1", - "countMax": 10, - "intervalMs": 3000, - "name": "ETHEREUM" - }, - "0x1042": { - "blockTime": 250, - "chainId": "4162", - "countMax": 15, - "intervalMs": 500, - "name": "SX_ROLLUP" - }, - "0x1142c": { - "blockTime": 250, - "chainId": "70700", - "countMax": 15, - "intervalMs": 500, - "name": "PROOF_OF_PLAY_APEX" - }, - "0x1142d": { - "blockTime": 250, - "chainId": "70701", - "countMax": 15, - "intervalMs": 500, - "name": "PROOF_OF_PLAY_BOSS" - }, - "0x11c3": { - "blockTime": 250, - "chainId": "4547", - "countMax": 15, - "intervalMs": 500, - "name": "TRUMPCHAIN" - }, - "0x123": { - "blockTime": 2000, - "chainId": "291", - "countMax": 10, - "intervalMs": 1300, - "name": "ORDERLY" - }, - "0x128ca": { - "blockTime": 250, - "chainId": "75978", - "countMax": 15, - "intervalMs": 500, - "name": "FUSION" - }, - "0x1331": { - "blockTime": 250, - "chainId": "4913", - "countMax": 15, - "intervalMs": 500, - "name": "API3" - }, - "0x134b3cf": { - "blockTime": 250, - "chainId": "20231119", - "countMax": 15, - "intervalMs": 500, - "name": "DERI" - }, - "0x13881": { - "blockTime": 2000, - "chainId": "80001", - "countMax": 10, - "intervalMs": 1300, - "name": "POLYGON_MUMBAI" - }, - "0x13882": { - "blockTime": 2667, - "chainId": "80002", - "countMax": 10, - "intervalMs": 1800, - "name": "POLYGON_AMOY" - }, - "0x138de": { - "blockTime": 2333, - "chainId": "80094", - "countMax": 10, - "intervalMs": 1600, - "name": "BERACHAIN" - }, - "0x13a": { - "blockTime": 30000, - "chainId": "314", - "countMax": 10, - "intervalMs": 3000, - "name": "FILECOIN" - }, - "0x13a43": { - "blockTime": 250, - "chainId": "80451", - "countMax": 15, - "intervalMs": 500, - "name": "GEO_GENESIS" - }, - "0x13bf8": { - "blockTime": 250, - "chainId": "80888", - "countMax": 15, - "intervalMs": 500, - "name": "ONYX" - }, - "0x13c23": { - "blockTime": 250, - "chainId": "80931", - "countMax": 15, - "intervalMs": 500, - "name": "FORTA" - }, - "0x13e31": { - "blockTime": 2000, - "chainId": "81457", - "countMax": 10, - "intervalMs": 1300, - "name": "BLAST" - }, - "0x13f8": { - "blockTime": 2000, - "chainId": "5112", - "countMax": 10, - "intervalMs": 1300, - "name": "HAM" - }, - "0x1406f40": { - "blockTime": 250, - "chainId": "21000000", - "countMax": 15, - "intervalMs": 500, - "name": "CORN" - }, - "0x142b6": { - "blockTime": 250, - "chainId": "82614", - "countMax": 15, - "intervalMs": 500, - "name": "VEMP" - }, - "0x144": { - "blockTime": 1000, - "chainId": "324", - "countMax": 10, - "intervalMs": 700, - "name": "ZKSYNC" - }, - "0x14a34": { - "blockTime": 2000, - "chainId": "84532", - "countMax": 10, - "intervalMs": 1300, - "name": "BASE_SEPOLIA_TESTNET" - }, - "0x158e5": { - "blockTime": 250, - "chainId": "88293", - "countMax": 15, - "intervalMs": 500, - "name": "SEQCORE" - }, - "0x15a9": { - "blockTime": 250, - "chainId": "5545", - "countMax": 15, - "intervalMs": 500, - "name": "DUCK" - }, - "0x15b43": { - "blockTime": 250, - "chainId": "88899", - "countMax": 15, - "intervalMs": 500, - "name": "UNITE" - }, - "0x15eb": { - "blockTime": 1000, - "chainId": "5611", - "countMax": 10, - "intervalMs": 700, - "name": "OPBNB_TESTNET" - }, - "0x163e7": { - "blockTime": 250, - "chainId": "91111", - "countMax": 15, - "intervalMs": 500, - "name": "HENEZ" - }, - "0x16876": { - "blockTime": 250, - "chainId": "92278", - "countMax": 15, - "intervalMs": 500, - "name": "MIRACLE" - }, - "0x16fd8": { - "blockTime": 250, - "chainId": "94168", - "countMax": 15, - "intervalMs": 500, - "name": "LUMITERRA" - }, - "0x1713c": { - "blockTime": 250, - "chainId": "94524", - "countMax": 15, - "intervalMs": 500, - "name": "IDEX" - }, - "0x18230": { - "blockTime": 1667, - "chainId": "98864", - "countMax": 10, - "intervalMs": 1100, - "name": "PLUME_TESTNET" - }, - "0x18231": { - "blockTime": 25000, - "chainId": "98865", - "countMax": 10, - "intervalMs": 3000, - "name": "PLUME" - }, - "0x18c6": { - "blockTime": 1000, - "chainId": "6342", - "countMax": 10, - "intervalMs": 700, - "name": "MEGAETH_TESTNET" - }, - "0x19": { - "blockTime": 5667, - "chainId": "25", - "countMax": 10, - "intervalMs": 3000, - "name": "CRONOS" - }, - "0x1b254": { - "blockTime": 250, - "chainId": "111188", - "countMax": 15, - "intervalMs": 500, - "name": "REAL" - }, - "0x1b58": { - "blockTime": 6000, - "chainId": "7000", - "countMax": 10, - "intervalMs": 3000, - "name": "ZETACHAIN" - }, - "0x1b59": { - "blockTime": 5667, - "chainId": "7001", - "countMax": 10, - "intervalMs": 3000, - "name": "ZETACHAIN_TESTNET" - }, - "0x1ecf": { - "blockTime": 250, - "chainId": "7887", - "countMax": 15, - "intervalMs": 500, - "name": "KINTO" - }, - "0x2105": { - "blockTime": 2000, - "chainId": "8453", - "countMax": 10, - "intervalMs": 1300, - "name": "BASE" - }, - "0x2272": { - "blockTime": 250, - "chainId": "8818", - "countMax": 15, - "intervalMs": 500, - "name": "CLINK" - }, - "0x2780b": { - "blockTime": 250, - "chainId": "161803", - "countMax": 15, - "intervalMs": 500, - "name": "EVENTUM" - }, - "0x27bc86aa": { - "blockTime": 250, - "chainId": "666666666", - "countMax": 15, - "intervalMs": 500, - "name": "DEGEN_CHAIN" - }, - "0x28c58": { - "blockTime": 36000, - "chainId": "167000", - "countMax": 10, - "intervalMs": 3000, - "name": "TAIKO" - }, - "0x28c61": { - "blockTime": 76000, - "chainId": "167009", - "countMax": 10, - "intervalMs": 3000, - "name": "TAIKO_HEKLA" - }, - "0x2b2": { - "blockTime": 2000, - "chainId": "690", - "countMax": 10, - "intervalMs": 1300, - "name": "REDSTONE" - }, - "0x2f0": { - "blockTime": 250, - "chainId": "752", - "countMax": 15, - "intervalMs": 500, - "name": "RIVALZ" - }, - "0x3023": { - "blockTime": 250, - "chainId": "12323", - "countMax": 15, - "intervalMs": 500, - "name": "HUDDLE01" - }, - "0x316b8": { - "blockTime": 250, - "chainId": "202424", - "countMax": 15, - "intervalMs": 500, - "name": "BLOCKFIT" - }, - "0x343b": { - "blockTime": 2000, - "chainId": "13371", - "countMax": 10, - "intervalMs": 1300, - "name": "IMMUTABLE" - }, - "0x34a1": { - "blockTime": 2000, - "chainId": "13473", - "countMax": 10, - "intervalMs": 1300, - "name": "IMMUTABLE_TESTNET" - }, - "0x34fb5e38": { - "blockTime": 2000, - "chainId": "888888888", - "countMax": 10, - "intervalMs": 1300, - "name": "ANXIENT8" - }, - "0x38": { - "blockTime": 3000, - "chainId": "56", - "countMax": 10, - "intervalMs": 2000, - "name": "BNB" - }, - "0x3bd": { - "blockTime": 2000, - "chainId": "957", - "countMax": 10, - "intervalMs": 1300, - "name": "LYRA" - }, - "0x4268": { - "blockTime": 12000, - "chainId": "17000", - "countMax": 10, - "intervalMs": 3000, - "name": "ETHEREUM_HOLESKY" - }, - "0x42af": { - "blockTime": 250, - "chainId": "17071", - "countMax": 15, - "intervalMs": 500, - "name": "ONCHAIN_POINTS" - }, - "0x46f": { - "blockTime": 2000, - "chainId": "1135", - "countMax": 10, - "intervalMs": 1300, - "name": "LISK" - }, - "0x515": { - "blockTime": 2000, - "chainId": "1301", - "countMax": 10, - "intervalMs": 1300, - "name": "UNICHAIN_SEPOLIA" - }, - "0x52415249": { - "blockTime": 250, - "chainId": "1380012617", - "countMax": 15, - "intervalMs": 500, - "name": "RARIBLE" - }, - "0x531": { - "blockTime": 333, - "chainId": "1329", - "countMax": 15, - "intervalMs": 500, - "name": "SEI" - }, - "0x5d979": { - "blockTime": 250, - "chainId": "383353", - "countMax": 15, - "intervalMs": 500, - "name": "CHEESE" - }, - "0x61": { - "blockTime": 3000, - "chainId": "97", - "countMax": 10, - "intervalMs": 2000, - "name": "BNB_TESTNET" - }, - "0x62ef": { - "blockTime": 250, - "chainId": "25327", - "countMax": 15, - "intervalMs": 500, - "name": "EVERCLEAR" - }, - "0x64": { - "blockTime": 5000, - "chainId": "100", - "countMax": 10, - "intervalMs": 3000, - "name": "GNOSIS" - }, - "0x659": { - "blockTime": 250, - "chainId": "1625", - "countMax": 15, - "intervalMs": 500, - "name": "GRAVITY" - }, - "0x6c1": { - "blockTime": 250, - "chainId": "1729", - "countMax": 15, - "intervalMs": 500, - "name": "REYA" - }, - "0x725": { - "blockTime": 250, - "chainId": "1829", - "countMax": 15, - "intervalMs": 500, - "name": "PLAYBLOCK" - }, - "0x74c": { - "blockTime": 2000, - "chainId": "1868", - "countMax": 10, - "intervalMs": 1300, - "name": "SONEIUM" - }, - "0x76adf1": { - "blockTime": 2000, - "chainId": "7777777", - "countMax": 10, - "intervalMs": 1300, - "name": "ZORA" - }, - "0x7c5": { - "blockTime": 250, - "chainId": "1989", - "countMax": 15, - "intervalMs": 500, - "name": "LYDIA" - }, - "0x7cc": { - "blockTime": 250, - "chainId": "1996", - "countMax": 15, - "intervalMs": 500, - "name": "SANKO" - }, - "0x7ea": { - "blockTime": 2000, - "chainId": "2026", - "countMax": 10, - "intervalMs": 1300, - "name": "EDGELESS" - }, - "0x813df": { - "blockTime": 250, - "chainId": "529375", - "countMax": 15, - "intervalMs": 500, - "name": "LAYER_K" - }, - "0x82": { - "blockTime": 2000, - "chainId": "130", - "countMax": 10, - "intervalMs": 1300, - "name": "UNICHAIN" - }, - "0x8274f": { - "blockTime": 3000, - "chainId": "534351", - "countMax": 10, - "intervalMs": 2000, - "name": "SCROLL_SEPOLIA" - }, - "0x82750": { - "blockTime": 3000, - "chainId": "534352", - "countMax": 10, - "intervalMs": 2000, - "name": "SCROLL" - }, - "0x8279": { - "blockTime": 250, - "chainId": "33401", - "countMax": 15, - "intervalMs": 500, - "name": "SLINGSHOTDAO" - }, - "0x868b": { - "blockTime": 2000, - "chainId": "34443", - "countMax": 10, - "intervalMs": 1300, - "name": "MODE" - }, - "0x88b": { - "blockTime": 250, - "chainId": "2187", - "countMax": 15, - "intervalMs": 500, - "name": "GAME7" - }, - "0x88bb0": { - "blockTime": 12000, - "chainId": "560048", - "countMax": 10, - "intervalMs": 3000, - "name": "HOODI" - }, - "0x89": { - "blockTime": 2000, - "chainId": "137", - "countMax": 10, - "intervalMs": 1300, - "name": "POLYGON" - }, - "0x974": { - "blockTime": 250, - "chainId": "2420", - "countMax": 15, - "intervalMs": 500, - "name": "DOGELON" - }, - "0x98967f": { - "blockTime": 250, - "chainId": "9999999", - "countMax": 15, - "intervalMs": 500, - "name": "FLUENCE" - }, - "0x99797f": { - "blockTime": 250, - "chainId": "10058111", - "countMax": 15, - "intervalMs": 500, - "name": "SPOTLIGHT" - }, - "0x9c4400": { - "blockTime": 250, - "chainId": "10241024", - "countMax": 15, - "intervalMs": 500, - "name": "ALIENX" - }, - "0x9c4401": { - "blockTime": 250, - "chainId": "10241025", - "countMax": 15, - "intervalMs": 500, - "name": "ALIENX_TESTNET" - }, - "0x9dd": { - "blockTime": 250, - "chainId": "2525", - "countMax": 15, - "intervalMs": 500, - "name": "INJECTIVE" - }, - "0xa": { - "blockTime": 2000, - "chainId": "10", - "countMax": 10, - "intervalMs": 1300, - "name": "OPTIMISM" - }, - "0xa0c71fd": { - "blockTime": 4000, - "chainId": "168587773", - "countMax": 10, - "intervalMs": 2700, - "name": "BLAST_SEPOLIA" - }, - "0xa1337": { - "blockTime": 250, - "chainId": "660279", - "countMax": 15, - "intervalMs": 500, - "name": "XAI" - }, - "0xa1ef": { - "blockTime": 250, - "chainId": "41455", - "countMax": 15, - "intervalMs": 500, - "name": "ALEPH_ZERO" - }, - "0xa33fc": { - "blockTime": 250, - "chainId": "668668", - "countMax": 15, - "intervalMs": 500, - "name": "CONWAI" - }, - "0xa3c3": { - "blockTime": 250, - "chainId": "41923", - "countMax": 15, - "intervalMs": 500, - "name": "EDUCHAIN" - }, - "0xa4b1": { - "blockTime": 250, - "chainId": "42161", - "countMax": 15, - "intervalMs": 500, - "name": "ARBITRUM_ONE" - }, - "0xa4ba": { - "blockTime": 250, - "chainId": "42170", - "countMax": 15, - "intervalMs": 500, - "name": "ARBITRUM_NOVA" - }, - "0xa86a": { - "blockTime": 1000, - "chainId": "43114", - "countMax": 10, - "intervalMs": 700, - "name": "AVALANCHE" - }, - "0xa9": { - "blockTime": 2000, - "chainId": "169", - "countMax": 10, - "intervalMs": 1300, - "name": "MANTA" - }, - "0xaa36a7": { - "blockTime": 12000, - "chainId": "11155111", - "countMax": 10, - "intervalMs": 3000, - "name": "ETHEREUM_SEPOLIA" - }, - "0xaa37dc": { - "blockTime": 2000, - "chainId": "11155420", - "countMax": 10, - "intervalMs": 1300, - "name": "OPTIMISM_SEPOLIA" - }, - "0xb1c9": { - "blockTime": 250, - "chainId": "45513", - "countMax": 15, - "intervalMs": 500, - "name": "BLESSNET" - }, - "0xb5f": { - "blockTime": 250, - "chainId": "2911", - "countMax": 15, - "intervalMs": 500, - "name": "HYTOPIA" - }, - "0xb9": { - "blockTime": 2000, - "chainId": "185", - "countMax": 10, - "intervalMs": 1300, - "name": "MINT" - }, - "0xbde31": { - "blockTime": 250, - "chainId": "777777", - "countMax": 15, - "intervalMs": 500, - "name": "WINR" - }, - "0xc350": { - "blockTime": 250, - "chainId": "50000", - "countMax": 15, - "intervalMs": 500, - "name": "CITRONUS" - }, - "0xca74": { - "blockTime": 250, - "chainId": "51828", - "countMax": 15, - "intervalMs": 500, - "name": "CHAINBOUNTY" - }, - "0xcc": { - "blockTime": 1000, - "chainId": "204", - "countMax": 10, - "intervalMs": 700, - "name": "OPBNB" - }, - "0xd0d0": { - "blockTime": 250, - "chainId": "53456", - "countMax": 15, - "intervalMs": 500, - "name": "DODO" - }, - "0xd7cc": { - "blockTime": 250, - "chainId": "55244", - "countMax": 15, - "intervalMs": 500, - "name": "SUPERPOSITION" - }, - "0xe4": { - "blockTime": 250, - "chainId": "228", - "countMax": 15, - "intervalMs": 500, - "name": "MIND" - }, - "0xe49b1": { - "blockTime": 250, - "chainId": "936369", - "countMax": 15, - "intervalMs": 500, - "name": "LOGX" - }, - "0xe705": { - "blockTime": 2000, - "chainId": "59141", - "countMax": 10, - "intervalMs": 1300, - "name": "LINEA_SEPOLIA" - }, - "0xe708": { - "blockTime": 2000, - "chainId": "59144", - "countMax": 10, - "intervalMs": 1300, - "name": "LINEA" - }, - "0xe8": { - "blockTime": 2000, - "chainId": "232", - "countMax": 10, - "intervalMs": 1300, - "name": "LENS" - }, - "0xf4290": { - "blockTime": 250, - "chainId": "1000080", - "countMax": 15, - "intervalMs": 500, - "name": "SCOREKOUNT" - }, - "0xfa": { - "blockTime": 2333, - "chainId": "250", - "countMax": 10, - "intervalMs": 1600, - "name": "FANTOM" - }, - "0xfc": { - "blockTime": 2000, - "chainId": "252", - "countMax": 10, - "intervalMs": 1300, - "name": "FRAXTAL" - }, - "0xfee": { - "blockTime": 250, - "chainId": "4078", - "countMax": 15, - "intervalMs": 500, - "name": "COMETH" - } - } - }, - "batchSizeLimit": 10, - "dev": true, - "gasEstimateFallback": { - "perChainConfig": { - "0x279f": { - "fixed": 1000000 - } - } - }, - "gasFeeRandomisation": { - "randomisedGasFeeDigits": { - "0x2105": 5 - } - } - }, - "contentfulCarouselEnabled": true, - "enableMultichainAccounts": { - "enabled": true, - "featureVersion": "1", - "minimumVersion": "13.0.0" - }, - "enableMultichainAccountsState2": { - "enabled": true, - "featureVersion": "2", - "minimumVersion": "13.5.0" - }, - "extensionSignedDeepLinkWarningEnabled": { - "name": "Warning enabled", - "value": true - }, - "extensionUpdatePromptMinimumVersion": "12.18.0", - "extensionUxDefiReferral": true, - "extensionUxSidepanel": true, - "gasFeesSponsoredNetwork": { - "0x38": true, - "0x531": true, - "0x8f": true - }, - "isSolanaBuyable": false, - "neNetworkDiscoverButton": { - "0x531": true, - "0x8f": true, - "0xe708": true, - "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": true - }, - "nonZeroUnusedApprovals": [ - "https://aerodrome.finance", - "https://app.bio.xyz", - "https://app.ethena.fi", - "https://app.euler.finance", - "https://app.rocketx.exchange", - "https://app.seer.pm", - "https://app.spark.fi", - "https://app.tea-fi.com", - "https://app.uniswap.org", - "https://bridge.gravity.xyz", - "https://evm.ekubo.org", - "https://flaunch.gg", - "https://fluid.io", - "https://flyingtulip.com", - "https://jumper.exchange", - "https://linea.build", - "https://pancakeswap.finance", - "https://privacypools.com", - "https://relay.link", - "https://revoke.cash", - "https://superbridge.app", - "https://swap.defillama.com", - "https://toros.finance", - "https://velodrome.finance", - "https://walletstats.io", - "https://www.bungee.exchange", - "https://www.dev.relay.link", - "https://www.fxhash.xyz", - "https://www.hydrex.fi", - "https://www.relay.link", - "https://yearn.fi" - ], - "perpsEnabled": false, - "rewards": true, - "rewardsEnabled": { - "enabled": true, - "minimumVersion": "13.6.0" - }, - "sendRedesign": { - "enabled": true - }, - "settingsRedesign": false, - "smartTransactionsNetworks": { - "0x1": { - "extensionActive": true, - "sentinelUrl": "https://tx-sentinel-ethereum-mainnet.api.cx.metamask.io" - }, - "0x2105": { - "extensionActive": true, - "sentinelUrl": "https://tx-sentinel-base-mainnet.api.cx.metamask.io" - }, - "0x38": { - "extensionActive": true, - "sentinelUrl": "https://tx-sentinel-bsc-mainnet.api.cx.metamask.io" - }, - "0xa4b1": { - "extensionActive": true, - "sentinelUrl": "https://tx-sentinel-arbitrum-mainnet.api.cx.metamask.io" - }, - "default": { - "batchStatusPollingInterval": 1000, - "extensionActive": true, - "extensionReturnTxHashAsap": true - } - }, - "solanaCardEnabled": false, - "solanaTestnetsEnabled": false, - "walletFrameworkRpcFailoverEnabled": true - } + "cacheTimestamp": 0, + "remoteFeatureFlags": {} }, "SeedlessOnboardingController": { "isSeedlessOnboardingUserAuthenticated": false, From dec9a3e6463408beba3c6e5e75f4ea19a35183b9 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Fri, 14 Nov 2025 21:06:53 +0530 Subject: [PATCH 011/154] fix: UI issues in DAPP shield (#37784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** UI fixesin DAPP Shield functionlity. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/6252 Fixes: https://github.com/MetaMask/MetaMask-planning/issues/6256 ## **Manual testing steps** 1. Trigger swap from test-dapp 2. Check dapp swap banner displayed for UI fixes ## **Screenshots/Recordings** Screenshot 2025-11-13 at 5 25 31 PM ## **Pre-merge author checklist** - [X] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/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. --- > [!NOTE] > Introduces quote-swap simulation details with savings highlight and tooltip, updates the Dapp Swap banner to use Market rate, and hides origin/recipient rows for quoted swaps with corresponding i18n and tests. > > - **Confirmations UI**: > - Add `QuoteSwapSimulationDetails` showing outgoing/incoming balance changes and a highlighted "Get $X more" savings banner; includes header tooltip via `bestQuoteTooltip`. > - Dapp Swap banner: switch selector label from `metamaskRate` to `marketRate`; remove auto-switch logic; wire selected quote to transaction update; minor styling import. > - Hide `Origin` and `Recipient` rows, and batch approve simulation, for quoted swaps; add guard when `txParams` missing. > - Extend simulation header to accept custom `titleTooltip`. > - **i18n**: > - Add `bestQuoteTooltip`, `getDollarMore`, `marketRate`; remove `metamaskRate` in en/en_GB. > - **Tests**: > - Update banner tests to expect "Market rate" and interactions; add tests for quote-swap details savings banner; update transaction details to skip rows for quoted swaps. > - **Misc**: > - Handle native-address source asset in quote simulation. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d60faafbc1826a5302407e5ea379d2857ff401c3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/_locales/en/messages.json | 12 +++-- app/_locales/en_GB/messages.json | 12 +++-- .../dapp-swap-comparison-banner.test.tsx | 6 +-- .../dapp-swap-comparison-banner.tsx | 13 ++--- .../batch-simulation-details.tsx | 3 +- .../transaction-details.test.tsx.snap | 2 +- .../transaction-details.test.tsx | 23 ++++++++ .../transaction-details.tsx | 6 ++- ui/pages/confirmations/components/index.scss | 1 + .../simulation-details/simulation-details.tsx | 39 +++++++++++--- .../quote-swap-simulation-details/index.scss | 7 +++ .../quote-swap-simulation-details.test.tsx | 2 + .../quote-swap-simulation-details.tsx | 54 ++++++++++++++++--- 13 files changed, 143 insertions(+), 37 deletions(-) create mode 100644 ui/pages/confirmations/components/transactions/quote-swap-simulation-details/index.scss diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 791ebdc90a7c..05109003592c 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -832,6 +832,9 @@ "bestQuote": { "message": "Best quote" }, + "bestQuoteTooltip": { + "message": "The best quote we found from providers, including provider fees and a 0.25% MetaMask fee. The minimum you'll receive if price changes is $20.82." + }, "beta": { "message": "Beta" }, @@ -2892,6 +2895,9 @@ "genericExplorerView": { "message": "View account on $1" }, + "getDollarMore": { + "message": "Get $$1 more" + }, "getTheNewestFeatures": { "message": "Get the newest features" }, @@ -3613,6 +3619,9 @@ "marketDetails": { "message": "Market details" }, + "marketRate": { + "message": "Market rate" + }, "maskicons": { "message": "Polycons" }, @@ -3666,9 +3675,6 @@ "metamaskNotificationsAreOff": { "message": "Wallet notifications are currently not active." }, - "metamaskRate": { - "message": "Metamask rate" - }, "metamaskSwap": { "message": "Metamask Swap" }, diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index 791ebdc90a7c..05109003592c 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -832,6 +832,9 @@ "bestQuote": { "message": "Best quote" }, + "bestQuoteTooltip": { + "message": "The best quote we found from providers, including provider fees and a 0.25% MetaMask fee. The minimum you'll receive if price changes is $20.82." + }, "beta": { "message": "Beta" }, @@ -2892,6 +2895,9 @@ "genericExplorerView": { "message": "View account on $1" }, + "getDollarMore": { + "message": "Get $$1 more" + }, "getTheNewestFeatures": { "message": "Get the newest features" }, @@ -3613,6 +3619,9 @@ "marketDetails": { "message": "Market details" }, + "marketRate": { + "message": "Market rate" + }, "maskicons": { "message": "Polycons" }, @@ -3666,9 +3675,6 @@ "metamaskNotificationsAreOff": { "message": "Wallet notifications are currently not active." }, - "metamaskRate": { - "message": "Metamask rate" - }, "metamaskSwap": { "message": "Metamask Swap" }, diff --git a/ui/pages/confirmations/components/confirm/dapp-swap-comparison-banner/dapp-swap-comparison-banner.test.tsx b/ui/pages/confirmations/components/confirm/dapp-swap-comparison-banner/dapp-swap-comparison-banner.test.tsx index d22d9ca872a9..a1fdc4a2d53a 100644 --- a/ui/pages/confirmations/components/confirm/dapp-swap-comparison-banner/dapp-swap-comparison-banner.test.tsx +++ b/ui/pages/confirmations/components/confirm/dapp-swap-comparison-banner/dapp-swap-comparison-banner.test.tsx @@ -105,7 +105,7 @@ describe('', () => { destinationTokenSymbol: 'TEST', } as ReturnType); const { getByText } = render(); - expect(getByText('Metamask rate')).toBeInTheDocument(); + expect(getByText('Market rate')).toBeInTheDocument(); expect(getByText('Metamask Swap')).toBeInTheDocument(); expect(getByText('Save and earn with MetaMask Swaps')).toBeInTheDocument(); expect(getByText('Save $0.02')).toBeInTheDocument(); @@ -141,7 +141,7 @@ describe('', () => { expect(mockDispatch).toHaveBeenCalledTimes(1); }); - it('call function to update confirmation when user clicks on Metamask rate button', () => { + it('call function to update confirmation when user clicks on Market rate button', () => { jest.spyOn(SwapCheckHook, 'useSwapCheck').mockReturnValue({ isQuotedSwap: true, }); @@ -153,7 +153,7 @@ describe('', () => { destinationTokenSymbol: 'TEST', } as ReturnType); const { getByText } = render(); - const quoteSwapButton = getByText('Metamask rate'); + const quoteSwapButton = getByText('Market rate'); fireEvent.click(quoteSwapButton); expect(mockDispatch).toHaveBeenCalledTimes(1); }); diff --git a/ui/pages/confirmations/components/confirm/dapp-swap-comparison-banner/dapp-swap-comparison-banner.tsx b/ui/pages/confirmations/components/confirm/dapp-swap-comparison-banner/dapp-swap-comparison-banner.tsx index 3531cdaefc00..0b17f09de274 100644 --- a/ui/pages/confirmations/components/confirm/dapp-swap-comparison-banner/dapp-swap-comparison-banner.tsx +++ b/ui/pages/confirmations/components/confirm/dapp-swap-comparison-banner/dapp-swap-comparison-banner.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { Box, BoxBackgroundColor, @@ -27,7 +27,6 @@ import { useI18nContext } from '../../../../../hooks/useI18nContext'; import { updateTransaction } from '../../../../../store/actions'; import { useConfirmContext } from '../../../context/confirm'; import { useDappSwapComparisonInfo } from '../../../hooks/transactions/dapp-swap-comparison/useDappSwapComparisonInfo'; -import { useSwapCheck } from '../../../hooks/transactions/dapp-swap-comparison/useSwapCheck'; import { QuoteSwapSimulationDetails } from '../../transactions/quote-swap-simulation-details/quote-swap-simulation-details'; const DAPP_SWAP_COMPARISON_ORIGIN = 'https://app.uniswap.org'; @@ -94,7 +93,6 @@ const DappSwapComparisonInner = () => { tokenDetails, } = useDappSwapComparisonInfo(); - const { isQuotedSwap } = useSwapCheck(); const dispatch = useDispatch(); const { currentConfirmation } = useConfirmContext(); const { dappSwapUi } = useSelector(getRemoteFeatureFlags) as { @@ -108,12 +106,6 @@ const DappSwapComparisonInner = () => { const [showDappSwapComparisonBanner, setShowDappSwapComparisonBanner] = useState(true); - useEffect(() => { - if (isQuotedSwap && selectedSwapType !== SwapType.Metamask) { - setSelectedSwapType(SwapType.Metamask); - } - }, [isQuotedSwap, selectedSwapType]); - const hideDappSwapComparisonBanner = useCallback(() => { setShowDappSwapComparisonBanner(false); }, [setShowDappSwapComparisonBanner]); @@ -196,7 +188,7 @@ const DappSwapComparisonInner = () => { : SwapButtonType.Text } onClick={updateSwapToCurrent} - label={t('metamaskRate')} + label={t('marketRate')} /> { quote={selectedQuote as QuoteResponse} tokenDetails={tokenDetails} sourceTokenAmount={sourceTokenAmount} + tokenAmountDifference={tokenAmountDifference} /> )} diff --git a/ui/pages/confirmations/components/confirm/info/batch/batch-simulation-details/batch-simulation-details.tsx b/ui/pages/confirmations/components/confirm/info/batch/batch-simulation-details/batch-simulation-details.tsx index 4c0c16b2bcfe..74d68e4d3382 100644 --- a/ui/pages/confirmations/components/confirm/info/batch/batch-simulation-details/batch-simulation-details.tsx +++ b/ui/pages/confirmations/components/confirm/info/batch/batch-simulation-details/batch-simulation-details.tsx @@ -80,7 +80,8 @@ export function BatchSimulationDetails() { if ( transactionMeta?.type === TransactionType.revokeDelegation || isUpgradeOnly || - isQuotedSwap + isQuotedSwap || + !transactionMeta?.txParams ) { return null; } diff --git a/ui/pages/confirmations/components/confirm/info/shared/transaction-details/__snapshots__/transaction-details.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/shared/transaction-details/__snapshots__/transaction-details.test.tsx.snap index 2ad90cf434a4..ad2e95deb6e5 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/transaction-details/__snapshots__/transaction-details.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/shared/transaction-details/__snapshots__/transaction-details.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Transaction Details does not render component for transaction details 1`] = `
diff --git a/ui/pages/confirmations/components/confirm/info/shared/transaction-details/transaction-details.test.tsx b/ui/pages/confirmations/components/confirm/info/shared/transaction-details/transaction-details.test.tsx index d42bcae09201..69e4f901b390 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/transaction-details/transaction-details.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/shared/transaction-details/transaction-details.test.tsx @@ -15,6 +15,7 @@ import { downgradeAccountConfirmation, upgradeAccountConfirmationOnly, } from '../../../../../../../../test/data/confirmations/batch-transaction'; +import * as SwapCheckHook from '../../../../../hooks/transactions/dapp-swap-comparison/useSwapCheck'; import { RowAlertKey } from '../../../../../../../components/app/confirm/info/row/constants'; import { Severity } from '../../../../../../../helpers/constants/design-system'; import { RecipientRow, TransactionDetails } from './transaction-details'; @@ -225,6 +226,28 @@ describe('Transaction Details', () => { getByTestId('transaction-details-recipient-row'), ).toBeInTheDocument(); }); + + it('does not render for quote swap', () => { + jest.spyOn(SwapCheckHook, 'useSwapCheck').mockReturnValue({ + isQuotedSwap: true, + }); + const state = getMockConfirmStateForTransaction( + genUnapprovedContractInteractionConfirmation(), + { + metamask: { + preferences: { + showConfirmationAdvancedDetails: true, + }, + }, + }, + ); + const mockStore = configureMockStore(middleware)(state); + const { queryByTestId } = renderWithConfirmContextProvider( + , + mockStore, + ); + expect(queryByTestId('transaction-details-recipient-row')).toBeNull(); + }); }); it('return null for transaction of type revokeDelegation', () => { diff --git a/ui/pages/confirmations/components/confirm/info/shared/transaction-details/transaction-details.tsx b/ui/pages/confirmations/components/confirm/info/shared/transaction-details/transaction-details.tsx index ea45fd9b32bc..292a664089e4 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/transaction-details/transaction-details.tsx +++ b/ui/pages/confirmations/components/confirm/info/shared/transaction-details/transaction-details.tsx @@ -20,6 +20,7 @@ import { useFourByte } from '../../hooks/useFourByte'; import { ConfirmInfoRowCurrency } from '../../../../../../../components/app/confirm/info/row/currency'; import { PRIMARY } from '../../../../../../../helpers/constants/common'; import { useUserPreferencedCurrency } from '../../../../../../../hooks/useUserPreferencedCurrency'; +import { useSwapCheck } from '../../../../../hooks/transactions/dapp-swap-comparison/useSwapCheck'; import { SmartContractWithLogo } from '../../../../smart-contract-with-logo'; import { useIsDowngradeTransaction, @@ -180,6 +181,7 @@ export const TransactionDetails = () => { ); const { isUpgradeOnly } = useIsUpgradeTransaction(); const isDowngrade = useIsDowngradeTransaction(); + const { isQuotedSwap } = useSwapCheck(); if (isUpgradeOnly || isDowngrade) { return null; @@ -195,8 +197,8 @@ export const TransactionDetails = () => { <> - - {!isBatch && } + {!isQuotedSwap && } + {!isBatch && !isQuotedSwap && } {showAdvancedDetails && } diff --git a/ui/pages/confirmations/components/index.scss b/ui/pages/confirmations/components/index.scss index 3467f40d856a..ed7c9806a56a 100644 --- a/ui/pages/confirmations/components/index.scss +++ b/ui/pages/confirmations/components/index.scss @@ -36,5 +36,6 @@ @import 'send/header/header'; @import 'send/recipient/recipient'; @import 'simulation-details/index'; +@import 'transactions/quote-swap-simulation-details/index'; @import 'UI/asset/index'; @import 'UI/recipient/index'; diff --git a/ui/pages/confirmations/components/simulation-details/simulation-details.tsx b/ui/pages/confirmations/components/simulation-details/simulation-details.tsx index 4b5aad1b2c29..6ae092b0597f 100644 --- a/ui/pages/confirmations/components/simulation-details/simulation-details.tsx +++ b/ui/pages/confirmations/components/simulation-details/simulation-details.tsx @@ -131,9 +131,11 @@ const EmptyContent: React.FC = () => { const HeaderWithAlert = ({ title, + titleTooltip, transactionId, }: { title?: string; + titleTooltip?: string; transactionId: string; }) => { const t = useI18nContext(); @@ -157,9 +159,11 @@ const HeaderWithAlert = ({ ? t('simulationDetailsTitleEnforced') : t('simulationDetailsTitle')); - const tooltip = isEnforced - ? t('simulationDetailsTitleTooltipEnforced') - : t('simulationDetailsTitleTooltip'); + const tooltip = + titleTooltip ?? + (isEnforced + ? t('simulationDetailsTitleTooltipEnforced') + : t('simulationDetailsTitleTooltip')); const [settingsModalVisible, setSettingsModalVisible] = useState(false); @@ -246,12 +250,20 @@ const LegacyHeader = () => { * @param props.isTransactionsRedesign * @param props.transactionId * @param props.title + * @param props.titleTooltip */ const HeaderLayout: React.FC<{ isTransactionsRedesign: boolean; transactionId: string; title?: string; -}> = ({ children, isTransactionsRedesign, transactionId, title }) => { + titleTooltip?: string; +}> = ({ + children, + isTransactionsRedesign, + transactionId, + title, + titleTooltip, +}) => { return ( {isTransactionsRedesign ? ( - + ) : ( )} @@ -274,6 +290,7 @@ const HeaderLayout: React.FC<{ * * @param props * @param props.title + * @param props.titleTooltip * @param props.inHeader * @param props.isTransactionsRedesign * @param props.children @@ -281,10 +298,18 @@ const HeaderLayout: React.FC<{ */ export const SimulationDetailsLayout: React.FC<{ title?: string; + titleTooltip?: string; inHeader?: React.ReactNode; isTransactionsRedesign: boolean; transactionId: string; -}> = ({ title, inHeader, isTransactionsRedesign, transactionId, children }) => +}> = ({ + title, + titleTooltip, + inHeader, + isTransactionsRedesign, + transactionId, + children, +}) => isTransactionsRedesign ? ( {inHeader} @@ -335,6 +361,7 @@ export const SimulationDetailsLayout: React.FC<{ {inHeader} diff --git a/ui/pages/confirmations/components/transactions/quote-swap-simulation-details/index.scss b/ui/pages/confirmations/components/transactions/quote-swap-simulation-details/index.scss new file mode 100644 index 000000000000..7d97657f0e65 --- /dev/null +++ b/ui/pages/confirmations/components/transactions/quote-swap-simulation-details/index.scss @@ -0,0 +1,7 @@ +.quote-swap { + &_highlighted-text { + border-radius: 4px; + float: right; + padding: 4px; + } +} diff --git a/ui/pages/confirmations/components/transactions/quote-swap-simulation-details/quote-swap-simulation-details.test.tsx b/ui/pages/confirmations/components/transactions/quote-swap-simulation-details/quote-swap-simulation-details.test.tsx index eac8c9a97c9e..b4134ea07194 100644 --- a/ui/pages/confirmations/components/transactions/quote-swap-simulation-details/quote-swap-simulation-details.test.tsx +++ b/ui/pages/confirmations/components/transactions/quote-swap-simulation-details/quote-swap-simulation-details.test.tsx @@ -76,6 +76,7 @@ function render(args: Record = {}) { standard: 'ERC20', }, }} + tokenAmountDifference={0.25} />, mockStore, ); @@ -89,5 +90,6 @@ describe('', () => { expect(getByText('You receive')).toBeInTheDocument(); expect(getByText('- 0.1')).toBeInTheDocument(); expect(getByText('+ 0.0991')).toBeInTheDocument(); + expect(getByText('Get $0.25 more')).toBeInTheDocument(); }); }); diff --git a/ui/pages/confirmations/components/transactions/quote-swap-simulation-details/quote-swap-simulation-details.tsx b/ui/pages/confirmations/components/transactions/quote-swap-simulation-details/quote-swap-simulation-details.tsx index 4c61d04284fb..55910cf327aa 100644 --- a/ui/pages/confirmations/components/transactions/quote-swap-simulation-details/quote-swap-simulation-details.tsx +++ b/ui/pages/confirmations/components/transactions/quote-swap-simulation-details/quote-swap-simulation-details.tsx @@ -1,10 +1,19 @@ import React, { useMemo } from 'react'; -import { Box, BoxFlexDirection } from '@metamask/design-system-react'; +import { + Box, + BoxBackgroundColor, + BoxFlexDirection, + BoxJustifyContent, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react'; import { Hex } from '@metamask/utils'; -import { QuoteResponse } from '@metamask/bridge-controller'; +import { isNativeAddress, QuoteResponse } from '@metamask/bridge-controller'; import { TransactionMeta } from '@metamask/transaction-controller'; import { toHex } from '@metamask/controller-utils'; +import { TokenStandard } from '../../../../../../shared/constants/transaction'; import { TokenStandAndDetails } from '../../../../../store/actions'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; import { calculateTokenAmount } from '../../../utils/token'; @@ -12,7 +21,10 @@ import { getTokenValueFromRecord } from '../../../utils/dapp-swap-comparison-uti import { useConfirmContext } from '../../../context/confirm'; import { SimulationDetailsLayout } from '../../simulation-details/simulation-details'; import { BalanceChangeRow } from '../../simulation-details/balance-change-row'; -import { TokenAssetIdentifier } from '../../simulation-details/types'; +import { + AssetIdentifier, + TokenAssetIdentifier, +} from '../../simulation-details/types'; const getSrcAssetBalanceChange = ( srcAsset: QuoteResponse['quote']['srcAsset'], @@ -20,12 +32,19 @@ const getSrcAssetBalanceChange = ( sourceTokenAmount: string | undefined, fiatRates: Record, ) => { - return { - asset: { - ...tokenDetails[srcAsset.address.toLowerCase() as Hex], + let asset = { + ...tokenDetails[srcAsset.address.toLowerCase() as Hex], + chainId: toHex(srcAsset.chainId), + address: srcAsset.address as Hex, + } as AssetIdentifier; + if (isNativeAddress(srcAsset.address)) { + asset = { chainId: toHex(srcAsset.chainId), - address: srcAsset.address as Hex, - } as unknown as TokenAssetIdentifier, + standard: TokenStandard.none, + }; + } + return { + asset, amount: calculateTokenAmount( sourceTokenAmount ?? '0x0', srcAsset.decimals, @@ -70,11 +89,13 @@ export const QuoteSwapSimulationDetails = ({ quote, sourceTokenAmount, tokenDetails, + tokenAmountDifference = 0, }: { fiatRates?: Record; quote?: QuoteResponse; sourceTokenAmount?: string; tokenDetails?: Record; + tokenAmountDifference?: number; }) => { const t = useI18nContext(); const { currentConfirmation } = useConfirmContext(); @@ -116,6 +137,7 @@ export const QuoteSwapSimulationDetails = ({ isTransactionsRedesign transactionId={transactionId} title={t('bestQuote')} + titleTooltip={t('bestQuoteTooltip')} > + {tokenAmountDifference > 0 && ( + + + + {t('getDollarMore', [tokenAmountDifference?.toFixed(2)])} + + + + )} ); From d89e04b00033786c99450ebafb3d35cda057a9c3 Mon Sep 17 00:00:00 2001 From: Nguyen Anh Tu Date: Fri, 14 Nov 2025 22:40:08 +0700 Subject: [PATCH 012/154] fix: shield-cta cp-13.10.0 (#37860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - Fix payment method select card if no crypto available between plan change - Fix copy text in confirmation tooltip and start now button [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/37860?quickstart=1) ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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. --- > [!NOTE] > Makes the Shield confirm CTA show “Start free trial” unless the user has already trialed, fixes the monthly tooltip copy order, and defaults payment method to card when crypto isn’t selected/available; updates locales and tests. > > - **Confirmations UI**: > - **Footer CTA**: For `TransactionType.shieldSubscriptionApprove`, choose `t('shieldStartNowCTAWithTrial')` unless the user has already trialed `PRODUCT_TYPES.SHIELD`, then use `t('shieldStartNowCTA')`. > - Integrates `useUserSubscriptions` to read `trialedProducts`. > - **Shield Plan**: > - **Payment Method Default**: Ensure selection defaults to `card` when no crypto token is available/selected or last used method is `card`. > - **Confirm Info (Shield Subscription Approve)**: > - **Monthly Tooltip**: Swap params to display per-month first, then total in `shieldEstimatedChangesMonthlyTooltipText`. > - **i18n**: > - Add `shieldStartNowCTAWithTrial` message in `app/_locales/en/messages.json` and `en_GB/messages.json`. > - **Tests**: > - Update `footer.test.tsx` to mock `useUserSubscriptions` and reflect CTA logic. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit bbee7cc4a7ef621b729ca34e03c8a37aea14cb73. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/_locales/en/messages.json | 3 +++ app/_locales/en_GB/messages.json | 3 +++ .../components/confirm/footer/footer.test.tsx | 10 ++++++++++ .../components/confirm/footer/footer.tsx | 11 ++++++++++- .../shield-subscription-approve/estimated-changes.tsx | 2 +- ui/pages/shield-plan/shield-plan.tsx | 4 ++++ 6 files changed, 31 insertions(+), 2 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 05109003592c..cc08dd5868a3 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -6219,6 +6219,9 @@ "shieldStartNowCTA": { "message": "Start now" }, + "shieldStartNowCTAWithTrial": { + "message": "Start free trial" + }, "shieldTx": { "message": "Transaction Shield" }, diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index 05109003592c..cc08dd5868a3 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -6219,6 +6219,9 @@ "shieldStartNowCTA": { "message": "Start now" }, + "shieldStartNowCTAWithTrial": { + "message": "Start free trial" + }, "shieldTx": { "message": "Transaction Shield" }, diff --git a/ui/pages/confirmations/components/confirm/footer/footer.test.tsx b/ui/pages/confirmations/components/confirm/footer/footer.test.tsx index ee8880d6978b..a23e171aa20f 100644 --- a/ui/pages/confirmations/components/confirm/footer/footer.test.tsx +++ b/ui/pages/confirmations/components/confirm/footer/footer.test.tsx @@ -32,6 +32,7 @@ import { useIsGaslessSupported } from '../../../hooks/gas/useIsGaslessSupported' import { useInsufficientBalanceAlerts } from '../../../hooks/alerts/transactions/useInsufficientBalanceAlerts'; import { useIsGaslessLoading } from '../../../hooks/gas/useIsGaslessLoading'; import { useConfirmationNavigation } from '../../../hooks/useConfirmationNavigation'; +import { useUserSubscriptions } from '../../../../../hooks/subscription/useSubscription'; import Footer from './footer'; jest.mock('../../../hooks/gas/useIsGaslessLoading'); @@ -59,6 +60,7 @@ jest.mock( ); jest.mock('../../../hooks/useOriginThrottling'); +jest.mock('../../../../../hooks/subscription/useSubscription'); jest.mock('react-router-dom-v5-compat', () => ({ useNavigate: jest.fn(), @@ -86,6 +88,7 @@ describe('ConfirmFooter', () => { ); const useIsGaslessLoadingMock = jest.mocked(useIsGaslessLoading); const useConfirmationNavigationMock = jest.mocked(useConfirmationNavigation); + const useUserSubscriptionsMock = jest.mocked(useUserSubscriptions); beforeEach(() => { mockUseOriginThrottling.mockReturnValue({ @@ -100,6 +103,13 @@ describe('ConfirmFooter', () => { useIsGaslessLoadingMock.mockReturnValue({ isGaslessLoading: false, }); + + useUserSubscriptionsMock.mockReturnValue({ + trialedProducts: [], + loading: false, + subscriptions: [], + error: undefined, + }); }); it('should match snapshot with signature confirmation', () => { diff --git a/ui/pages/confirmations/components/confirm/footer/footer.tsx b/ui/pages/confirmations/components/confirm/footer/footer.tsx index 3a7d9733a0a0..7a7fd54bb31b 100644 --- a/ui/pages/confirmations/components/confirm/footer/footer.tsx +++ b/ui/pages/confirmations/components/confirm/footer/footer.tsx @@ -4,6 +4,7 @@ import { } from '@metamask/transaction-controller'; import React, { useCallback, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { PRODUCT_TYPES } from '@metamask/subscription-controller'; import { MetaMetricsEventLocation } from '../../../../../../shared/constants/metametrics'; import { isCorrectDeveloperTransactionType } from '../../../../../../shared/lib/confirmation.utils'; import { ConfirmAlertModal } from '../../../../../components/app/alert-system/confirm-alert-modal'; @@ -38,6 +39,7 @@ import { } from '../../../hooks/useAddEthereumChain'; import { isSignatureTransactionType } from '../../../utils'; import { getConfirmationSender } from '../utils'; +import { useUserSubscriptions } from '../../../../../hooks/subscription/useSubscription'; import OriginThrottleModal from './origin-throttle-modal'; import ShieldFooterAgreement from './shield-footer-agreement'; import ShieldFooterCoverageIndicator from './shield-footer-coverage-indicator/shield-footer-coverage-indicator'; @@ -117,6 +119,9 @@ const ConfirmButton = ({ setConfirmModalVisible(true); }, []); + const { trialedProducts } = useUserSubscriptions(); + const isShieldTrialed = trialedProducts?.includes(PRODUCT_TYPES.SHIELD); + return ( <> {confirmModalVisible && ( @@ -157,7 +162,11 @@ const ConfirmButton = ({ > {currentConfirmation?.type === TransactionType.shieldSubscriptionApprove - ? t('shieldStartNowCTA') + ? t( + isShieldTrialed + ? 'shieldStartNowCTA' + : 'shieldStartNowCTAWithTrial', + ) : t('confirm')} )} diff --git a/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/estimated-changes.tsx b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/estimated-changes.tsx index 6cfc87095b77..24b6ad6284ed 100644 --- a/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/estimated-changes.tsx +++ b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/estimated-changes.tsx @@ -39,8 +39,8 @@ export const EstimatedChanges = ({ isYearlySubscription ? null : t('shieldEstimatedChangesMonthlyTooltipText', [ - `$${approvalAmount}`, `$${Number(approvalAmount) / 12}`, + `$${approvalAmount}`, ]) } /> diff --git a/ui/pages/shield-plan/shield-plan.tsx b/ui/pages/shield-plan/shield-plan.tsx index d0564e24acba..40cf6ff12f6a 100644 --- a/ui/pages/shield-plan/shield-plan.tsx +++ b/ui/pages/shield-plan/shield-plan.tsx @@ -146,6 +146,7 @@ const ShieldPlan = () => { productType: PRODUCT_TYPES.SHIELD, }); const hasAvailableToken = availableTokenBalances.length > 0; + const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(() => { // always default to card if no token is available @@ -157,6 +158,7 @@ const ShieldPlan = () => { } return PAYMENT_TYPES.byCrypto; }); + // default options for the new subscription request const defaultOptions = useMemo(() => { const paymentType = @@ -224,6 +226,8 @@ const ShieldPlan = () => { // if the last used payment method is not crypto, don't set default method if (selectedToken && lastUsedPaymentMethod !== PAYMENT_TYPES.byCard) { setSelectedPaymentMethod(PAYMENT_TYPES.byCrypto); + } else { + setSelectedPaymentMethod(PAYMENT_TYPES.byCard); } }, [selectedToken, setSelectedPaymentMethod, lastUsedPaymentDetails]); From 1f1806a17dba07b4541f3272f26e62204fff3ac9 Mon Sep 17 00:00:00 2001 From: Francis Nepomuceno Date: Fri, 14 Nov 2025 11:00:03 -0500 Subject: [PATCH 013/154] fix: edit account circular dependency (#37765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fix the edit-account-modal circular dependency [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/37765?quickstart=1) ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/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. --- > [!NOTE] > Removes a circular dependency by updating `AccountListItem` import in `edit-accounts-modal` and reflecting the change in `development/circular-deps.jsonc`. > > - **Circular dependencies**: > - Removed cycle involving `ui/components/multichain/edit-accounts-modal/*` from `development/circular-deps.jsonc`. > - **Multichain UI**: > - `ui/components/multichain/edit-accounts-modal/edit-accounts-modal.tsx`: Update `AccountListItem` import from `..` to `../account-list-item` to break the cycle. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 77c4c44bd1fd8f5d468e136c415d15fdc81d802c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- development/circular-deps.jsonc | 5 ----- .../multichain/edit-accounts-modal/edit-accounts-modal.tsx | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/development/circular-deps.jsonc b/development/circular-deps.jsonc index 5e2a400d1382..27b68289fedf 100644 --- a/development/circular-deps.jsonc +++ b/development/circular-deps.jsonc @@ -190,11 +190,6 @@ "ui/components/multichain/asset-picker-amount/index.ts", "ui/components/multichain/index.js" ], - [ - "ui/components/multichain/edit-accounts-modal/edit-accounts-modal.tsx", - "ui/components/multichain/edit-accounts-modal/index.ts", - "ui/components/multichain/index.js" - ], [ "ui/components/multichain/edit-networks-modal/edit-networks-modal.js", "ui/components/multichain/edit-networks-modal/index.js", diff --git a/ui/components/multichain/edit-accounts-modal/edit-accounts-modal.tsx b/ui/components/multichain/edit-accounts-modal/edit-accounts-modal.tsx index 8aa4b505d661..895c24ca5586 100644 --- a/ui/components/multichain/edit-accounts-modal/edit-accounts-modal.tsx +++ b/ui/components/multichain/edit-accounts-modal/edit-accounts-modal.tsx @@ -19,7 +19,7 @@ import { IconName, Icon, } from '../../component-library'; -import { AccountListItem } from '..'; +import { AccountListItem } from '../account-list-item'; import { JustifyContent, From 87f4c430244b0933850e792622ae4acaa8ade689 Mon Sep 17 00:00:00 2001 From: AugmentedMode <31675118+AugmentedMode@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:01:32 -0500 Subject: [PATCH 014/154] feat: add Token Insights modal and integrate within bridge destination token selector (#37469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR introduces a new Token Insights modal in the Bridge flow to provide users with detailed information about tokens directly within the bridge interface to help users verify the correct token they want to swap into without taking them out of the swaps flow. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/37469?quickstart=1) ## **Changelog** CHANGELOG entry: Added a new Token Insights modal to enhance token verification accessibility. ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2025-11-05 at 9 39 23 AM ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/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. --- > [!NOTE] > Adds a Token Insights modal (price, % change, volume, FDV market cap, contract address copy) and opens it via an info icon on destination tokens; includes data hook, utils, tests, and locale strings. > > - **UI/Bridge**: > - **TokenListItem (`ui/components/multichain/token-list-item/token-list-item.tsx`)**: Adds info icon to destination tokens that opens `TokenInsightsModal`; updates aria label and styles (`index.scss`). > - **TokenInsightsModal (`ui/pages/bridge/token-insights-modal/`)**: New modal showing formatted `price`, `percentChange` (with arrows/color), `volume`, `marketCapFDV`, and optional contract address copy; tracks open/copy events and handles outside-click close. > - **Data/Logic**: > - **Hook (`ui/hooks/useTokenInsightsData.ts`)**: New hook sourcing market data from cache (EVM) or API (non‑EVM), computes fiat values, detects native tokens; comprehensive tests added (`useTokenInsightsData.test.ts`). > - **Utils (`ui/helpers/utils/token-insights.ts`)**: Helpers for native address detection, percentage/currency formatting, checksummed/CAIP address formatting, price change color, and address display rules. > - **i18n**: > - Add locale keys in `app/_locales/en*/messages.json`: `insights`, `marketCapFDV`, `percentChange`, `viewTokenDetails`, `volume`. > - **Tests**: > - Modal tests (`token-insights-modal.test.tsx`) covering rendering, behavior, and tracking. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8fb74db6aea0a3dc5c70e173c57b4074038bab30. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/_locales/en/messages.json | 15 + app/_locales/en_GB/messages.json | 15 + .../multichain/token-list-item/index.scss | 8 + .../token-list-item/token-list-item.tsx | 31 ++ ui/helpers/utils/token-insights.ts | 135 +++++ ui/hooks/useTokenInsightsData.test.ts | 521 ++++++++++++++++++ ui/hooks/useTokenInsightsData.ts | 255 +++++++++ ui/pages/bridge/token-insights-modal/index.ts | 1 + .../token-insights-modal.test.tsx | 252 +++++++++ .../token-insights-modal.tsx | 285 ++++++++++ 10 files changed, 1518 insertions(+) create mode 100644 ui/helpers/utils/token-insights.ts create mode 100644 ui/hooks/useTokenInsightsData.test.ts create mode 100644 ui/hooks/useTokenInsightsData.ts create mode 100644 ui/pages/bridge/token-insights-modal/index.ts create mode 100644 ui/pages/bridge/token-insights-modal/token-insights-modal.test.tsx create mode 100644 ui/pages/bridge/token-insights-modal/token-insights-modal.tsx diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index cc08dd5868a3..cea59e7c2831 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -3186,6 +3186,9 @@ "initialTransactionConfirmed": { "message": "Your initial transaction was confirmed by the network. Click OK to go back." }, + "insights": { + "message": "Insights" + }, "insightsFromSnap": { "message": "Insights from $1", "description": "$1 represents the name of the snap" @@ -3616,6 +3619,9 @@ "marketCap": { "message": "Market cap" }, + "marketCapFDV": { + "message": "Market cap (FDV)" + }, "marketDetails": { "message": "Market details" }, @@ -4707,6 +4713,9 @@ "message": "Learn how to cancel or speed up a transaction.", "description": "The text for the hyperlink in the pending transaction alert message" }, + "percentChange": { + "message": "Percent change" + }, "permissionDetails": { "message": "Permission details" }, @@ -8179,6 +8188,9 @@ "viewOnOpensea": { "message": "View on Opensea" }, + "viewTokenDetails": { + "message": "View token details" + }, "viewTransaction": { "message": "View transaction" }, @@ -8204,6 +8216,9 @@ "visitWebSite": { "message": "Visit our website" }, + "volume": { + "message": "Volume" + }, "wallet": { "message": "Wallet" }, diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index cc08dd5868a3..cea59e7c2831 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -3186,6 +3186,9 @@ "initialTransactionConfirmed": { "message": "Your initial transaction was confirmed by the network. Click OK to go back." }, + "insights": { + "message": "Insights" + }, "insightsFromSnap": { "message": "Insights from $1", "description": "$1 represents the name of the snap" @@ -3616,6 +3619,9 @@ "marketCap": { "message": "Market cap" }, + "marketCapFDV": { + "message": "Market cap (FDV)" + }, "marketDetails": { "message": "Market details" }, @@ -4707,6 +4713,9 @@ "message": "Learn how to cancel or speed up a transaction.", "description": "The text for the hyperlink in the pending transaction alert message" }, + "percentChange": { + "message": "Percent change" + }, "permissionDetails": { "message": "Permission details" }, @@ -8179,6 +8188,9 @@ "viewOnOpensea": { "message": "View on Opensea" }, + "viewTokenDetails": { + "message": "View token details" + }, "viewTransaction": { "message": "View transaction" }, @@ -8204,6 +8216,9 @@ "visitWebSite": { "message": "Visit our website" }, + "volume": { + "message": "Volume" + }, "wallet": { "message": "Wallet" }, diff --git a/ui/components/multichain/token-list-item/index.scss b/ui/components/multichain/token-list-item/index.scss index f40fcf5a1df6..b83e56c6767b 100644 --- a/ui/components/multichain/token-list-item/index.scss +++ b/ui/components/multichain/token-list-item/index.scss @@ -11,4 +11,12 @@ &__badge { align-self: center; } + + &__info-icon { + flex-shrink: 0; + margin-left: 12px; + opacity: 1; + cursor: pointer; + align-self: center; + } } diff --git a/ui/components/multichain/token-list-item/token-list-item.tsx b/ui/components/multichain/token-list-item/token-list-item.tsx index 0ae0dd395162..5f74cd58f29f 100644 --- a/ui/components/multichain/token-list-item/token-list-item.tsx +++ b/ui/components/multichain/token-list-item/token-list-item.tsx @@ -18,6 +18,7 @@ import { TextColor, TextVariant, } from '../../../helpers/constants/design-system'; +import { TokenInsightsModal } from '../../../pages/bridge/token-insights-modal'; import { AvatarNetwork, AvatarNetworkSize, @@ -124,6 +125,7 @@ export const TokenListItemComponent = ({ const dispatch = useDispatch(); const [showScamWarningModal, setShowScamWarningModal] = useState(false); + const [showTokenInsights, setShowTokenInsights] = useState(false); const history = useHistory(); const getTokenTitle = () => { @@ -376,6 +378,21 @@ export const TokenListItemComponent = ({ )} + + {isDestinationToken && ( + { + e.stopPropagation(); + e.preventDefault(); + setShowTokenInsights(true); + }} + className="multichain-token-list-item__info-icon" + color={IconColor.iconAlternative} + ariaLabel={t('viewTokenDetails')} + /> + )} {isEvm && showScamWarningModal ? ( setShowScamWarningModal(false)}> @@ -407,6 +424,20 @@ export const TokenListItemComponent = ({ ) : null} + + {showTokenInsights && ( + setShowTokenInsights(false)} + token={{ + address, + symbol: tokenSymbol || title, + name: title, + chainId, + iconUrl: tokenImage, + }} + /> + )} ); }; diff --git a/ui/helpers/utils/token-insights.ts b/ui/helpers/utils/token-insights.ts new file mode 100644 index 000000000000..408af1ae0e72 --- /dev/null +++ b/ui/helpers/utils/token-insights.ts @@ -0,0 +1,135 @@ +import { + isCaipAssetType, + parseCaipAssetType, + isStrictHexString, +} from '@metamask/utils'; +import { isNativeAddress as isNativeAddressFromBridge } from '@metamask/bridge-controller'; +import { TextColor } from '@metamask/design-system-react'; +import { toChecksumHexAddress } from '../../../shared/modules/hexstring-utils'; + +/** + * Checks if an address represents a native token + * + * @param address - The token address to check + * @returns true if the address is a native token address + */ +export const isNativeAddress = (address: string): boolean => { + if (!address) { + return false; + } + + if (isNativeAddressFromBridge(address)) { + return true; + } + + const normalized = address.toLowerCase(); + return [ + '0', + '0x0', + '0x', + '0x0000000000000000000000000000000000000000', + ].includes(normalized); +}; + +/** + * Formats a percentage value for display + * + * @param value - The percentage value + * @returns Formatted percentage string + */ +export const formatPercentage = (value: number | undefined): string => { + if (value === undefined || value === null) { + return '—'; + } + const sign = value > 0 ? '+' : ''; + return `${sign}${value.toFixed(2)}%`; +}; + +/** + * Formats a currency value in compact notation + * + * @param value - The value to format + * @param currency - The currency code + * @param locale - The locale for formatting (optional) + * @returns Formatted currency string + */ +export const formatCompactCurrency = ( + value: number | undefined, + currency: string, + locale = 'en-US', +): string => { + if (!value) { + return '—'; + } + + const formatter = new Intl.NumberFormat(locale, { + style: 'currency', + currency, + notation: 'compact', + maximumFractionDigits: 2, + }); + + return formatter.format(value); +}; + +/** + * Formats a contract address, handling CAIP format if needed + * + * @param address - The address to format + * @returns Checksummed address for EVM chains, or the original address for non-EVM chains + */ +export const formatContractAddress = (address: string | null): string => { + if (!address) { + return ''; + } + + if (isCaipAssetType(address)) { + const { assetReference } = parseCaipAssetType(address); + + if (isStrictHexString(assetReference)) { + return toChecksumHexAddress(assetReference); + } + + return assetReference; + } + + if (isStrictHexString(address)) { + return toChecksumHexAddress(address); + } + + return address; +}; + +/** + * Gets the text color based on price change + * + * @param change - The price change percentage + * @returns TextColor enum value + */ +export const getPriceChangeColor = (change: number): TextColor => { + if (change > 0) { + return TextColor.SuccessDefault; + } + if (change < 0) { + return TextColor.ErrorDefault; + } + return TextColor.TextDefault; +}; + +/** + * Determines if contract address should be shown + * + * @param address - The token address + * @returns true if contract address should be displayed + */ +export const shouldShowContractAddress = (address: string | null): boolean => { + if (!address) { + return false; + } + if (isNativeAddress(address)) { + return false; + } + + const normalized = address.toLowerCase(); + return !['0', '0x0', '0x'].includes(normalized); +}; diff --git a/ui/hooks/useTokenInsightsData.test.ts b/ui/hooks/useTokenInsightsData.test.ts new file mode 100644 index 000000000000..cfa84af5aef3 --- /dev/null +++ b/ui/hooks/useTokenInsightsData.test.ts @@ -0,0 +1,521 @@ +import { waitFor } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import { + formatChainIdToCaip, + isNativeAddress, +} from '@metamask/bridge-controller'; +import { handleFetch } from '@metamask/controller-utils'; +import { isEvmChainId, toAssetId } from '../../shared/lib/asset-utils'; +import { formatCompactCurrency } from '../helpers/utils/token-insights'; +import { useFormatters } from './useFormatters'; +import { + useTokenInsightsData, + TokenInsightsToken, +} from './useTokenInsightsData'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../selectors', () => ({ + getMarketData: jest.fn((state) => state.marketData), +})); + +jest.mock('../ducks/metamask/metamask', () => ({ + getCurrentCurrency: jest.fn((state) => state.currentCurrency), +})); + +jest.mock('../selectors/selectors', () => ({ + getCurrencyRates: jest.fn((state) => state.currencyRates), +})); + +jest.mock('@metamask/bridge-controller', () => ({ + BridgeClientId: { + EXTENSION: 'extension', + }, + formatChainIdToCaip: jest.fn(), + isNativeAddress: jest.fn(), +})); + +jest.mock('@metamask/utils', () => ({ + isCaipChainId: jest.fn((chainId: string) => chainId.includes(':')), + Hex: {}, +})); + +jest.mock('../../shared/lib/asset-utils', () => ({ + isEvmChainId: jest.fn(), + toAssetId: jest.fn(), +})); + +jest.mock('@metamask/controller-utils', () => ({ + handleFetch: jest.fn(), +})); + +jest.mock('./useFormatters', () => ({ + useFormatters: jest.fn(), +})); + +jest.mock('../helpers/utils/token-insights', () => ({ + formatCompactCurrency: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.Mock; +const mockIsEvmChainId = isEvmChainId as jest.Mock; +const mockIsNativeAddress = isNativeAddress as jest.Mock; +const mockFormatChainIdToCaip = formatChainIdToCaip as jest.Mock; +const mockToAssetId = toAssetId as jest.Mock; +const mockHandleFetch = handleFetch as jest.Mock; +const mockUseFormatters = useFormatters as jest.Mock; +const mockFormatCompactCurrency = formatCompactCurrency as jest.Mock; + +describe('useTokenInsightsData', () => { + const defaultToken: TokenInsightsToken = { + address: '0x1234567890123456789012345678901234567890', + symbol: 'TEST', + name: 'Test Token', + chainId: '0x1', + iconUrl: 'https://example.com/icon.png', + }; + + const defaultMarketData = { + price: 100, + pricePercentChange1d: 5.25, + totalVolume: 1000000, + marketCap: 50000000, + dilutedMarketCap: 55000000, + }; + + const mockEvmMarketData = { + ...defaultMarketData, + currency: 'ETH', + }; + + const defaultCurrencyRates = { + ETH: { conversionRate: 2000 }, + BTC: { conversionRate: 40000 }, + TEST: { conversionRate: 100 }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockIsEvmChainId.mockReturnValue(true); + mockIsNativeAddress.mockReturnValue(false); + mockFormatChainIdToCaip.mockReturnValue('eip155:1'); + mockToAssetId.mockReturnValue( + 'eip155:1/erc20:0x1234567890123456789012345678901234567890', + ); + mockUseFormatters.mockReturnValue({ + formatCurrencyWithMinThreshold: jest + .fn() + .mockImplementation((value) => `$${value}`), + }); + mockFormatCompactCurrency.mockImplementation((value) => { + if (!value) { + return '—'; + } + return `$${(value / 1000000).toFixed(2)}M`; + }); + }); + + describe('EVM tokens with cache data', () => { + it('should return cached market data for EVM tokens', () => { + mockUseSelector + .mockReturnValueOnce('USD') // getCurrentCurrency + .mockReturnValueOnce(defaultCurrencyRates) // getCurrencyRates + .mockReturnValueOnce({ + // getMarketData + '0x1': { + '0x1234567890123456789012345678901234567890': mockEvmMarketData, + }, + }); + + const { result } = renderHook(() => useTokenInsightsData(defaultToken)); + + expect(result.current.marketData).toEqual({ + price: 100, + pricePercentChange1d: 5.25, + totalVolume: 1000000, + marketCap: 50000000, + dilutedMarketCap: 55000000, + }); + expect(result.current.error).toBe(null); + expect(mockHandleFetch).not.toHaveBeenCalled(); + }); + + it('should convert EVM token prices to fiat', () => { + mockUseSelector + .mockReturnValueOnce('USD') // getCurrentCurrency + .mockReturnValueOnce(defaultCurrencyRates) // getCurrencyRates + .mockReturnValueOnce({ + // getMarketData + '0x1': { + '0x1234567890123456789012345678901234567890': mockEvmMarketData, + }, + }); + + const { result } = renderHook(() => useTokenInsightsData(defaultToken)); + + expect(result.current.marketDataFiat.price).toBe(200000); + expect(result.current.marketDataFiat.volume).toBe(2000000000); + expect(result.current.marketDataFiat.marketCap).toBe(110000000000); + expect(result.current.marketDataFiat.formattedPrice).toBe('$200000'); + expect(result.current.marketDataFiat.formattedVolume).toBe('$2000.00M'); + expect(result.current.marketDataFiat.formattedMarketCap).toBe( + '$110000.00M', + ); + }); + + it('should use diluted market cap when available', () => { + mockUseSelector + .mockReturnValueOnce('USD') // getCurrentCurrency + .mockReturnValueOnce(defaultCurrencyRates) // getCurrencyRates + .mockReturnValueOnce({ + // getMarketData + '0x1': { + '0x1234567890123456789012345678901234567890': { + ...mockEvmMarketData, + dilutedMarketCap: 60000000, + }, + }, + }); + + const { result } = renderHook(() => useTokenInsightsData(defaultToken)); + + expect(result.current.marketData?.dilutedMarketCap).toBe(60000000); + }); + + it('should fallback to market cap when diluted market cap is not available', () => { + mockUseSelector + .mockReturnValueOnce('USD') // getCurrentCurrency + .mockReturnValueOnce(defaultCurrencyRates) // getCurrencyRates + .mockReturnValueOnce({ + // getMarketData + '0x1': { + '0x1234567890123456789012345678901234567890': { + ...mockEvmMarketData, + dilutedMarketCap: undefined, + }, + }, + }); + + const { result } = renderHook(() => useTokenInsightsData(defaultToken)); + + expect(result.current.marketData?.dilutedMarketCap).toBe(50000000); + }); + }); + + describe('Non-EVM tokens', () => { + beforeEach(() => { + mockIsEvmChainId.mockReturnValue(false); + }); + + it('should fetch data from API for non-EVM tokens', async () => { + mockUseSelector + .mockReturnValueOnce('USD') // getCurrentCurrency + .mockReturnValueOnce(defaultCurrencyRates) // getCurrencyRates + .mockReturnValueOnce(null); // getMarketData + + const apiResponse = { + 'eip155:1/erc20:0x1234567890123456789012345678901234567890': { + price: 150, + pricePercentChange1d: 3.5, + totalVolume: 2000000, + marketCap: 75000000, + dilutedMarketCap: 80000000, + }, + }; + + mockHandleFetch.mockResolvedValue(apiResponse); + + const { result } = renderHook(() => useTokenInsightsData(defaultToken)); + + // Wait for loading to complete + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.marketData).toEqual({ + price: 150, + pricePercentChange1d: 3.5, + totalVolume: 2000000, + marketCap: 75000000, + dilutedMarketCap: 80000000, + }); + + expect(mockHandleFetch).toHaveBeenCalledWith( + expect.stringContaining( + 'https://price.api.cx.metamask.io/v3/spot-prices', + ), + { + method: 'GET', + headers: { 'X-Client-Id': 'extension' }, + }, + ); + }); + + it('should use direct values for non-EVM tokens without conversion', async () => { + mockUseSelector + .mockReturnValueOnce('USD') // getCurrentCurrency + .mockReturnValueOnce(defaultCurrencyRates) // getCurrencyRates + .mockReturnValueOnce(null); // getMarketData + + const apiResponse = { + 'eip155:1/erc20:0x1234567890123456789012345678901234567890': { + price: 150, + totalVolume: 2000000, + marketCap: 75000000, + }, + }; + + mockHandleFetch.mockResolvedValue(apiResponse); + + const { result } = renderHook(() => useTokenInsightsData(defaultToken)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.marketDataFiat.price).toBe(150); + expect(result.current.marketDataFiat.formattedPrice).toBe('$150'); + expect(result.current.marketDataFiat.formattedVolume).toBe('$2.00M'); + expect(result.current.marketDataFiat.formattedMarketCap).toBe('$75.00M'); + }); + }); + + describe('Native tokens', () => { + beforeEach(() => { + mockIsNativeAddress.mockReturnValue(true); + }); + + it('should identify native tokens correctly', () => { + const nativeToken = { + ...defaultToken, + address: '0x0000000000000000000000000000000000000000', + }; + + const { result } = renderHook(() => useTokenInsightsData(nativeToken)); + + expect(result.current.isNativeToken).toBe(true); + }); + + it('should use token symbol for native token currency conversion', () => { + mockUseSelector + .mockReturnValueOnce('USD') // getCurrentCurrency + .mockReturnValueOnce({ + // getCurrencyRates + ETH: { conversionRate: 2000 }, + }) + .mockReturnValueOnce({ + // getMarketData + '0x1': { + '0x0000000000000000000000000000000000000000': { + price: 1, + currency: 'USD', + }, + }, + }); + + const nativeToken = { + ...defaultToken, + symbol: 'ETH', + address: '0x0000000000000000000000000000000000000000', + }; + + const { result } = renderHook(() => useTokenInsightsData(nativeToken)); + + expect(result.current.marketDataFiat.price).toBe(2000); + expect(result.current.marketDataFiat.formattedPrice).toBe('$2000'); + }); + }); + + describe('API fetching scenarios', () => { + it('should handle API errors gracefully', async () => { + mockUseSelector + .mockReturnValueOnce('USD') // getCurrentCurrency + .mockReturnValueOnce(defaultCurrencyRates) // getCurrencyRates + .mockReturnValueOnce(null); // getMarketData + + mockHandleFetch.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useTokenInsightsData(defaultToken)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe('Network error'); + expect(result.current.marketData).toBe(null); + }); + + it('should handle empty API response', async () => { + mockUseSelector + .mockReturnValueOnce('USD') // getCurrentCurrency + .mockReturnValueOnce(defaultCurrencyRates) // getCurrencyRates + .mockReturnValueOnce(null); // getMarketData + + mockHandleFetch.mockResolvedValue({}); + + const { result } = renderHook(() => useTokenInsightsData(defaultToken)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Empty API response doesn't populate marketData + expect(result.current.marketData).toBe(null); + }); + + it('should use correct URL with currency parameter', async () => { + mockUseSelector + .mockReturnValueOnce('EUR') // getCurrentCurrency + .mockReturnValueOnce(defaultCurrencyRates) // getCurrencyRates + .mockReturnValueOnce(null); // getMarketData + + mockHandleFetch.mockResolvedValue({}); + + renderHook(() => useTokenInsightsData(defaultToken)); + + await waitFor(() => { + expect(mockHandleFetch).toHaveBeenCalled(); + }); + + const callArgs = mockHandleFetch.mock.calls[0][0]; + expect(callArgs).toContain('vsCurrency=eur'); + }); + }); + + describe('Currency conversion', () => { + it('should handle missing exchange rate', () => { + mockUseSelector + .mockReturnValueOnce('USD') // getCurrentCurrency + .mockReturnValueOnce({}) // getCurrencyRates - No rates available + .mockReturnValueOnce({ + // getMarketData + '0x1': { + '0x1234567890123456789012345678901234567890': mockEvmMarketData, + }, + }); + + const { result } = renderHook(() => useTokenInsightsData(defaultToken)); + + // Should use direct values without conversion + expect(result.current.marketDataFiat.price).toBe(100); + expect(result.current.marketDataFiat.formattedPrice).toBe('$100'); + }); + + it('should handle null values in market data', () => { + mockUseSelector + .mockReturnValueOnce('USD') // getCurrentCurrency + .mockReturnValueOnce(defaultCurrencyRates) // getCurrencyRates + .mockReturnValueOnce({ + // getMarketData + '0x1': { + '0x1234567890123456789012345678901234567890': { + price: null, + totalVolume: null, + marketCap: null, + currency: 'ETH', + }, + }, + }); + + const { result } = renderHook(() => useTokenInsightsData(defaultToken)); + + expect(result.current.marketDataFiat.price).toBeUndefined(); + expect(result.current.marketDataFiat.volume).toBeUndefined(); + expect(result.current.marketDataFiat.marketCap).toBeUndefined(); + expect(result.current.marketDataFiat.formattedPrice).toBe('—'); + expect(result.current.marketDataFiat.formattedVolume).toBe('—'); + expect(result.current.marketDataFiat.formattedMarketCap).toBe('—'); + }); + }); + + describe('Edge cases', () => { + it('should handle null token input', () => { + const { result } = renderHook(() => useTokenInsightsData(null)); + + expect(result.current.marketData).toBe(null); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + expect(result.current.isNativeToken).toBe(false); + expect(mockHandleFetch).not.toHaveBeenCalled(); + }); + + it('should handle token without address', () => { + mockUseSelector + .mockReturnValueOnce('USD') // getCurrentCurrency + .mockReturnValueOnce(defaultCurrencyRates) // getCurrencyRates + .mockReturnValueOnce(null); // getMarketData + + const tokenWithoutAddress = { + ...defaultToken, + address: '', + }; + + const { result } = renderHook(() => + useTokenInsightsData(tokenWithoutAddress), + ); + + expect(result.current.isNativeToken).toBe(false); + }); + + it('should handle CAIP chain IDs correctly', () => { + mockUseSelector + .mockReturnValueOnce('USD') // getCurrentCurrency + .mockReturnValueOnce(defaultCurrencyRates) // getCurrencyRates + .mockReturnValueOnce(null); // getMarketData + + const tokenWithCaipChainId = { + ...defaultToken, + chainId: 'eip155:1', + }; + + renderHook(() => useTokenInsightsData(tokenWithCaipChainId)); + + expect(mockFormatChainIdToCaip).not.toHaveBeenCalled(); + }); + + it('should not fetch when token is in cache for EVM', () => { + mockUseSelector + .mockReturnValueOnce('USD') // getCurrentCurrency + .mockReturnValueOnce(defaultCurrencyRates) // getCurrencyRates + .mockReturnValueOnce({ + // getMarketData + '0x1': { + '0x1234567890123456789012345678901234567890': mockEvmMarketData, + }, + }); + + renderHook(() => useTokenInsightsData(defaultToken)); + + expect(mockHandleFetch).not.toHaveBeenCalled(); + }); + + it('should handle zero price change', async () => { + mockUseSelector + .mockReturnValueOnce('USD') // getCurrentCurrency + .mockReturnValueOnce(defaultCurrencyRates) // getCurrencyRates + .mockReturnValueOnce(null); // getMarketData + + const apiResponse = { + 'eip155:1/erc20:0x1234567890123456789012345678901234567890': { + price: 150, + pricePercentChange1d: 0, + totalVolume: 2000000, + marketCap: 75000000, + }, + }; + + mockHandleFetch.mockResolvedValue(apiResponse); + + const { result } = renderHook(() => useTokenInsightsData(defaultToken)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.marketData?.pricePercentChange1d).toBe(0); + }); + }); +}); diff --git a/ui/hooks/useTokenInsightsData.ts b/ui/hooks/useTokenInsightsData.ts new file mode 100644 index 000000000000..3a8ef9311eba --- /dev/null +++ b/ui/hooks/useTokenInsightsData.ts @@ -0,0 +1,255 @@ +import { useEffect, useState, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { + BridgeClientId, + formatChainIdToCaip, + isNativeAddress as isNativeAddressFromBridge, +} from '@metamask/bridge-controller'; +import { isCaipChainId, Hex } from '@metamask/utils'; +import { handleFetch } from '@metamask/controller-utils'; +import { isEvmChainId, toAssetId } from '../../shared/lib/asset-utils'; +import { getMarketData } from '../selectors'; +import { getCurrentCurrency } from '../ducks/metamask/metamask'; +import { getCurrencyRates } from '../selectors/selectors'; +import { formatCompactCurrency } from '../helpers/utils/token-insights'; +import { useFormatters } from './useFormatters'; + +export type TokenInsightsToken = { + address: string | null; + symbol: string; + name?: string; + chainId: string; + iconUrl?: string; +}; + +export type MarketData = { + price?: number; + pricePercentChange1d?: number; + totalVolume?: number; + marketCap?: number; + dilutedMarketCap?: number; +}; + +export type EvmMarketTokenData = { + price?: number; + pricePercentChange1d?: number; + totalVolume?: number; + marketCap?: number; + dilutedMarketCap?: number; + currency?: string; +}; + +export type EvmMarketDataState = Record< + Hex, + Record +>; + +export type CurrencyRatesMap = Record; + +export type TokenInsightsData = { + marketData: MarketData | null; + marketDataFiat: { + price?: number; + volume?: number; + marketCap?: number; + formattedPrice: string; + formattedVolume: string; + formattedMarketCap: string; + }; + isLoading: boolean; + error: string | null; + isNativeToken: boolean; +}; + +export const useTokenInsightsData = ( + token: TokenInsightsToken | null, +): TokenInsightsData => { + const currentCurrency = useSelector(getCurrentCurrency) as string; + const currencyRates = useSelector(getCurrencyRates) as CurrencyRatesMap; + const isEvm = token ? isEvmChainId(token.chainId as Hex) : false; + const { formatCurrencyWithMinThreshold } = useFormatters(); + + // Check TokenRatesController cache (EVM only) + const marketDataState = useSelector(getMarketData) as + | EvmMarketDataState + | undefined; + + const evmMarketData = useMemo(() => { + if (!token || !isEvm || !marketDataState || !token.address) { + return null; + } + const chainData = marketDataState[token.chainId as Hex]; + return chainData?.[token.address.toLowerCase()] || null; + }, [token, isEvm, marketDataState]); + + const [apiData, setApiData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Check if address is native + const isNativeToken = useMemo(() => { + if (!token?.address) { + return false; + } + return isNativeAddressFromBridge(token.address); + }, [token?.address]); + + const baseCurrency = useMemo(() => { + if (!isEvm || !evmMarketData) { + return undefined; + } + if (isNativeToken) { + return token?.symbol; + } + return evmMarketData.currency; + }, [isEvm, isNativeToken, token, evmMarketData]); + + const exchangeRate = baseCurrency + ? currencyRates?.[baseCurrency]?.conversionRate + : undefined; + + // Fetch from API if not in cache + useEffect(() => { + if (!token || evmMarketData) { + return; + } + + const fetchMarketData = async () => { + setIsLoading(true); + setError(null); + + try { + const caipChainId = isCaipChainId(token.chainId) + ? token.chainId + : formatChainIdToCaip(token.chainId as Hex); + const assetId = toAssetId(token.address || '', caipChainId); + + if (!assetId) { + setError('Invalid asset ID'); + return; + } + + const url = `https://price.api.cx.metamask.io/v3/spot-prices?assetIds=${assetId}&includeMarketData=true&vsCurrency=${currentCurrency.toLowerCase()}`; + + const response = await handleFetch(url, { + method: 'GET', + headers: { 'X-Client-Id': BridgeClientId.EXTENSION }, + }); + + const tokenData = assetId ? response?.[assetId] : null; + if (tokenData) { + const marketData: MarketData = { + price: tokenData.price || tokenData.usd, + pricePercentChange1d: + tokenData.pricePercentChange1d || + tokenData.pricePercentChange?.P1D || + 0, + totalVolume: tokenData.totalVolume, + marketCap: tokenData.marketCap, + dilutedMarketCap: tokenData.dilutedMarketCap, + }; + setApiData(marketData); + } + } catch (err) { + setError((err as Error).message); + } finally { + setIsLoading(false); + } + }; + + fetchMarketData(); + }, [token, evmMarketData, currentCurrency]); + + // Combine data sources and convert to fiat for EVM tokens + const marketData = useMemo(() => { + if (evmMarketData) { + return { + price: evmMarketData.price, + pricePercentChange1d: evmMarketData.pricePercentChange1d, + totalVolume: evmMarketData.totalVolume, + marketCap: evmMarketData.marketCap, + dilutedMarketCap: + evmMarketData.dilutedMarketCap ?? evmMarketData.marketCap, + }; + } + return apiData; + }, [evmMarketData, apiData]); + + const marketDataFiat = useMemo(() => { + const formatPriceWithThreshold = (price: number | undefined): string => { + if (!price) { + return '—'; + } + return formatCurrencyWithMinThreshold(price, currentCurrency); + }; + + // For non-EVM tokens or when no exchange rate, use direct values + if (!isEvm || !evmMarketData || !exchangeRate) { + const price = marketData?.price; + const volume = marketData?.totalVolume; + const marketCap = marketData?.dilutedMarketCap ?? marketData?.marketCap; + + return { + price, + volume, + marketCap, + formattedPrice: formatPriceWithThreshold(price), + formattedVolume: formatCompactCurrency(volume, currentCurrency), + formattedMarketCap: formatCompactCurrency(marketCap, currentCurrency), + }; + } + + // For EVM tokens with native currency rates, convert to fiat + let priceFiat: number | undefined; + if (isNativeToken && token?.symbol) { + priceFiat = currencyRates?.[token.symbol]?.conversionRate; + } else if ( + evmMarketData.price !== undefined && + evmMarketData.price !== null + ) { + priceFiat = exchangeRate * Number(evmMarketData.price); + } else { + priceFiat = undefined; + } + + const volumeFiat = + evmMarketData.totalVolume !== undefined && + evmMarketData.totalVolume !== null + ? exchangeRate * Number(evmMarketData.totalVolume) + : undefined; + + const marketCapSource = + evmMarketData.dilutedMarketCap ?? evmMarketData.marketCap; + const marketCapFiat = + marketCapSource !== undefined && marketCapSource !== null + ? exchangeRate * Number(marketCapSource) + : undefined; + + return { + price: priceFiat, + volume: volumeFiat, + marketCap: marketCapFiat, + formattedPrice: formatPriceWithThreshold(priceFiat), + formattedVolume: formatCompactCurrency(volumeFiat, currentCurrency), + formattedMarketCap: formatCompactCurrency(marketCapFiat, currentCurrency), + }; + }, [ + marketData, + isEvm, + evmMarketData, + exchangeRate, + isNativeToken, + token, + currencyRates, + currentCurrency, + formatCurrencyWithMinThreshold, + ]); + + return { + marketData, + marketDataFiat, + isLoading, + error, + isNativeToken, + }; +}; diff --git a/ui/pages/bridge/token-insights-modal/index.ts b/ui/pages/bridge/token-insights-modal/index.ts new file mode 100644 index 000000000000..f6188627c4aa --- /dev/null +++ b/ui/pages/bridge/token-insights-modal/index.ts @@ -0,0 +1 @@ +export { TokenInsightsModal } from './token-insights-modal'; diff --git a/ui/pages/bridge/token-insights-modal/token-insights-modal.test.tsx b/ui/pages/bridge/token-insights-modal/token-insights-modal.test.tsx new file mode 100644 index 000000000000..8a948ccee747 --- /dev/null +++ b/ui/pages/bridge/token-insights-modal/token-insights-modal.test.tsx @@ -0,0 +1,252 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { useTokenInsightsData } from '../../../hooks/useTokenInsightsData'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { MetaMetricsContext } from '../../../contexts/metametrics'; +import { MetaMetricsEventCategory } from '../../../../shared/constants/metametrics'; +import * as tokenInsightsUtils from '../../../helpers/utils/token-insights'; +import { TokenInsightsModal } from './token-insights-modal'; + +jest.mock('../../../hooks/useTokenInsightsData'); +jest.mock('../../../hooks/useI18nContext'); +jest.mock('../../../components/multichain', () => ({ + AddressCopyButton: ({ + address, + shorten, + }: { + address: string; + shorten: boolean; + }) => ( + + ), +})); + +jest.mock('../../../helpers/utils/token-insights', () => ({ + formatPercentage: jest.fn((value: number) => `${value.toFixed(2)}%`), + formatContractAddress: jest.fn((address: string) => address), + shouldShowContractAddress: jest.fn(() => true), + getPriceChangeColor: jest.fn((value: number) => { + if (value > 0) { + return 'success'; + } + if (value < 0) { + return 'error'; + } + return 'default'; + }), +})); + +const mockTrackEvent = jest.fn(); +const mockT = (key: string) => key; + +const defaultToken = { + address: '0x1234567890123456789012345678901234567890', + symbol: 'TEST', + name: 'Test Token', + chainId: '0x1', + iconUrl: 'https://example.com/icon.png', +}; + +const defaultMarketData = { + price: 100, + pricePercentChange1d: 5.25, + volume: 1000000, + marketCap: 50000000, +}; + +const defaultMarketDataFiat = { + formattedPrice: '$100.00', + formattedVolume: '$1.00M', + formattedMarketCap: '$50.00M', +}; + +describe('TokenInsightsModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useI18nContext as jest.Mock).mockReturnValue(mockT); + (useTokenInsightsData as jest.Mock).mockReturnValue({ + marketData: defaultMarketData, + marketDataFiat: defaultMarketDataFiat, + isLoading: false, + }); + // Reset mock implementations + (tokenInsightsUtils.shouldShowContractAddress as jest.Mock).mockReturnValue( + true, + ); + }); + + const renderComponent = (props = {}) => { + const defaultProps = { + isOpen: true, + onClose: jest.fn(), + token: defaultToken, + ...props, + }; + + return render( + + + , + ); + }; + + describe('Rendering', () => { + it('should render modal when isOpen is true', () => { + renderComponent(); + expect(screen.getByTestId('token-insights-icon')).toBeInTheDocument(); + expect(screen.getByText('TEST insights')).toBeInTheDocument(); + expect(screen.getByTestId('token-price')).toBeInTheDocument(); + expect(screen.getByTestId('token-price-change')).toBeInTheDocument(); + expect(screen.getByTestId('token-volume')).toBeInTheDocument(); + expect(screen.getByTestId('token-market-cap')).toBeInTheDocument(); + expect(screen.getByTestId('token-contract-address')).toBeInTheDocument(); + expect(screen.getByText('$100.00')).toBeInTheDocument(); + expect(screen.getByText('5.25%')).toBeInTheDocument(); + expect(screen.getByText('$1.00M')).toBeInTheDocument(); + expect(screen.getByText('$50.00M')).toBeInTheDocument(); + }); + }); + + describe('Price Change Display', () => { + it('should show positive price change for positive price change', () => { + renderComponent(); + const priceChangeRow = screen.getByTestId('token-price-change'); + expect(priceChangeRow).toHaveTextContent('5.25%'); + expect(tokenInsightsUtils.getPriceChangeColor).toHaveBeenCalledWith(5.25); + }); + + it('should show negative price change for negative price change', () => { + (useTokenInsightsData as jest.Mock).mockReturnValue({ + marketData: { + ...defaultMarketData, + pricePercentChange1d: -3.75, + }, + marketDataFiat: defaultMarketDataFiat, + isLoading: false, + }); + + renderComponent(); + const priceChangeRow = screen.getByTestId('token-price-change'); + expect(priceChangeRow).toHaveTextContent('-3.75%'); + expect(tokenInsightsUtils.getPriceChangeColor).toHaveBeenCalledWith( + -3.75, + ); + }); + }); + + describe('Contract Address', () => { + it('should show contract address when shouldShowContractAddress returns true', () => { + renderComponent(); + expect(screen.getByTestId('token-contract-address')).toBeInTheDocument(); + expect( + screen.getByTestId('address-copy-button-text'), + ).toBeInTheDocument(); + }); + + it('should hide contract address when shouldShowContractAddress returns false', () => { + ( + tokenInsightsUtils.shouldShowContractAddress as jest.Mock + ).mockReturnValue(false); + renderComponent(); + expect( + screen.queryByTestId('token-contract-address'), + ).not.toBeInTheDocument(); + }); + + it('should track event when copying contract address', () => { + renderComponent(); + const copyButton = screen.getByTestId('address-copy-button-text'); + fireEvent.click(copyButton); + + expect(mockTrackEvent).toHaveBeenCalledWith({ + event: 'Token Contract Address Copied', + category: MetaMetricsEventCategory.Swaps, + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + token_symbol: 'TEST', + // eslint-disable-next-line @typescript-eslint/naming-convention + token_address: '0x1234567890123456789012345678901234567890', + }, + }); + }); + }); + + describe('Modal Behavior', () => { + it('should call onClose when close button is clicked', () => { + const onClose = jest.fn(); + renderComponent({ onClose }); + + const closeButton = screen.getByLabelText('close'); + fireEvent.click(closeButton); + + expect(onClose).toHaveBeenCalled(); + }); + + it('should close modal when clicking outside', async () => { + const onClose = jest.fn(); + renderComponent({ onClose }); + + // Simulate click outside the modal + fireEvent.mouseDown(document.body); + + expect(onClose).toHaveBeenCalled(); + }); + + it('should not close modal when clicking inside', () => { + const onClose = jest.fn(); + renderComponent({ onClose }); + + const modalContent = screen.getByTestId('token-price'); + fireEvent.mouseDown(modalContent); + + expect(onClose).not.toHaveBeenCalled(); + }); + }); + + describe('Edge Cases', () => { + it('should handle missing price change data', () => { + (useTokenInsightsData as jest.Mock).mockReturnValue({ + marketData: { ...defaultMarketData, pricePercentChange1d: undefined }, + marketDataFiat: defaultMarketDataFiat, + isLoading: false, + }); + + renderComponent(); + expect(screen.getByTestId('token-price-change')).toHaveTextContent( + '0.00%', + ); + }); + + it('should handle missing market data', () => { + (useTokenInsightsData as jest.Mock).mockReturnValue({ + marketData: null, + marketDataFiat: null, + isLoading: false, + }); + + renderComponent(); + expect(screen.getByTestId('token-price')).toHaveTextContent('—'); + expect(screen.getByTestId('token-price-change')).toHaveTextContent( + '0.00%', + ); + expect(screen.getByTestId('token-volume')).toHaveTextContent('—'); + expect(screen.getByTestId('token-market-cap')).toHaveTextContent('—'); + }); + + it('should handle token without icon URL', () => { + const tokenWithoutIcon = { ...defaultToken, iconUrl: undefined }; + renderComponent({ token: tokenWithoutIcon }); + + expect(screen.getByTestId('token-insights-icon')).toBeInTheDocument(); + }); + + it('should handle token with only symbol (no name)', () => { + const tokenWithOnlySymbol = { ...defaultToken, name: undefined }; + renderComponent({ token: tokenWithOnlySymbol }); + + expect(screen.getByText('TEST insights')).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/pages/bridge/token-insights-modal/token-insights-modal.tsx b/ui/pages/bridge/token-insights-modal/token-insights-modal.tsx new file mode 100644 index 000000000000..64486421931c --- /dev/null +++ b/ui/pages/bridge/token-insights-modal/token-insights-modal.tsx @@ -0,0 +1,285 @@ +import React, { useCallback, useEffect } from 'react'; +import { + Text, + Box, + AvatarToken, + Icon, + IconName, + IconSize, + AvatarTokenSize, + TextVariant, + TextColor, + IconColor, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, +} from '@metamask/design-system-react'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, +} from '../../../components/component-library'; +import { AddressCopyButton } from '../../../components/multichain/address-copy-button'; +import { + Display, + FlexDirection, + AlignItems, +} from '../../../helpers/constants/design-system'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { + useTokenInsightsData, + TokenInsightsToken, +} from '../../../hooks/useTokenInsightsData'; +import { + formatPercentage, + formatContractAddress, + shouldShowContractAddress, + getPriceChangeColor, +} from '../../../helpers/utils/token-insights'; +import { MetaMetricsEventCategory } from '../../../../shared/constants/metametrics'; +import { MetaMetricsContext } from '../../../contexts/metametrics'; + +type TokenInsightsModalProps = { + isOpen: boolean; + onClose: () => void; + token: TokenInsightsToken | null; +}; + +type MarketDataRowProps = { + label: string; + value: React.ReactNode; + 'data-testid'?: string; +}; + +const MarketDataRow: React.FC = ({ + label, + value, + 'data-testid': dataTestId, +}) => ( + + + {label} + + {value} + +); + +export const TokenInsightsModal: React.FC = ({ + isOpen, + onClose, + token, +}) => { + const t = useI18nContext(); + const trackEvent = React.useContext(MetaMetricsContext); + const dialogRef = React.useRef(null); + const hasTrackedOpen = React.useRef(false); + + const { marketData, marketDataFiat } = useTokenInsightsData(token); + + useEffect(() => { + if (isOpen && token && !hasTrackedOpen.current) { + hasTrackedOpen.current = true; + trackEvent({ + event: 'Token Insights Modal Opened', + category: MetaMetricsEventCategory.Swaps, + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + token_symbol: token.symbol, + // eslint-disable-next-line @typescript-eslint/naming-convention + token_address: token.address, + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_id: token.chainId, + }, + }); + } + + if (!isOpen) { + hasTrackedOpen.current = false; + } + }, [isOpen, token, trackEvent]); + + // Ensure only the top modal closes on outside click by intercepting + // document mousedown and stopping propagation. + useEffect(() => { + if (!isOpen) { + return () => undefined; + } + const handleDocMouseDownCapture = (event: MouseEvent) => { + const el = dialogRef.current; + // this prevents the modal from closing when clicking on the modal content + if (el && !el.contains(event.target as Node)) { + onClose(); + event.stopPropagation(); + event.stopImmediatePropagation(); + event.preventDefault(); + } + }; + document.addEventListener('mousedown', handleDocMouseDownCapture, true); + return () => { + document.removeEventListener( + 'mousedown', + handleDocMouseDownCapture, + true, + ); + }; + }, [isOpen, onClose]); + + const priceChange24h = marketData?.pricePercentChange1d || 0; + + const handleCopyAddress = useCallback(() => { + if (token) { + trackEvent({ + event: 'Token Contract Address Copied', + category: MetaMetricsEventCategory.Swaps, + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + token_symbol: token.symbol, + // eslint-disable-next-line @typescript-eslint/naming-convention + token_address: token.address, + }, + }); + } + }, [token, trackEvent]); + + if (!token) { + return null; + } + + return ( + + + e.stopPropagation()} + onMouseDown={(e: React.MouseEvent) => e.stopPropagation()} + onPointerDown={(e: React.PointerEvent) => e.stopPropagation()} + modalDialogProps={{ + ref: dialogRef as React.RefObject, + onMouseDown: (e: React.MouseEvent) => e.stopPropagation(), + onPointerDown: (e: React.PointerEvent) => e.stopPropagation(), + onClick: (e: React.MouseEvent) => e.stopPropagation(), + }} + > + + + + {token.symbol || token.name} {t('insights')} + + + + {/* Market Data */} + + + {marketDataFiat?.formattedPrice || '—'} + + } + data-testid="token-price" + /> + + + {priceChange24h !== 0 && ( + 0 + ? IconName.Arrow2Up + : IconName.Arrow2Down + } + color={ + priceChange24h > 0 + ? IconColor.SuccessDefault + : IconColor.ErrorDefault + } + size={IconSize.Sm} + /> + )} + + {formatPercentage(priceChange24h)} + + + } + data-testid="token-price-change" + /> + + + {marketDataFiat?.formattedVolume || '—'} + + } + data-testid="token-volume" + /> + + + {marketDataFiat?.formattedMarketCap || '—'} + + } + data-testid="token-market-cap" + /> + + {/* Contract Address */} + {shouldShowContractAddress(token.address) && ( + { + e.stopPropagation(); + handleCopyAddress(); + }} + onMouseDown={(e: React.MouseEvent) => e.stopPropagation()} + onPointerDown={(e: React.PointerEvent) => + e.stopPropagation() + } + > + + + } + data-testid="token-contract-address" + /> + )} + + + + + ); +}; From 995ddafaac11f2922380ae51a4ff5635b6628793 Mon Sep 17 00:00:00 2001 From: David Drazic Date: Mon, 17 Nov 2025 09:57:47 +0100 Subject: [PATCH 015/154] fix: Deep link page UI (#37872) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR adds UI changes to the deep link page. - Added new page design and image for `Error 404 - Page not found`. - Updated layout and components to better match [design specified on Figma](https://www.figma.com/design/kCPHsjZYCQXUlN4Ta67FhT/Deeplinking-warning-page?node-id=0-1&p=f&t=e3M6zHNCHMsMpHGZ-0). [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/37872?quickstart=1) Figma design reference: https://www.figma.com/design/kCPHsjZYCQXUlN4Ta67FhT/Deeplinking-warning-page?node-id=0-1&p=f&t=e3M6zHNCHMsMpHGZ-0 ## **Changelog** CHANGELOG entry: Fixed deep link page design inconsistencies ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/5213 ## **Manual testing steps** 1. Try using different variations of deep link that would trigger different pages and messages. 2. Make sure that interstitial page is displayed correctly. **Deep links that can be used for testing** - Error 404: Page not found: http://link.metamask.io/whatever - Swaps page without signature (interstitial page is displayed): https://link.metamask.io/swap - Swaps page with signature (no regular interstitial): https://link.metamask.io/swap?sig=AWqgclBcX7wDKXJ-ZbABoRU2pzVS7xQAA5UsIuWEzKVchvqyYos_w0At4zR33_0wJdFAypIJM4VgboiU3ghhUQ ## **Screenshots/Recordings** ### **Before** Screenshot 2025-11-14 at 19 53 24 Screenshot 2025-11-14 at 19 53 33 Screenshot 2025-11-14 at 19 53 40 ### **After** Flask build, dark theme: Screenshot 2025-11-14 at 19 33 13 Screenshot 2025-11-14 at 19 33 24 Screenshot 2025-11-14 at 19 33 40 Normal build, light theme: Screenshot 2025-11-14 at 19 35 29 Screenshot 2025-11-14 at 19 35 38 Screenshot 2025-11-14 at 19 35 46 ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/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. --- > [!NOTE] > Redesigns the deep link interstitial with centered layout/styling, adds explicit 404 state with illustration, and refines state handling across parsing and effects. > > - **Frontend (Deep Link UI)**: > - **Page redesign**: Rebuilds `ui/pages/deep-link/deep-link.tsx` layout using `Container` + `Box` with centered flex, background, border, padding, and full-width sections; updates title to bold and adjusts spacing; renders description via `Text` with alternative color. > - **404 handling**: Introduces `pageNotFoundError` state; updates `set404` and `updateStateFromUrl` to set/reset it; conditionally displays a new 404 image; adds `alt` text; updates effect deps to include setter. > - **CTA and controls**: Makes primary `Button` full-width; wraps signed-route checkbox/label in a full-width styled row; preserves redirect/verification logic. > - **Design tokens**: Adds usage of `AlignItems`, `JustifyContent`, `BorderColor`, `TextColor` and related constants. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1f74349e7666b64b63e4a42eec3cc675607cd1c5. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/images/deep-link-error-404.png | Bin 0 -> 32520 bytes ui/pages/deep-link/deep-link.tsx | 86 ++++++++++++++++++++++------- 2 files changed, 65 insertions(+), 21 deletions(-) create mode 100644 app/images/deep-link-error-404.png diff --git a/app/images/deep-link-error-404.png b/app/images/deep-link-error-404.png new file mode 100644 index 0000000000000000000000000000000000000000..575ebc8852de81ad1508df3e6178d2c0e75d1e22 GIT binary patch literal 32520 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 literal 0 HcmV?d00001 diff --git a/ui/pages/deep-link/deep-link.tsx b/ui/pages/deep-link/deep-link.tsx index 122ebffeb179..6b6596dc1a17 100644 --- a/ui/pages/deep-link/deep-link.tsx +++ b/ui/pages/deep-link/deep-link.tsx @@ -11,13 +11,17 @@ import { parse } from '../../../shared/lib/deep-links/parse'; import { DEEP_LINK_HOST } from '../../../shared/lib/deep-links/constants'; import { useI18nContext } from '../../hooks/useI18nContext'; import { + AlignItems, BackgroundColor, BlockSize, + BorderColor, BorderRadius, Display, FlexDirection, FontWeight, + JustifyContent, TextAlign, + TextColor, TextVariant, } from '../../helpers/constants/design-system'; import { Text } from '../../components/component-library/text/text'; @@ -52,14 +56,17 @@ const { getExtensionURL } = globalThis.platform; * @param setDescription - The function to call to set the description state. * @param setTitle - The function to call to set the title state. * @param t - The translation function. + * @param setPageNotFoundError - The function to call to set the error 404 state. */ function set404( setDescription: React.Dispatch>, setTitle: React.Dispatch>, t: TranslateFunction, + setPageNotFoundError: React.Dispatch>, ) { setDescription(t('deepLink_Error404Description')); setTitle(t('deepLink_Error404Title')); + setPageNotFoundError(true); } /** @@ -75,6 +82,7 @@ function set404( * @param setCta - The function to call to set the call-to-action state. * @param t - The translation function. * @param abortController + * @param setPageNotFoundError - The function to call to set the error 404 state. */ async function updateStateFromUrl( urlPathAndQuery: string, @@ -86,6 +94,7 @@ async function updateStateFromUrl( setCta: React.Dispatch>, t: TranslateFunction, abortController: AbortController, + setPageNotFoundError: React.Dispatch>, ) { try { const fullUrlStr = `https://${DEEP_LINK_HOST}${urlPathAndQuery}`; @@ -119,9 +128,10 @@ async function updateStateFromUrl( signed ? t('deepLink_RedirectingToMetaMask') : t('deepLink_Caution'), ); setCta(t('deepLink_Continue', [t(title)])); + setPageNotFoundError(false); } else { setRoute(null); - set404(setDescription, setTitle, t); + set404(setDescription, setTitle, t, setPageNotFoundError); setCta(t('deepLink_GoToTheHomePageButton')); const signature = await verify(url); @@ -149,6 +159,7 @@ async function updateStateFromUrl( setRoute(null); setTitle(t('deepLink_ErrorOtherTitle')); setCta(t('deepLink_GoToTheHomePageButton')); + setPageNotFoundError(false); } finally { setIsLoading(false); } @@ -171,6 +182,7 @@ export const DeepLink = ({ location }: DeepLinkProps) => { ); const [description, setDescription] = useState(null); + const [pageNotFoundError, setPageNotFoundError] = useState(false); const [extraDescription, setExtraDescription] = useState(null); const [route, setRoute] = useState(null); const [title, setTitle] = useState(null); @@ -199,7 +211,7 @@ export const DeepLink = ({ location }: DeepLinkProps) => { setRoute(null); setIsLoading(false); if (errorCode === '404') { - set404(setDescription, setTitle, t); + set404(setDescription, setTitle, t, setPageNotFoundError); if (urlStr) { try { @@ -241,6 +253,7 @@ export const DeepLink = ({ location }: DeepLinkProps) => { setDescription(null); setExtraDescription(null); setTitle(t('deepLink_ErrorMissingUrl')); + setPageNotFoundError(false); } setCta(t('deepLink_GoToTheHomePageButton')); return; @@ -256,6 +269,7 @@ export const DeepLink = ({ location }: DeepLinkProps) => { setCta, t, abortController, + setPageNotFoundError, ); }; @@ -263,7 +277,7 @@ export const DeepLink = ({ location }: DeepLinkProps) => { // Cleanup function return () => abortController.abort(); - }, [location.search, t]); + }, [location.search, t, setPageNotFoundError]); // Cleanup on unmount useEffect(() => () => abortControllerRef.current?.abort(), []); @@ -276,15 +290,42 @@ export const DeepLink = ({ location }: DeepLinkProps) => { return ( - <> - - + + + {pageNotFoundError ? ( + Error 404: Page not found + ) : ( + MetaMask logo + )} {isLoading && ( { {title} @@ -313,23 +354,26 @@ export const DeepLink = ({ location }: DeepLinkProps) => { data-testid="deep-link-description" paddingBottom={12} > - + {description} - + {extraDescription ? ( - - {extraDescription} - + {extraDescription} ) : ( '' )} )} - + {route?.signed ? ( { )} - + ); }; From 3e614376808f826348e16ccac5df796e2da3c852 Mon Sep 17 00:00:00 2001 From: Nguyen Anh Tu Date: Mon, 17 Nov 2025 16:39:29 +0700 Subject: [PATCH 016/154] fix: format currency fraction digit (#37893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/37893?quickstart=1) Fix format currency fraction digit display in shield settings screen billing details for card payment, show 2 fraction digit if has fraction and 0 if no fraction ## **Changelog** CHANGELOG entry: fix fraction digit display in shield settings billing details for card payment ## **Related issues** Fixes: ## **Manual testing steps** 1. subscribe to shield with card 2. go to shield settings screen ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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. --- > [!NOTE] > Updates fiat charge formatting in Shield settings to show up to 2 decimal places (0 if none). > > - **Frontend** > - **Settings › Transaction Shield**: Update `formatCurrency` in `ui/pages/settings/transaction-shield-tab/transaction-shield.tsx` to use `{ maximumFractionDigits: 2, minimumFractionDigits: 0 }` for fiat charges, enabling up to 2 decimals while showing 0 when not needed. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 4df991ad11dcdd579d3b4fc9dd71dde423ba5f7e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../settings/transaction-shield-tab/transaction-shield.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/pages/settings/transaction-shield-tab/transaction-shield.tsx b/ui/pages/settings/transaction-shield-tab/transaction-shield.tsx index 70910a40ff0c..85da24dc65f5 100644 --- a/ui/pages/settings/transaction-shield-tab/transaction-shield.tsx +++ b/ui/pages/settings/transaction-shield-tab/transaction-shield.tsx @@ -882,7 +882,8 @@ const TransactionShield = () => { getProductPrice(productInfo as Product), productInfo?.currency.toUpperCase(), { - maximumFractionDigits: 0, + maximumFractionDigits: 2, + minimumFractionDigits: 0, }, )} (${displayedShieldSubscription.interval === RECURRING_INTERVALS.year ? t('shieldPlanAnnual') : t('shieldPlanMonthly')})`, 'shield-detail-charges', From 7d89d43abba40ce2f5677f01b92a438dc0bac0f8 Mon Sep 17 00:00:00 2001 From: sophieqgu <37032128+sophieqgu@users.noreply.github.com> Date: Mon, 17 Nov 2025 05:50:26 -0500 Subject: [PATCH 017/154] chore: update rewards metametrics (#37873) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** https://consensyssoftware.atlassian.net/browse/RWDS-268 Part 2 of https://github.com/MetaMask/metamask-extension/pull/36827 pertaining to metametrics changes [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/37873?quickstart=1) ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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. --- > [!NOTE] > Expose a MetaMetrics traits update API and add rewards-specific traits, events, and category constants. > > - **MetaMetrics**: > - Expose `updateMetaMetricsTraits` via `getApi()` in `app/scripts/metamask-controller.js` (binds to `metaMetricsController.updateTraits`). > - Extend rewards-related tracking in `shared/constants/metametrics.ts`: > - Add user traits: `has_rewards_opted_in`, `rewards_referred`, `rewards_referral_code_used` and corresponding `MetaMetricsUserTrait` enums. > - Add event names for rewards flows: `REWARDS_OPT_IN_*` and `REWARDS_ACCOUNT_LINKING_*`. > - Add `Rewards` to `MetaMetricsEventCategory`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9773647e9929e56b46217d1fc6b286c83a4d3379. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/scripts/metamask-controller.js | 3 +++ shared/constants/metametrics.ts | 32 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 1e2ad7e9e46b..300ac4c7c674 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -3297,6 +3297,9 @@ export default class MetamaskController extends EventEmitter { metaMetricsController, ), trackInsightSnapView: this.trackInsightSnapView.bind(this), + updateMetaMetricsTraits: metaMetricsController.updateTraits.bind( + metaMetricsController, + ), // MetaMetrics buffering for onboarding addEventBeforeMetricsOptIn: diff --git a/shared/constants/metametrics.ts b/shared/constants/metametrics.ts index 08aaf889e2a5..0fdf04ed3a30 100644 --- a/shared/constants/metametrics.ts +++ b/shared/constants/metametrics.ts @@ -541,6 +541,24 @@ export type MetaMetricsUserTraits = { // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention profile_id?: string; + /** + * Whether the user has opted into Rewards. + */ + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + has_rewards_opted_in?: string; + /** + * Whether the user was referred when opting into Rewards. + */ + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + rewards_referred?: boolean; + /** + * The referral code used when opting into Rewards. + */ + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + rewards_referral_code_used?: string; }; export enum MetaMetricsUserTrait { @@ -647,6 +665,12 @@ export enum MetaMetricsUserTrait { * Identified when the user adds or removes configured chains (evm or non-evm) */ ChainIdList = 'chain_id_list', + /** + * Rewards-specific traits + */ + HasRewardsOptedIn = 'has_rewards_opted_in', + RewardsReferred = 'rewards_referred', + RewardsReferralCodeUsed = 'rewards_referral_code_used', } /** @@ -942,6 +966,13 @@ export enum MetaMetricsEventName { // Extension Port Stream PortStreamChunked = 'Port Stream Chunked', ViewportSwitched = 'Viewport Switched', + // Rewards + RewardsOptInStarted = 'REWARDS_OPT_IN_STARTED', + RewardsOptInFailed = 'REWARDS_OPT_IN_FAILED', + RewardsOptInCompleted = 'REWARDS_OPT_IN_COMPLETED', + RewardsAccountLinkingStarted = 'REWARDS_ACCOUNT_LINKING_STARTED', + RewardsAccountLinkingCompleted = 'REWARDS_ACCOUNT_LINKING_COMPLETED', + RewardsAccountLinkingFailed = 'REWARDS_ACCOUNT_LINKING_FAILED', // Shield ShieldEntryModal = 'Shield Entry Modal', ShieldSubscriptionRequest = 'Shield Subscription Request', @@ -1022,6 +1053,7 @@ export enum MetaMetricsEventCategory { Confirmations = 'Confirmations', CrossChainSwaps = 'Cross Chain Swaps', PortStream = 'Port Stream', + Rewards = 'Rewards', Shield = 'Shield', } From 30c0ec12270505347f9af05bad0b0c0056b25088 Mon Sep 17 00:00:00 2001 From: sophieqgu <37032128+sophieqgu@users.noreply.github.com> Date: Mon, 17 Nov 2025 05:55:23 -0500 Subject: [PATCH 018/154] feat: rewards onboarding tour controller and data services (#37871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** https://consensyssoftware.atlassian.net/browse/RWDS-268 Part 1 of https://github.com/MetaMask/metamask-extension/pull/36827 pertaining to rewards controller and data services changes [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/37871?quickstart=1) ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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. --- > [!NOTE] > Refactors Rewards opt-in to accept provided accounts, renames/fixes geo metadata API, moves shared DTOs, defaults Rewards API base URL to PRD, and surfaces new rewards methods via MetaMaskController. > > - **Rewards Controller**: > - Refactor `optIn` to `optIn(accounts, referralCode?)`; iterates provided accounts, links remaining on success, throws if all fail. > - Rename `getGeoRewardsMetadata` → `getRewardsGeoMetadata`; cache behavior preserved. > - Register updated handlers (`getRewardsGeoMetadata`, `linkAccountsToSubscriptionCandidate`, etc.) and expose new utilities (`getCandidateSubscriptionId`, `isOptInSupported`, `getOptInStatus`). > - Internal opt-in flow simplified; consistent state updates and token storage. > - **Shared Types**: > - Move `RewardsGeoMetadata`, `OptInStatusInputDto`, `OptInStatusDto`, `OptOutDto` to `shared/types/rewards` and update imports; remove duplicates from controller types. > - **Rewards Data Service**: > - Default API base URL to `PRD` (including non-prod) and update tests/URLs. > - Add `REWARDS_ERROR_MESSAGES`; use as default messages for `AuthorizationFailedError` and `SeasonNotFoundError`. > - Minor response handling tweaks and new endpoints wired (`getSeasonMetadata`, `getDiscoverSeasons`). > - **MetaMask Controller (UI API)**: > - Expose rewards APIs: `validateRewardsReferralCode`, `getRewardsGeoMetadata`, `rewardsOptIn`, `rewardsIsOptInSupported`, `rewardsGetOptInStatus`, `rewardsLinkAccountsToSubscriptionCandidate`. > - Misc fixes: rename `subscriptionService` → `SubscriptionService`; update Shield subscription metrics capture; add `updateMetaMetricsTraits`. > - **Tests**: > - Update and expand unit tests for new opt-in signature/behavior, geo method rename, PRD URLs, and new data service endpoints. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fc7f860f29df323d7a94f0d0b09d7483251df44e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../rewards/rewards-controller.test.ts | 204 +++++++++++++----- .../controllers/rewards/rewards-controller.ts | 101 +++++---- .../rewards/rewards-controller.types.ts | 71 +----- .../rewards/rewards-data-service.test.ts | 22 +- .../rewards/rewards-data-service.ts | 27 +-- app/scripts/metamask-controller.js | 18 ++ shared/constants/rewards.ts | 8 + shared/types/rewards.ts | 69 ++++++ 8 files changed, 335 insertions(+), 185 deletions(-) diff --git a/app/scripts/controllers/rewards/rewards-controller.test.ts b/app/scripts/controllers/rewards/rewards-controller.test.ts index dd7ddd518605..e36724b3420b 100644 --- a/app/scripts/controllers/rewards/rewards-controller.test.ts +++ b/app/scripts/controllers/rewards/rewards-controller.test.ts @@ -1115,7 +1115,25 @@ describe('RewardsController', () => { describe('optIn', () => { it('should return null when rewards are disabled', async () => { await withController({ isDisabled: true }, async ({ controller }) => { - const result = await controller.optIn(); + const result = await controller.optIn([MOCK_INTERNAL_ACCOUNT]); + + expect(result).toBeNull(); + }); + }); + + it('should return null when accounts is null', async () => { + await withController({ isDisabled: false }, async ({ controller }) => { + const result = await controller.optIn( + null as unknown as InternalAccount[], + ); + + expect(result).toBeNull(); + }); + }); + + it('should return null when accounts is empty array', async () => { + await withController({ isDisabled: false }, async ({ controller }) => { + const result = await controller.optIn([]); expect(result).toBeNull(); }); @@ -1126,12 +1144,6 @@ describe('RewardsController', () => { { isDisabled: false }, async ({ controller, mockMessengerCall }) => { mockMessengerCall.mockImplementation((actionType) => { - if ( - actionType === - 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [MOCK_INTERNAL_ACCOUNT]; - } if (actionType === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xmocksignature'); } @@ -1141,7 +1153,10 @@ describe('RewardsController', () => { return undefined; }); - const result = await controller.optIn('REF123'); + const result = await controller.optIn( + [MOCK_INTERNAL_ACCOUNT], + 'REF123', + ); expect(result).toBe(MOCK_SUBSCRIPTION_ID); expect( @@ -1169,12 +1184,6 @@ describe('RewardsController', () => { }, async ({ controller, mockMessengerCall }) => { mockMessengerCall.mockImplementation((actionType) => { - if ( - actionType === - 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [MOCK_INTERNAL_ACCOUNT]; - } if (actionType === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xmocksignature'); } @@ -1195,18 +1204,131 @@ describe('RewardsController', () => { return undefined; }); - const result = await controller.optIn(); + const result = await controller.optIn([MOCK_INTERNAL_ACCOUNT]); expect(result).toBe(MOCK_SUBSCRIPTION_ID); }, ); }); + + it('should throw error when all accounts fail to opt in', async () => { + await withController( + { isDisabled: false }, + async ({ controller, mockMessengerCall }) => { + mockMessengerCall.mockImplementation((actionType) => { + if (actionType === 'KeyringController:signPersonalMessage') { + return Promise.resolve('0xmocksignature'); + } + if (actionType === 'RewardsDataService:mobileOptin') { + return Promise.reject(new Error('Opt-in failed')); + } + return undefined; + }); + + await expect( + controller.optIn([MOCK_INTERNAL_ACCOUNT]), + ).rejects.toThrow( + 'Failed to opt in any account from the account group', + ); + }, + ); + }); + + it('should link remaining accounts when one account succeeds', async () => { + const account2: InternalAccount = { + ...MOCK_INTERNAL_ACCOUNT, + id: 'account-2', + address: MOCK_ACCOUNT_ADDRESS_ALT, + }; + + await withController( + { + isDisabled: false, + state: { + rewardsSubscriptions: { + [MOCK_SUBSCRIPTION_ID]: MOCK_SUBSCRIPTION, + }, + rewardsSubscriptionTokens: { + [MOCK_SUBSCRIPTION_ID]: MOCK_SESSION_TOKEN, + }, + }, + }, + async ({ controller, mockMessengerCall }) => { + let optInCallCount = 0; + mockMessengerCall.mockImplementation((actionType) => { + if (actionType === 'KeyringController:signPersonalMessage') { + return Promise.resolve('0xmocksignature'); + } + if (actionType === 'RewardsDataService:mobileOptin') { + optInCallCount += 1; + if (optInCallCount === 1) { + // First account fails + return Promise.reject(new Error('First account failed')); + } + // Second account succeeds + return Promise.resolve(MOCK_LOGIN_RESPONSE); + } + if (actionType === 'RewardsDataService:mobileJoin') { + return Promise.resolve(MOCK_SUBSCRIPTION); + } + return undefined; + }); + + const result = await controller.optIn([ + MOCK_INTERNAL_ACCOUNT, + account2, + ]); + + expect(result).toBe(MOCK_SUBSCRIPTION_ID); + expect(optInCallCount).toBe(2); + }, + ); + }); + + it('should opt in with multiple accounts and link remaining ones', async () => { + const account2: InternalAccount = { + ...MOCK_INTERNAL_ACCOUNT, + id: 'account-2', + address: MOCK_ACCOUNT_ADDRESS_ALT, + }; + + await withController( + { isDisabled: false }, + async ({ controller, mockMessengerCall }) => { + mockMessengerCall.mockImplementation((actionType) => { + if (actionType === 'KeyringController:signPersonalMessage') { + return Promise.resolve('0xmocksignature'); + } + if (actionType === 'RewardsDataService:mobileOptin') { + return Promise.resolve(MOCK_LOGIN_RESPONSE); + } + if (actionType === 'RewardsDataService:mobileJoin') { + return Promise.resolve(MOCK_SUBSCRIPTION); + } + return undefined; + }); + + const result = await controller.optIn( + [MOCK_INTERNAL_ACCOUNT, account2], + 'REF123', + ); + + expect(result).toBe(MOCK_SUBSCRIPTION_ID); + expect( + controller.state.rewardsAccounts[MOCK_CAIP_ACCOUNT], + ).toMatchObject({ + hasOptedIn: true, + subscriptionId: MOCK_SUBSCRIPTION_ID, + }); + }, + ); + }); }); describe('getGeoRewardsMetadata', () => { it('should return unknown location when rewards are disabled', async () => { await withController({ isDisabled: true }, async ({ controller }) => { - const result = await controller.getGeoRewardsMetadata(); + const result = await controller.getRewardsGeoMetadata(); expect(result).toEqual({ geoLocation: 'UNKNOWN', @@ -1226,7 +1348,7 @@ describe('RewardsController', () => { return undefined; }); - const result = await controller.getGeoRewardsMetadata(); + const result = await controller.getRewardsGeoMetadata(); expect(result).toEqual({ geoLocation: 'US', @@ -1234,7 +1356,7 @@ describe('RewardsController', () => { }); // Verify caching - second call should not fetch again - const cachedResult = await controller.getGeoRewardsMetadata(); + const cachedResult = await controller.getRewardsGeoMetadata(); expect(cachedResult).toEqual(result); }, ); @@ -1251,7 +1373,7 @@ describe('RewardsController', () => { return undefined; }); - const result = await controller.getGeoRewardsMetadata(); + const result = await controller.getRewardsGeoMetadata(); expect(result).toEqual({ geoLocation: 'UK', @@ -2562,24 +2684,11 @@ describe('Additional RewardsController edge cases', () => { describe('optIn - edge cases', () => { it('should return null when no accounts in account group', async () => { - await withController( - { isDisabled: false }, - async ({ controller, mockMessengerCall }) => { - mockMessengerCall.mockImplementation((actionType) => { - if ( - actionType === - 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return null; - } - return undefined; - }); - - const result = await controller.optIn(); + await withController({ isDisabled: false }, async ({ controller }) => { + const result = await controller.optIn([]); - expect(result).toBeNull(); - }, - ); + expect(result).toBeNull(); + }); }); it('should throw error when all accounts fail to opt in', async () => { @@ -2587,19 +2696,15 @@ describe('Additional RewardsController edge cases', () => { { isDisabled: false }, async ({ controller, mockMessengerCall }) => { mockMessengerCall.mockImplementation((actionType) => { - if ( - actionType === - 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [MOCK_INTERNAL_ACCOUNT]; - } if (actionType === 'KeyringController:signPersonalMessage') { return Promise.reject(new Error('Signature failed')); } return undefined; }); - await expect(controller.optIn()).rejects.toThrow( + await expect( + controller.optIn([MOCK_INTERNAL_ACCOUNT]), + ).rejects.toThrow( 'Failed to opt in any account from the account group', ); }, @@ -2617,12 +2722,6 @@ describe('Additional RewardsController edge cases', () => { { isDisabled: false }, async ({ controller, mockMessengerCall }) => { mockMessengerCall.mockImplementation((actionType) => { - if ( - actionType === - 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [MOCK_INTERNAL_ACCOUNT, account2]; - } if (actionType === 'KeyringController:signPersonalMessage') { return Promise.resolve('0xmocksignature'); } @@ -2635,7 +2734,10 @@ describe('Additional RewardsController edge cases', () => { return undefined; }); - const result = await controller.optIn('REF123'); + const result = await controller.optIn( + [MOCK_INTERNAL_ACCOUNT, account2], + 'REF123', + ); expect(result).toBe(MOCK_SUBSCRIPTION_ID); }, @@ -3050,7 +3152,7 @@ describe('Additional RewardsController edge cases', () => { return undefined; }); - const result = await controller.getGeoRewardsMetadata(); + const result = await controller.getRewardsGeoMetadata(); expect(result).toEqual({ geoLocation: 'UNKNOWN', diff --git a/app/scripts/controllers/rewards/rewards-controller.ts b/app/scripts/controllers/rewards/rewards-controller.ts index 14ca33bde367..22b8a0b4eedd 100644 --- a/app/scripts/controllers/rewards/rewards-controller.ts +++ b/app/scripts/controllers/rewards/rewards-controller.ts @@ -18,6 +18,9 @@ import { SeasonDtoState, SeasonStatusState, SeasonTierState, + RewardsGeoMetadata, + OptInStatusInputDto, + OptInStatusDto, } from '../../../../shared/types/rewards'; import { type RewardsControllerState, @@ -26,9 +29,6 @@ import { type SeasonTierDto, type SeasonStatusDto, type SubscriptionDto, - type OptInStatusInputDto, - type OptInStatusDto, - GeoRewardsMetadata, SeasonStateDto, SeasonMetadataDto, DiscoverSeasonsDto, @@ -209,7 +209,7 @@ export class RewardsController extends BaseController< RewardsControllerState, RewardsControllerMessenger > { - #geoLocation: GeoRewardsMetadata | null = null; + #geoLocation: RewardsGeoMetadata | null = null; #isDisabled: () => boolean; @@ -368,7 +368,7 @@ export class RewardsController extends BaseController< ); this.messenger.registerActionHandler( 'RewardsController:getGeoRewardsMetadata', - this.getGeoRewardsMetadata.bind(this), + this.getRewardsGeoMetadata.bind(this), ); this.messenger.registerActionHandler( 'RewardsController:validateReferralCode', @@ -1378,18 +1378,18 @@ export class RewardsController extends BaseController< /** * Perform the complete opt-in process for rewards * + * @param accounts - The accounts to opt in * @param referralCode - Optional referral code */ - async optIn(referralCode?: string): Promise { + async optIn( + accounts: InternalAccount[], + referralCode?: string, + ): Promise { const rewardsEnabled = this.isRewardsFeatureEnabled(); if (!rewardsEnabled) { return null; } - const accounts = await this.messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ); - if (!accounts || accounts.length === 0) { return null; } @@ -1408,7 +1408,7 @@ export class RewardsController extends BaseController< try { optinResult = await this.#optIn(accountToTry, referralCode); } catch { - // Silent auth failed for this account + // Allow one failure to pass through } if (optinResult) { @@ -1506,47 +1506,44 @@ export class RewardsController extends BaseController< throw error; } }; - try { - const optinResponse = await executeMobileOptin(timestamp, signature); - // Store the subscription token for authenticated requests - if (optinResponse.subscription?.id && optinResponse.sessionId) { - this.#storeSubscriptionToken( - optinResponse.subscription.id, - optinResponse.sessionId, - ); - } - // Update state with opt-in response data - this.update((state) => { - const caipAccount: CaipAccountId | null = - this.convertInternalAccountToCaipAccountId(account); - if (!caipAccount) { - return; - } - const accountState: RewardsAccountState = { - account: caipAccount, - hasOptedIn: true, - subscriptionId: optinResponse.subscription.id, - perpsFeeDiscount: null, - lastPerpsDiscountRateFetched: null, - }; - if ( - state.rewardsActiveAccount && - state.rewardsActiveAccount.account === caipAccount - ) { - state.rewardsActiveAccount = accountState; - } - state.rewardsAccounts[caipAccount] = accountState; - state.rewardsSubscriptions[optinResponse.subscription.id] = - optinResponse.subscription; - }); - return { - subscription: optinResponse.subscription, - sessionId: optinResponse.sessionId, - }; - } catch (error) { - return null; + const optinResponse = await executeMobileOptin(timestamp, signature); + // Store the subscription token for authenticated requests + if (optinResponse.subscription?.id && optinResponse.sessionId) { + this.#storeSubscriptionToken( + optinResponse.subscription.id, + optinResponse.sessionId, + ); } + // Update state with opt-in response data + this.update((state) => { + const caipAccount: CaipAccountId | null = + this.convertInternalAccountToCaipAccountId(account); + if (!caipAccount) { + return; + } + const accountState: RewardsAccountState = { + account: caipAccount, + hasOptedIn: true, + subscriptionId: optinResponse.subscription.id, + perpsFeeDiscount: null, + lastPerpsDiscountRateFetched: null, + }; + if ( + state.rewardsActiveAccount && + state.rewardsActiveAccount.account === caipAccount + ) { + state.rewardsActiveAccount = accountState; + } + + state.rewardsAccounts[caipAccount] = accountState; + state.rewardsSubscriptions[optinResponse.subscription.id] = + optinResponse.subscription; + }); + return { + subscription: optinResponse.subscription, + sessionId: optinResponse.sessionId, + }; } /** @@ -1554,7 +1551,7 @@ export class RewardsController extends BaseController< * * @returns Promise - The geo rewards metadata */ - async getGeoRewardsMetadata(): Promise { + async getRewardsGeoMetadata(): Promise { const rewardsEnabled = this.isRewardsFeatureEnabled(); if (!rewardsEnabled) { return { @@ -1578,7 +1575,7 @@ export class RewardsController extends BaseController< (blockedRegion) => geoLocation.startsWith(blockedRegion), ); - const result: GeoRewardsMetadata = { + const result: RewardsGeoMetadata = { geoLocation, optinAllowedForGeo, }; diff --git a/app/scripts/controllers/rewards/rewards-controller.types.ts b/app/scripts/controllers/rewards/rewards-controller.types.ts index 7c4b3f8c1ff1..a1437f11fc9f 100644 --- a/app/scripts/controllers/rewards/rewards-controller.types.ts +++ b/app/scripts/controllers/rewards/rewards-controller.types.ts @@ -3,6 +3,9 @@ import { InternalAccount } from '@metamask/keyring-internal-api'; import { EstimatedPointsDto, EstimatePointsDto, + OptInStatusDto, + OptInStatusInputDto, + RewardsGeoMetadata, SeasonDtoState, SeasonRewardType, SeasonStatusState, @@ -516,7 +519,10 @@ export type Patch = { */ export type RewardsControllerOptInAction = { type: 'RewardsController:optIn'; - handler: (referralCode?: string) => Promise; + handler: ( + accounts: InternalAccount[], + referralCode?: string, + ) => Promise; }; /** @@ -547,20 +553,6 @@ export type PerpsDiscountData = { discountBips: number; }; -/** - * Geo rewards metadata containing location and support info - */ -export type GeoRewardsMetadata = { - /** - * The geographic location string (e.g., 'US', 'CA-ON', 'FR') - */ - geoLocation: string; - /** - * Whether the location is allowed for opt-in - */ - optinAllowedForGeo: boolean; -}; - /** * Action for getting opt-in status of multiple addresses with feature flag check */ @@ -624,7 +616,7 @@ export type RewardsControllerLinkAccountsToSubscriptionCandidateAction = { */ export type RewardsControllerGetGeoRewardsMetadataAction = { type: 'RewardsController:getGeoRewardsMetadata'; - handler: () => Promise; + handler: () => Promise; }; /** @@ -677,50 +669,3 @@ export type RewardsControllerGetSeasonStatusAction = { seasonId: string, ) => Promise; }; - -/** - * Input DTO for getting opt-in status of multiple addresses - */ -export type OptInStatusInputDto = { - /** - * The addresses to check opt-in status for - * - * @example [ - * '0xDE37C32E8dbD1CD325B8023a00550a5beA97eF13', - * '0xDE37C32E8dbD1CD325B8023a00550a5beA97eF14', - * '0xDE37C32E8dbD1CD325B8023a00550a5beA97eF15' - * ] - */ - addresses: string[]; -}; - -/** - * Response DTO for opt-in status of multiple addresses - */ -export type OptInStatusDto = { - /** - * The opt-in status of the addresses in the same order as the input - * - * @example [true, true, false] - */ - ois: boolean[]; - - /** - * The subscription IDs of the addresses in the same order as the input - * - * @example ['sub_123', 'sub_456', null] - */ - sids: (string | null)[]; -}; - -/** - * Response DTO for opt-out operation - */ -export type OptOutDto = { - /** - * Whether the opt-out operation was successful - * - * @example true - */ - success: boolean; -}; diff --git a/app/scripts/controllers/rewards/rewards-data-service.test.ts b/app/scripts/controllers/rewards/rewards-data-service.test.ts index 4a95d1373549..e38072d6ff30 100644 --- a/app/scripts/controllers/rewards/rewards-data-service.test.ts +++ b/app/scripts/controllers/rewards/rewards-data-service.test.ts @@ -114,7 +114,7 @@ describe('RewardsDataService', () => { expect(service.name).toBe('RewardsDataService'); }); - it('uses UAT URL by default when no environment is set', () => { + it('uses PRD URL by default when no environment is set', () => { service = createService(); expect(service.name).toBe('RewardsDataService'); }); @@ -155,6 +155,14 @@ describe('RewardsDataService', () => { 'RewardsDataService:getOptInStatus', expect.any(Function), ); + expect(registerSpy).toHaveBeenCalledWith( + 'RewardsDataService:getSeasonMetadata', + expect.any(Function), + ); + expect(registerSpy).toHaveBeenCalledWith( + 'RewardsDataService:getDiscoverSeasons', + expect.any(Function), + ); }); }); @@ -516,7 +524,7 @@ describe('RewardsDataService', () => { expect(result).toEqual(mockLoginResponse); expect(mockFetch).toHaveBeenCalledWith( - `${REWARDS_API_URL.UAT}/auth/mobile-login`, + `${REWARDS_API_URL.PRD}/auth/mobile-login`, expect.objectContaining({ method: 'POST', body: JSON.stringify(mockLoginRequest), @@ -780,7 +788,7 @@ describe('RewardsDataService', () => { expect(result).toEqual(mockSeasonStateResponse); expect(mockFetch).toHaveBeenCalledWith( - `${REWARDS_API_URL.UAT}/seasons/${mockSeasonId}/state`, + `${REWARDS_API_URL.PRD}/seasons/${mockSeasonId}/state`, { credentials: 'omit', method: 'GET', @@ -1061,7 +1069,7 @@ describe('RewardsDataService', () => { // Assert expect(result).toEqual(mockOptInStatusResponse); expect(mockFetch).toHaveBeenCalledWith( - `${REWARDS_API_URL.UAT}/public/rewards/ois`, + `${REWARDS_API_URL.PRD}/public/rewards/ois`, expect.objectContaining({ method: 'POST', body: JSON.stringify(mockOptInStatusRequest), @@ -1382,7 +1390,7 @@ describe('RewardsDataService', () => { ); }); - it('uses UAT URL for non-production environments', async () => { + it('uses PRD URL for non-production environments', async () => { delete process.env.METAMASK_ENVIRONMENT; service = createService(); @@ -1395,11 +1403,11 @@ describe('RewardsDataService', () => { await service.validateReferralCode('TEST'); expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining(REWARDS_API_URL.UAT), + expect.stringContaining(REWARDS_API_URL.PRD), expect.any(Object), ); expect(mockFetch).toHaveBeenCalledWith( - `${REWARDS_API_URL.UAT}/referral/validate?code=TEST`, + `${REWARDS_API_URL.PRD}/referral/validate?code=TEST`, expect.any(Object), ); }); diff --git a/app/scripts/controllers/rewards/rewards-data-service.ts b/app/scripts/controllers/rewards/rewards-data-service.ts index 81454df62180..deeff2d8f7b1 100644 --- a/app/scripts/controllers/rewards/rewards-data-service.ts +++ b/app/scripts/controllers/rewards/rewards-data-service.ts @@ -4,19 +4,22 @@ import log from 'loglevel'; import { ENVIRONMENT } from '../../../../development/build/constants'; import ExtensionPlatform from '../../platforms/extension'; -import { REWARDS_API_URL } from '../../../../shared/constants/rewards'; +import { + REWARDS_API_URL, + REWARDS_ERROR_MESSAGES, +} from '../../../../shared/constants/rewards'; import type { RewardsDataServiceMessenger } from '../../controller-init/messengers/reward-data-service-messenger'; import { FALLBACK_LOCALE } from '../../../../shared/modules/i18n'; import { EstimatePointsDto, EstimatedPointsDto, + OptInStatusDto, + OptInStatusInputDto, } from '../../../../shared/types/rewards'; import type { LoginResponseDto, MobileLoginDto, SubscriptionDto, - OptInStatusInputDto, - OptInStatusDto, MobileOptinDto, SeasonStateDto, SeasonMetadataDto, @@ -40,7 +43,9 @@ export class InvalidTimestampError extends Error { * Custom error for authorization failures */ export class AuthorizationFailedError extends Error { - constructor(message: string) { + static readonly defaultMessage = REWARDS_ERROR_MESSAGES.AUTHORIZATION_FAILED; + + constructor(message: string = AuthorizationFailedError.defaultMessage) { super(message); this.name = 'AuthorizationFailedError'; } @@ -50,7 +55,9 @@ export class AuthorizationFailedError extends Error { * Custom error for season not found */ export class SeasonNotFoundError extends Error { - constructor(message: string) { + static readonly defaultMessage = REWARDS_ERROR_MESSAGES.SEASON_NOT_FOUND; + + constructor(message: string = SeasonNotFoundError.defaultMessage) { super(message); this.name = 'SeasonNotFoundError'; } @@ -161,7 +168,7 @@ export class RewardsDataService { ) { return REWARDS_API_URL.PRD; } - return REWARDS_API_URL.UAT; + return REWARDS_API_URL.PRD; } /** @@ -402,15 +409,11 @@ export class RewardsDataService { if (!response.ok) { const errorData = await response.json(); if (errorData?.message?.includes('Rewards authorization failed')) { - throw new AuthorizationFailedError( - 'Rewards authorization failed. Please login and try again.', - ); + throw new AuthorizationFailedError(); } if (errorData?.message?.includes('Season not found')) { - throw new SeasonNotFoundError( - 'Season not found. Please try again with a different season.', - ); + throw new SeasonNotFoundError(); } throw new Error(`Get season state failed: ${response.status}`); diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 300ac4c7c674..9d64cc4df585 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -2604,6 +2604,24 @@ export default class MetamaskController extends EventEmitter { estimateRewardsPoints: this.rewardsController.estimatePoints.bind( this.rewardsController, ), + validateRewardsReferralCode: + this.rewardsController.validateReferralCode.bind( + this.rewardsController, + ), + getRewardsGeoMetadata: this.rewardsController.getRewardsGeoMetadata.bind( + this.rewardsController, + ), + rewardsOptIn: this.rewardsController.optIn.bind(this.rewardsController), + rewardsIsOptInSupported: this.rewardsController.isOptInSupported.bind( + this.rewardsController, + ), + rewardsGetOptInStatus: this.rewardsController.getOptInStatus.bind( + this.rewardsController, + ), + rewardsLinkAccountsToSubscriptionCandidate: + this.rewardsController.linkAccountsToSubscriptionCandidate.bind( + this.rewardsController, + ), // claims getSubmitClaimConfig: this.claimsController.getSubmitClaimConfig.bind( diff --git a/shared/constants/rewards.ts b/shared/constants/rewards.ts index 1d965449db6c..4c6db27c9c37 100644 --- a/shared/constants/rewards.ts +++ b/shared/constants/rewards.ts @@ -2,3 +2,11 @@ export const REWARDS_API_URL = { UAT: 'https://rewards.uat-api.cx.metamask.io', PRD: 'https://rewards.api.cx.metamask.io', }; + +// Error message constants for rewards errors +export const REWARDS_ERROR_MESSAGES = { + AUTHORIZATION_FAILED: + 'Rewards authorization failed. Please login and try again.', + SEASON_NOT_FOUND: + 'Season not found. Please try again with a different season.', +} as const; diff --git a/shared/types/rewards.ts b/shared/types/rewards.ts index 247a13f52330..c43d839da3d4 100644 --- a/shared/types/rewards.ts +++ b/shared/types/rewards.ts @@ -200,3 +200,72 @@ export type EstimatedPointsDto = { */ bonusBips: number; }; + +/** + * UI toast state for rewards errors. + */ +export type RewardsErrorToastState = { + isOpen: boolean; + title: string; + description: string; + actionText?: string; + onActionClick?: () => void; +}; + +export type RewardsGeoMetadata = { + /** + * The geographic location string (e.g., 'US', 'CA-ON', 'FR') + */ + geoLocation: string; + /** + * Whether the location is allowed for opt-in + */ + optinAllowedForGeo: boolean; +}; + +/** + * Input DTO for getting opt-in status of multiple addresses + */ +export type OptInStatusInputDto = { + /** + * The addresses to check opt-in status for + * + * @example [ + * '0xDE37C32E8dbD1CD325B8023a00550a5beA97eF13', + * '0xDE37C32E8dbD1CD325B8023a00550a5beA97eF14', + * '0xDE37C32E8dbD1CD325B8023a00550a5beA97eF15' + * ] + */ + addresses: string[]; +}; + +/** + * Response DTO for opt-in status of multiple addresses + */ +export type OptInStatusDto = { + /** + * The opt-in status of the addresses in the same order as the input + * + * @example [true, true, false] + */ + ois: boolean[]; + + /** + * The subscription IDs of the addresses in the same order as the input + * + * @example ['sub_123', 'sub_456', null] + */ + sids: (string | null)[]; +}; + +/** + * Response DTO for opt-out operation + */ +export type OptOutDto = { + /** + * Whether the opt-out operation was successful + * + * @example true + */ + success: boolean; +}; From 6e35f41dcf8503426e76d53f818aeaec682d1a00 Mon Sep 17 00:00:00 2001 From: Lwin <147362763+lwin-kyaw@users.noreply.github.com> Date: Mon, 17 Nov 2025 19:36:46 +0800 Subject: [PATCH 019/154] feat: reset wallet on MaxKeyChainLengthExceeded error in social login (#37881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR updates the error handler for the social login `MaxKeyChainLengthExceeded` error where user login to very old device and could not recover the device local vault. In such cases, we gonna force users to re-login (restart the onboarding again) so that user will be able to get required Authentication data from Social login authentication to perform further operations (such as Token Refresh, Change Password, Import SRPs/PrivKeys etc...) [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/37881?quickstart=1) ## **Changelog** CHANGELOG entry: fixed error handling for social login `MaxKeyChainLengthExceeded` error ## **Related issues** Fixes: ## **Manual testing steps** 1. Login to the wallet with social login in one device (browser) - A. 2. Complete the onboarding and lock the wallet. 3. Login to another device (browser) (B) than 20 times. 5. In device A, unlock the wallet with the latest password. 6. You should see the `MaxKeyChainLengthExceeded` error and should be able to reset wallet. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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. --- > [!NOTE] > Show a Connections Removed modal on MaxKeyChainLengthExceeded during social login unlock to prompt wallet reset, refactoring flow to avoid app state and making unlock sync return void. > > - **Backend**: > - Change `syncPasswordAndUnlockWallet(password)` to return `void` and simplify flow (remove `isPasswordSynced` branches and special recovery path). > - Preserve error mapping; UI now reacts to `MaxKeyChainLengthExceeded`. > - **UI**: > - Update `ConnectionsRemovedModal` to accept `onConfirm` and add test IDs; render from `unlock-page` when `MaxKeyChainLengthExceeded` occurs to trigger `resetWallet`. > - Remove modal usage from `home` and related story. > - **State/Redux**: > - Remove `showConnectionsRemovedModal` state, selector, and actions; update `tryUnlockMetamask` callback (no boolean result). > - **Tests (E2E/Unit)**: > - Add login-page helpers for the new modal; update unlock/reset wallet specs to assert modal and reset via it; remove legacy reset flow; adjust controller tests to not expect boolean return. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c3528cdb1da08e0f8d1297f35d989294a6ed6d40. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../metamask-controller.actions.test.js | 5 +- app/scripts/metamask-controller.js | 32 +------- test/e2e/page-objects/pages/login-page.ts | 17 +++++ test/e2e/tests/account/unlock-wallet.spec.ts | 22 +++++- .../errors-after-init-opt-in-ui-state.json | 3 +- .../tests/reset-wallet/reset-wallet.spec.ts | 73 ++----------------- .../connections-removed-modal.tsx | 24 +++--- ui/ducks/app/app.ts | 7 -- ui/pages/home/home.component.js | 4 - ui/pages/home/home.component.stories.tsx | 1 - ui/pages/home/home.container.js | 2 - ui/pages/unlock-page/unlock-page.component.js | 18 ++++- ui/selectors/selectors.js | 4 - ui/store/actionConstants.ts | 2 - ui/store/actions.ts | 16 +--- 15 files changed, 79 insertions(+), 151 deletions(-) diff --git a/app/scripts/metamask-controller.actions.test.js b/app/scripts/metamask-controller.actions.test.js index 086f0c8660f2..71f512bf9f33 100644 --- a/app/scripts/metamask-controller.actions.test.js +++ b/app/scripts/metamask-controller.actions.test.js @@ -835,10 +835,7 @@ describe('MetaMaskController', function () { // We now need the Snap keyring after unlocking the wallet. jest.spyOn(metamaskController, 'getSnapKeyring').mockReturnValue({}); - const syncAndUnlockResult = - await metamaskController.syncPasswordAndUnlockWallet(password); - - expect(syncAndUnlockResult).toBe(true); + await metamaskController.syncPasswordAndUnlockWallet(password); expect(keyringSubmitPwdSpy).toHaveBeenCalled(); expect(seedlessSubmitPwdSpy).toHaveBeenCalled(); }); diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 9d64cc4df585..e9b5518f7ce4 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -4001,11 +4001,9 @@ export default class MetamaskController extends EventEmitter { * Unlock the vault with the latest global password. * * @param {string} password - latest global seedless password - * @returns {boolean} true if the sync was successful, false otherwise. Sync can fail if user is on a very old device - * and user has changed password more than 20 times since the last time they used the app on this device. + * @returns {void} */ async syncPasswordAndUnlockWallet(password) { - let isPasswordSynced = false; const isSocialLoginFlow = this.onboardingController.getIsSocialLoginFlow(); // check if the password is outdated let isPasswordOutdated = false; @@ -4041,8 +4039,7 @@ export default class MetamaskController extends EventEmitter { }); }); } - isPasswordSynced = true; - return isPasswordSynced; + return; } const releaseLock = await this.seedlessOperationMutex.acquire(); @@ -4067,10 +4064,6 @@ export default class MetamaskController extends EventEmitter { globalPassword: password, maxKeyChainLength: 20, }) - .then(() => { - // Case 1. - isPasswordSynced = true; - }) .catch((err) => { log.error(`error while submitting global password: ${err.message}`); if (err instanceof RecoveryError) { @@ -4085,28 +4078,10 @@ export default class MetamaskController extends EventEmitter { ); } throw new JsonRpcError(-32603, err.message, err.data); - } else if ( - err.message === - SeedlessOnboardingControllerErrorMessage.MaxKeyChainLengthExceeded - ) { - isPasswordSynced = false; - } else { - throw err; } + throw err; }); - // we are unable to recover the old pwd enc key as user is on a very old device. - // create a new vault and encrypt the new vault with the latest global password. - // also show a info popup to user. - if (!isPasswordSynced) { - // refresh the current auth tokens to get the latest auth tokens - await this.seedlessOnboardingController.refreshAuthTokens(); - // create a new vault and encrypt the new vault with the latest global password - await this.restoreSocialBackupAndGetSeedPhrase(password); - // display info popup to user based on the password sync status - return isPasswordSynced; - } - // re-encrypt the old vault data with the latest global password const keyringEncryptionKey = await this.seedlessOnboardingController.loadKeyringEncryptionKey(); @@ -4172,7 +4147,6 @@ export default class MetamaskController extends EventEmitter { data: { success: changePasswordSuccess }, }); } - return isPasswordSynced; } finally { releaseLock(); } diff --git a/test/e2e/page-objects/pages/login-page.ts b/test/e2e/page-objects/pages/login-page.ts index 59bd77a1167e..af4fcf7abd85 100644 --- a/test/e2e/page-objects/pages/login-page.ts +++ b/test/e2e/page-objects/pages/login-page.ts @@ -16,6 +16,10 @@ class LoginPage { private resetWalletButton: string; + private connectionsRemovedModal: string; + + private connectionsRemovedModalButton: string; + private incorrectPasswordMessage: { css: string; text: string }; constructor(driver: Driver) { @@ -37,6 +41,9 @@ class LoginPage { }; this.resetWalletButton = '[data-testid="login-error-modal-button"]'; + this.connectionsRemovedModal = '[data-testid="connections-removed-modal"]'; + this.connectionsRemovedModalButton = + '[data-testid="connections-removed-modal-button"]'; } async checkPageIsLoaded(): Promise { @@ -87,6 +94,16 @@ class LoginPage { ); await this.driver.clickElementAndWaitToDisappear(this.resetWalletButton); } + + async checkConnectionsRemovedModalIsDisplayed(): Promise { + console.log('Checking if connections removed modal is displayed'); + await this.driver.waitForSelector(this.connectionsRemovedModal); + } + + async resetWalletFromConnectionsRemovedModal(): Promise { + console.log('Resetting wallet from connections removed modal'); + await this.driver.clickElement(this.connectionsRemovedModalButton); + } } export default LoginPage; diff --git a/test/e2e/tests/account/unlock-wallet.spec.ts b/test/e2e/tests/account/unlock-wallet.spec.ts index 493f09240866..7dd2542151f6 100644 --- a/test/e2e/tests/account/unlock-wallet.spec.ts +++ b/test/e2e/tests/account/unlock-wallet.spec.ts @@ -1,4 +1,5 @@ import { Mockttp } from 'mockttp'; +import { Browser } from 'selenium-webdriver'; import { withFixtures } from '../../helpers'; import FixtureBuilder from '../../fixture-builder'; import { Driver } from '../../webdriver/driver'; @@ -9,11 +10,15 @@ import LoginPage from '../../page-objects/pages/login-page'; import { loginWithBalanceValidation } from '../../page-objects/flows/login.flow'; import { MOCK_GOOGLE_ACCOUNT, WALLET_PASSWORD } from '../../constants'; import { OAuthMockttpService } from '../../helpers/seedless-onboarding/mocks'; -import { importWalletWithSocialLoginOnboardingFlow } from '../../page-objects/flows/onboarding.flow'; +import { + importWalletWithSocialLoginOnboardingFlow, + onboardingMetricsFlow, +} from '../../page-objects/flows/onboarding.flow'; import SettingsPage from '../../page-objects/pages/settings/settings-page'; import PrivacySettings from '../../page-objects/pages/settings/privacy-settings'; import HeaderNavbar from '../../page-objects/pages/header-navbar'; import ChangePasswordPage from '../../page-objects/pages/settings/change-password-page'; +import StartOnboardingPage from '../../page-objects/pages/onboarding/start-onboarding-page'; describe('Unlock wallet - ', function () { it('handle incorrect password during unlock and login successfully', async function () { @@ -96,9 +101,20 @@ describe('Unlock wallet - ', function () { const loginPage = new LoginPage(driver); await loginPage.loginToHomepage(WALLET_PASSWORD); + await loginPage.checkConnectionsRemovedModalIsDisplayed(); + // reset wallet from connections removed modal + await loginPage.resetWalletFromConnectionsRemovedModal(); - await homePage.checkPageIsLoaded(); - await homePage.checkConnectionsRemovedModalIsDisplayed(); + if (process.env.SELENIUM_BROWSER === Browser.FIREFOX) { + await onboardingMetricsFlow(driver, { + participateInMetaMetrics: false, + dataCollectionForMarketing: false, + }); + } + + // check onboarding welcome page is loaded after resetting the wallet + const startOnboardingPage = new StartOnboardingPage(driver); + await startOnboardingPage.checkLoginPageIsLoaded(); }, ); }); diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json index 76bbef8fb579..b23fda8a3b53 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -70,8 +70,7 @@ "showPasswordChangeToast": null, "showCopyAddressToast": "boolean", "showClaimSubmitToast": null, - "showSupportDataConsentModal": "boolean", - "showConnectionsRemovedModal": "boolean" + "showSupportDataConsentModal": "boolean" }, "bridge": "object", "confirmAlerts": "object", diff --git a/test/e2e/tests/reset-wallet/reset-wallet.spec.ts b/test/e2e/tests/reset-wallet/reset-wallet.spec.ts index 3f67a680c099..30222a8c5f60 100644 --- a/test/e2e/tests/reset-wallet/reset-wallet.spec.ts +++ b/test/e2e/tests/reset-wallet/reset-wallet.spec.ts @@ -1,5 +1,4 @@ import { Mockttp } from 'mockttp'; -import { AuthConnection } from '@metamask/seedless-onboarding-controller'; import { Browser } from 'selenium-webdriver'; import FixtureBuilder from '../../fixture-builder'; import { withFixtures } from '../../helpers'; @@ -21,70 +20,6 @@ import OnboardingPasswordPage from '../../page-objects/pages/onboarding/onboardi import SecureWalletPage from '../../page-objects/pages/onboarding/secure-wallet-page'; describe('Reset Wallet - ', function () { - it('should be able to reset wallet when encounters un-recoverable error in social login unlock', async function () { - await withFixtures( - { - fixtures: new FixtureBuilder({ onboarding: true }).build(), - title: this.test?.fullTitle(), - ignoredConsoleErrors: [ - 'unable to proceed, wallet is locked', - 'The snap "npm:@metamask/message-signing-snap" has been terminated during execution', // issue #37342 - 'npm:@metamask/message-signing-snap was stopped and the request was cancelled. This is likely because the Snap crashed.', - ], - testSpecificMock: (server: Mockttp) => { - // using this to mock the OAuth Service (Web Authentication flow + Auth server) - const oAuthMockttpService = new OAuthMockttpService(); - return oAuthMockttpService.setup(server, { - userEmail: MOCK_GOOGLE_ACCOUNT, - throwAuthenticationErrorAtUnlock: true, // <=== This is intentional error to test the reset wallet flow - passwordOutdated: true, - }); - }, - }, - async ({ driver }: { driver: Driver }) => { - await importWalletWithSocialLoginOnboardingFlow({ - driver, - }); - - const homePage = new HomePage(driver); - await homePage.checkPageIsLoaded(); - - const headerNavbar = new HeaderNavbar(driver); - - await headerNavbar.lockMetaMask(); - - const loginPage = new LoginPage(driver); - - // login should fail due to Authentication Error - await loginPage.loginToHomepage(WALLET_PASSWORD); - - // reset the wallet - await loginPage.resetWallet(); - - if (process.env.SELENIUM_BROWSER === Browser.FIREFOX) { - // In Firefox, we need to go to the metametrics page first - await onboardingMetricsFlow(driver, { - participateInMetaMetrics: true, - dataCollectionForMarketing: false, - }); - } - - // should be on the welcome page after resetting the wallet - const startOnboardingPage = new StartOnboardingPage(driver); - await startOnboardingPage.checkLoginPageIsLoaded(); - - // import wallet with social login and start a new session - await startOnboardingPage.importWalletWithSocialLogin( - AuthConnection.Google, - ); - - await loginPage.checkPageIsLoaded(); - await loginPage.loginToHomepage(WALLET_PASSWORD); - await homePage.headerNavbar.checkPageIsLoaded(); - }, - ); - }); - it('creates a new wallet with SRP and completes the onboarding process after resetting the wallet', async function () { await withFixtures( { @@ -123,7 +58,9 @@ describe('Reset Wallet - ', function () { await loginPage.loginToHomepage(WALLET_PASSWORD); // reset the wallet - await loginPage.resetWallet(); + await loginPage.checkConnectionsRemovedModalIsDisplayed(); + // reset wallet from connections removed modal + await loginPage.resetWalletFromConnectionsRemovedModal(); if (process.env.SELENIUM_BROWSER === Browser.FIREFOX) { // In Firefox, we need to go to the metametrics page first @@ -205,7 +142,9 @@ describe('Reset Wallet - ', function () { await loginPage.loginToHomepage(WALLET_PASSWORD); // reset the wallet - await loginPage.resetWallet(); + await loginPage.checkConnectionsRemovedModalIsDisplayed(); + // reset wallet from connections removed modal + await loginPage.resetWalletFromConnectionsRemovedModal(); if (process.env.SELENIUM_BROWSER === Browser.FIREFOX) { // In Firefox, we need to go to the metametrics page first diff --git a/ui/components/app/connections-removed-modal/connections-removed-modal.tsx b/ui/components/app/connections-removed-modal/connections-removed-modal.tsx index e83694049d94..db3ec33ecc98 100644 --- a/ui/components/app/connections-removed-modal/connections-removed-modal.tsx +++ b/ui/components/app/connections-removed-modal/connections-removed-modal.tsx @@ -1,5 +1,4 @@ -import React, { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; +import React from 'react'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { AlignItems, @@ -24,17 +23,17 @@ import { ModalBody, ButtonSize, } from '../../component-library'; -import { setShowConnectionsRemovedModal } from '../../../store/actions'; + +type ConnectionsRemovedModalProps = { + onConfirm: () => void; +}; // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention -export default function ConnectionsRemovedModal() { +export default function ConnectionsRemovedModal({ + onConfirm, +}: ConnectionsRemovedModalProps) { const t = useI18nContext(); - const dispatch = useDispatch(); - - const onConfirm = useCallback(() => { - dispatch(setShowConnectionsRemovedModal(false)); - }, [dispatch]); return ( {t('connectionsRemovedModalDescription')} - diff --git a/ui/ducks/app/app.ts b/ui/ducks/app/app.ts index ae252e2557b0..463680af3547 100644 --- a/ui/ducks/app/app.ts +++ b/ui/ducks/app/app.ts @@ -132,7 +132,6 @@ type AppState = { errorInSettings: string | null; showNewSrpAddedToast: boolean; showPasswordChangeToast: PasswordChangeToastType | null; - showConnectionsRemovedModal: boolean; showCopyAddressToast: boolean; showClaimSubmitToast: ClaimSubmitToastType | null; shieldEntryModal?: { @@ -243,7 +242,6 @@ const initialState: AppState = { showCopyAddressToast: false, showClaimSubmitToast: null, showSupportDataConsentModal: false, - showConnectionsRemovedModal: false, }; export default function reduceApp( @@ -799,11 +797,6 @@ export default function reduceApp( showSupportDataConsentModal: action.payload, }; - case actionConstants.SET_SHOW_CONNECTIONS_REMOVED: - return { - ...appState, - showConnectionsRemovedModal: action.value, - }; case actionConstants.SET_SHOW_SHIELD_ENTRY_MODAL_ONCE: return { ...appState, diff --git a/ui/pages/home/home.component.js b/ui/pages/home/home.component.js index eacf6ce081bf..565468084238 100644 --- a/ui/pages/home/home.component.js +++ b/ui/pages/home/home.component.js @@ -68,7 +68,6 @@ import { AccountOverview } from '../../components/multichain/account-overview'; import { setEditedNetwork } from '../../store/actions'; import { navigateToConfirmation } from '../confirmations/hooks/useConfirmationNavigation'; import PasswordOutdatedModal from '../../components/app/password-outdated-modal'; -import ConnectionsRemovedModal from '../../components/app/connections-removed-modal'; import ShieldEntryModal from '../../components/app/shield-entry-modal'; ///: BEGIN:ONLY_INCLUDE_IF(build-beta) import BetaHomeFooter from './beta/beta-home-footer.component'; @@ -169,7 +168,6 @@ export default class Home extends PureComponent { setAccountDetailsAddress: PropTypes.func, isSeedlessPasswordOutdated: PropTypes.bool, isPrimarySeedPhraseBackedUp: PropTypes.bool, - showConnectionsRemovedModal: PropTypes.bool, showShieldEntryModal: PropTypes.bool, isSocialLoginFlow: PropTypes.bool, lookupSelectedNetworks: PropTypes.func.isRequired, @@ -866,7 +864,6 @@ export default class Home extends PureComponent { showUpdateModal, isSeedlessPasswordOutdated, isPrimarySeedPhraseBackedUp, - showConnectionsRemovedModal, showShieldEntryModal, isSocialLoginFlow, } = this.props; @@ -934,7 +931,6 @@ export default class Home extends PureComponent { {showTermsOfUse ? ( ) : null} - {showConnectionsRemovedModal && } {showShieldEntryModal && } {isPopup && !connectedStatusPopoverHasBeenShown ? this.renderPopover() diff --git a/ui/pages/home/home.component.stories.tsx b/ui/pages/home/home.component.stories.tsx index 095a7aab8e59..0f231dfd9f94 100644 --- a/ui/pages/home/home.component.stories.tsx +++ b/ui/pages/home/home.component.stories.tsx @@ -75,7 +75,6 @@ const meta: Meta = { redirectAfterDefaultPage: null, isSeedlessPasswordOutdated: false, isPrimarySeedPhraseBackedUp: true, - showConnectionsRemovedModal: false, showShieldEntryModal: false, isSocialLoginFlow: false, diff --git a/ui/pages/home/home.container.js b/ui/pages/home/home.container.js index 12e79f82a856..6d5e4e4a2c89 100644 --- a/ui/pages/home/home.container.js +++ b/ui/pages/home/home.container.js @@ -28,7 +28,6 @@ import { getEditedNetwork, selectPendingApprovalsForNavigation, getShowUpdateModal, - getShowConnectionsRemovedModal, getIsSocialLoginFlow, getShowShieldEntryModal, getPendingShieldCohort, @@ -192,7 +191,6 @@ const mapStateToProps = (state) => { redirectAfterDefaultPage, isSeedlessPasswordOutdated: getIsSeedlessPasswordOutdated(state), isPrimarySeedPhraseBackedUp: getIsPrimarySeedPhraseBackedUp(state), - showConnectionsRemovedModal: getShowConnectionsRemovedModal(state), showShieldEntryModal: getShowShieldEntryModal(state), isSocialLoginFlow: getIsSocialLoginFlow(state), pendingShieldCohort: getPendingShieldCohort(state), diff --git a/ui/pages/unlock-page/unlock-page.component.js b/ui/pages/unlock-page/unlock-page.component.js index 1ad525d82075..18f8402e66ce 100644 --- a/ui/pages/unlock-page/unlock-page.component.js +++ b/ui/pages/unlock-page/unlock-page.component.js @@ -45,6 +45,7 @@ import { withMetaMetrics } from '../../contexts/metametrics'; import LoginErrorModal from '../onboarding-flow/welcome/login-error-modal'; import { LOGIN_ERROR } from '../onboarding-flow/welcome/types'; import MetaFoxHorizontalLogo from '../../components/ui/metafox-logo/horizontal-logo'; +import ConnectionsRemovedModal from '../../components/app/connections-removed-modal'; import { getCaretCoordinates } from './unlock-page.util'; import ResetPasswordModal from './reset-password-modal'; import FormattedCounter from './formatted-counter'; @@ -129,6 +130,7 @@ class UnlockPage extends Component { isSubmitting: false, unlockDelayPeriod: 0, showLoginErrorModal: false, + showConnectionsRemovedModal: false, }; failed_attempts = 0; @@ -269,6 +271,7 @@ class UnlockPage extends Component { let finalUnlockDelayPeriod = 0; let errorReason; let shouldShowLoginErrorModal = false; + let shouldShowConnectionsRemovedModal = false; // Check if we are in the onboarding flow if (!isOnboardingCompleted) { @@ -308,6 +311,10 @@ class UnlockPage extends Component { shouldShowLoginErrorModal = true; } break; + case SeedlessOnboardingControllerErrorMessage.MaxKeyChainLengthExceeded: + finalErrorMessage = message; + shouldShowConnectionsRemovedModal = true; + break; default: finalErrorMessage = message; break; @@ -340,6 +347,7 @@ class UnlockPage extends Component { error: finalErrorMessage, unlockDelayPeriod: finalUnlockDelayPeriod, showLoginErrorModal: shouldShowLoginErrorModal, + showConnectionsRemovedModal: shouldShowConnectionsRemovedModal, }); }; @@ -461,7 +469,11 @@ class UnlockPage extends Component { }; onResetWallet = async () => { - this.setState({ showLoginErrorModal: false }); + this.setState({ + showLoginErrorModal: false, + showConnectionsRemovedModal: false, + showResetPasswordModal: false, + }); await this.props.resetWallet(); await this.props.forceUpdateMetamaskState(); this.props.navigate(DEFAULT_ROUTE, { replace: true }); @@ -474,6 +486,7 @@ class UnlockPage extends Component { isLocked, showResetPasswordModal, showLoginErrorModal, + showConnectionsRemovedModal, } = this.state; const { isOnboardingCompleted, isSocialLoginFlow } = this.props; const { t } = this.context; @@ -504,6 +517,9 @@ class UnlockPage extends Component { loginError={LOGIN_ERROR.RESET_WALLET} /> )} + {showConnectionsRemovedModal && ( + + )} state.appState.pendingTokens; -export function getShowConnectionsRemovedModal(state) { - return state.appState.showConnectionsRemovedModal; -} - export function getShowShieldEntryModal(state) { return state.appState.shieldEntryModal?.show; } diff --git a/ui/store/actionConstants.ts b/ui/store/actionConstants.ts index 38d5b5ae45df..f8a39a95b7f2 100644 --- a/ui/store/actionConstants.ts +++ b/ui/store/actionConstants.ts @@ -196,7 +196,5 @@ export const SET_SHOW_CLAIM_SUBMIT_TOAST = 'SET_SHOW_CLAIM_SUBMIT_TOAST'; export const SET_SHOW_SUPPORT_DATA_CONSENT_MODAL = 'SET_SHOW_SUPPORT_DATA_CONSENT_MODAL'; -export const SET_SHOW_CONNECTIONS_REMOVED = 'SET_SHOW_CONNECTIONS_REMOVED'; - export const SET_SHOW_SHIELD_ENTRY_MODAL_ONCE = 'SET_SHOW_SHIELD_ENTRY_MODAL_ONCE'; diff --git a/ui/store/actions.ts b/ui/store/actions.ts index f159bf086fc3..1c8293acc56b 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -856,16 +856,11 @@ export function tryUnlockMetamask( callBackgroundMethod( 'syncPasswordAndUnlockWallet', [password], - (error, isPasswordSynced) => { + (error) => { if (error) { reject(error); return; } - // if password is not synced show connections removal warning to user. - if (!isPasswordSynced) { - dispatch(setShowConnectionsRemovedModal(true)); - } - resolve(); }, ); @@ -2488,15 +2483,6 @@ export function unlockSucceeded(message?: string) { }; } -export function setShowConnectionsRemovedModal( - showConnectionsRemovedModal: boolean, -) { - return { - type: actionConstants.SET_SHOW_CONNECTIONS_REMOVED, - value: showConnectionsRemovedModal, - }; -} - export function updateMetamaskState( patches: Patch[], ): ThunkAction { From c4a264b4faa17c7a763a279e95fc520a27d59b0e Mon Sep 17 00:00:00 2001 From: hieu-w Date: Mon, 17 Nov 2025 18:41:20 +0700 Subject: [PATCH 020/154] fix: update Shield copy and error messages for consistency (#37869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Update Shield copy and error messages for consistency [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/37869?quickstart=1) ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/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. --- > [!NOTE] > Standardizes Shield-related localization by using “MetaMask Transaction Shield,” lowercases the file uploader label, and refines plan detail wording in both en and en_GB. > > - **i18n (en, en_GB)**: > - **Branding consistency**: Replace `Transaction Shield` with `MetaMask Transaction Shield` across coverage alert messages (high risk, chain not supported, paused, potential risks, signature not supported, tx type not supported, unknown). > - **Copy tweaks**: > - Update `shieldClaimFileUploader` label to `Image upload` (casing). > - Revise `shieldTxDetails1Description` to “Secures assets on covered transactions.” > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d29d69bf3fde90aab5a4677dee9597c29a9f6448. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/_locales/en/messages.json | 18 +++++++++--------- app/_locales/en_GB/messages.json | 18 +++++++++--------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index cea59e7c2831..e2106250fe40 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -5916,7 +5916,7 @@ "message": "Total file size exceeds the maximum allowed size." }, "shieldClaimFileUploader": { - "message": "Image Upload" + "message": "Image upload" }, "shieldClaimFileUploaderAcceptText": { "message": "PDF, PNG, JPG (Max $1MB)", @@ -6045,25 +6045,25 @@ "message": "You're protected up to $2 with Metamask Transaction Shield. $1." }, "shieldCoverageAlertHighRiskTransaction": { - "message": "This is a high risk transaction, so it isn't protected by Transaction Shield. $1." + "message": "This is a high risk transaction, so it isn't protected by MetaMask Transaction Shield. $1." }, "shieldCoverageAlertMessageChainNotSupported": { - "message": "This chain is not supported, so it isn't protected by Transaction Shield. $1." + "message": "This chain is not supported, so it isn't protected by MetaMask Transaction Shield. $1" }, "shieldCoverageAlertMessageLearnHowCoverageWorks": { "message": "See What's Covered" }, "shieldCoverageAlertMessagePaused": { - "message": "There was an issue with your Transaction Shield plan payment. Please update your payment method to resume coverage." + "message": "There was an issue with your MetaMask Transaction Shield plan payment. Please update your payment method to resume coverage." }, "shieldCoverageAlertMessagePausedAcknowledgeButton": { "message": "Update payment method" }, "shieldCoverageAlertMessagePotentialRisks": { - "message": "This transaction has potential risks, so it isn't protected by Transaction Shield. $1." + "message": "This transaction has potential risks, so it isn't protected by MetaMask Transaction Shield. $1" }, "shieldCoverageAlertMessageSignatureNotSupported": { - "message": "This signature isn't supported, so it isn't protected by Transaction Shield. $1." + "message": "This signature isn't supported, so it isn't protected by MetaMask Transaction Shield. $1" }, "shieldCoverageAlertMessageTitle": { "message": "This transaction isn't covered" @@ -6084,10 +6084,10 @@ "message": "This token shows strong signs of malicious behavior. Continuing may result in loss of funds. $1." }, "shieldCoverageAlertMessageTxTypeNotSupported": { - "message": "This transaction type is not supported, so it isn't protected by Transaction Shield. $1." + "message": "This transaction type is not supported, so it isn't protected by MetaMask Transaction Shield. $1." }, "shieldCoverageAlertMessageUnknown": { - "message": "This request can't be verified, so it isn't protected by Transaction Shield. $1." + "message": "This request can't be verified, so it isn't protected by MetaMask Transaction Shield. $1." }, "shieldCoverageEnding": { "message": "Shield coverage ends soon" @@ -6242,7 +6242,7 @@ "message": "Your plan isn't active while paused. If you cancel, your plan will immediately end." }, "shieldTxDetails1Description": { - "message": "Secures your assets from risky transactions" + "message": "Secures assets on covered transactions" }, "shieldTxDetails1Title": { "message": "Up to $10,000 transaction protection" diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index cea59e7c2831..e2106250fe40 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -5916,7 +5916,7 @@ "message": "Total file size exceeds the maximum allowed size." }, "shieldClaimFileUploader": { - "message": "Image Upload" + "message": "Image upload" }, "shieldClaimFileUploaderAcceptText": { "message": "PDF, PNG, JPG (Max $1MB)", @@ -6045,25 +6045,25 @@ "message": "You're protected up to $2 with Metamask Transaction Shield. $1." }, "shieldCoverageAlertHighRiskTransaction": { - "message": "This is a high risk transaction, so it isn't protected by Transaction Shield. $1." + "message": "This is a high risk transaction, so it isn't protected by MetaMask Transaction Shield. $1." }, "shieldCoverageAlertMessageChainNotSupported": { - "message": "This chain is not supported, so it isn't protected by Transaction Shield. $1." + "message": "This chain is not supported, so it isn't protected by MetaMask Transaction Shield. $1" }, "shieldCoverageAlertMessageLearnHowCoverageWorks": { "message": "See What's Covered" }, "shieldCoverageAlertMessagePaused": { - "message": "There was an issue with your Transaction Shield plan payment. Please update your payment method to resume coverage." + "message": "There was an issue with your MetaMask Transaction Shield plan payment. Please update your payment method to resume coverage." }, "shieldCoverageAlertMessagePausedAcknowledgeButton": { "message": "Update payment method" }, "shieldCoverageAlertMessagePotentialRisks": { - "message": "This transaction has potential risks, so it isn't protected by Transaction Shield. $1." + "message": "This transaction has potential risks, so it isn't protected by MetaMask Transaction Shield. $1" }, "shieldCoverageAlertMessageSignatureNotSupported": { - "message": "This signature isn't supported, so it isn't protected by Transaction Shield. $1." + "message": "This signature isn't supported, so it isn't protected by MetaMask Transaction Shield. $1" }, "shieldCoverageAlertMessageTitle": { "message": "This transaction isn't covered" @@ -6084,10 +6084,10 @@ "message": "This token shows strong signs of malicious behavior. Continuing may result in loss of funds. $1." }, "shieldCoverageAlertMessageTxTypeNotSupported": { - "message": "This transaction type is not supported, so it isn't protected by Transaction Shield. $1." + "message": "This transaction type is not supported, so it isn't protected by MetaMask Transaction Shield. $1." }, "shieldCoverageAlertMessageUnknown": { - "message": "This request can't be verified, so it isn't protected by Transaction Shield. $1." + "message": "This request can't be verified, so it isn't protected by MetaMask Transaction Shield. $1." }, "shieldCoverageEnding": { "message": "Shield coverage ends soon" @@ -6242,7 +6242,7 @@ "message": "Your plan isn't active while paused. If you cancel, your plan will immediately end." }, "shieldTxDetails1Description": { - "message": "Secures your assets from risky transactions" + "message": "Secures assets on covered transactions" }, "shieldTxDetails1Title": { "message": "Up to $10,000 transaction protection" From 00e100624e2d8c31e3d462dd864fdf01a15b5368 Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Mon, 17 Nov 2025 13:09:33 +0100 Subject: [PATCH 021/154] fix: Revert `6286882567a0520732be6d70f5af264d839ec26c` (#37898) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR reverts https://github.com/MetaMask/metamask-extension/pull/37778 as it introduced regression on sidepanel. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/37898?quickstart=1) ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/CEUX-701?atlOrigin=eyJpIjoiZmQ4YjE2NmQ5M2E1NGQ0ZWE3MDA4NzEwZTNhMDZiNDkiLCJwIjoiaiJ9 ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/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. --- > [!NOTE] > Makes confirmation actions async, routes addEthereumChain to home and otherwise advances to next confirmation, removes sidepanel-specific redirect, and updates tests accordingly. > > - **Confirmations UI**: > - `ui/pages/confirmations/components/confirm/footer/footer.tsx` > - Make `onSubmit`/`handleFooterCancel` async and await actions. > - For `wallet_addEthereumChain`, navigate to `DEFAULT_ROUTE` after confirm/cancel; otherwise call `navigateNext(currentConfirmation.id)`. > - Await `onTransactionConfirm`/`resolvePendingApproval` and reset transaction state. > - **Confirm Actions**: > - `ui/pages/confirmations/hooks/useConfirmActions.ts` > - Make `rejectApproval` and `onCancel` async; await `rejectPendingApproval` and then reset state. > - **Confirm Transaction Flow**: > - `ui/pages/confirmations/confirm-transaction/confirm-transaction.component.js` > - Remove sidepanel-related redirect logic and associated hooks/selectors. > - **Tests**: > - `ui/pages/confirmations/components/confirm/footer/footer.test.tsx` > - Mock thunk-aware `dispatch`; add async `waitFor` assertions. > - Mock `useAddEthereumChain`, `useTransactionConfirm`, and navigation; update expectations to use the current confirmation id. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 37a537b7b87a4cc917755139deeb4c9ed4c3a219. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../components/confirm/footer/footer.test.tsx | 95 +++++++++++++++---- .../components/confirm/footer/footer.tsx | 38 ++++++-- .../confirm-transaction.component.js | 10 -- .../confirmations/hooks/useConfirmActions.ts | 10 +- 4 files changed, 110 insertions(+), 43 deletions(-) diff --git a/ui/pages/confirmations/components/confirm/footer/footer.test.tsx b/ui/pages/confirmations/components/confirm/footer/footer.test.tsx index a23e171aa20f..34dbee7f7821 100644 --- a/ui/pages/confirmations/components/confirm/footer/footer.test.tsx +++ b/ui/pages/confirmations/components/confirm/footer/footer.test.tsx @@ -19,7 +19,7 @@ import { } from '../../../../../../test/data/confirmations/personal_sign'; import { permitSignatureMsg } from '../../../../../../test/data/confirmations/typed_sign'; import mockState from '../../../../../../test/data/mock-state.json'; -import { fireEvent } from '../../../../../../test/jest'; +import { fireEvent, waitFor } from '../../../../../../test/jest'; import { renderWithConfirmContextProvider } from '../../../../../../test/lib/confirmations/render-helpers'; import { Alert } from '../../../../../ducks/confirm-alerts/confirm-alerts'; import { Severity } from '../../../../../helpers/constants/design-system'; @@ -38,9 +38,23 @@ import Footer from './footer'; jest.mock('../../../hooks/gas/useIsGaslessLoading'); jest.mock('../../../hooks/alerts/transactions/useInsufficientBalanceAlerts'); jest.mock('../../../hooks/gas/useIsGaslessSupported'); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let mockStore: any = null; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockDispatch: any = jest.fn((action: unknown) => { + if (typeof action === 'function') { + // Thunk actions need both dispatch and getState + const mockGetState = mockStore ? mockStore.getState : jest.fn(() => ({})); + return action(mockDispatch, mockGetState); + } + return action; +}); + jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), - useDispatch: () => jest.fn(), + useDispatch: () => mockDispatch, })); jest.mock('../../../hooks/useConfirmationNavigation', () => ({ useConfirmationNavigation: jest.fn(() => ({ @@ -60,7 +74,26 @@ jest.mock( ); jest.mock('../../../hooks/useOriginThrottling'); + jest.mock('../../../../../hooks/subscription/useSubscription'); +jest.mock('../../../hooks/useAddEthereumChain', () => ({ + useAddEthereumChain: jest.fn(() => ({ + onSubmit: jest.fn().mockResolvedValue(undefined), + })), + isAddEthereumChainType: jest.fn( + (confirmation) => confirmation?.type === 'wallet_addEthereumChain', + ), +})); +jest.mock('../../../hooks/transactions/useTransactionConfirm', () => ({ + useTransactionConfirm: jest.fn(() => ({ + onTransactionConfirm: jest.fn().mockResolvedValue(undefined), + })), +})); +jest.mock('../../../hooks/useConfirmSendNavigation', () => ({ + useConfirmSendNavigation: jest.fn(() => ({ + navigateBackIfSend: jest.fn(), + })), +})); jest.mock('react-router-dom-v5-compat', () => ({ useNavigate: jest.fn(), @@ -68,6 +101,7 @@ jest.mock('react-router-dom-v5-compat', () => ({ const render = (args?: Record) => { const store = configureStore(args ?? getMockPersonalSignConfirmState()); + mockStore = store; return renderWithConfirmContextProvider(