From 7c278276719e0914fcae970d53638d31784c9fc5 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Thu, 19 Mar 2026 21:22:35 +0000 Subject: [PATCH 01/54] [skip ci] Bump version number to 4105 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 97165e04944..1bc0963af64 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 3607 + versionCode 4105 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index c8f848cf5bd..53ee5e63a6d 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 3911 + VERSION_NUMBER: 4105 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 3911 + FLASK_VERSION_NUMBER: 4105 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 6726013ffa4..88b91c62638 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3911; + CURRENT_PROJECT_VERSION = 4105; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3911; + CURRENT_PROJECT_VERSION = 4105; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3911; + CURRENT_PROJECT_VERSION = 4105; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3911; + CURRENT_PROJECT_VERSION = 4105; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3911; + CURRENT_PROJECT_VERSION = 4105; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3911; + CURRENT_PROJECT_VERSION = 4105; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From d620dc5e032e30e29c6f463b5a54046cac352bb1 Mon Sep 17 00:00:00 2001 From: Bryan Fullam Date: Fri, 20 Mar 2026 09:24:59 +0100 Subject: [PATCH 02/54] cherry-pick of #27690: feat: Add A/B test for bridge token selector balance layout (#27714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Cherry pick swaps a/b test that barely missed RC cutoff. This PR adds an A/B test for the bridge token selector balance layout. Control keeps the current presentation by showing fiat balance on the top row and keeping the ticker in the token balance text. Treatment moves the token balance to the top row, removes the duplicate ticker from the token balance text, and keeps the top and bottom rows aligned with the intended size and color hierarchy. The PR also passes the active experiment through the bridge page-view and submit analytics paths using `active_ab_tests` so the treatment can be evaluated against downstream conversion metrics. ## **Changelog** CHANGELOG entry: Added an experiment for the bridge token selector balance layout. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Bridge token selector balance layout experiment Scenario: User sees the control layout in the bridge token selector Given the token selector balance layout experiment is in the control variant And the user opens the Bridge flow When the token selector list is shown Then the fiat balance is shown on the top row And the token balance is shown on the bottom row with the ticker included Scenario: User sees the treatment layout in the bridge token selector Given the token selector balance layout experiment is in the treatment variant And the user opens the Bridge flow When the token selector list is shown Then the token balance is shown on the top row without the duplicate ticker And the fiat balance is shown on the bottom row Scenario: Analytics include the active experiment Given the token selector balance layout experiment is active When the user opens the Bridge flow And submits a bridge quote Then the relevant page-view and submit analytics payloads include active_ab_tests for the active experiment ``` ## **Screenshots/Recordings** ### **Before** Control variant: Screenshot 2026-03-18 at 19 11 16 ### **After** Treatment variant: Screenshot 2026-03-19 at 18 24 35 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Medium risk because it changes bridge token list balance rendering and threads new `activeAbTests` metadata through bridge submission/page-view analytics, which could affect UI correctness and controller call signatures if mismatched. > > **Overview** > Adds a new A/B experiment (`swapsSWAPS4242AbtestTokenSelectorBalanceLayout`) that toggles the bridge token selector’s balance layout: *control* keeps fiat-on-top with token balance including ticker, while *treatment* shows token balance first and can omit the ticker. > > Updates bridge analytics and submission paths to include an `active_ab_tests`/`activeAbTests` array when experiments are active (now aggregating both the existing numpad quick actions test and the new token selector test), with new/updated unit tests covering the variant-driven UI ordering and the forwarded experiment metadata. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fb210460eafefd04d0684425d031b24bc996c31c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). ## **Description** ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../TokenSelectorItem.abTestConfig.ts | 26 ++++ .../components/TokenSelectorItem.test.tsx | 67 +++++++++ .../Bridge/components/TokenSelectorItem.tsx | 139 ++++++++++++++---- .../hooks/useTrackSwapPageViewed/index.ts | 51 +++++-- .../bridge/hooks/useSubmitBridgeTx.test.tsx | 101 +++++++++++++ app/util/bridge/hooks/useSubmitBridgeTx.ts | 45 ++++++ 6 files changed, 390 insertions(+), 39 deletions(-) create mode 100644 app/components/UI/Bridge/components/TokenSelectorItem.abTestConfig.ts diff --git a/app/components/UI/Bridge/components/TokenSelectorItem.abTestConfig.ts b/app/components/UI/Bridge/components/TokenSelectorItem.abTestConfig.ts new file mode 100644 index 00000000000..17b2db140d8 --- /dev/null +++ b/app/components/UI/Bridge/components/TokenSelectorItem.abTestConfig.ts @@ -0,0 +1,26 @@ +export const TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY = + 'swapsSWAPS4242AbtestTokenSelectorBalanceLayout'; + +export enum TokenSelectorBalanceLayoutVariant { + Control = 'control', + Treatment = 'treatment', +} + +interface TokenSelectorBalanceLayoutConfig { + showTokenBalanceFirst: boolean; + removeTickerFromTokenBalance: boolean; +} + +export const TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS: Record< + TokenSelectorBalanceLayoutVariant, + TokenSelectorBalanceLayoutConfig +> = { + [TokenSelectorBalanceLayoutVariant.Control]: { + showTokenBalanceFirst: false, + removeTickerFromTokenBalance: false, + }, + [TokenSelectorBalanceLayoutVariant.Treatment]: { + showTokenBalanceFirst: true, + removeTickerFromTokenBalance: true, + }, +}; diff --git a/app/components/UI/Bridge/components/TokenSelectorItem.test.tsx b/app/components/UI/Bridge/components/TokenSelectorItem.test.tsx index 5f03b70aaa4..53529bd157a 100644 --- a/app/components/UI/Bridge/components/TokenSelectorItem.test.tsx +++ b/app/components/UI/Bridge/components/TokenSelectorItem.test.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; +import { Text as RNText } from 'react-native'; import { TokenSelectorItem } from './TokenSelectorItem'; import { ethers } from 'ethers'; +import { useABTest } from '../../../../hooks'; import { createMockTokenWithBalance } from '../testUtils/fixtures'; import { TOKEN_BALANCE_LOADING, @@ -13,6 +15,10 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(() => []), })); +jest.mock('../../../../hooks', () => ({ + useABTest: jest.fn(), +})); + jest.mock('../../../../../locales/i18n', () => ({ strings: (key: string) => { const translations: Record = { @@ -91,9 +97,18 @@ jest.mock('../../../../component-library/components/Tags/Tag', () => { describe('TokenSelectorItem', () => { const mockOnPress = jest.fn(); + const mockUseABTest = jest.mocked(useABTest); beforeEach(() => { jest.clearAllMocks(); + mockUseABTest.mockReturnValue({ + variant: { + showTokenBalanceFirst: false, + removeTickerFromTokenBalance: false, + }, + variantName: 'control', + isActive: false, + }); }); describe('rendering', () => { @@ -382,4 +397,56 @@ describe('TokenSelectorItem', () => { expect(fiatBalanceElement.props.numberOfLines).toBe(1); }); }); + + describe('A/B variants', () => { + it('keeps fiat above token balance in the control layout', () => { + const token = createMockTokenWithBalance({ + balance: '50.0', + balanceFiat: '$500', + symbol: 'USDC', + }); + + const controlRender = render( + , + ); + expect(controlRender.getByText('50 USDC')).toBeOnTheScreen(); + + const controlTextOrder = controlRender + .UNSAFE_getAllByType(RNText) + .map((textNode) => String(textNode.props.children)); + expect(controlTextOrder.indexOf('$500')).toBeLessThan( + controlTextOrder.indexOf('50 USDC'), + ); + }); + + it('shows token balance first without the ticker in the treatment layout', () => { + mockUseABTest.mockReturnValue({ + variant: { + showTokenBalanceFirst: true, + removeTickerFromTokenBalance: true, + }, + variantName: 'treatment', + isActive: true, + }); + + const token = createMockTokenWithBalance({ + balance: '50.0', + balanceFiat: '$500', + symbol: 'USDC', + }); + + const treatmentRender = render( + , + ); + expect(treatmentRender.getByText('50')).toBeOnTheScreen(); + expect(treatmentRender.queryByText('50 USDC')).not.toBeOnTheScreen(); + + const treatmentTextOrder = treatmentRender + .UNSAFE_getAllByType(RNText) + .map((textNode) => String(textNode.props.children)); + expect(treatmentTextOrder.indexOf('50')).toBeLessThan( + treatmentTextOrder.indexOf('$500'), + ); + }); + }); }); diff --git a/app/components/UI/Bridge/components/TokenSelectorItem.tsx b/app/components/UI/Bridge/components/TokenSelectorItem.tsx index cf6f6881a44..be71f41ce6f 100644 --- a/app/components/UI/Bridge/components/TokenSelectorItem.tsx +++ b/app/components/UI/Bridge/components/TokenSelectorItem.tsx @@ -5,6 +5,8 @@ import { View, TouchableOpacity, Platform, + StyleProp, + TextStyle, } from 'react-native'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../locales/i18n'; @@ -46,6 +48,12 @@ import { ACCOUNT_TYPE_LABELS } from '../../../../constants/account-type-labels'; import parseAmount from '../../../../util/parseAmount'; import { getTokenImageSource } from '../utils'; import { useRWAToken } from '../hooks/useRWAToken'; +import { useABTest } from '../../../../hooks'; +import { + TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS, + TokenSelectorBalanceLayoutVariant, +} from './TokenSelectorItem.abTestConfig'; const createStyles = ({ theme, @@ -136,12 +144,22 @@ interface TokenSelectorItemProps { isNoFeeAsset?: boolean; } +const isLoadingBalance = (balance?: string) => + balance === TOKEN_BALANCE_LOADING || + balance === TOKEN_BALANCE_LOADING_UPPERCASE; + const FiatBalanceView = ({ balance, isSelected, + textStyle, + textVariant, + textColor, }: { balance?: string; isSelected: boolean; + textStyle?: StyleProp; + textVariant: TextVariant; + textColor: TextColor; }) => { const { styles } = useStyles(createStyles, { isSelected }); @@ -149,18 +167,51 @@ const FiatBalanceView = ({ return null; } - if ( - balance === TOKEN_BALANCE_LOADING || - balance === TOKEN_BALANCE_LOADING_UPPERCASE - ) { + if (isLoadingBalance(balance)) { + return ; + } + + return ( + + {balance} + + ); +}; + +const TokenBalanceView = ({ + balance, + isSelected, + textStyle, + textVariant, + textColor, +}: { + balance?: string; + isSelected: boolean; + textStyle?: StyleProp; + textVariant: TextVariant; + textColor: TextColor; +}) => { + const { styles } = useStyles(createStyles, { isSelected }); + + if (!balance) { + return null; + } + + if (isLoadingBalance(balance)) { return ; } return ( {balance} @@ -178,6 +229,10 @@ export const TokenSelectorItem: React.FC = ({ isNoFeeAsset = false, }) => { const { styles } = useStyles(createStyles, { isSelected }); + const { variant } = useABTest( + TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS, + ); const noFeeAssets = useSelector((state: RootState) => selectNoFeeAssets(state, token.chainId), ); @@ -197,8 +252,18 @@ export const TokenSelectorItem: React.FC = ({ return parseAmount(balance, 5) || balance; }; - const cryptoBalance = token.balance - ? `${formatTokenBalance(token.balance)} ${token.symbol}` + const selectedVariant = + variant ?? + TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS[ + TokenSelectorBalanceLayoutVariant.Control + ]; + const formattedTokenBalance = token.balance + ? formatTokenBalance(token.balance) + : undefined; + const cryptoBalance = formattedTokenBalance + ? selectedVariant.removeTickerFromTokenBalance + ? formattedTokenBalance + : `${formattedTokenBalance} ${token.symbol}` : undefined; const isNative = token.address === ethers.constants.AddressZero; @@ -206,8 +271,16 @@ export const TokenSelectorItem: React.FC = ({ // to check if the token is a stock by checking if the name includes 'ondo' or 'stock' const { isStockToken } = useRWAToken(); - const balance = shouldShowBalance ? fiatValue : undefined; - const secondaryBalance = shouldShowBalance ? cryptoBalance : undefined; + const fiatBalance = shouldShowBalance ? fiatValue : undefined; + const tokenBalance = shouldShowBalance ? cryptoBalance : undefined; + const topRowBalanceTextStyle = { + textVariant: TextVariant.BodyMDMedium, + textColor: TextColor.Default, + }; + const bottomRowBalanceTextStyle = { + textVariant: TextVariant.BodyMD, + textColor: TextColor.Alternative, + }; const label = token.accountType ? ACCOUNT_TYPE_LABELS[token.accountType] @@ -291,21 +364,21 @@ export const TokenSelectorItem: React.FC = ({ )} - {secondaryBalance ? ( - secondaryBalance === TOKEN_BALANCE_LOADING || - secondaryBalance === TOKEN_BALANCE_LOADING_UPPERCASE ? ( - - ) : ( - - {secondaryBalance} - - ) - ) : null} + {selectedVariant.showTokenBalanceFirst ? ( + + ) : ( + + )} = ({ {token.name} - + {selectedVariant.showTokenBalanceFirst ? ( + + ) : ( + + )} {isStockToken(token as BridgeToken) && } diff --git a/app/components/UI/Bridge/hooks/useTrackSwapPageViewed/index.ts b/app/components/UI/Bridge/hooks/useTrackSwapPageViewed/index.ts index 26fe449e1bb..758bf428b42 100644 --- a/app/components/UI/Bridge/hooks/useTrackSwapPageViewed/index.ts +++ b/app/components/UI/Bridge/hooks/useTrackSwapPageViewed/index.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useSelector } from 'react-redux'; import { useABTest } from '../../../../../hooks'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; @@ -13,15 +13,46 @@ import { NUMPAD_QUICK_ACTIONS_AB_KEY, NUMPAD_QUICK_ACTIONS_VARIANTS, } from '../../components/GaslessQuickPickOptions/abTestConfig'; +import { + TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS, +} from '../../components/TokenSelectorItem.abTestConfig'; export const useTrackSwapPageViewed = () => { const { trackEvent, createEventBuilder } = useAnalytics(); const sourceToken = useSelector(selectSourceToken); const destToken = useSelector(selectDestToken); const abTestContext = useSelector(selectAbTestContext); - const { variantName, isActive } = useABTest( - NUMPAD_QUICK_ACTIONS_AB_KEY, - NUMPAD_QUICK_ACTIONS_VARIANTS, + const { variantName: numpadVariantName, isActive: isNumpadAbActive } = + useABTest(NUMPAD_QUICK_ACTIONS_AB_KEY, NUMPAD_QUICK_ACTIONS_VARIANTS); + const { + variantName: tokenSelectorVariantName, + isActive: isTokenSelectorAbActive, + } = useABTest( + TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS, + ); + + const activeABTests = useMemo( + () => [ + ...(isNumpadAbActive + ? [{ key: NUMPAD_QUICK_ACTIONS_AB_KEY, value: numpadVariantName }] + : []), + ...(isTokenSelectorAbActive + ? [ + { + key: TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + value: tokenSelectorVariantName, + }, + ] + : []), + ], + [ + isNumpadAbActive, + numpadVariantName, + isTokenSelectorAbActive, + tokenSelectorVariantName, + ], ); const hasTrackedPageView = useRef(false); @@ -44,13 +75,8 @@ export const useTrackSwapPageViewed = () => { abTestContext.assetsASSETS2493AbtestTokenDetailsLayout, }, }), - ...(isActive && { - active_ab_tests: [ - { - key: NUMPAD_QUICK_ACTIONS_AB_KEY, - value: variantName, - }, - ], + ...(activeABTests.length > 0 && { + active_ab_tests: activeABTests, }), }; trackEvent( @@ -64,8 +90,7 @@ export const useTrackSwapPageViewed = () => { destToken, trackEvent, createEventBuilder, - isActive, - variantName, + activeABTests, abTestContext, ]); }; diff --git a/app/util/bridge/hooks/useSubmitBridgeTx.test.tsx b/app/util/bridge/hooks/useSubmitBridgeTx.test.tsx index 33eee482723..b17aa823315 100644 --- a/app/util/bridge/hooks/useSubmitBridgeTx.test.tsx +++ b/app/util/bridge/hooks/useSubmitBridgeTx.test.tsx @@ -12,8 +12,14 @@ import { QuoteMetadata, QuoteResponse } from '@metamask/bridge-controller'; import { backgroundState } from '../../test/initial-root-state'; import { TransactionMeta } from '@metamask/transaction-controller'; import { selectSourceWalletAddress } from '../../../selectors/bridge'; +import { useABTest } from '../../../hooks'; type BridgeQuoteResponse = QuoteResponse & QuoteMetadata; +interface MockABTestResult { + variant: unknown; + variantName: string; + isActive: boolean; +} let mockSubmitTx: jest.Mock< Promise, @@ -97,11 +103,37 @@ jest.mock('../../../selectors/bridge', () => ({ ), })); +jest.mock('../../../hooks', () => ({ + useABTest: jest.fn(), +})); + const mockStore = configureMockStore(); +const inactiveABTestResult: MockABTestResult = { + variant: undefined, + variantName: 'control', + isActive: false, +}; describe('useSubmitBridgeTx', () => { + const mockABTests = ({ + first = inactiveABTestResult, + second = inactiveABTestResult, + }: { + first?: MockABTestResult; + second?: MockABTestResult; + } = {}) => { + jest + .mocked(useABTest) + .mockReset() + .mockReturnValue(inactiveABTestResult) + .mockReturnValueOnce(first) + .mockReturnValueOnce(second); + }; + beforeEach(() => { jest.clearAllMocks(); + // Default every test to the non-experiment path unless it opts in. + mockABTests(); }); const createWrapper = (mockState = {}) => { @@ -179,6 +211,7 @@ describe('useSubmitBridgeTx', () => { undefined, undefined, undefined, + undefined, ); expect(txResult).toEqual({ chainId: '0x1', @@ -190,6 +223,46 @@ describe('useSubmitBridgeTx', () => { from: '0x1234567890123456789012345678901234567890', }, }); + + // Re-render with an active assignment to verify submitTx forwards activeAbTests. + mockABTests({ + second: { + variant: {}, + variantName: 'treatment', + isActive: true, + }, + }); + mockSubmitTx.mockResolvedValueOnce({ + chainId: '0x1', + id: '2', + networkClientId: '1', + status: 'submitted', + time: Date.now(), + txParams: { + from: '0x1234567890123456789012345678901234567890', + }, + } as TransactionMeta); + + const { result: activeResult } = renderHook(() => useSubmitBridgeTx(), { + wrapper: createWrapper(), + }); + + await activeResult.current.submitBridgeTx({ + quoteResponse: mockQuoteResponse as BridgeQuoteResponse, + }); + + expect(mockSubmitTx).toHaveBeenLastCalledWith( + '0x1234567890123456789012345678901234567890', + { + ...mockQuoteResponse, + approval: undefined, + }, + true, + undefined, + undefined, + undefined, + [{ key: expect.any(String), value: 'treatment' }], + ); }); it('should handle bridge transaction with approval', async () => { @@ -227,6 +300,7 @@ describe('useSubmitBridgeTx', () => { undefined, undefined, undefined, + undefined, ); expect(txResult).toEqual({ chainId: '0x1', @@ -427,6 +501,33 @@ describe('useSubmitBridgeTx', () => { accountAddress: '0x1234567890123456789012345678901234567890', location: undefined, abTests: undefined, + activeAbTests: undefined, + }); + + // Re-render with an active assignment to verify submitIntent forwards activeAbTests. + mockABTests({ + second: { + variant: {}, + variantName: 'treatment', + isActive: true, + }, + }); + mockSubmitIntent.mockResolvedValueOnce(mockIntentResult); + + const { result: activeResult } = renderHook(() => useSubmitBridgeTx(), { + wrapper: createWrapper(), + }); + + await activeResult.current.submitBridgeTx({ + quoteResponse: mockQuoteResponse, + }); + + expect(mockSubmitIntent).toHaveBeenLastCalledWith({ + quoteResponse: mockQuoteResponse, + accountAddress: '0x1234567890123456789012345678901234567890', + location: undefined, + abTests: undefined, + activeAbTests: [{ key: expect.any(String), value: 'treatment' }], }); expect(mockSubmitTx).not.toHaveBeenCalled(); expect(txResult).toEqual(mockIntentResult); diff --git a/app/util/bridge/hooks/useSubmitBridgeTx.ts b/app/util/bridge/hooks/useSubmitBridgeTx.ts index 735beaf5c1d..4a7c3d46577 100644 --- a/app/util/bridge/hooks/useSubmitBridgeTx.ts +++ b/app/util/bridge/hooks/useSubmitBridgeTx.ts @@ -8,11 +8,30 @@ import { useSelector } from 'react-redux'; import { selectShouldUseSmartTransaction } from '../../../selectors/smartTransactionsController'; import { selectSourceWalletAddress } from '../../../selectors/bridge'; import { selectAbTestContext } from '../../../core/redux/slices/bridge'; +import { useABTest } from '../../../hooks'; +import { + NUMPAD_QUICK_ACTIONS_AB_KEY, + NUMPAD_QUICK_ACTIONS_VARIANTS, +} from '../../../components/UI/Bridge/components/GaslessQuickPickOptions/abTestConfig'; +import { + TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS, +} from '../../../components/UI/Bridge/components/TokenSelectorItem.abTestConfig'; +import { useMemo } from 'react'; export default function useSubmitBridgeTx() { const stxEnabled = useSelector(selectShouldUseSmartTransaction); const walletAddress = useSelector(selectSourceWalletAddress); const abTestContext = useSelector(selectAbTestContext); + const { variantName: numpadVariantName, isActive: isNumpadAbActive } = + useABTest(NUMPAD_QUICK_ACTIONS_AB_KEY, NUMPAD_QUICK_ACTIONS_VARIANTS); + const { + variantName: tokenSelectorVariantName, + isActive: isTokenSelectorAbActive, + } = useABTest( + TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + TOKEN_SELECTOR_BALANCE_LAYOUT_VARIANTS, + ); const abTests = abTestContext?.assetsASSETS2493AbtestTokenDetailsLayout ? { @@ -20,6 +39,30 @@ export default function useSubmitBridgeTx() { abTestContext.assetsASSETS2493AbtestTokenDetailsLayout, } : undefined; + const activeAbTests = useMemo(() => { + const tests: { key: string; value: string }[] = []; + + if (isNumpadAbActive) { + tests.push({ + key: NUMPAD_QUICK_ACTIONS_AB_KEY, + value: numpadVariantName, + }); + } + + if (isTokenSelectorAbActive) { + tests.push({ + key: TOKEN_SELECTOR_BALANCE_LAYOUT_AB_KEY, + value: tokenSelectorVariantName, + }); + } + + return tests.length > 0 ? tests : undefined; + }, [ + isNumpadAbActive, + numpadVariantName, + isTokenSelectorAbActive, + tokenSelectorVariantName, + ]); const submitBridgeTx = async ({ quoteResponse, @@ -40,6 +83,7 @@ export default function useSubmitBridgeTx() { accountAddress: walletAddress, location, abTests, + activeAbTests, }); } return Engine.context.BridgeStatusController.submitTx( @@ -52,6 +96,7 @@ export default function useSubmitBridgeTx() { undefined, // quotesReceivedContext location, abTests, + activeAbTests, ); }; From 058316b9e103592873810844ebc4e2d3b9977f4a Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Fri, 20 Mar 2026 08:26:30 +0000 Subject: [PATCH 03/54] [skip ci] Bump version number to 4116 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 1bc0963af64..862cdc30894 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4105 + versionCode 4116 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 53ee5e63a6d..43ad0bfd14b 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4105 + VERSION_NUMBER: 4116 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4105 + FLASK_VERSION_NUMBER: 4116 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 88b91c62638..b2a1b2576fd 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4105; + CURRENT_PROJECT_VERSION = 4116; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4105; + CURRENT_PROJECT_VERSION = 4116; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4105; + CURRENT_PROJECT_VERSION = 4116; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4105; + CURRENT_PROJECT_VERSION = 4116; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4105; + CURRENT_PROJECT_VERSION = 4116; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4105; + CURRENT_PROJECT_VERSION = 4116; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 979ebf4dab9071a30fe576dd12fd6eaad12e7c87 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 09:08:53 +0000 Subject: [PATCH 04/54] chore(runway): cherry-pick chore: Exempt `metamaskbotv2` from CLA check cp-7.71.0 (#27763) - chore: Exempt `metamaskbotv2` from CLA check cp-7.71.0 (#27758) ## **Description** The CLABot workflow has been updated to exempt `metamaskv2` (i.e. commits created by Patroll tokens) from the CLA check. We saw the CLA check fail recently on a release branch due to some commits appearing for the first time from Patroll (see https://github.com/MetaMask/metamask-mobile/pull/27708#issuecomment-4092630720). This change will fix that CI failure. ## **Changelog** CHANGELOG entry: null ## **Related issues** N/A ## **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 Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk workflow change limited to expanding the CLA bot allowlist; main impact is potentially skipping CLA enforcement for this additional bot account. > > **Overview** > Updates the `CLA Signature Bot` GitHub Actions workflow to add `metamaskbotv2[bot]` to the CLA exemption allowlist, preventing CLA check failures on PRs/merge groups created by that bot. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f1f4342f672c3170241c1afa8ad69771b3182c7f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [0f0a400](https://github.com/MetaMask/metamask-mobile/commit/0f0a400b07fac335e2093237a75011051c60f65f) Co-authored-by: Mark Stacey --- .github/workflows/cla.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index b358b99881e..50fde16b047 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -6,7 +6,7 @@ on: types: [opened,closed,synchronize] merge_group: types: [checks_requested] - + jobs: CLABot: if: github.event_name == 'pull_request_target' || contains(github.event.comment.html_url, '/pull/') @@ -24,6 +24,6 @@ jobs: url-to-cladocument: 'https://metamask.io/cla.html' # This branch can't have protections, commits are made directly to the specified branch. branch: 'cla-signatures' - allowlist: 'dependabot[bot],metamaskbot,crowdin-bot,runway-github[bot],cursorbot,cursoragent' + allowlist: 'dependabot[bot],metamaskbot,metamaskbotv2[bot],crowdin-bot,runway-github[bot],cursorbot,cursoragent' allow-organization-members: true blockchain-storage-flag: false From b29c766eaafb94bb2fb8190869e3460dc04cee11 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 23 Mar 2026 09:10:25 +0000 Subject: [PATCH 05/54] [skip ci] Bump version number to 4144 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 862cdc30894..78db3285bd4 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4116 + versionCode 4144 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 43ad0bfd14b..e4dca4d495c 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4116 + VERSION_NUMBER: 4144 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4116 + FLASK_VERSION_NUMBER: 4144 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index b2a1b2576fd..d8cc4f162dd 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4116; + CURRENT_PROJECT_VERSION = 4144; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4116; + CURRENT_PROJECT_VERSION = 4144; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4116; + CURRENT_PROJECT_VERSION = 4144; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4116; + CURRENT_PROJECT_VERSION = 4144; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4116; + CURRENT_PROJECT_VERSION = 4144; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4116; + CURRENT_PROJECT_VERSION = 4144; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 4246be96cbb770243cef39aa64e3c0520b10df9f Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:04:51 +0100 Subject: [PATCH 06/54] chore(runway): cherry-pick refactor: simplify rampsUnifiedBuyV2 feature flag to single selector cp-7.71.0 (#27762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refactor: simplify rampsUnifiedBuyV2 feature flag to single selector cp-7.71.0 (#27760) ## **Description** The `rampsUnifiedBuyV2` feature flag previously used three chained selectors (`selectRampsUnifiedBuyV2Config` → `selectRampsUnifiedBuyV2ActiveFlag` / `selectRampsUnifiedBuyV2MinimumVersionFlag`) and a custom 2-arg `hasMinimumRequiredVersion` utility. This was inconsistent with how other feature flags (e.g. homepage redesign) are handled in the codebase. This PR consolidates the three selectors into a single `selectRampsUnifiedBuyV2Enabled` selector that uses the shared `validatedVersionGatedFeatureFlag` utility from `app/util/remoteFeatureFlag`. The remote flag shape is updated from `{ active, minimumVersion }` to `{ enabled, minimumVersion }` to match the standard `VersionGatedFeatureFlag` type. **Key changes:** - **Selector file** (`rampsUnifiedBuyV2.ts`): Replaced 3 selectors + `RampsUnifiedBuyV2Config` interface with a single `selectRampsUnifiedBuyV2Enabled` selector - **Hook** (`useRampsUnifiedV2Enabled.ts`): Simplified from two `useSelector` calls + `hasMinimumRequiredVersion` to a single `useSelector` - **Utility** (`isRampsUnifiedV2Enabled.ts`): Simplified to delegate directly to the selector - **Controller init** (`ramps-controller-init.ts`): Replaced local interface + `hasMinimumRequiredVersion` with `validatedVersionGatedFeatureFlag`; imports shared flag key constant - **Flag key constant**: Exported `RAMPS_UNIFIED_BUY_V2_FLAG_KEY` from the selector file as single source of truth - **E2E mocks/fixtures**: Updated flag shape from `active` to `enabled` in `FixtureBuilder`, `feature-flags-mocks`, and `feature-flag-registry` ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** N/A — this is a pure refactor of internal selector structure. Behavior is unchanged. All unit tests have been updated and pass. ## **Screenshots/Recordings** ### **Before** N/A ### **After** https://github.com/user-attachments/assets/13ab83a0-f3b1-4862-95cc-ec02fc5b89b5 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Touches feature-flag gating that controls whether the ramps V2 flow and controller initialization run, and changes the expected remote flag shape from `active` to `enabled`. Main risk is misconfigured/older flag payloads causing the feature to be incorrectly disabled. > > **Overview** > Simplifies `rampsUnifiedBuyV2` enablement checks by replacing the chained config/active/min-version selectors and custom gating logic with a single `selectRampsUnifiedBuyV2Enabled` that delegates to `validatedVersionGatedFeatureFlag`. > > Updates the Ramp hook/utility and `ramps-controller-init` to consume this unified version-gated flag (keeping the build-flag override), and standardizes the remote flag payload from `{ active, minimumVersion }` to `{ enabled, minimumVersion }` across unit tests, E2E mocks/fixtures, and the feature-flag registry defaults. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3edd562babe66c83b700fbbac49c57cb1ea522fd. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [73198ff](https://github.com/MetaMask/metamask-mobile/commit/73198ff6128dcb3f8d2b15c476042864a3a3cdb9) Co-authored-by: Pedro Pablo Aste Kompen Co-authored-by: chloeYue <105063779+chloeYue@users.noreply.github.com> --- .../hooks/useRampsUnifiedV2Enabled.test.ts | 56 ++--- .../UI/Ramp/hooks/useRampsUnifiedV2Enabled.ts | 32 +-- .../utils/isRampsUnifiedV2Enabled.test.ts | 24 +- .../UI/Ramp/utils/isRampsUnifiedV2Enabled.ts | 10 +- .../ramps-controller-init.test.ts | 18 +- .../ramps-controller/ramps-controller-init.ts | 21 +- .../ramps/rampsUnifiedBuyV2.test.ts | 210 +++++------------- .../ramps/rampsUnifiedBuyV2.ts | 36 +-- .../mock-responses/feature-flags-mocks.ts | 8 +- tests/feature-flags/feature-flag-registry.ts | 2 +- tests/framework/fixtures/FixtureBuilder.ts | 2 +- 11 files changed, 139 insertions(+), 280 deletions(-) diff --git a/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.test.ts b/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.test.ts index cab3107eea9..e6b4193e728 100644 --- a/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.test.ts @@ -6,11 +6,11 @@ import useRampsUnifiedV2Enabled from './useRampsUnifiedV2Enabled'; import { getVersion } from 'react-native-device-info'; function mockInitialState({ - rampsUnifiedBuyV2ActiveFlag = true, - rampsUnifiedBuyV2MinimumVersionFlag, + enabled = true, + minimumVersion, }: { - rampsUnifiedBuyV2ActiveFlag?: boolean; - rampsUnifiedBuyV2MinimumVersionFlag?: string | null; + enabled?: boolean; + minimumVersion?: string | null; } = {}) { return { ...initialRootState, @@ -20,9 +20,9 @@ function mockInitialState({ RemoteFeatureFlagController: { remoteFeatureFlags: { rampsUnifiedBuyV2: { - active: rampsUnifiedBuyV2ActiveFlag, - ...(rampsUnifiedBuyV2MinimumVersionFlag !== undefined && { - minimumVersion: rampsUnifiedBuyV2MinimumVersionFlag, + enabled, + ...(minimumVersion !== undefined && { + minimumVersion, }), }, }, @@ -59,8 +59,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: false, - rampsUnifiedBuyV2MinimumVersionFlag: '2.0.0', + enabled: false, + minimumVersion: '2.0.0', }), }, ); @@ -76,8 +76,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: '1.0.0', + enabled: true, + minimumVersion: '1.0.0', }), }, ); @@ -93,8 +93,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: '2.0.0', + enabled: true, + minimumVersion: '2.0.0', }), }, ); @@ -111,8 +111,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: '7.63.0', + enabled: true, + minimumVersion: '7.63.0', }), }, ); @@ -127,8 +127,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: false, - rampsUnifiedBuyV2MinimumVersionFlag: '7.63.0', + enabled: false, + minimumVersion: '7.63.0', }), }, ); @@ -143,8 +143,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: '7.63.0', + enabled: true, + minimumVersion: '7.63.0', }), }, ); @@ -159,8 +159,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: null, + enabled: true, + minimumVersion: null, }), }, ); @@ -175,8 +175,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: undefined, + enabled: true, + minimumVersion: undefined, }), }, ); @@ -191,8 +191,8 @@ describe('useRampsUnifiedV2Enabled', () => { () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: true, - rampsUnifiedBuyV2MinimumVersionFlag: '7.63.0', + enabled: true, + minimumVersion: '7.63.0', }), }, ); @@ -200,15 +200,15 @@ describe('useRampsUnifiedV2Enabled', () => { expect(result.current).toBe(true); }); - it('returns false when both active flag and minimum version are not set', () => { + it('returns false when both enabled flag and minimum version are not set', () => { mockGetVersion.mockReturnValue('8.0.0'); const { result } = renderHookWithProvider( () => useRampsUnifiedV2Enabled(), { state: mockInitialState({ - rampsUnifiedBuyV2ActiveFlag: false, - rampsUnifiedBuyV2MinimumVersionFlag: null, + enabled: false, + minimumVersion: null, }), }, ); diff --git a/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.ts b/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.ts index 2259af515b8..a586257ae1c 100644 --- a/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.ts +++ b/app/components/UI/Ramp/hooks/useRampsUnifiedV2Enabled.ts @@ -1,33 +1,13 @@ import { useSelector } from 'react-redux'; -import { - selectRampsUnifiedBuyV2ActiveFlag, - selectRampsUnifiedBuyV2MinimumVersionFlag, -} from '../../../../selectors/featureFlagController/ramps/rampsUnifiedBuyV2'; -import { hasMinimumRequiredVersion } from '../utils/hasMinimumRequiredVersion'; +import { selectRampsUnifiedBuyV2Enabled } from '../../../../selectors/featureFlagController/ramps/rampsUnifiedBuyV2'; export default function useRampsUnifiedV2Enabled() { - const rampsUnifiedBuyV2MinimumVersionFlag = useSelector( - selectRampsUnifiedBuyV2MinimumVersionFlag, - ); - const rampsUnifiedBuyV2ActiveFlag = useSelector( - selectRampsUnifiedBuyV2ActiveFlag, - ); + const isEnabled = useSelector(selectRampsUnifiedBuyV2Enabled); - const rampsUnifiedBuyV2BuildFlag = - process.env.MM_RAMPS_UNIFIED_BUY_V2_ENABLED; - - // if build flag is defined, it takes precedence over remote feature flag - if ( - rampsUnifiedBuyV2BuildFlag === 'true' || - rampsUnifiedBuyV2BuildFlag === 'false' - ) { - return rampsUnifiedBuyV2BuildFlag === 'true'; + const buildFlag = process.env.MM_RAMPS_UNIFIED_BUY_V2_ENABLED; + if (buildFlag === 'true' || buildFlag === 'false') { + return buildFlag === 'true'; } - const isRampsUnifiedV2Enabled = hasMinimumRequiredVersion( - rampsUnifiedBuyV2MinimumVersionFlag, - rampsUnifiedBuyV2ActiveFlag, - ); - - return isRampsUnifiedV2Enabled; + return isEnabled; } diff --git a/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.test.ts b/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.test.ts index fb04ee960b9..cc0c454ac77 100644 --- a/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.test.ts +++ b/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.test.ts @@ -9,10 +9,10 @@ jest.mock('react-native-device-info', () => ({ })); function buildState({ - active = true, + enabled = true, minimumVersion, }: { - active?: boolean; + enabled?: boolean; minimumVersion?: string | null; } = {}) { return { @@ -24,7 +24,7 @@ function buildState({ ...backgroundState.RemoteFeatureFlagController, remoteFeatureFlags: { rampsUnifiedBuyV2: { - active, + enabled, ...(minimumVersion !== undefined && { minimumVersion }), }, }, @@ -53,7 +53,7 @@ describe('isRampsUnifiedV2Enabled', () => { process.env.MM_RAMPS_UNIFIED_BUY_V2_ENABLED = 'true'; const result = isRampsUnifiedV2Enabled( - buildState({ active: false, minimumVersion: '99.0.0' }), + buildState({ enabled: false, minimumVersion: '99.0.0' }), ); expect(result).toBe(true); @@ -63,7 +63,7 @@ describe('isRampsUnifiedV2Enabled', () => { process.env.MM_RAMPS_UNIFIED_BUY_V2_ENABLED = 'false'; const result = isRampsUnifiedV2Enabled( - buildState({ active: true, minimumVersion: '1.0.0' }), + buildState({ enabled: true, minimumVersion: '1.0.0' }), ); expect(result).toBe(false); @@ -71,21 +71,21 @@ describe('isRampsUnifiedV2Enabled', () => { }); describe('remote feature flag behavior when build flag is not set', () => { - it('returns true when active and version meets minimum requirement', () => { + it('returns true when enabled and version meets minimum requirement', () => { mockGetVersion.mockReturnValue('8.0.0'); const result = isRampsUnifiedV2Enabled( - buildState({ active: true, minimumVersion: '7.63.0' }), + buildState({ enabled: true, minimumVersion: '7.63.0' }), ); expect(result).toBe(true); }); - it('returns false when active flag is false', () => { + it('returns false when enabled flag is false', () => { mockGetVersion.mockReturnValue('8.0.0'); const result = isRampsUnifiedV2Enabled( - buildState({ active: false, minimumVersion: '7.63.0' }), + buildState({ enabled: false, minimumVersion: '7.63.0' }), ); expect(result).toBe(false); @@ -95,7 +95,7 @@ describe('isRampsUnifiedV2Enabled', () => { mockGetVersion.mockReturnValue('7.0.0'); const result = isRampsUnifiedV2Enabled( - buildState({ active: true, minimumVersion: '7.63.0' }), + buildState({ enabled: true, minimumVersion: '7.63.0' }), ); expect(result).toBe(false); @@ -105,7 +105,7 @@ describe('isRampsUnifiedV2Enabled', () => { mockGetVersion.mockReturnValue('8.0.0'); const result = isRampsUnifiedV2Enabled( - buildState({ active: true, minimumVersion: null }), + buildState({ enabled: true, minimumVersion: null }), ); expect(result).toBe(false); @@ -115,7 +115,7 @@ describe('isRampsUnifiedV2Enabled', () => { mockGetVersion.mockReturnValue('7.63.0'); const result = isRampsUnifiedV2Enabled( - buildState({ active: true, minimumVersion: '7.63.0' }), + buildState({ enabled: true, minimumVersion: '7.63.0' }), ); expect(result).toBe(true); diff --git a/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.ts b/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.ts index 5065e228945..956ccd194b1 100644 --- a/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.ts +++ b/app/components/UI/Ramp/utils/isRampsUnifiedV2Enabled.ts @@ -1,8 +1,4 @@ -import { - selectRampsUnifiedBuyV2ActiveFlag, - selectRampsUnifiedBuyV2MinimumVersionFlag, -} from '../../../../selectors/featureFlagController/ramps/rampsUnifiedBuyV2'; -import { hasMinimumRequiredVersion } from './hasMinimumRequiredVersion'; +import { selectRampsUnifiedBuyV2Enabled } from '../../../../selectors/featureFlagController/ramps/rampsUnifiedBuyV2'; import { RootState } from '../../../../reducers'; /** @@ -16,7 +12,5 @@ export function isRampsUnifiedV2Enabled(state: RootState): boolean { return buildFlag === 'true'; } - const activeFlag = selectRampsUnifiedBuyV2ActiveFlag(state); - const minimumVersion = selectRampsUnifiedBuyV2MinimumVersionFlag(state); - return hasMinimumRequiredVersion(minimumVersion, activeFlag); + return selectRampsUnifiedBuyV2Enabled(state); } diff --git a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts index a47f66344cf..e96ce515ecb 100644 --- a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts +++ b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts @@ -67,17 +67,17 @@ jest.mock('react-native-device-info', () => ({ const createMockInitMessenger = ( overrides: { - active?: boolean; + enabled?: boolean; minimumVersion?: string | null; } = {}, ): RampsControllerInitMessenger => { - const { active = false, minimumVersion = null } = overrides; + const { enabled = false, minimumVersion = null } = overrides; return { call: jest.fn().mockReturnValue({ remoteFeatureFlags: { rampsUnifiedBuyV2: { - active, + enabled, minimumVersion, }, }, @@ -196,7 +196,7 @@ describe('ramps controller init', () => { describe('when V2 feature flag is enabled', () => { it('calls init at startup', async () => { initRequestMock.initMessenger = createMockInitMessenger({ - active: true, + enabled: true, minimumVersion: '1.0.0', }); @@ -209,7 +209,7 @@ describe('ramps controller init', () => { it('handles init failure gracefully', async () => { initRequestMock.initMessenger = createMockInitMessenger({ - active: true, + enabled: true, minimumVersion: '1.0.0', }); mockInit.mockRejectedValue(new Error('Network error')); @@ -225,7 +225,7 @@ describe('ramps controller init', () => { describe('when V2 feature flag is disabled', () => { it('does not call init at startup', async () => { initRequestMock.initMessenger = createMockInitMessenger({ - active: false, + enabled: false, }); rampsControllerInit(initRequestMock); @@ -235,9 +235,9 @@ describe('ramps controller init', () => { }); }); - it('does not call init when active is true but minimumVersion is missing', async () => { + it('does not call init when enabled is true but minimumVersion is missing', async () => { initRequestMock.initMessenger = createMockInitMessenger({ - active: true, + enabled: true, minimumVersion: null, }); @@ -265,7 +265,7 @@ describe('ramps controller init', () => { it('always returns the controller instance regardless of flag state', () => { initRequestMock.initMessenger = createMockInitMessenger({ - active: false, + enabled: false, }); const result = rampsControllerInit(initRequestMock); diff --git a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts index a419253e8fb..f7a02ca5605 100644 --- a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts +++ b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts @@ -5,17 +5,11 @@ import { getDefaultRampsControllerState, } from '@metamask/ramps-controller'; import type { RampsControllerInitMessenger } from '../../messengers/ramps-controller-messenger'; -import { hasMinimumRequiredVersion } from '../../../../components/UI/Ramp/utils/hasMinimumRequiredVersion'; +import { validatedVersionGatedFeatureFlag } from '../../../../util/remoteFeatureFlag'; +import { RAMPS_UNIFIED_BUY_V2_FLAG_KEY } from '../../../../selectors/featureFlagController/ramps/rampsUnifiedBuyV2'; import { handleOrderStatusChangedForNotifications } from './event-handlers/notification'; import { handleOrderStatusChangedForMetrics } from './event-handlers/analytics'; -interface RampsUnifiedBuyV2Config { - active?: boolean; - minimumVersion?: string; -} - -const RAMPS_UNIFIED_BUY_V2_FLAG_KEY = 'rampsUnifiedBuyV2'; - /** * Determines whether the ramps unified buy V2 feature is enabled * by reading the remote feature flag state. @@ -30,14 +24,9 @@ function getIsRampsUnifiedBuyV2Enabled( const remoteState = initMessenger.call( 'RemoteFeatureFlagController:getState', ); - const config = (remoteState?.remoteFeatureFlags?.[ - RAMPS_UNIFIED_BUY_V2_FLAG_KEY - ] ?? {}) as RampsUnifiedBuyV2Config; - - return hasMinimumRequiredVersion( - config.minimumVersion, - config.active ?? false, - ); + const remoteFlag = + remoteState?.remoteFeatureFlags?.[RAMPS_UNIFIED_BUY_V2_FLAG_KEY]; + return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; } catch { return false; } diff --git a/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV2.test.ts b/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV2.test.ts index bc93d1943e6..c8a9b757fd3 100644 --- a/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV2.test.ts +++ b/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV2.test.ts @@ -1,165 +1,77 @@ -import { - RampsUnifiedBuyV2Config, - selectRampsUnifiedBuyV2Config, - selectRampsUnifiedBuyV2ActiveFlag, - selectRampsUnifiedBuyV2MinimumVersionFlag, -} from './rampsUnifiedBuyV2'; -import { selectRemoteFeatureFlags } from '..'; -import { FeatureFlags } from '@metamask/remote-feature-flag-controller'; - -describe('RampsUnifiedBuyV2 selectors', () => { - const mockRemoteFeatureFlags: ReturnType & { - rampsUnifiedBuyV2: RampsUnifiedBuyV2Config; - } = { - rampsUnifiedBuyV2: { - active: true, - minimumVersion: '7.63.0', - }, - }; - - const mockEmptyRemoteFeatureFlags = {}; - - describe('selectRampsUnifiedBuyV2Config', () => { - it('returns the rampsUnifiedBuyV2Config when it exists', () => { - const result = selectRampsUnifiedBuyV2Config.resultFunc( - mockRemoteFeatureFlags, - ); - - expect(result).toEqual(mockRemoteFeatureFlags.rampsUnifiedBuyV2); - }); - - it('returns an empty object when rampsUnifiedBuyV2Config does not exist', () => { - const result = selectRampsUnifiedBuyV2Config.resultFunc( - mockEmptyRemoteFeatureFlags, - ); - - expect(result).toEqual({}); - }); - - it('returns an empty object when remoteFeatureFlags is null', () => { - const result = selectRampsUnifiedBuyV2Config.resultFunc( - null as unknown as FeatureFlags, - ); - - expect(result).toEqual({}); - }); - - it('returns an empty object when remoteFeatureFlags is undefined', () => { - const result = selectRampsUnifiedBuyV2Config.resultFunc( - undefined as unknown as FeatureFlags, - ); - - expect(result).toEqual({}); - }); +import { selectRampsUnifiedBuyV2Enabled } from './rampsUnifiedBuyV2'; +// eslint-disable-next-line import-x/no-namespace +import * as remoteFeatureFlagModule from '../../../util/remoteFeatureFlag'; + +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn().mockReturnValue('1.0.0'), +})); + +describe('selectRampsUnifiedBuyV2Enabled', () => { + let mockHasMinimumRequiredVersion: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockHasMinimumRequiredVersion = jest.spyOn( + remoteFeatureFlagModule, + 'hasMinimumRequiredVersion', + ); + mockHasMinimumRequiredVersion.mockReturnValue(true); }); - describe('selectRampsUnifiedBuyV2ActiveFlag', () => { - it('returns true when active is set to true', () => { - const result = selectRampsUnifiedBuyV2ActiveFlag.resultFunc( - mockRemoteFeatureFlags.rampsUnifiedBuyV2, - ); - - expect(result).toBe(true); - }); - - it('returns false when active is set to false', () => { - const mockConfigWithActiveFalse: RampsUnifiedBuyV2Config = { - active: false, - minimumVersion: '7.63.0', - }; - - const result = selectRampsUnifiedBuyV2ActiveFlag.resultFunc( - mockConfigWithActiveFalse, - ); - - expect(result).toBe(false); - }); - - it('returns false when active is not set', () => { - const result = selectRampsUnifiedBuyV2ActiveFlag.resultFunc({}); - - expect(result).toBe(false); - }); - - it('returns false when active is null', () => { - const mockConfigWithActiveNull: RampsUnifiedBuyV2Config = { - active: null as unknown as boolean, - minimumVersion: '7.63.0', - }; - - const result = selectRampsUnifiedBuyV2ActiveFlag.resultFunc( - mockConfigWithActiveNull, - ); - - expect(result).toBe(false); - }); - - it('returns false when active is undefined', () => { - const mockConfigWithActiveUndefined: RampsUnifiedBuyV2Config = { - active: undefined as unknown as boolean, - minimumVersion: '7.63.0', - }; - - const result = selectRampsUnifiedBuyV2ActiveFlag.resultFunc( - mockConfigWithActiveUndefined, - ); - - expect(result).toBe(false); - }); + afterEach(() => { + mockHasMinimumRequiredVersion?.mockRestore(); }); - describe('selectRampsUnifiedBuyV2MinimumVersionFlag', () => { - it('returns the minimumVersion when it exists', () => { - const result = selectRampsUnifiedBuyV2MinimumVersionFlag.resultFunc( - mockRemoteFeatureFlags.rampsUnifiedBuyV2, - ); - - expect(result).toBe('7.63.0'); + it('returns true when remote flag is valid and enabled', () => { + const result = selectRampsUnifiedBuyV2Enabled.resultFunc({ + rampsUnifiedBuyV2: { + enabled: true, + minimumVersion: '1.0.0', + }, }); + expect(result).toBe(true); + }); - it('returns null when minimumVersion is not set', () => { - const result = selectRampsUnifiedBuyV2MinimumVersionFlag.resultFunc({}); - - expect(result).toBeNull(); + it('returns false when remote flag is valid but disabled', () => { + const result = selectRampsUnifiedBuyV2Enabled.resultFunc({ + rampsUnifiedBuyV2: { + enabled: false, + minimumVersion: '1.0.0', + }, }); + expect(result).toBe(false); + }); - it('returns null when minimumVersion is null', () => { - const mockConfigWithVersionNull: RampsUnifiedBuyV2Config = { - active: true, - minimumVersion: null as unknown as string, - }; - - const result = selectRampsUnifiedBuyV2MinimumVersionFlag.resultFunc( - mockConfigWithVersionNull, - ); - - expect(result).toBeNull(); + it('returns false when version check fails', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + const result = selectRampsUnifiedBuyV2Enabled.resultFunc({ + rampsUnifiedBuyV2: { + enabled: true, + minimumVersion: '99.0.0', + }, }); + expect(result).toBe(false); + }); - it('returns null when minimumVersion is undefined', () => { - const mockConfigWithVersionUndefined: RampsUnifiedBuyV2Config = { - active: true, - minimumVersion: undefined, - }; - - const result = selectRampsUnifiedBuyV2MinimumVersionFlag.resultFunc( - mockConfigWithVersionUndefined, - ); - - expect(result).toBeNull(); + it('returns false when remote flag is invalid', () => { + const result = selectRampsUnifiedBuyV2Enabled.resultFunc({ + rampsUnifiedBuyV2: { + enabled: 'invalid', + minimumVersion: 123, + }, }); + expect(result).toBe(false); + }); - it('returns the minimumVersion when it is an empty string', () => { - const mockConfigWithEmptyVersion: RampsUnifiedBuyV2Config = { - active: true, - minimumVersion: '', - }; - - const result = selectRampsUnifiedBuyV2MinimumVersionFlag.resultFunc( - mockConfigWithEmptyVersion, - ); + it('returns false when remote feature flags are empty', () => { + const result = selectRampsUnifiedBuyV2Enabled.resultFunc({}); + expect(result).toBe(false); + }); - expect(result).toBe(''); + it('returns false when rampsUnifiedBuyV2 is null', () => { + const result = selectRampsUnifiedBuyV2Enabled.resultFunc({ + rampsUnifiedBuyV2: null, }); + expect(result).toBe(false); }); }); diff --git a/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV2.ts b/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV2.ts index 2d537337695..da25e5058ab 100644 --- a/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV2.ts +++ b/app/selectors/featureFlagController/ramps/rampsUnifiedBuyV2.ts @@ -1,34 +1,18 @@ import { createSelector } from 'reselect'; import { selectRemoteFeatureFlags } from '..'; +import { + validatedVersionGatedFeatureFlag, + VersionGatedFeatureFlag, +} from '../../../util/remoteFeatureFlag'; -export interface RampsUnifiedBuyV2Config { - active?: boolean; - minimumVersion?: string; -} +export const RAMPS_UNIFIED_BUY_V2_FLAG_KEY = 'rampsUnifiedBuyV2'; -const FLAG_KEY = 'rampsUnifiedBuyV2'; - -export const selectRampsUnifiedBuyV2Config = createSelector( +export const selectRampsUnifiedBuyV2Enabled = createSelector( selectRemoteFeatureFlags, (remoteFeatureFlags) => { - const rampsUnifiedBuyV2Config = remoteFeatureFlags?.[FLAG_KEY]; - return (rampsUnifiedBuyV2Config ?? {}) as RampsUnifiedBuyV2Config; - }, -); - -export const selectRampsUnifiedBuyV2ActiveFlag = createSelector( - selectRampsUnifiedBuyV2Config, - (rampsUnifiedBuyV2Config) => { - const rampsUnifiedBuyV2ActiveFlag = rampsUnifiedBuyV2Config?.active; - return rampsUnifiedBuyV2ActiveFlag ?? false; - }, -); - -export const selectRampsUnifiedBuyV2MinimumVersionFlag = createSelector( - selectRampsUnifiedBuyV2Config, - (rampsUnifiedBuyV2Config) => { - const rampsUnifiedBuyV2MinimumVersion = - rampsUnifiedBuyV2Config?.minimumVersion; - return rampsUnifiedBuyV2MinimumVersion ?? null; + const remoteFlag = remoteFeatureFlags[ + RAMPS_UNIFIED_BUY_V2_FLAG_KEY + ] as unknown as VersionGatedFeatureFlag; + return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; }, ); diff --git a/tests/api-mocking/mock-responses/feature-flags-mocks.ts b/tests/api-mocking/mock-responses/feature-flags-mocks.ts index f607bca1ea3..fb7c485d8f8 100644 --- a/tests/api-mocking/mock-responses/feature-flags-mocks.ts +++ b/tests/api-mocking/mock-responses/feature-flags-mocks.ts @@ -135,9 +135,9 @@ export const remoteFeatureFlagRampsUnifiedV1Enabled = (active = true) => ({ }, }); -export const remoteFeatureFlagRampsUnifiedV2Enabled = (active = true) => ({ +export const remoteFeatureFlagRampsUnifiedV2Enabled = (enabled = true) => ({ rampsUnifiedBuyV2: { - active, + enabled, minimumVersion: '7.63.0', }, }); @@ -157,14 +157,14 @@ export const remoteFeatureFlagRampsUnifiedEnabled = (active = true) => ({ */ export const remoteFeatureFlagRampsUnifiedMatrixForE2E = ( rampsUnifiedBuyV1Active: boolean, - rampsUnifiedBuyV2Active: boolean, + rampsUnifiedBuyV2Enabled: boolean, ) => ({ rampsUnifiedBuyV1: { active: rampsUnifiedBuyV1Active, minimumVersion: '0.0.0', }, rampsUnifiedBuyV2: { - active: rampsUnifiedBuyV2Active, + enabled: rampsUnifiedBuyV2Enabled, minimumVersion: '0.0.0', }, }); diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index f37172447ce..bb05b3aa658 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -3240,7 +3240,7 @@ export const FEATURE_FLAG_REGISTRY: Record = { type: FeatureFlagType.Remote, inProd: true, productionDefault: { - active: false, + enabled: false, minimumVersion: '7.61.0', }, status: FeatureFlagStatus.Active, diff --git a/tests/framework/fixtures/FixtureBuilder.ts b/tests/framework/fixtures/FixtureBuilder.ts index b8cdf670961..01d30e82d21 100644 --- a/tests/framework/fixtures/FixtureBuilder.ts +++ b/tests/framework/fixtures/FixtureBuilder.ts @@ -517,7 +517,7 @@ class FixtureBuilder { minimumVersion: '0.0.0', }, rampsUnifiedBuyV2: { - active: rampsUnifiedBuyV2, + enabled: rampsUnifiedBuyV2, minimumVersion: '0.0.0', }, }, From 3ce6160893975ae467d125335dfee2b39203e6ce Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 23 Mar 2026 12:06:26 +0000 Subject: [PATCH 07/54] [skip ci] Bump version number to 4146 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 78db3285bd4..11266e9e0f9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4144 + versionCode 4146 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index e4dca4d495c..8ca114a72d6 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4144 + VERSION_NUMBER: 4146 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4144 + FLASK_VERSION_NUMBER: 4146 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index d8cc4f162dd..5462a9c3e89 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4144; + CURRENT_PROJECT_VERSION = 4146; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4144; + CURRENT_PROJECT_VERSION = 4146; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4144; + CURRENT_PROJECT_VERSION = 4146; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4144; + CURRENT_PROJECT_VERSION = 4146; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4144; + CURRENT_PROJECT_VERSION = 4146; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4144; + CURRENT_PROJECT_VERSION = 4146; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 5834a304ea24311fb97d7b4d378198ca45325c7f Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:33:03 +0100 Subject: [PATCH 08/54] chore(runway): cherry-pick fix(ramps): Preserve user-entered amount during Transak navigation reset -> cp-7.71.0 (#27772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(ramps): Preserve user-entered amount during Transak navigation reset -> cp-7.71.0 (#27742) ## **Description** Fixes the Buy flow amount reverting from the user-entered value back to the default $100 during the Transak native provider loading transition. When a user enters a custom amount (e.g. $30) and taps Continue, the Transak routing callbacks use `navigation.reset()` to rebuild the navigation stack with a fresh `BuildQuote` screen as the base route. This fresh instance initialized with `DEFAULT_AMOUNT = 100`, causing a visible flash of $100 during the transition to the checkout/KYC screen. The fix passes the current `quote.fiatAmount` as a route param (`amount`) to the `AMOUNT_INPUT` base route in every `navigation.reset()` call. `BuildQuote` now reads `params?.amount` as the initial state, preserving the user-entered amount through stack resets. ## **Changelog** CHANGELOG entry: Fixed Buy flow amount input reverting to $100 during Transak native provider checkout transition. ## **Related issues** Fixes: [TRAM-3348](https://consensyssoftware.atlassian.net/browse/TRAM-3348) ## **Manual testing steps** ```gherkin Feature: Amount persists through Transak native provider checkout transition Scenario: Custom amount does not revert to default during Continue loading Given the user is on the Buy screen with Transak Native provider And the default amount is $100 When the user changes the amount to $30 And the user taps Continue Then the displayed amount remains $30 during the loading transition And the amount does not flash back to $100 Scenario: Default amount is preserved when no custom amount is entered Given the user is on the Buy screen with Transak Native provider And the default amount is $100 When the user taps Continue without changing the amount Then the displayed amount remains $100 throughout the flow Scenario: Amount persists when navigating back from KYC/checkout screens Given the user entered $50 and proceeded through Continue When the user navigates back to the Buy screen Then the amount input shows $50 (not $100) ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/ba7fb42b-c43a-43f8-b438-0484090d7895 ### **After** https://github.com/user-attachments/assets/6d60c1ae-f0eb-448b-b7f3-297efbce85df https://github.com/user-attachments/assets/099ebf69-face-4bb0-8568-80357f59b35e Screenshot 2026-03-20 175035 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [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] > **Medium Risk** > Updates Transak native-provider navigation/reset logic and initial screen state; mistakes could regress Buy/KYC routing or amount display during transitions, but changes are localized and covered by tests. > > **Overview** > Fixes the Transak native Buy flow so the user-entered fiat amount is preserved when the app uses `navigation.reset()` during KYC/checkout transitions. > > `BuildQuote` now supports an `amount` route param to initialize the amount state (and to prevent region defaults from overriding it), and `useTransakRouting` propagates this amount through all reset-based navigation paths (KYC approved → checkout, KYC forms, additional verification, verify identity, and KYC webview). Tests are updated/added to assert the amount is passed through routing callbacks and used as the initial displayed value. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a8bdee0a10fab193fe58381e27316828206756ce. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [8a03f66](https://github.com/MetaMask/metamask-mobile/commit/8a03f66803964cbbe25b37cde717aeed03ec4bf7) Co-authored-by: Yu-Gi-Oh! <54774811+imyugioh@users.noreply.github.com> Co-authored-by: chloeYue <105063779+chloeYue@users.noreply.github.com> --- .../Ramp/Views/BuildQuote/BuildQuote.test.tsx | 37 ++++- .../UI/Ramp/Views/BuildQuote/BuildQuote.tsx | 16 +- .../AdditionalVerification.test.tsx | 1 + .../NativeFlow/AdditionalVerification.tsx | 6 +- .../UI/Ramp/hooks/useTransakRouting.test.ts | 143 ++++++++++++++---- .../UI/Ramp/hooks/useTransakRouting.ts | 58 +++++-- 6 files changed, 207 insertions(+), 54 deletions(-) diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx index db775cb0ccd..aea62bd39ff 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx @@ -402,6 +402,41 @@ describe('BuildQuote', () => { }); }); + describe('amount param initialization', () => { + it('uses DEFAULT_AMOUNT (100) when no amount param is provided', () => { + mockUseParams.mockReturnValue({}); + + const { getByTestId } = renderWithProvider(, { + state: initialRootState, + }); + + const amountInput = getByTestId(BuildQuoteSelectors.AMOUNT_INPUT); + expect(amountInput.props.children).toContain('100'); + }); + + it('uses amount param as initial value when provided via route params', () => { + mockUseParams.mockReturnValue({ amount: 30 }); + + const { getByTestId } = renderWithProvider(, { + state: initialRootState, + }); + + const amountInput = getByTestId(BuildQuoteSelectors.AMOUNT_INPUT); + expect(amountInput.props.children).toContain('30'); + }); + + it('does not override amount with region default when amount param is provided', () => { + mockUseParams.mockReturnValue({ amount: 50 }); + + const { getByTestId } = renderWithProvider(, { + state: initialRootState, + }); + + const amountInput = getByTestId(BuildQuoteSelectors.AMOUNT_INPUT); + expect(amountInput.props.children).toContain('50'); + }); + }); + describe('navigateAfterExternalBrowser', () => { it('resets to BuildQuote when returnDestination is buildQuote (Android external browser path)', async () => { mockDeviceIsAndroid.mockReturnValue(true); @@ -725,7 +760,7 @@ describe('BuildQuote', () => { '/payments/debit-credit-card', '100', ); - expect(mockRouteAfterAuth).toHaveBeenCalledWith(MOCK_TRANSAK_QUOTE); + expect(mockRouteAfterAuth).toHaveBeenCalledWith(MOCK_TRANSAK_QUOTE, 100); }); it('navigates to VerifyIdentity when user has no token', async () => { diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx index 88c6ed6d94f..407507a18b5 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx @@ -109,6 +109,8 @@ export interface BuildQuoteParams { nativeFlowError?: string; /** Which flow the user used to enter the Buy screen. */ buyFlowOrigin?: BuyFlowOrigin; + /** Pre-fill the amount input (e.g. when restoring state after a navigation reset). */ + amount?: number; } /** @@ -148,13 +150,17 @@ function BuildQuote() { const { formatCurrency } = useFormatters(); const cursorOpacity = useBlinkingCursor(); - const [amount, setAmount] = useState(() => String(DEFAULT_AMOUNT)); - const [amountAsNumber, setAmountAsNumber] = useState(DEFAULT_AMOUNT); - const [userHasEnteredAmount, setUserHasEnteredAmount] = useState(false); + const params = useParams(); + const initialAmount = params?.amount ?? DEFAULT_AMOUNT; + + const [amount, setAmount] = useState(() => String(initialAmount)); + const [amountAsNumber, setAmountAsNumber] = useState(initialAmount); + const [userHasEnteredAmount, setUserHasEnteredAmount] = useState( + params?.amount != null, + ); const [keyboardIsDirty, setKeyboardIsDirty] = useState(false); const [isContinueLoading, setIsContinueLoading] = useState(false); const [rampsError, setRampsError] = useState(null); - const params = useParams(); useEffect(() => { if (params?.nativeFlowError) { @@ -573,7 +579,7 @@ function BuildQuote() { if (!quote) { throw new Error(strings('deposit.buildQuote.unexpectedError')); } - await transakRouteAfterAuth(quote); + await transakRouteAfterAuth(quote, amountAsNumber); } else { navigation.navigate( ...createV2VerifyIdentityNavDetails({ diff --git a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx index ce6144e1834..6375f3e7a3e 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx @@ -71,6 +71,7 @@ describe('V2AdditionalVerification', () => { expect(mockNavigateToKycWebview).toHaveBeenCalledWith({ kycUrl: 'https://kyc.example.com', + amount: 100, }); }); diff --git a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx index 3137b730f81..190666ee128 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx @@ -27,7 +27,7 @@ interface V2AdditionalVerificationParams { const V2AdditionalVerification = () => { const navigation = useNavigation(); - const { kycUrl } = useParams(); + const { kycUrl, quote } = useParams(); const { styles, theme } = useStyles(styleSheet, {}); @@ -46,8 +46,8 @@ const V2AdditionalVerification = () => { }, [navigation, theme]); const handleContinuePress = useCallback(() => { - navigateToKycWebview({ kycUrl }); - }, [navigateToKycWebview, kycUrl]); + navigateToKycWebview({ kycUrl, amount: quote?.fiatAmount }); + }, [navigateToKycWebview, kycUrl, quote?.fiatAmount]); return ( diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts index 96ea42b5452..bde067dce25 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts @@ -223,14 +223,20 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockReset).toHaveBeenCalledWith( expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: mockQuote.fiatAmount }, + }), expect.objectContaining({ name: 'RampBasicInfo', params: expect.objectContaining({ quote: mockQuote }), @@ -267,7 +273,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockRequestOtt).toHaveBeenCalled(); @@ -281,7 +290,10 @@ describe('useTransakRouting', () => { expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: mockQuote.fiatAmount }, + }), expect.objectContaining({ name: 'Checkout', params: expect.objectContaining({ @@ -328,7 +340,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockTransakCreateOrder).toHaveBeenCalledWith( @@ -361,14 +376,20 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockReset).toHaveBeenCalledWith( expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: mockQuote.fiatAmount }, + }), expect.objectContaining({ name: 'RampKycProcessing', params: expect.objectContaining({ quote: mockQuote }), @@ -389,7 +410,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockLogoutFromProvider).toHaveBeenCalledWith(false); @@ -423,7 +447,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockSubmitPurposeOfUsageForm).toHaveBeenCalledWith([ @@ -455,14 +482,20 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockReset).toHaveBeenCalledWith( expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: mockQuote.fiatAmount }, + }), expect.objectContaining({ name: 'RampAdditionalVerification', params: expect.objectContaining({ @@ -493,7 +526,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -515,7 +551,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -537,7 +576,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -560,7 +602,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockRequestOtt).toHaveBeenCalled(); @@ -586,7 +631,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockRequestOtt).toHaveBeenCalled(); @@ -606,7 +654,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -622,7 +673,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -643,14 +697,20 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockReset).toHaveBeenCalledWith( expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: mockQuote.fiatAmount }, + }), expect.objectContaining({ name: 'RampKycProcessing', }), @@ -670,7 +730,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -693,7 +756,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -717,7 +783,10 @@ describe('useTransakRouting', () => { await expect( act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }), ).rejects.toThrow(); }); @@ -740,7 +809,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); expect(mockRequestOtt).toHaveBeenCalled(); @@ -752,14 +824,20 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); act(() => { - result.current.navigateToVerifyIdentity({ quote: mockQuote as never }); + result.current.navigateToVerifyIdentity({ + quote: mockQuote as never, + amount: 30, + }); }); expect(mockReset).toHaveBeenCalledWith( expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: 30 }, + }), expect.objectContaining({ name: 'RampVerifyIdentity', params: expect.objectContaining({ quote: mockQuote }), @@ -771,12 +849,13 @@ describe('useTransakRouting', () => { }); describe('navigateToKycWebview', () => { - it('resets navigation stack to the KYC webview', () => { + it('resets navigation stack to the KYC webview with amount preserved', () => { const { result } = renderHook(() => useTransakRouting()); act(() => { result.current.navigateToKycWebview({ kycUrl: 'https://kyc.example.com', + amount: 30, }); }); @@ -784,7 +863,10 @@ describe('useTransakRouting', () => { expect.objectContaining({ index: 1, routes: [ - expect.objectContaining({ name: 'RampAmountInput' }), + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: 30 }, + }), expect.objectContaining({ name: 'Checkout', params: expect.objectContaining({ @@ -822,7 +904,10 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication(mockQuote as never); + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); }); return capturedHandleNavigationStateChange; diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.ts b/app/components/UI/Ramp/hooks/useTransakRouting.ts index e83dec59811..661863ddf24 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.ts @@ -159,11 +159,14 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { ); const navigateToVerifyIdentityCallback = useCallback( - ({ quote }: { quote: TransakBuyQuote }) => { + ({ quote, amount }: { quote: TransakBuyQuote; amount?: number }) => { navigation.reset({ index: 1, routes: [ - { name: Routes.RAMP.AMOUNT_INPUT }, + { + name: Routes.RAMP.AMOUNT_INPUT, + params: { amount }, + }, { name: Routes.RAMP.VERIFY_IDENTITY, params: { quote } }, ], }); @@ -175,14 +178,19 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { ({ quote, previousFormData, + amount, }: { quote: TransakBuyQuote; previousFormData?: BasicInfoFormData & AddressFormData; + amount?: number; }) => { navigation.reset({ index: 1, routes: [ - { name: Routes.RAMP.AMOUNT_INPUT }, + { + name: Routes.RAMP.AMOUNT_INPUT, + params: { amount }, + }, { name: Routes.RAMP.BASIC_INFO, params: { quote, previousFormData }, @@ -234,15 +242,20 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { quote, kycUrl, workFlowRunId, + amount, }: { quote: TransakBuyQuote; kycUrl: string; workFlowRunId: string; + amount?: number; }) => { navigation.reset({ index: 1, routes: [ - { name: Routes.RAMP.AMOUNT_INPUT }, + { + name: Routes.RAMP.AMOUNT_INPUT, + params: { amount }, + }, { name: Routes.RAMP.ADDITIONAL_VERIFICATION, params: { quote, kycUrl, workFlowRunId }, @@ -341,7 +354,7 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { ); const navigateToWebviewModalCallback = useCallback( - ({ paymentUrl }: { paymentUrl: string }) => { + ({ paymentUrl, amount }: { paymentUrl: string; amount?: number }) => { const callbackKey = registerCheckoutCallback(handleNavigationStateChange); const [routeName, routeParams] = createCheckoutNavDetails({ url: paymentUrl, @@ -351,7 +364,10 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { navigation.reset({ index: 1, routes: [ - { name: Routes.RAMP.AMOUNT_INPUT }, + { + name: Routes.RAMP.AMOUNT_INPUT, + params: { amount }, + }, { name: routeName, params: routeParams }, ], }); @@ -360,11 +376,14 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { ); const navigateToKycProcessingCallback = useCallback( - ({ quote }: { quote: TransakBuyQuote }) => { + ({ quote, amount }: { quote: TransakBuyQuote; amount?: number }) => { navigation.reset({ index: 1, routes: [ - { name: Routes.RAMP.AMOUNT_INPUT }, + { + name: Routes.RAMP.AMOUNT_INPUT, + params: { amount }, + }, { name: Routes.RAMP.KYC_PROCESSING, params: { quote } }, ], }); @@ -373,7 +392,7 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { ); const navigateToKycWebviewCallback = useCallback( - ({ kycUrl }: { kycUrl: string }) => { + ({ kycUrl, amount }: { kycUrl: string; amount?: number }) => { const [routeName, routeParams] = createCheckoutNavDetails({ url: kycUrl, providerName: 'Transak', @@ -381,7 +400,10 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { navigation.reset({ index: 1, routes: [ - { name: Routes.RAMP.AMOUNT_INPUT }, + { + name: Routes.RAMP.AMOUNT_INPUT, + params: { amount }, + }, { name: routeName, params: routeParams }, ], }); @@ -390,7 +412,7 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { ); const routeAfterAuthentication = useCallback( - async (quote: TransakBuyQuote, depth = 0) => { + async (quote: TransakBuyQuote, amount?: number, depth = 0) => { try { const userDetails = await getUserDetails(); const previousFormData = { @@ -473,7 +495,10 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { throw new Error('Failed to generate payment URL'); } - navigateToWebviewModalCallback({ paymentUrl }); + navigateToWebviewModalCallback({ + paymentUrl, + amount, + }); } return true; } catch (error) { @@ -493,7 +518,7 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { region: regionIsoCode, }); - navigateToBasicInfoCallback({ quote, previousFormData }); + navigateToBasicInfoCallback({ quote, previousFormData, amount }); return; case 'ADDITIONAL_FORMS_REQUIRED': { @@ -511,7 +536,7 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { await submitPurposeOfUsageForm([ 'Buying/selling crypto for investments', ]); - await routeAfterAuthentication(quote, depth + 1); + await routeAfterAuthentication(quote, amount, depth + 1); } else { Logger.error( new Error(`Submit of purpose depth exceeded: ${depth}`), @@ -538,16 +563,17 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { quote, kycUrl: metadata.kycUrl, workFlowRunId: metadata.workFlowRunId, + amount, }); return; } - navigateToKycProcessingCallback({ quote }); + navigateToKycProcessingCallback({ quote, amount }); return; } case 'SUBMITTED': { - navigateToKycProcessingCallback({ quote }); + navigateToKycProcessingCallback({ quote, amount }); return; } From 445b43065bc2151030ceaaf9b543dc58c3031708 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 23 Mar 2026 12:34:42 +0000 Subject: [PATCH 09/54] [skip ci] Bump version number to 4147 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 11266e9e0f9..9b0eb3897d2 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4146 + versionCode 4147 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 8ca114a72d6..8b8ae3de325 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4146 + VERSION_NUMBER: 4147 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4146 + FLASK_VERSION_NUMBER: 4147 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 5462a9c3e89..f39563a32dd 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4146; + CURRENT_PROJECT_VERSION = 4147; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4146; + CURRENT_PROJECT_VERSION = 4147; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4146; + CURRENT_PROJECT_VERSION = 4147; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4146; + CURRENT_PROJECT_VERSION = 4147; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4146; + CURRENT_PROJECT_VERSION = 4147; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4146; + CURRENT_PROJECT_VERSION = 4147; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From cc3dfdc4175cce96400e5901e22ec6b49eded279 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:40:51 +0100 Subject: [PATCH 10/54] chore(runway): cherry-pick fix(ramp): fixes order details bug cp-7.71.0 (#27801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(ramp): fixes order details bug cp-7.71.0 (#27755) ## **Description** Fixes an order details UI bug where the title for bank transfer details was displaying for non bank-transfer orders. [TRAM 3359](https://consensyssoftware.atlassian.net/browse/TRAM-3359) ## **Changelog** CHANGELOG entry: fixes an order details UI bug ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### * Screenshot 2026-03-20 at 10 03 02 AM *Before** ### **After** Screenshot 2026-03-20 at 11 39 11 AM ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk UI-only change that gates rendering of the bank details section; main risk is unintentionally hiding bank details if upstream field names/structure change. > > **Overview** > Fixes an order details UI bug where the bank-transfer section header could render for non-bank-transfer orders. > > `OrderContent` now returns `null` for `bankDetailFields` unless at least one expected bank detail field (e.g., amount, routing/account, IBAN/BIC) is present, and tests add coverage for absent/empty/non-matching `paymentDetails` vs. bank-transfer/SEPA scenarios. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 65f6cfc1b351e77d3b011a716ff0fb955001daae. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [7e9748e](https://github.com/MetaMask/metamask-mobile/commit/7e9748e60f6ffe74eb7296f745aa982e0fb105b3) Co-authored-by: George Weiler --- .../Views/OrderDetails/OrderContent.test.tsx | 88 ++++++++++++++++++- .../Ramp/Views/OrderDetails/OrderContent.tsx | 12 +++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx index d7083fb197a..f4c86a0f6fc 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx @@ -7,6 +7,14 @@ import { type RampsOrder, RampsOrderStatus } from '@metamask/ramps-controller'; import Clipboard from '@react-native-clipboard/clipboard'; import InAppBrowser from 'react-native-inappbrowser-reborn'; +type RampsOrderWithPaymentDetails = RampsOrder & { + paymentDetails: { + fiatCurrency: string; + paymentMethod: string; + fields: { name: string; id: string; value: string }[]; + }[]; +}; + const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -177,6 +185,84 @@ describe('OrderContent', () => { ).toBeOnTheScreen(); }); + it('does not render bank details section when paymentDetails is absent', () => { + renderOrder(mockOrder); + + expect(screen.queryByText('To complete your order')).toBeNull(); + }); + + it('does not render bank details section when paymentDetails has no matching fields', () => { + const orderWithPaymentDetails: RampsOrderWithPaymentDetails = { + ...mockOrder, + paymentDetails: [ + { + fiatCurrency: 'USD', + paymentMethod: 'credit_debit_card', + fields: [], + }, + ], + }; + + renderOrder(orderWithPaymentDetails); + + expect(screen.queryByText('To complete your order')).toBeNull(); + }); + + it('renders bank details section when paymentDetails has bank transfer fields', () => { + const orderWithPaymentDetails: RampsOrderWithPaymentDetails = { + ...mockOrder, + paymentDetails: [ + { + fiatCurrency: 'USD', + paymentMethod: 'manual_bank_transfer', + fields: [ + { name: 'Amount', id: 'amount', value: '$100.00' }, + { + name: 'Routing Number', + id: 'routingNumber', + value: '021000021', + }, + { + name: 'Account Number', + id: 'accountNumber', + value: '1234567890', + }, + ], + }, + ], + }; + + renderOrder(orderWithPaymentDetails); + + expect(screen.getByText('To complete your order')).toBeOnTheScreen(); + expect(screen.getByText(/Routing number/i)).toBeOnTheScreen(); + expect(screen.getByText('021000021')).toBeOnTheScreen(); + }); + + it('renders bank details section when paymentDetails only includes SEPA fields', () => { + const orderWithPaymentDetails: RampsOrderWithPaymentDetails = { + ...mockOrder, + paymentDetails: [ + { + fiatCurrency: 'EUR', + paymentMethod: 'sepa_bank_transfer', + fields: [ + { name: 'IBAN', id: 'iban', value: 'DE89370400440532013000' }, + { name: 'BIC', id: 'bic', value: 'COBADEFFXXX' }, + ], + }, + ], + }; + + renderOrder(orderWithPaymentDetails); + + expect(screen.getByText('To complete your order')).toBeOnTheScreen(); + expect(screen.getByText(/^IBAN$/i)).toBeOnTheScreen(); + expect(screen.getByText('DE89370400440532013000')).toBeOnTheScreen(); + expect(screen.getByText(/^BIC$/i)).toBeOnTheScreen(); + expect(screen.getByText('COBADEFFXXX')).toBeOnTheScreen(); + }); + it('truncates long crypto amounts to 5 decimal places', () => { const longDecimalOrder: RampsOrder = { ...mockOrder, @@ -195,7 +281,7 @@ describe('OrderContent', () => { }; renderOrder(tinyAmountOrder); const tokenAmount = screen.getByTestId('ramps-order-details-token-amount'); - // 0.00000614 has 5 leading zeros → "0.0₅614" + // 0.00000614 has 5 leading zeros -> "0.0₅614" expect(tokenAmount).toHaveTextContent('0.0₅614 ETH'); }); diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx index ed6c21707a0..2843e9d9712 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx @@ -280,6 +280,18 @@ const OrderContent: React.FC = ({ const iban = getFieldValue('IBAN'); const bic = getFieldValue('BIC'); + const hasAnyField = + amount || + accountName || + accountType || + bankName || + routingNumber || + accountNumber || + iban || + bic; + + if (!hasAnyField) return null; + return { amount, accountName, From 38b2cc9842b2de3c5b1229328723ae621eced009 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 23 Mar 2026 14:42:39 +0000 Subject: [PATCH 11/54] [skip ci] Bump version number to 4148 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 9b0eb3897d2..365668f38d1 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4147 + versionCode 4148 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 8b8ae3de325..af6f803f5e3 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4147 + VERSION_NUMBER: 4148 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4147 + FLASK_VERSION_NUMBER: 4148 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index f39563a32dd..54d4427a731 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4147; + CURRENT_PROJECT_VERSION = 4148; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4147; + CURRENT_PROJECT_VERSION = 4148; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4147; + CURRENT_PROJECT_VERSION = 4148; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4147; + CURRENT_PROJECT_VERSION = 4148; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4147; + CURRENT_PROJECT_VERSION = 4148; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4147; + CURRENT_PROJECT_VERSION = 4148; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From ddf47210c1bfbc9dc55788880cfa5e33b2491422 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:38:42 +0100 Subject: [PATCH 12/54] chore(runway): cherry-pick fix: start Ramps V2 init when remote feature flags hydrate cp-7.71.0 (#27807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: start Ramps V2 init when remote feature flags hydrate cp-7.71.0 (#27778) ## **Description** On a fresh install, `RemoteFeatureFlagController` loads flags asynchronously while Engine builds controllers. `rampsControllerInit` previously read the unified buy V2 flag only once; if flags were not in state yet, `RampsController.init()` never ran, so buy token lists stayed empty until a full app restart. This change subscribes to `RemoteFeatureFlagController:stateChange` (already delegated on `RampsControllerInitMessenger`) and re-runs the same V2 startup path when remote flag state updates. Order-status subscriptions are registered at most once. `RampsController.init()` remains idempotent for repeated calls. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TRAM-3350 ## **Manual testing steps** ```gherkin Feature: Unified buy V2 after fresh install Scenario: Buy token list loads without restarting the app Given a dev build with unified buy V2 enabled via remote flags And the app is installed fresh (or remote flag cache cleared) When the user completes onboarding and opens Buy / token selection Then tokens and providers load without requiring an app restart ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Adds a new subscription-driven initialization path triggered by `RemoteFeatureFlagController:stateChange`, which can change startup behavior and potentially cause repeated init/polling if underlying idempotency assumptions are wrong. > > **Overview** > Ensures Unified Buy V2 startup runs even when remote feature flags hydrate *after* Engine/controller initialization by subscribing to `RemoteFeatureFlagController:stateChange` and re-checking the V2 flag. > > Refactors V2 startup into a helper that conditionally calls `RampsController.init()`/`startOrderPolling()` and registers order-status subscriptions only once. Updates tests to cover the “flag off at startup then enabled on stateChange” scenario and to include `subscribe` in the thrown-state mock. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 78ff8000147e10ce2325b2dfa563dd0dd16b878c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [c6d96b6](https://github.com/MetaMask/metamask-mobile/commit/c6d96b6ff1e0efa6ad7172666f29e817a3e3ce9e) Co-authored-by: Amitabh Aggarwal --- .../ramps-controller-init.test.ts | 35 +++++++++++++++++++ .../ramps-controller/ramps-controller-init.ts | 35 ++++++++++++++----- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts index e96ce515ecb..89918d75251 100644 --- a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts +++ b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.test.ts @@ -207,6 +207,40 @@ describe('ramps controller init', () => { }); }); + it('calls init when remote flags were off at startup then V2 enables on RemoteFeatureFlagController:stateChange', async () => { + let remoteEnabled = false; + const subscribeMock = jest.fn(); + const initMessenger = { + call: jest.fn(() => ({ + remoteFeatureFlags: { + rampsUnifiedBuyV2: remoteEnabled + ? { enabled: true, minimumVersion: '1.0.0' } + : { enabled: false }, + }, + })), + subscribe: subscribeMock, + } as unknown as RampsControllerInitMessenger; + + initRequestMock.initMessenger = initMessenger; + + rampsControllerInit(initRequestMock); + + expect(mockInit).not.toHaveBeenCalled(); + + const stateChangeHandler = subscribeMock.mock.calls.find( + (call) => call[0] === 'RemoteFeatureFlagController:stateChange', + )?.[1] as () => void; + + expect(stateChangeHandler).toBeDefined(); + + remoteEnabled = true; + stateChangeHandler(); + + await waitFor(() => { + expect(mockInit).toHaveBeenCalledTimes(1); + }); + }); + it('handles init failure gracefully', async () => { initRequestMock.initMessenger = createMockInitMessenger({ enabled: true, @@ -253,6 +287,7 @@ describe('ramps controller init', () => { call: jest.fn().mockImplementation(() => { throw new Error('Controller not ready'); }), + subscribe: jest.fn(), } as unknown as RampsControllerInitMessenger; rampsControllerInit(initRequestMock); diff --git a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts index f7a02ca5605..09ebbe7a9f1 100644 --- a/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts +++ b/app/core/Engine/controllers/ramps-controller/ramps-controller-init.ts @@ -11,8 +11,7 @@ import { handleOrderStatusChangedForNotifications } from './event-handlers/notif import { handleOrderStatusChangedForMetrics } from './event-handlers/analytics'; /** - * Determines whether the ramps unified buy V2 feature is enabled - * by reading the remote feature flag state. + * Whether Unified Buy V2 is enabled per RemoteFeatureFlagController state. * * @param initMessenger - The init messenger to read RemoteFeatureFlagController state. * @returns Whether V2 is enabled. @@ -54,21 +53,28 @@ export const rampsControllerInit: ControllerInitFunction< state: rampsControllerState, }); - const isV2Enabled = getIsRampsUnifiedBuyV2Enabled(initMessenger); + let orderSubscriptionsRegistered = false; - if (isV2Enabled) { + const registerUnifiedBuyV2OrderSubscriptions = (): void => { + if (orderSubscriptionsRegistered) { + return; + } + orderSubscriptionsRegistered = true; initMessenger.subscribe( 'RampsController:orderStatusChanged', handleOrderStatusChangedForNotifications, ); - initMessenger.subscribe( 'RampsController:orderStatusChanged', handleOrderStatusChangedForMetrics, ); + }; - // Start init immediately so tokens (and providers) load on app start. - // init() is async and does not block controller creation. + const startUnifiedBuyV2IfEnabled = (): void => { + if (!getIsRampsUnifiedBuyV2Enabled(initMessenger)) { + return; + } + registerUnifiedBuyV2OrderSubscriptions(); controller .init() .then(() => { @@ -77,7 +83,20 @@ export const rampsControllerInit: ControllerInitFunction< .catch(() => { // Initialization failed - error state will be available via selectors }); - } + }; + + startUnifiedBuyV2IfEnabled(); + + // Remote flags can be empty on first Engine init and fill in once the + // controller has fetched; re-check so RampsController.init() runs then. + // + // This event fires for any RemoteFeatureFlagController state update — not + // only rampsUnifiedBuyV2. When V2 is off, startUnifiedBuyV2IfEnabled returns + // immediately. When V2 is on, order subscriptions register once; init() and + // startOrderPolling() are idempotent, so repeat invocations are safe. + initMessenger.subscribe('RemoteFeatureFlagController:stateChange', () => { + startUnifiedBuyV2IfEnabled(); + }); return { controller, From b1585639f33dd7d2caaedd79ce3c430df9e1bb4d Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 23 Mar 2026 15:40:26 +0000 Subject: [PATCH 13/54] [skip ci] Bump version number to 4149 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 365668f38d1..17bf777c9ba 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4148 + versionCode 4149 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index af6f803f5e3..a279f3d0f7a 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4148 + VERSION_NUMBER: 4149 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4148 + FLASK_VERSION_NUMBER: 4149 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 54d4427a731..5ef615ead93 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4148; + CURRENT_PROJECT_VERSION = 4149; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4148; + CURRENT_PROJECT_VERSION = 4149; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4148; + CURRENT_PROJECT_VERSION = 4149; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4148; + CURRENT_PROJECT_VERSION = 4149; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4148; + CURRENT_PROJECT_VERSION = 4149; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4148; + CURRENT_PROJECT_VERSION = 4149; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 0cc5d5e51a9effeab0afadebf4f11fe58aab03f3 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:36:14 +0100 Subject: [PATCH 14/54] chore(runway): cherry-pick fix(ramps): fixes 0 ETH ramps issue when order data is not yet available cp-7.71.0 (#27812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(ramps): fixes 0 ETH ramps issue when order data is not yet available cp-7.71.0 (#27756) ## **Description** "0 ETH" was displayed on some order pages when the order info was not yet available. This bug fixes by adding "..." placeholder until the info arrives. [TRAM-3360](https://consensyssoftware.atlassian.net/browse/TRAM-3360) ## **Changelog** CHANGELOG entry: Fixes small UI issue with ramps orders ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2026-03-20 at 11 51 50 AM Screenshot 2026-03-20 at 11 53 40 AM ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Adjusts order-details loading/terminal-state logic and amount formatting, which could change what users see for certain pending/failed orders; impact is limited to UI display and snapshots. > > **Overview** > Prevents ramps order UIs from showing `0 ETH`/`0` amounts when data hasn’t arrived by treating `0`/missing `cryptoAmount` (and related fiat fields) as **unknown** and rendering an `...` placeholder in both the orders list (`displayOrder`) and order details (`OrderContent`). > > Order details now distinguishes *loading* vs *terminal* statuses (e.g., `Failed`, `Cancelled`) so terminal orders without amounts render placeholders instead of skeleton loaders, and fiat `fees`/`total` formatting is switched to `formatWithThreshold` currency formatting (snapshot updates included). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a9bc07215c68095a9f9630fdbeb8b2b36fb35382. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Cursor Agent [174afa0](https://github.com/MetaMask/metamask-mobile/commit/174afa013f99a09a0552f87fb249dcfa8b5b65a1) Co-authored-by: George Weiler Co-authored-by: Cursor Agent --- .../__snapshots__/OrdersList.test.tsx.snap | 12 ++-- .../Views/OrderDetails/OrderContent.test.tsx | 27 ++++++-- .../Ramp/Views/OrderDetails/OrderContent.tsx | 66 +++++++++++++------ .../__snapshots__/OrderContent.test.tsx.snap | 18 ++--- .../__snapshots__/displayOrder.test.ts.snap | 2 +- .../UI/Ramp/utils/displayOrder.test.ts | 31 +++++++-- app/components/UI/Ramp/utils/displayOrder.ts | 12 +++- 7 files changed, 120 insertions(+), 48 deletions(-) diff --git a/app/components/UI/Ramp/Aggregator/Views/OrdersList/__snapshots__/OrdersList.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/OrdersList/__snapshots__/OrdersList.test.tsx.snap index 5091ad05fb1..7ea9855e1a1 100644 --- a/app/components/UI/Ramp/Aggregator/Views/OrdersList/__snapshots__/OrdersList.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/OrdersList/__snapshots__/OrdersList.test.tsx.snap @@ -127,7 +127,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` { "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", "createdAt": 0, - "cryptoAmount": 0, + "cryptoAmount": "...", "cryptoCurrencySymbol": "ETH", "fiatAmount": undefined, "fiatCurrencyCode": "USD", @@ -1336,7 +1336,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` } testID="orders-list-crypto-amount-buy-6" > - 0 + ... ETH @@ -1505,7 +1505,7 @@ exports[`OrdersList renders correctly 1`] = ` { "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", "createdAt": 0, - "cryptoAmount": 0, + "cryptoAmount": "...", "cryptoCurrencySymbol": "ETH", "fiatAmount": undefined, "fiatCurrencyCode": "USD", @@ -2864,7 +2864,7 @@ exports[`OrdersList renders correctly 1`] = ` } testID="orders-list-crypto-amount-buy-7" > - 0 + ... ETH @@ -4979,7 +4979,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` { "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", "createdAt": 0, - "cryptoAmount": 0, + "cryptoAmount": "...", "cryptoCurrencySymbol": "ETH", "fiatAmount": undefined, "fiatCurrencyCode": "USD", @@ -6338,7 +6338,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` } testID="orders-list-crypto-amount-buy-7" > - 0 + ... ETH diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx index f4c86a0f6fc..caabda17194 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.test.tsx @@ -90,13 +90,14 @@ describe('OrderContent', () => { const pendingOrder: RampsOrder = { ...mockOrder, fiatAmount: 0, + cryptoAmount: 0, status: RampsOrderStatus.Pending, }; renderOrder(pendingOrder); expect(screen.toJSON()).toMatchSnapshot(); }); - it('shows ellipsis for token amount when cryptoAmount is 0 or missing', () => { + it('shows placeholder for token amount when cryptoAmount is 0 or missing', () => { const orderWithZeroCrypto: RampsOrder = { ...mockOrder, cryptoAmount: 0, @@ -285,7 +286,7 @@ describe('OrderContent', () => { expect(tokenAmount).toHaveTextContent('0.0₅614 ETH'); }); - it('shows "..." when cryptoAmount is missing', () => { + it('shows placeholder when cryptoAmount is missing', () => { const noAmountOrder: RampsOrder = { ...mockOrder, cryptoAmount: undefined as unknown as number, @@ -295,14 +296,32 @@ describe('OrderContent', () => { expect(tokenAmount).toHaveTextContent('... ETH'); }); - it('renders "0" when cryptoAmount is zero', () => { + it('shows placeholder when cryptoAmount is zero', () => { const zeroAmountOrder: RampsOrder = { ...mockOrder, cryptoAmount: 0, }; renderOrder(zeroAmountOrder); const tokenAmount = screen.getByTestId('ramps-order-details-token-amount'); - expect(tokenAmount).toHaveTextContent('0 ETH'); + expect(tokenAmount).toHaveTextContent('... ETH'); + }); + + it('shows placeholder amounts for terminal orders with no amounts', () => { + const failedOrder: RampsOrder = { + ...mockOrder, + cryptoAmount: 0, + fiatAmount: 0, + totalFeesFiat: 0, + status: RampsOrderStatus.Failed, + }; + + renderOrder(failedOrder); + + expect(screen.getByText('Failed')).toBeOnTheScreen(); + expect( + screen.getByTestId('ramps-order-details-token-amount'), + ).toHaveTextContent('... ETH'); + expect(screen.getAllByText('...')).toHaveLength(2); }); it('does not render info row when statusDescription is absent', () => { diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx index 2843e9d9712..abb5fc9491d 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderContent.tsx @@ -26,7 +26,6 @@ import BadgeWrapper, { import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; import I18n, { strings } from '../../../../../../locales/i18n'; import { toDateFormat } from '../../../../../util/date'; -import { renderFiat } from '../../../../../util/number'; import { formatSubscriptNotation } from '../../../../../util/number/subscriptNotation'; import { formatWithThreshold } from '../../../../../util/assets'; import { getNetworkImageSource } from '../../../../../util/networks'; @@ -41,6 +40,14 @@ import BankDetailRow from '../../Deposit/components/BankDetailRow/BankDetailRow' import Routes from '../../../../../constants/navigation/Routes'; import { RampsOrderDetailsSelectorsIDs } from './OrderDetails.testIds'; +const AMOUNT_PLACEHOLDER = '...'; +const TERMINAL_STATUSES = new Set([ + RampsOrderStatus.Completed, + RampsOrderStatus.Failed, + RampsOrderStatus.Cancelled, + RampsOrderStatus.IdExpired, +]); + const localStyles = StyleSheet.create({ badgeWrapperCenter: { alignSelf: 'center', @@ -168,7 +175,16 @@ const OrderContent: React.FC = ({ } }; - const isLoading = !order.fiatAmount; + const fiatCurrencyCode = order.fiatCurrency?.symbol ?? ''; + const cryptoSymbol = order.cryptoCurrency?.symbol ?? ''; + + const hasAmounts = Boolean( + fiatCurrencyCode && + ((order.fiatAmount != null && Number(order.fiatAmount) > 0) || + (order.cryptoAmount != null && Number(order.cryptoAmount) > 0)), + ); + const isTerminal = TERMINAL_STATUSES.has(order.status); + const isLoading = !hasAmounts && !isTerminal; const handleClose = useCallback(() => { trackEvent( @@ -207,10 +223,6 @@ const OrderContent: React.FC = ({ trackEvent, ]); - const fiatDenomSymbol = order.fiatCurrency?.denomSymbol ?? ''; - const fiatCurrencyCode = order.fiatCurrency?.symbol ?? ''; - const cryptoSymbol = order.cryptoCurrency?.symbol ?? ''; - const normalizeChainIdForBadge = (chainId: string): string => { if (!chainId || chainId.includes(':') || chainId.startsWith('0x')) { return chainId; @@ -332,7 +344,7 @@ const OrderContent: React.FC = ({ fontWeight={FontWeight.Bold} twClassName="mt-6 text-center" > - {order.cryptoAmount != null + {order.cryptoAmount != null && Number(order.cryptoAmount) > 0 ? (formatSubscriptNotation( parseFloat(String(order.cryptoAmount)), ) ?? @@ -345,7 +357,7 @@ const OrderContent: React.FC = ({ maximumFractionDigits: 5, }, )) - : '...'}{' '} + : AMOUNT_PLACEHOLDER}{' '} {cryptoSymbol} @@ -475,12 +487,19 @@ const OrderContent: React.FC = ({ ) : ( - {fiatDenomSymbol} - {renderFiat( - Number(order.totalFeesFiat ?? 0), - fiatCurrencyCode, - fiatDecimals, - )} + {hasAmounts + ? formatWithThreshold( + Number(order.totalFeesFiat ?? 0), + 0, + I18n.locale, + { + style: 'currency', + currency: fiatCurrencyCode, + minimumFractionDigits: fiatDecimals, + maximumFractionDigits: fiatDecimals, + }, + ) + : AMOUNT_PLACEHOLDER} )} @@ -501,12 +520,19 @@ const OrderContent: React.FC = ({ ) : ( - {fiatDenomSymbol} - {renderFiat( - Number(order.fiatAmount ?? 0), - fiatCurrencyCode, - fiatDecimals, - )} + {hasAmounts + ? formatWithThreshold( + Number(order.fiatAmount ?? 0), + 0, + I18n.locale, + { + style: 'currency', + currency: fiatCurrencyCode, + minimumFractionDigits: fiatDecimals, + maximumFractionDigits: fiatDecimals, + }, + ) + : AMOUNT_PLACEHOLDER} )} diff --git a/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderContent.test.tsx.snap b/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderContent.test.tsx.snap index ddbffbb2581..2f20bd21c42 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderContent.test.tsx.snap +++ b/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderContent.test.tsx.snap @@ -472,8 +472,7 @@ exports[`OrderContent renders completed state correctly 1`] = ` ] } > - $ - 2.5 USD + $2.50 - $ - 100 USD + $100.00 - 0.05 + ... ETH @@ -1095,7 +1093,7 @@ exports[`OrderContent renders loading state when order has no amount 1`] = ` `; -exports[`OrderContent shows ellipsis for token amount when cryptoAmount is 0 or missing 1`] = ` +exports[`OrderContent shows placeholder for token amount when cryptoAmount is 0 or missing 1`] = ` - 0 + ... ETH @@ -1567,8 +1565,7 @@ exports[`OrderContent shows ellipsis for token amount when cryptoAmount is 0 or ] } > - $ - 2.5 USD + $2.50 - $ - 100 USD + $100.00 { expect(result).toMatchSnapshot(); }); - it('defaults cryptoAmount to 0 when undefined', () => { - const fiatOrder = createMockFiatOrder({ cryptoAmount: undefined }); - const result = fiatOrderToDisplayOrder(fiatOrder); - expect(result.cryptoAmount).toBe(0); + it('uses placeholder when cryptoAmount is undefined or zero', () => { + expect( + fiatOrderToDisplayOrder( + createMockFiatOrder({ cryptoAmount: undefined }), + ).cryptoAmount, + ).toBe('...'); + expect( + fiatOrderToDisplayOrder(createMockFiatOrder({ cryptoAmount: 0 })) + .cryptoAmount, + ).toBe('...'); }); }); @@ -141,6 +147,23 @@ describe('displayOrder', () => { const result = rampsOrderToDisplayOrder(order); expect(result.createdAt).toBe(0); }); + + it('uses placeholder when cryptoAmount is 0 or missing', () => { + expect( + rampsOrderToDisplayOrder(createMockRampsOrder({ cryptoAmount: 0 })) + .cryptoAmount, + ).toBe('...'); + expect( + rampsOrderToDisplayOrder( + createMockRampsOrder({ cryptoAmount: undefined }), + ).cryptoAmount, + ).toBe('...'); + expect( + rampsOrderToDisplayOrder( + createMockRampsOrder({ cryptoAmount: null as unknown as string }), + ).cryptoAmount, + ).toBe('...'); + }); }); describe('mergeDisplayOrders', () => { diff --git a/app/components/UI/Ramp/utils/displayOrder.ts b/app/components/UI/Ramp/utils/displayOrder.ts index bf51aede63d..ad111e7b051 100644 --- a/app/components/UI/Ramp/utils/displayOrder.ts +++ b/app/components/UI/Ramp/utils/displayOrder.ts @@ -5,6 +5,8 @@ import { } from '../../../../reducers/fiatOrders'; import { FIAT_ORDER_PROVIDERS } from '../../../../constants/on-ramp'; +const AMOUNT_PLACEHOLDER = '...'; + export interface DisplayOrder { id: string; source: 'legacy' | 'v2'; @@ -37,7 +39,10 @@ export function fiatOrderToDisplayOrder(order: FiatOrder): DisplayOrder { createdAt: toEpochMs(order.createdAt), fiatAmount: order.amount, fiatCurrencyCode: order.currency, - cryptoAmount: order.cryptoAmount ?? 0, + cryptoAmount: + order.cryptoAmount != null && Number(order.cryptoAmount) > 0 + ? order.cryptoAmount + : AMOUNT_PLACEHOLDER, cryptoCurrencySymbol: order.cryptocurrency, network: order.network, status: order.state, @@ -65,7 +70,10 @@ export function rampsOrderToDisplayOrder(order: RampsOrder): DisplayOrder { createdAt: toEpochMs(order.createdAt), fiatAmount: order.fiatAmount, fiatCurrencyCode: order.fiatCurrency?.symbol ?? '', - cryptoAmount: order.cryptoAmount, + cryptoAmount: + order.cryptoAmount != null && Number(order.cryptoAmount) > 0 + ? order.cryptoAmount + : AMOUNT_PLACEHOLDER, cryptoCurrencySymbol: order.cryptoCurrency?.symbol ?? '', network: order.network?.chainId ?? '', status: RAMPS_STATUS_TO_DISPLAY[order.status] ?? 'PENDING', From 9035d5c9543099d9500c122b505716917360a49b Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 23 Mar 2026 16:38:29 +0000 Subject: [PATCH 15/54] [skip ci] Bump version number to 4150 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 17bf777c9ba..1641e9c34a8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4149 + versionCode 4150 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index a279f3d0f7a..844fa82a20f 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4149 + VERSION_NUMBER: 4150 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4149 + FLASK_VERSION_NUMBER: 4150 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 5ef615ead93..68dd8095c2b 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4149; + CURRENT_PROJECT_VERSION = 4150; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4149; + CURRENT_PROJECT_VERSION = 4150; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4149; + CURRENT_PROJECT_VERSION = 4150; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4149; + CURRENT_PROJECT_VERSION = 4150; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4149; + CURRENT_PROJECT_VERSION = 4150; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4149; + CURRENT_PROJECT_VERSION = 4150; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 8d1b7c053da3766ccbc12372f17118838809a766 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 23 Mar 2026 16:44:43 +0000 Subject: [PATCH 16/54] [skip ci] Bump version number to 4151 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 1641e9c34a8..942c5aa6490 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4150 + versionCode 4151 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 844fa82a20f..471161513ea 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4150 + VERSION_NUMBER: 4151 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4150 + FLASK_VERSION_NUMBER: 4151 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 68dd8095c2b..7ff6e0d55cf 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4150; + CURRENT_PROJECT_VERSION = 4151; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4150; + CURRENT_PROJECT_VERSION = 4151; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4150; + CURRENT_PROJECT_VERSION = 4151; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4150; + CURRENT_PROJECT_VERSION = 4151; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4150; + CURRENT_PROJECT_VERSION = 4151; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4150; + CURRENT_PROJECT_VERSION = 4151; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From c6a9f8783e64602b42734edd2e44d50e0dde1791 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:21:43 +0000 Subject: [PATCH 17/54] chore(runway): cherry-pick chore(deps): ramps-controller preview for MetaMask/core#8251 -> cp-7.71.0 (#27823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chore(deps): ramps-controller preview for MetaMask/core#8251 -> cp-7.71.0 (#27709) ## **Description** Integration PR to validate **MetaMask/core** changes in mobile CI/E2E by resolving `@metamask/ramps-controller` from a **preview** npm package (`previewBuilds` in `package.json` + updated `yarn.lock`). No application code changes. **Core PR:** https://github.com/MetaMask/core/pull/8251 After core merges and a **released** version is published, this PR should be updated to remove `previewBuilds` and bump `dependencies` to the real version before merge. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: N/A (validation / dependency preview only) ## **Manual testing steps** ```gherkin Feature: ramps-controller preview validation Scenario: app resolves preview package Given a clean install from this branch When the app bundles and runs Then @metamask/ramps-controller resolves to the preview version from previewBuilds Scenario: ramps flows still work Given the app is built from this branch When user exercises on-ramp flows that use ramps-controller Then no regressions vs main (same UX; underlying package is preview) ``` ## **Screenshots/Recordings** N/A — dependency-only change. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [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] > **Low Risk** > Low risk because this is a dependency source/version switch for `@metamask/ramps-controller` with no application code changes; main risk is behavior changes introduced by the preview package at runtime. > > **Overview** > Switches `@metamask/ramps-controller` to a **preview build** via `previewBuilds` in `package.json` and `yarn.lock` (e.g. `@metamask-previews/ramps-controller@12.0.0-preview-434bd0c`). Update the preview version string if the bot publishes a newer build for core#8251. --- > [!NOTE] > **Low Risk** > Low risk patch-level dependency update; main risk is any runtime behavior changes in `@metamask/ramps-controller` affecting ramp flows, plus potential test fixture mismatches if state shape changes again. > > **Overview** > Updates `@metamask/ramps-controller` from `12.0.0` to `12.0.1` (including lockfile resolution changes). > > Aligns the default E2E/unit fixture (`default-fixture.json`) with the newer `RampsController` state shape by adding persisted sub-state for countries, providers/payment methods/tokens, native provider (Transak) auth/kyc/user details, requests, and orders. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0b2fc2f72048eb5b41fbf6932072f9d063b9bf43. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [4a4f0ae](https://github.com/MetaMask/metamask-mobile/commit/4a4f0ae17c807e332bfa524c20efceb0cd3dc6e6) Co-authored-by: Yu-Gi-Oh! <54774811+imyugioh@users.noreply.github.com> --- package.json | 2 +- .../fixtures/json/default-fixture.json | 49 +++++++++++++++++++ yarn.lock | 10 ++-- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 3b088af877e..53e998ccddc 100644 --- a/package.json +++ b/package.json @@ -277,7 +277,7 @@ "@metamask/preinstalled-example-snap": "^0.7.2", "@metamask/profile-metrics-controller": "^3.1.0", "@metamask/profile-sync-controller": "^28.0.0", - "@metamask/ramps-controller": "^12.0.0", + "@metamask/ramps-controller": "^12.0.1", "@metamask/react-native-acm": "1.2.0", "@metamask/react-native-actionsheet": "2.4.2", "@metamask/react-native-button": "^3.0.0", diff --git a/tests/framework/fixtures/json/default-fixture.json b/tests/framework/fixtures/json/default-fixture.json index ee5e110fd05..88ec07135f4 100644 --- a/tests/framework/fixtures/json/default-fixture.json +++ b/tests/framework/fixtures/json/default-fixture.json @@ -480,6 +480,55 @@ "useTransactionSimulations": true }, "RampsController": { + "countries": { + "data": [], + "error": null, + "isLoading": false, + "selected": null + }, + "nativeProviders": { + "transak": { + "buyQuote": { + "data": null, + "error": null, + "isLoading": false, + "selected": null + }, + "isAuthenticated": false, + "kycRequirement": { + "data": null, + "error": null, + "isLoading": false, + "selected": null + }, + "userDetails": { + "data": null, + "error": null, + "isLoading": false, + "selected": null + } + } + }, + "orders": [], + "paymentMethods": { + "data": [], + "error": null, + "isLoading": false, + "selected": null + }, + "providers": { + "data": [], + "error": null, + "isLoading": false, + "selected": null + }, + "requests": {}, + "tokens": { + "data": null, + "error": null, + "isLoading": false, + "selected": null + }, "userRegion": null }, "RemoteFeatureFlagController": { diff --git a/yarn.lock b/yarn.lock index c37bb543ce0..1bfe4fef13d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9387,14 +9387,14 @@ __metadata: languageName: node linkType: hard -"@metamask/ramps-controller@npm:^12.0.0": - version: 12.0.0 - resolution: "@metamask/ramps-controller@npm:12.0.0" +"@metamask/ramps-controller@npm:^12.0.1": + version: 12.0.1 + resolution: "@metamask/ramps-controller@npm:12.0.1" dependencies: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" "@metamask/messenger": "npm:^0.3.0" - checksum: 10/fb22bbab95045b7c5d80d3fb24bd41b831e0516733cdf1a0159514cbd8b0aededd599818adecfbc86802299095a223251733c271a8d0b6db2d88b25c971da1ed + checksum: 10/a7f9428cb824bd0175ee1cc603d77c650fa7a23c7183e2cc0a0f21ee9b6378d80bbd1e496654e40d2edcfc840e60dd4a09d80feacb1746087a67b66761e1e6c7 languageName: node linkType: hard @@ -35597,7 +35597,7 @@ __metadata: "@metamask/profile-metrics-controller": "npm:^3.1.0" "@metamask/profile-sync-controller": "npm:^28.0.0" "@metamask/providers": "npm:^18.3.1" - "@metamask/ramps-controller": "npm:^12.0.0" + "@metamask/ramps-controller": "npm:^12.0.1" "@metamask/react-native-acm": "npm:1.2.0" "@metamask/react-native-actionsheet": "npm:2.4.2" "@metamask/react-native-button": "npm:^3.0.0" From b7b8475f4680ee7e8286b44a489d186a12a9dd7d Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Mon, 23 Mar 2026 20:23:20 +0000 Subject: [PATCH 18/54] [skip ci] Bump version number to 4155 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 942c5aa6490..8ae5c474c06 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4151 + versionCode 4155 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 471161513ea..9b6601a86c3 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4151 + VERSION_NUMBER: 4155 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4151 + FLASK_VERSION_NUMBER: 4155 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 7ff6e0d55cf..82deec54976 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4151; + CURRENT_PROJECT_VERSION = 4155; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4151; + CURRENT_PROJECT_VERSION = 4155; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4151; + CURRENT_PROJECT_VERSION = 4155; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4151; + CURRENT_PROJECT_VERSION = 4155; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4151; + CURRENT_PROJECT_VERSION = 4155; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4151; + CURRENT_PROJECT_VERSION = 4155; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 5ee4f82601e11c622fd17814fa85941831ec0ee2 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:17:33 +0100 Subject: [PATCH 19/54] chore(runway): cherry-pick chore: adds market insights metric to Perps view entry point cp-7.71.0 (#27821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chore: adds market insights metric to Perps view entry point cp-7.71.0 (#27814) ## **Description** Adds two missing metric events to the Perps Market Details view to bring it to parity with the token details flow. `MARKET_INSIGHTS_OPENED` now fires whenever a user taps the Market Insights entry card, and `PERPS_SCREEN_VIEWED` now includes a `market_insights_displayed` boolean property that reflects whether a report was actually shown, with the event held until the insights fetch resolves so the value is fully accurate. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk: changes are limited to analytics instrumentation and event timing gated on Market Insights loading, with added unit tests to validate tracking and navigation behavior. > > **Overview** > Adds missing Market Insights instrumentation to the Perps market details screen. > > `PerpsMarketDetailsView` now fires `MetaMetricsEvents.MARKET_INSIGHTS_OPENED` (with `perps_market`) when the Market Insights entry card is tapped, and delays `MetaMetricsEvents.PERPS_SCREEN_VIEWED` until insights loading completes so it can include an accurate `market_insights_displayed` boolean. > > Updates/extends `PerpsMarketDetailsView.test.tsx` with mocks for `useMarketInsights`/feature flags and new tests covering the new tracking payloads and navigation to `Routes.MARKET_INSIGHTS.VIEW` with `isPerps: true`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit dc97116e56c15cbf0514e47c2529982ba03b75b8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [6e0f698](https://github.com/MetaMask/metamask-mobile/commit/6e0f698a7fc6d58c005e7c86f05f1288dfeb2607) Co-authored-by: António Regadas --- .../PerpsMarketDetailsView.test.tsx | 160 ++++++++++++++++++ .../PerpsMarketDetailsView.tsx | 17 +- 2 files changed, 174 insertions(+), 3 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index 0c4c30acf07..28bd6e07d45 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -10,6 +10,8 @@ import { import { PerpsConnectionProvider } from '../../providers/PerpsConnectionProvider'; import { useDefaultPayWithTokenWhenNoPerpsBalance } from '../../hooks/useDefaultPayWithTokenWhenNoPerpsBalance'; import { Linking } from 'react-native'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import Routes from '../../../../../constants/navigation/Routes'; // Mock Linking jest.mock('react-native/Libraries/Linking/Linking', () => ({ @@ -394,6 +396,34 @@ jest.mock('../../hooks/usePerpsEventTracking', () => ({ })), })); +const mockUseMarketInsights = jest.fn( + (_assetId?: string | null, _isEnabled?: boolean) => ({ + report: null as Record | null, + isLoading: false, + error: null, + timeAgo: '', + }), +); + +jest.mock('../../../MarketInsights', () => ({ + useMarketInsights: (assetId: string | null | undefined, isEnabled: boolean) => + mockUseMarketInsights(assetId, isEnabled), + MarketInsightsEntryCard: ({ onPress }: { onPress: () => void }) => { + const { TouchableOpacity } = jest.requireActual('react-native'); + return ( + + ); + }, + selectMarketInsightsEnabled: jest.fn(), +})); + +jest.mock( + '../../../../../selectors/featureFlagController/marketInsights', + () => ({ + selectMarketInsightsPerpsEnabled: jest.fn(), + }), +); + jest.mock('../../hooks/usePerpsPrices', () => ({ usePerpsPrices: jest.fn(() => ({})), })); @@ -3264,4 +3294,134 @@ describe('PerpsMarketDetailsView', () => { expect(queryByText('25x')).toBeNull(); }); }); + + describe('Market Insights analytics', () => { + const mockReport = { + summary: 'BTC momentum is building with increased buying pressure.', + sentiment: 'bullish', + generatedAt: new Date().toISOString(), + }; + + // Stable track mock reference set up in beforeEach via mockImplementation + const mockTrack = jest.fn(); + + beforeEach(() => { + // Override usePerpsEventTracking to expose a capturable track mock + const { usePerpsEventTracking: mockUsePerpsEventTrackingFn } = + jest.requireMock('../../hooks/usePerpsEventTracking'); + mockUsePerpsEventTrackingFn.mockImplementation(() => ({ + track: mockTrack, + })); + + // Enable perps market insights feature flag + const { useSelector } = jest.requireMock('react-redux'); + const { selectPerpsEligibility } = jest.requireMock( + '../../selectors/perpsController', + ); + const { selectMarketInsightsPerpsEnabled } = jest.requireMock( + '../../../../../selectors/featureFlagController/marketInsights', + ); + useSelector.mockImplementation((selector: unknown) => { + if (selector === selectPerpsEligibility) return true; + if (selector === selectMarketInsightsPerpsEnabled) return true; + return undefined; + }); + + // Default: a report is available and loading is complete + mockUseMarketInsights.mockReturnValue({ + report: mockReport, + isLoading: false, + error: null, + timeAgo: '5m ago', + }); + }); + + afterEach(() => { + mockTrack.mockClear(); + }); + + it('fires MARKET_INSIGHTS_OPENED with perps_market when entry card is pressed', () => { + const { getByTestId } = renderWithProvider( + + + , + { state: initialState }, + ); + + fireEvent.press(getByTestId('market-insights-entry-card')); + + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.MARKET_INSIGHTS_OPENED, + expect.objectContaining({ perps_market: 'BTC' }), + ); + }); + + it('navigates to MarketInsightsView with isPerps flag when entry card is pressed', () => { + const { getByTestId } = renderWithProvider( + + + , + { state: initialState }, + ); + + fireEvent.press(getByTestId('market-insights-entry-card')); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MARKET_INSIGHTS.VIEW, + expect.objectContaining({ + assetIdentifier: 'BTC', + isPerps: true, + }), + ); + }); + + it('passes market_insights_displayed: true to PERPS_SCREEN_VIEWED when a report is available', () => { + renderWithProvider( + + + , + { state: initialState }, + ); + + const { usePerpsEventTracking: mockUsePerpsEventTrackingFn } = + jest.requireMock('../../hooks/usePerpsEventTracking'); + + expect(mockUsePerpsEventTrackingFn).toHaveBeenCalledWith( + expect.objectContaining({ + eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, + properties: expect.objectContaining({ + market_insights_displayed: true, + }), + }), + ); + }); + + it('passes market_insights_displayed: false to PERPS_SCREEN_VIEWED when no report is returned', () => { + mockUseMarketInsights.mockReturnValue({ + report: null, + isLoading: false, + error: null, + timeAgo: '', + }); + + renderWithProvider( + + + , + { state: initialState }, + ); + + const { usePerpsEventTracking: mockUsePerpsEventTrackingFn } = + jest.requireMock('../../hooks/usePerpsEventTracking'); + + expect(mockUsePerpsEventTrackingFn).toHaveBeenCalledWith( + expect.objectContaining({ + eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, + properties: expect.objectContaining({ + market_insights_displayed: false, + }), + }), + ); + }); + }); }); diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 8f879e8bfc9..8fccd94f0b9 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -229,8 +229,11 @@ const PerpsMarketDetailsView: React.FC = () => { // Feature flag for Market Insights in Perps const isPerpsInsightsEnabled = useSelector(selectMarketInsightsPerpsEnabled); - const { report: perpsInsightsReport, timeAgo: perpsInsightsTimeAgo } = - useMarketInsights(market?.symbol, isPerpsInsightsEnabled); + const { + report: perpsInsightsReport, + timeAgo: perpsInsightsTimeAgo, + isLoading: isPerpsInsightsLoading, + } = useMarketInsights(market?.symbol, isPerpsInsightsEnabled); // Check if current market is in watchlist const selectIsWatchlist = useMemo( @@ -542,6 +545,8 @@ const PerpsMarketDetailsView: React.FC = () => { }); // Track asset screen viewed event - declarative (main's event name) + // Waits for market insights to finish loading so market_insights_displayed + // reflects the actual display state rather than a loading-time snapshot. usePerpsEventTracking({ eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, conditions: [ @@ -549,6 +554,7 @@ const PerpsMarketDetailsView: React.FC = () => { !!marketStats, !isLoadingHistory, !isLoadingPosition, + !isPerpsInsightsLoading, ], properties: { [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: @@ -558,6 +564,8 @@ const PerpsMarketDetailsView: React.FC = () => { source || PERPS_EVENT_VALUE.SOURCE.PERP_MARKETS, [PERPS_EVENT_PROPERTY.OPEN_POSITION]: existingPosition ? 1 : 0, [PERPS_EVENT_PROPERTY.OPEN_ORDER]: openOrders.length, + market_insights_displayed: + isPerpsInsightsEnabled && Boolean(perpsInsightsReport), // A/B Test context (TAT-1937) - for baseline exposure tracking ...(isButtonColorTestEnabled && { [PERPS_EVENT_PROPERTY.AB_TEST_BUTTON_COLOR]: buttonColorVariant, @@ -1021,6 +1029,9 @@ const PerpsMarketDetailsView: React.FC = () => { // Handler for market insights card tap - navigates to full market insights view const handleMarketInsightsPress = useCallback(() => { if (!market?.symbol) return; + track(MetaMetricsEvents.MARKET_INSIGHTS_OPENED, { + perps_market: market.symbol, + }); trace({ name: TraceName.MarketInsightsViewLoad, op: TraceOperation.MarketInsightsLoad, @@ -1030,7 +1041,7 @@ const PerpsMarketDetailsView: React.FC = () => { assetIdentifier: market.symbol, isPerps: true, }); - }, [market?.symbol, navigation]); + }, [market?.symbol, navigation, track]); // Handler for order selection - navigates to order details const handleOrderSelect = useCallback( From 27fa10a3e169bd92c964c3676235da0bacecabad Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:19:18 +0100 Subject: [PATCH 20/54] chore(runway): cherry-pick fix(ramps): improve external-browser callback redirection cp-7.71.0 (#27829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(ramps): improve external-browser callback redirection cp-7.71.0 (#27804) ## **Description** Fixes the external-browser return flow for unified ramps by moving callback resolution out of Build Quote and into Order Details. The bug was that external-browser returns were resolved too early in BuildQuote. If callback parsing or order lookup failed there, users could get bounced around or end up on a broken Order Details screen. This change fixes that by moving callback resolution into Order Details itself. BuildQuote now only hands off the callback context, and Order Details fetches the real order itself. That makes the flow more reliable: bailed callbacks return to Build Quote, and real fetch failures show a retryable error instead of a broken redirect. **What changed** - **Build Quote -> Order Details callback handoff** After a successful external-browser return, Build Quote now navigates to Order Details with the full `callbackUrl`, `providerCode`, and `walletAddress` instead of trying to resolve the order immediately. - **Order Details callback bootstrap** Order Details now supports loading from callback params, fetching the real order on first render, and updating route params once the order has been resolved. - **Bailed / invalid callback handling** If the callback resolves to a bailed order state or no usable order, the user is sent back to Build Quote instead of landing on a blank or broken Order Details screen. - **Retryable callback error state** If fetching the order from the callback URL fails, Order Details now shows a retryable error screen rather than silently resetting away. This makes transient backend/network failures recoverable. - **Navigation tests updated** Tests were updated to reflect the callback-based route shape and the new Order Details retry behavior. **What stays untouched** This PR does not change Order Content amount rendering, list display formatting, or duplicate placeholder cleanup. It is scoped only to fixing the external-browser redirection and callback-resolution path. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** Paypal Order going to build quote page first: Uploading Screen Recording 2026-03-23 at 1.00.39 PM.mov… ### **After** Native Transak Redirection https://github.com/user-attachments/assets/32d1a7f9-23c7-4df1-aba8-f639338d7a6f Bailed Paypal order (return to build quote page): https://github.com/user-attachments/assets/8ed07fa3-e7df-4b69-b2f0-9318799c8249 Paypal order going to order details page: https://github.com/user-attachments/assets/5a2e8489-a4b0-488d-8aca-7982df63c45c ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes the unified ramps external-browser return flow and navigation params, which can impact users reaching the correct order state after checkout; failures may surface as new retry/error behaviors. > > **Overview** > Fixes unified ramps external-browser return handling by **moving callback URL resolution out of `BuildQuote` and into `OrderDetails`**. > > `BuildQuote` no longer calls `getOrderFromCallback`/`addOrder` on InAppBrowser success; it now resets navigation to `OrderDetails` with `callbackUrl`, `providerCode`, and `walletAddress`. `OrderDetails` bootstraps from these callback params, fetches the real order (bailing back to `BuildQuote` for invalid/bailed statuses), updates route params to the resolved `orderId`, and shows a retryable error state if the callback fetch fails. > > Updates `rampsNavigation` to support an `OrderDetails` route shaped around callback params (and makes `orderId` optional), and adjusts/adds tests to cover the new handoff and retry behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0eabfd6193462b1ac1eeeb1796b2a2f682a26a39. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [4efb704](https://github.com/MetaMask/metamask-mobile/commit/4efb70495c55584929c4271bb3576d35fc5a4681) Co-authored-by: George Weiler --- .../Ramp/Views/BuildQuote/BuildQuote.test.tsx | 14 +- .../UI/Ramp/Views/BuildQuote/BuildQuote.tsx | 42 ++---- .../Views/OrderDetails/OrderDetails.test.tsx | 48 ++++++- .../Ramp/Views/OrderDetails/OrderDetails.tsx | 124 ++++++++++++++++-- .../UI/Ramp/utils/rampsNavigation.test.ts | 20 +++ .../UI/Ramp/utils/rampsNavigation.ts | 21 ++- 6 files changed, 212 insertions(+), 57 deletions(-) diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx index aea62bd39ff..7108612c1d9 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx @@ -476,12 +476,6 @@ describe('BuildQuote', () => { type: 'success', url: 'metamask://on-ramp/providers/moonpay?orderId=ord-123', }); - mockGetOrderFromCallback.mockResolvedValue({ - providerOrderId: 'ord-123', - status: 'Pending', - cryptoAmount: '0.05', - cryptoCurrency: { symbol: 'ETH' }, - }); mockGetBuyWidgetData.mockResolvedValue({ url: 'https://widget.example.com/checkout', browser: 'IN_APP_OS_BROWSER', @@ -497,14 +491,18 @@ describe('BuildQuote', () => { }); await waitFor(() => { - expect(mockAddOrder).toHaveBeenCalled(); + expect(mockAddOrder).not.toHaveBeenCalled(); + expect(mockGetOrderFromCallback).not.toHaveBeenCalled(); expect(mockNavigationReset).toHaveBeenCalledWith({ index: 0, routes: [ { name: Routes.RAMP.RAMPS_ORDER_DETAILS, params: { - orderId: 'ord-123', + callbackUrl: + 'metamask://on-ramp/providers/moonpay?orderId=ord-123', + providerCode: 'moonpay', + walletAddress: '0x1234567890123456789012345678901234567890', showCloseButton: true, }, }, diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx index 407507a18b5..3f51b2f80f7 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx @@ -20,7 +20,6 @@ import { getWidgetRedirectConfig, } from '../../utils/buildQuoteWithRedirectUrl'; import { computeAmountUpdate } from '../../utils/computeAmountUpdate'; -import { extractOrderCode } from '../../utils/extractOrderCode'; import { getRampCallbackBaseUrl } from '../../utils/getRampCallbackBaseUrl'; import { getNavigateAfterExternalBrowserRoutes } from '../../utils/rampsNavigation'; import { reportRampsError } from '../../utils/reportRampsError'; @@ -176,8 +175,6 @@ function BuildQuote() { paymentMethods, getBuyWidgetData, addPrecreatedOrder, - addOrder, - getOrderFromCallback, paymentMethodsLoading, paymentMethodsFetching, paymentMethodsStatus, @@ -683,36 +680,17 @@ function BuildQuote() { return; } - try { - const order = await getOrderFromCallback( - providerCode, - result.url, - effectiveWallet, - ); - - if (!order || isBailedOrderStatus(order.status)) { - navigateAfterExternalBrowser({ returnDestination: 'buildQuote' }); - return; - } - - addOrder(order); - - const rawOrderId = order.providerOrderId ?? effectiveOrderId; - if (!rawOrderId) { - navigateAfterExternalBrowser({ returnDestination: 'buildQuote' }); - return; - } - - const orderCode = extractOrderCode(rawOrderId); - navigateAfterExternalBrowser({ - returnDestination: 'order', - orderCode, - providerCode, - walletAddress: effectiveWallet || undefined, - }); - } catch { + if (!effectiveWallet) { navigateAfterExternalBrowser({ returnDestination: 'buildQuote' }); + return; } + + navigateAfterExternalBrowser({ + returnDestination: 'order', + callbackUrl: result.url, + providerCode, + walletAddress: effectiveWallet, + }); } finally { InAppBrowser.closeAuth(); } @@ -757,8 +735,6 @@ function BuildQuote() { navigation, getBuyWidgetData, addPrecreatedOrder, - getOrderFromCallback, - addOrder, navigateAfterExternalBrowser, ]); diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.test.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.test.tsx index 1fb9a0b7c91..e9870faf295 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.test.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { ActivityIndicator } from 'react-native'; -import { fireEvent, waitFor } from '@testing-library/react-native'; +import { fireEvent, waitFor, act } from '@testing-library/react-native'; import OrderDetails, { createRampsOrderDetailsNavDetails, } from './OrderDetails'; @@ -11,21 +11,29 @@ import { RampsOrderStatus } from '@metamask/ramps-controller'; const mockSetOptions = jest.fn(); const mockNavigate = jest.fn(); +const mockSetParams = jest.fn(); +const mockReset = jest.fn(); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: () => ({ setOptions: mockSetOptions, navigate: mockNavigate, goBack: jest.fn(), + setParams: mockSetParams, + reset: mockReset, }), })); const mockGetOrderById = jest.fn(); const mockRefreshOrder = jest.fn(); +const mockGetOrderFromCallback = jest.fn(); +const mockAddOrder = jest.fn(); jest.mock('../../hooks/useRampsOrders', () => ({ useRampsOrders: () => ({ getOrderById: mockGetOrderById, refreshOrder: mockRefreshOrder, + getOrderFromCallback: mockGetOrderFromCallback, + addOrder: mockAddOrder, }), })); @@ -52,7 +60,9 @@ jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ }), })); -const mockUseParams = jest.fn(() => ({ orderId: 'test-order-123' })); +const mockUseParams = jest.fn, []>(() => ({ + orderId: 'test-order-123', +})); jest.mock('../../../../../util/navigation/navUtils', () => ({ ...jest.requireActual('../../../../../util/navigation/navUtils'), useParams: () => mockUseParams(), @@ -167,7 +177,9 @@ describe('OrderDetails', () => { expect(getByText('ramps_order_details.try_again')).toBeOnTheScreen(); }); - fireEvent.press(getByText('ramps_order_details.try_again')); + await act(async () => { + fireEvent.press(getByText('ramps_order_details.try_again')); + }); expect(mockRefreshOrder).toHaveBeenCalled(); }); @@ -187,4 +199,34 @@ describe('OrderDetails', () => { const result = createRampsOrderDetailsNavDetails(); expect(result[0]).toBe(Routes.RAMP.RAMPS_ORDER_DETAILS); }); + + it('shows error state with retry when initial callback fetch fails', async () => { + mockUseParams.mockReturnValue({ + callbackUrl: 'metamask://on-ramp/providers/paypal?orderId=abc', + providerCode: 'paypal', + walletAddress: '0x123', + }); + mockGetOrderById.mockReturnValue(undefined); + mockGetOrderFromCallback.mockRejectedValue( + new Error('Network request failed'), + ); + + const { getByText } = render(); + + await waitFor(() => { + expect(getByText('Network request failed')).toBeOnTheScreen(); + }); + expect(getByText('ramps_order_details.try_again')).toBeOnTheScreen(); + + await act(async () => { + fireEvent.press(getByText('ramps_order_details.try_again')); + }); + expect(mockGetOrderFromCallback).toHaveBeenCalledTimes(2); + expect(mockGetOrderFromCallback).toHaveBeenNthCalledWith( + 2, + 'paypal', + 'metamask://on-ramp/providers/paypal?orderId=abc', + '0x123', + ); + }); }); diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx index 051781bc99c..e903408e333 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx @@ -15,7 +15,12 @@ import { normalizeProviderCode, RampsOrderStatus, } from '@metamask/ramps-controller'; +import { isBailedOrderStatus } from '../BuildQuote/BuildQuote'; import { extractOrderCode } from '../../utils/extractOrderCode'; +import { + getNavigateAfterExternalBrowserRoutes, + type RampsOrderDetailsParams, +} from '../../utils/rampsNavigation'; import Button, { ButtonVariants, ButtonSize, @@ -36,10 +41,6 @@ import { useRampsOrders } from '../../hooks/useRampsOrders'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { RampsOrderDetailsSelectorsIDs } from './OrderDetails.testIds'; -interface RampsOrderDetailsParams { - orderId: string; - showCloseButton?: boolean; -} export const createRampsOrderDetailsNavDetails = createNavigationDetails( @@ -69,12 +70,16 @@ const styles = StyleSheet.create({ const OrderDetails = () => { const params = useParams(); - const { getOrderById, refreshOrder } = useRampsOrders(); + const { getOrderById, refreshOrder, getOrderFromCallback, addOrder } = + useRampsOrders(); const orderCode = params.orderId ? extractOrderCode(params.orderId) : ''; const order = getOrderById(orderCode); const isPending = order ? PENDING_STATUSES.has(order.status) : false; + const hasCallbackParams = Boolean( + params.callbackUrl && params.providerCode && params.walletAddress, + ); - const [isLoading, setIsLoading] = useState(isPending); + const [isLoading, setIsLoading] = useState(isPending || hasCallbackParams); const [error, setError] = useState(null); const theme = useTheme(); const { colors } = theme; @@ -82,6 +87,72 @@ const OrderDetails = () => { const { trackEvent, createEventBuilder } = useAnalytics(); const [isRefreshing, setIsRefreshing] = useState(false); + const hasFetchedFromCallback = useRef(false); + + const executeCallbackFetch = useCallback( + async ( + providerCode: string, + callbackUrl: string, + walletAddress: string, + logContext: string, + ) => { + try { + setError(null); + const fetchedOrder = await getOrderFromCallback( + providerCode, + callbackUrl, + walletAddress, + ); + if (!fetchedOrder || isBailedOrderStatus(fetchedOrder.status)) { + navigation.reset({ + index: 0, + routes: getNavigateAfterExternalBrowserRoutes({ + returnDestination: 'buildQuote', + }), + }); + return; + } + addOrder(fetchedOrder); + navigation.setParams({ + orderId: fetchedOrder.providerOrderId, + callbackUrl: undefined, + providerCode: undefined, + walletAddress: undefined, + }); + } catch (fetchError) { + Logger.error(fetchError as Error, { + message: `RampsOrderDetails: error fetching order from callback URL${logContext}`, + callbackUrl, + }); + setError( + fetchError instanceof Error && fetchError.message + ? fetchError.message + : strings('ramps_order_details.error_message'), + ); + } finally { + setIsLoading(false); + } + }, + [getOrderFromCallback, addOrder, navigation], + ); + + const handleRetryCallbackFetch = useCallback(async () => { + if (!params.callbackUrl || !params.providerCode || !params.walletAddress) { + return; + } + setIsLoading(true); + await executeCallbackFetch( + params.providerCode, + params.callbackUrl, + params.walletAddress, + ' (retry)', + ); + }, [ + params.callbackUrl, + params.providerCode, + params.walletAddress, + executeCallbackFetch, + ]); useEffect(() => { navigation.setOptions( @@ -148,12 +219,38 @@ const OrderDetails = () => { }, [order, refreshOrder]); useEffect(() => { - if (isPending) { + if (isPending && !hasCallbackParams) { handleOnRefresh(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if ( + !hasCallbackParams || + hasFetchedFromCallback.current || + !params.callbackUrl || + !params.providerCode || + !params.walletAddress + ) { + return; + } + hasFetchedFromCallback.current = true; + + executeCallbackFetch( + params.providerCode, + params.callbackUrl, + params.walletAddress, + '', + ); + }, [ + hasCallbackParams, + params.callbackUrl, + params.providerCode, + params.walletAddress, + executeCallbackFetch, + ]); + if (isLoading) { return ( @@ -166,11 +263,10 @@ const OrderDetails = () => { ); } - if (!order) { - return ; - } - if (error) { + const onRetry = hasCallbackParams + ? handleRetryCallbackFetch + : handleOnRefresh; return ( @@ -198,7 +294,7 @@ const OrderDetails = () => { size={ButtonSize.Lg} width={ButtonWidthTypes.Full} label={strings('ramps_order_details.try_again')} - onPress={handleOnRefresh} + onPress={onRetry} /> @@ -206,6 +302,10 @@ const OrderDetails = () => { ); } + if (!order) { + return ; + } + return ( { }, }); }); + + it('returns order details route with callbackUrl when returning from external browser', () => { + const routes = getNavigateAfterExternalBrowserRoutes({ + returnDestination: 'order', + callbackUrl: 'metamask://on-ramp/providers/paypal?orderId=abc123', + providerCode: 'paypal-staging', + walletAddress: '0x1234', + }); + + expect(routes).toHaveLength(1); + expect(routes[0]).toEqual({ + name: Routes.RAMP.RAMPS_ORDER_DETAILS, + params: { + callbackUrl: 'metamask://on-ramp/providers/paypal?orderId=abc123', + providerCode: 'paypal-staging', + walletAddress: '0x1234', + showCloseButton: true, + }, + }); + }); }); }); diff --git a/app/components/UI/Ramp/utils/rampsNavigation.ts b/app/components/UI/Ramp/utils/rampsNavigation.ts index 8b9546ed755..844941fedae 100644 --- a/app/components/UI/Ramp/utils/rampsNavigation.ts +++ b/app/components/UI/Ramp/utils/rampsNavigation.ts @@ -1,8 +1,11 @@ import Routes from '../../../../constants/navigation/Routes'; export interface RampsOrderDetailsParams { - orderId: string; + orderId?: string; showCloseButton?: boolean; + callbackUrl?: string; + providerCode?: string; + walletAddress?: string; } export function createRampsOrderDetailsRoute(params: RampsOrderDetailsParams): { @@ -29,6 +32,12 @@ export type NavigateAfterExternalBrowserOpts = orderCode: string; providerCode: string; walletAddress?: string; + } + | { + returnDestination: 'order'; + callbackUrl: string; + providerCode: string; + walletAddress: string; }; /** @@ -43,6 +52,16 @@ export function getNavigateAfterExternalBrowserRoutes( | ReturnType )[] { if (opts.returnDestination === 'order') { + if ('callbackUrl' in opts) { + return [ + createRampsOrderDetailsRoute({ + callbackUrl: opts.callbackUrl, + providerCode: opts.providerCode, + walletAddress: opts.walletAddress, + showCloseButton: true, + }), + ]; + } return [ createRampsOrderDetailsRoute({ orderId: opts.orderCode, From aa8a7887125e842be184b35fbaf8dd5d59d028ff Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Tue, 24 Mar 2026 08:20:54 +0000 Subject: [PATCH 21/54] [skip ci] Bump version number to 4165 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 8ae5c474c06..83413c6695c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4155 + versionCode 4165 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 9b6601a86c3..127d6b76c58 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4155 + VERSION_NUMBER: 4165 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4155 + FLASK_VERSION_NUMBER: 4165 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 82deec54976..fb921c6f29a 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4155; + CURRENT_PROJECT_VERSION = 4165; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4155; + CURRENT_PROJECT_VERSION = 4165; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4155; + CURRENT_PROJECT_VERSION = 4165; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4155; + CURRENT_PROJECT_VERSION = 4165; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4155; + CURRENT_PROJECT_VERSION = 4165; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4155; + CURRENT_PROJECT_VERSION = 4165; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 8b303c432339f6585436e54f78bc0eb857cb3b3f Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:12:18 +0100 Subject: [PATCH 22/54] chore(runway): cherry-pick fix: support webcredentials cp-7.71.0 (#27845) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: support webcredentials cp-7.71.0 (#27741) ## **Description** This pr patch the expo-web-browser to support https redirect schema Taking reference from expo-web-browser sdk 55 https://github.com/expo/expo/blob/308031a6665f885811760aff7aebb68aea4a846a/packages/expo-web-browser/ios/WebAuthSession.swift#L36 ## **Changelog** CHANGELOG entry: expo-web-browser support https redirect scheme CHANGELOG entry: use webcredential for ios google login ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Moderate risk because it changes iOS `ASWebAuthenticationSession` callback configuration and entitlements, which can affect login/redirect flows and associated-domain behavior. > > **Overview** > Enables **HTTPS redirect-based auth callbacks** on iOS by patching `expo-web-browser`’s `WebAuthSession` to use iOS 17.4+/macOS 14.4+ `.https(host:path)` callbacks when the `redirectUrl` is `https`, falling back to the legacy `callbackURLScheme` behavior otherwise. > > Updates iOS entitlements (`MetaMask.entitlements` and `MetaMaskDebug.entitlements`) to include `webcredentials:link.metamask.io`, and wires the patch into the build via a Yarn `resolutions` entry plus corresponding `yarn.lock` changes. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 7730be370643b502854f27531eb6ccad29619946. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [a2f8164](https://github.com/MetaMask/metamask-mobile/commit/a2f8164fd22f439025b15d8780eccfd3223d57a8) Co-authored-by: ieow <4881057+ieow@users.noreply.github.com> --- ...po-web-browser-npm-14.0.2-98d00ce880.patch | 50 +++++++++++++++++++ ios/MetaMask/MetaMask.entitlements | 1 + ios/MetaMask/MetaMaskDebug.entitlements | 1 + package.json | 3 +- yarn.lock | 12 ++++- 5 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 .yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch diff --git a/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch b/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch new file mode 100644 index 00000000000..94024b5585b --- /dev/null +++ b/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch @@ -0,0 +1,50 @@ +diff --git a/ios/WebAuthSession.swift b/ios/WebAuthSession.swift +index 0d8101b01d7c6cd803acf6a359ceaa026993bdd0..c1beeabd962e561bf48392d58c084272247a95cc 100644 +--- a/ios/WebAuthSession.swift ++++ b/ios/WebAuthSession.swift +@@ -20,17 +20,34 @@ final internal class WebAuthSession { + private var presentationContextProvider = PresentationContextProvider() + + init(authUrl: URL, redirectUrl: URL?, options: AuthSessionOptions) { +- self.authSession = ASWebAuthenticationSession( +- url: authUrl, +- callbackURLScheme: redirectUrl?.scheme, +- completionHandler: { callbackUrl, error in +- self.finish(with: [ +- "type": callbackUrl != nil ? "success" : "cancel", +- "url": callbackUrl?.absoluteString, +- "error": error?.localizedDescription +- ]) +- } +- ) ++ let completionHandler: (URL?, Error?) -> Void = { callbackUrl, error in ++ self.finish(with: [ ++ "type": callbackUrl != nil ? "success" : "cancel", ++ "url": callbackUrl?.absoluteString, ++ "error": error?.localizedDescription ++ ]) ++ } ++ ++ // iOS 17.4+/macOS 14.4+ supports HTTPS callbacks with host/path matching ++ if #available(iOS 17.4, macOS 14.4, *), ++ let redirectUrl, ++ redirectUrl.scheme?.lowercased() == "https", ++ let host = redirectUrl.host(percentEncoded: false), ++ !host.isEmpty { ++ let rawPath = redirectUrl.path ++ let path = (rawPath.isEmpty || rawPath == "/") ? "" : rawPath ++ self.authSession = ASWebAuthenticationSession( ++ url: authUrl, ++ callback: .https(host: host, path: path), ++ completionHandler: completionHandler ++ ) ++ } else { ++ self.authSession = ASWebAuthenticationSession( ++ url: authUrl, ++ callbackURLScheme: redirectUrl?.scheme, ++ completionHandler: completionHandler ++ ) ++ } + self.authSession?.prefersEphemeralWebBrowserSession = options.preferEphemeralSession + } + diff --git a/ios/MetaMask/MetaMask.entitlements b/ios/MetaMask/MetaMask.entitlements index 8a7c420fb63..5b41008a05e 100644 --- a/ios/MetaMask/MetaMask.entitlements +++ b/ios/MetaMask/MetaMask.entitlements @@ -15,6 +15,7 @@ applinks:metamask-alternate.app.link applinks:link.metamask.io applinks:link-test.metamask.io + webcredentials:link.metamask.io com.apple.developer.in-app-payments diff --git a/ios/MetaMask/MetaMaskDebug.entitlements b/ios/MetaMask/MetaMaskDebug.entitlements index bb932ad1889..e4cafc45491 100644 --- a/ios/MetaMask/MetaMaskDebug.entitlements +++ b/ios/MetaMask/MetaMaskDebug.entitlements @@ -15,6 +15,7 @@ applinks:metamask-alternate.app.link applinks:link.metamask.io applinks:link-test.metamask.io + webcredentials:link.metamask.io com.apple.developer.in-app-payments diff --git a/package.json b/package.json index 53e998ccddc..1fd95d9ba02 100644 --- a/package.json +++ b/package.json @@ -185,7 +185,8 @@ "@metamask/accounts-controller": "37.0.0", "@metamask/core-backend": "^5.0.0", "bn.js@npm:4.11.6": "4.12.3", - "bn.js@npm:5.2.1": "5.2.3" + "bn.js@npm:5.2.1": "5.2.3", + "expo-web-browser@npm:~14.0.2": "patch:expo-web-browser@npm%3A14.0.2#~/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch" }, "dependencies": { "@config-plugins/detox": "^9.0.0", diff --git a/yarn.lock b/yarn.lock index 1bfe4fef13d..c98b0ac0f33 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29212,7 +29212,7 @@ __metadata: languageName: node linkType: hard -"expo-web-browser@npm:~14.0.2": +"expo-web-browser@npm:14.0.2": version: 14.0.2 resolution: "expo-web-browser@npm:14.0.2" peerDependencies: @@ -29222,6 +29222,16 @@ __metadata: languageName: node linkType: hard +"expo-web-browser@patch:expo-web-browser@npm%3A14.0.2#~/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch": + version: 14.0.2 + resolution: "expo-web-browser@patch:expo-web-browser@npm%3A14.0.2#~/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch::version=14.0.2&hash=158d79" + peerDependencies: + expo: "*" + react-native: "*" + checksum: 10/68989f3d82afed74782e67aa9106df73c76a817cea8f7dbee54206177efb7176962f050b421699cebeb87a0cf2acad501e2dcf9d1e94d487b3fde07c8c20dc99 + languageName: node + linkType: hard + "expo@npm:~52.0.47": version: 52.0.47 resolution: "expo@npm:52.0.47" From b4f3655f11b3842afd39b8134dbac7ce75359371 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Tue, 24 Mar 2026 11:13:55 +0000 Subject: [PATCH 23/54] [skip ci] Bump version number to 4168 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 83413c6695c..816ba0868ce 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4165 + versionCode 4168 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 127d6b76c58..e3b633d97c8 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4165 + VERSION_NUMBER: 4168 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4165 + FLASK_VERSION_NUMBER: 4168 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index fb921c6f29a..869a674492a 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4165; + CURRENT_PROJECT_VERSION = 4168; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4165; + CURRENT_PROJECT_VERSION = 4168; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4165; + CURRENT_PROJECT_VERSION = 4168; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4165; + CURRENT_PROJECT_VERSION = 4168; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4165; + CURRENT_PROJECT_VERSION = 4168; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4165; + CURRENT_PROJECT_VERSION = 4168; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 6db5885d546b9c4b5dffe38bfec845a127f240be Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:45:09 +0100 Subject: [PATCH 24/54] chore(runway): cherry-pick fix: add metrics opt In event cp-7.71.0 (#27868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat: add metrics opt In event (#27846) ## **Description** * Add Metrics Opt In event in Onboarding, Optinmetrics and MetaMetricsAndDataCollectionSection screen ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: METRICS_OPT_IN analytics on user opt-in Scenario: User opts in from onboarding MetaMetrics screen Given the user is on the onboarding MetaMetrics / data collection screen with basic usage enabled by default When the user continues without turning off basic usage Then the app completes onboarding as before and analytics pipelines receive a "Metrics Opt In" event with onboarding location and expected properties in addition to "Analytics Preference Selected" Scenario: User enables MetaMetrics from Settings Given the user is logged in and MetaMetrics is currently off When the user opens Settings > Security & privacy and turns the MetaMetrics switch on Then the app opts in successfully and emits "Metrics Opt In" with settings location and updated_after_onboarding before the preference-selected event Scenario: User enables marketing which requires MetaMetrics Given MetaMetrics is off and marketing data collection is off When the user turns marketing data collection on (which enables MetaMetrics) Then MetaMetrics turns on and "Metrics Opt In" is recorded before the subsequent preference events ``` ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2026-03-24 at 3 35 19 PM Screenshot 2026-03-24 at 3 36 30 PM Screenshot 2026-03-24 at 3 40 10 PM ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk analytics-only change that adds an additional tracking call when users enable metrics (onboarding, social login, and settings). Main risk is event ordering/duplication affecting downstream dashboards rather than app behavior. > > **Overview** > Adds a new `MetaMetricsEvents.METRICS_OPT_IN` event and emits it whenever users enable metrics, including the onboarding opt-in screen (`location: onboarding_metametrics`), social login onboarding flow (`location: onboarding_social_login`), and the settings MetaMetrics toggle (`location: settings` / `onboarding_default_settings`). > > Updates tests to assert the new opt-in event is sent (and in settings/onboarding cases is sent *before* `ANALYTICS_PREFERENCE_SELECTED`), including verifying `updated_after_onboarding` and optional `account_type` properties. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9968f73a4a81ca8b53ccb4dcfc581560fc4652bd. Configure [here](https://cursor.com/dashboard?tab=bugbot). [79b1aa8](https://github.com/MetaMask/metamask-mobile/commit/79b1aa88a8bc5618a2a54dc633c34aef844c6a8f) Co-authored-by: Gaurav Goel --- app/components/UI/OptinMetrics/index.test.tsx | 30 +++++++++++ app/components/UI/OptinMetrics/index.tsx | 28 ++++++----- .../Views/Onboarding/index.test.tsx | 8 +++ app/components/Views/Onboarding/index.tsx | 13 ++++- ...taMetricsAndDataCollectionSection.test.tsx | 50 +++++++++++++++++-- .../MetaMetricsAndDataCollectionSection.tsx | 11 ++++ app/core/Analytics/MetaMetrics.events.ts | 2 + 7 files changed, 124 insertions(+), 18 deletions(-) diff --git a/app/components/UI/OptinMetrics/index.test.tsx b/app/components/UI/OptinMetrics/index.test.tsx index f40b25cf32c..a8c5373eadc 100644 --- a/app/components/UI/OptinMetrics/index.test.tsx +++ b/app/components/UI/OptinMetrics/index.test.tsx @@ -146,6 +146,16 @@ describe('OptinMetrics', () => { await waitFor(() => { expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( 1, + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + location: 'onboarding_metametrics', + updated_after_onboarding: false, + }), + }), + ); + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ name: 'Analytics Preference Selected', properties: expect.objectContaining({ @@ -177,6 +187,16 @@ describe('OptinMetrics', () => { await waitFor(() => { expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( 1, + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + location: 'onboarding_metametrics', + updated_after_onboarding: false, + }), + }), + ); + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ name: 'Analytics Preference Selected', properties: expect.objectContaining({ @@ -212,6 +232,16 @@ describe('OptinMetrics', () => { ); await waitFor(() => { + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + location: 'onboarding_metametrics', + updated_after_onboarding: false, + account_type: AccountType.Imported, + }), + }), + ); expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( expect.objectContaining({ name: 'Analytics Preference Selected', diff --git a/app/components/UI/OptinMetrics/index.tsx b/app/components/UI/OptinMetrics/index.tsx index 63871a546da..7485dff5b08 100644 --- a/app/components/UI/OptinMetrics/index.tsx +++ b/app/components/UI/OptinMetrics/index.tsx @@ -168,19 +168,21 @@ const OptinMetrics = () => { dispatch(setDataCollectionForMarketing(isMarketingChecked)); - // Track opt-out event if user opted out of metrics - if (!isBasicUsageChecked) { - metrics.trackEvent( - metrics - .createEventBuilder(MetaMetricsEvents.METRICS_OPT_OUT) - .addProperties({ - updated_after_onboarding: false, - location: 'onboarding_metametrics', - ...(accountType && { account_type: accountType }), - }) - .build(), - ); - } + // Track opt-in / opt-out for metrics + metrics.trackEvent( + metrics + .createEventBuilder( + isBasicUsageChecked + ? MetaMetricsEvents.METRICS_OPT_IN + : MetaMetricsEvents.METRICS_OPT_OUT, + ) + .addProperties({ + updated_after_onboarding: false, + location: 'onboarding_metametrics', + ...(accountType && { account_type: accountType }), + }) + .build(), + ); metrics.trackEvent( metrics diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx index 4608707035d..771c308e15b 100644 --- a/app/components/Views/Onboarding/index.test.tsx +++ b/app/components/Views/Onboarding/index.test.tsx @@ -75,6 +75,7 @@ import { captureException } from '@sentry/react-native'; import Logger from '../../../util/Logger'; import { MIGRATION_ERROR_HAPPENED } from '../../../constants/storage'; import { AccountType } from '../../../constants/onboarding'; +import { MetaMetricsEvents } from '../../../core/Analytics'; // Mock netinfo - using existing mock jest.mock('@react-native-community/netinfo'); @@ -1990,6 +1991,13 @@ describe('Onboarding', () => { await waitFor(() => { expect(mockAnalytics.optIn).toHaveBeenCalled(); + expect( + mockCreateEventBuilder.mock.calls.some( + (call) => + (call[0] as { category: string }).category === + MetaMetricsEvents.METRICS_OPT_IN.category, + ), + ).toBe(true); }); }); }); diff --git a/app/components/Views/Onboarding/index.tsx b/app/components/Views/Onboarding/index.tsx index 353227bd74e..f2d715f0a34 100644 --- a/app/components/Views/Onboarding/index.tsx +++ b/app/components/Views/Onboarding/index.tsx @@ -733,6 +733,18 @@ const Onboarding = () => { discardBufferedTraces(); await setupSentry(); + const accountType = getSocialAccountType(provider, !createWallet); + metrics.trackEvent( + metrics + .createEventBuilder(MetaMetricsEvents.METRICS_OPT_IN) + .addProperties({ + updated_after_onboarding: false, + location: 'onboarding_social_login', + account_type: accountType, + }) + .build(), + ); + // use new trace instead of buffered trace for social login onboardingTraceCtx.current = trace({ name: TraceName.OnboardingJourneyOverall, @@ -740,7 +752,6 @@ const Onboarding = () => { tags: getTraceTags(store.getState()), }); - const accountType = getSocialAccountType(provider, !createWallet); if (createWallet) { track(MetaMetricsEvents.WALLET_SETUP_STARTED, { account_type: accountType, diff --git a/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.test.tsx b/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.test.tsx index c5ebee60e6d..545f466e354 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.test.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.test.tsx @@ -377,7 +377,18 @@ describe('MetaMetricsAndDataCollectionSection', () => { deviceProp: 'Device value', userProp: 'User value', }); - expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + updated_after_onboarding: true, + location: 'settings', + }), + }), + ); + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category, properties: expect.objectContaining({ @@ -407,7 +418,18 @@ describe('MetaMetricsAndDataCollectionSection', () => { fireEvent(metaMetricsSwitch, 'valueChange', true); await waitFor(() => { - expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + updated_after_onboarding: true, + location: 'onboarding_default_settings', + }), + }), + ); + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category, properties: expect.objectContaining({ @@ -467,6 +489,16 @@ describe('MetaMetricsAndDataCollectionSection', () => { fireEvent(metaMetricsSwitch, 'valueChange', true); await waitFor(() => { + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + updated_after_onboarding: true, + location: 'settings', + account_type: AccountType.MetamaskGoogle, + }), + }), + ); expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( expect.objectContaining({ name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category, @@ -808,6 +840,16 @@ describe('MetaMetricsAndDataCollectionSection', () => { }); expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( 1, + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + location: 'settings', + updated_after_onboarding: true, + }), + }), + ); + expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category, properties: expect.objectContaining({ @@ -827,8 +869,8 @@ describe('MetaMetricsAndDataCollectionSection', () => { }, ); expect(mockAnalytics.trackEvent).toHaveBeenNthCalledWith( - // if MetaMetrics is initially disabled, trackEvent is called twice and this is 2nd call - !metaMetricsInitiallyEnabled ? 2 : 1, + // if MetaMetrics is initially disabled, marketing consent is the 3rd trackEvent + !metaMetricsInitiallyEnabled ? 3 : 1, expect.objectContaining({ name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category, properties: expect.objectContaining({ diff --git a/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx b/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx index c2940f9408b..59debfafdfb 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx @@ -114,6 +114,17 @@ const MetaMetricsAndDataCollectionSection: React.FC< setAnalyticsEnabled(true); analytics.identify(consolidatedTraits); + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( + MetaMetricsEvents.METRICS_OPT_IN, + ) + .addProperties({ + updated_after_onboarding: true, + location: analyticsLocation, + ...(accountType && { account_type: accountType }), + }) + .build(), + ); analytics.trackEvent( AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED, diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 063eb7bc621..3e0d1d23446 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -124,6 +124,7 @@ enum EVENT_NAME { // Analytics ANALYTICS_PREFERENCE_SELECTED = 'Analytics Preference Selected', + METRICS_OPT_IN = 'Metrics Opt In', METRICS_OPT_OUT = 'Metrics Opt Out', ANALYTICS_REQUEST_DATA_DELETION = 'Delete MetaMetrics Data Request Submitted', EXPERIMENT_VIEWED = 'Experiment Viewed', @@ -829,6 +830,7 @@ const events = { ANALYTICS_PREFERENCE_SELECTED: generateOpt( EVENT_NAME.ANALYTICS_PREFERENCE_SELECTED, ), + METRICS_OPT_IN: generateOpt(EVENT_NAME.METRICS_OPT_IN), METRICS_OPT_OUT: generateOpt(EVENT_NAME.METRICS_OPT_OUT), ANALYTICS_REQUEST_DATA_DELETION: generateOpt( EVENT_NAME.ANALYTICS_REQUEST_DATA_DELETION, From 6ebe48251eb83aa24a05e954a80552dc102a8949 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Tue, 24 Mar 2026 14:47:02 +0000 Subject: [PATCH 25/54] [skip ci] Bump version number to 4171 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 816ba0868ce..8f01fbabd04 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4168 + versionCode 4171 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index e3b633d97c8..8331c859bd7 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4168 + VERSION_NUMBER: 4171 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4168 + FLASK_VERSION_NUMBER: 4171 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 869a674492a..4fc1bf7cc4b 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4168; + CURRENT_PROJECT_VERSION = 4171; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4168; + CURRENT_PROJECT_VERSION = 4171; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4168; + CURRENT_PROJECT_VERSION = 4171; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4168; + CURRENT_PROJECT_VERSION = 4171; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4168; + CURRENT_PROJECT_VERSION = 4171; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4168; + CURRENT_PROJECT_VERSION = 4171; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 8f743b409bc613d1c998025200ba63fc3ed687d4 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:50:24 +0100 Subject: [PATCH 26/54] chore(runway): cherry-pick fix(ramps): filter activity tab's transfer details for selected account -> cp-7.71.0 (#27865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(ramps): filter activity tab's transfer details for selected account -> cp-7.71.0 (#27830) ## **Description** ### Activity — on-ramp orders scoped to the selected account The Activity **Orders** tab merges legacy fiat orders with V2 `RampsController` orders. Legacy rows were already limited to the **selected account group** via `getOrders`. V2 orders were not filtered, so purchase history from other wallets could appear. This change adds `selectRampsOrdersForSelectedAccountGroup`, which keeps only orders whose `walletAddress` matches any formatted address in the selected account group (same semantics as legacy, using `areAddressesEqual` for EVM vs non-EVM). Hook and modal consumers that should reflect “current wallet context” now use this selector instead of the raw controller list. ### Transak — preserve user-entered fiat amount on Build Quote (in-app) After **additional verification**, opening the KYC/payment webview called `navigateToKycWebview` with **`quote.fiatAmount`**, and the stack reset rewrote **RampAmountInput** params with that value. The quote total can differ from what the user typed (e.g. fees), so Build Quote could show **27.37** after the user entered **25**, and that value persisted after closing the sheet or going back. **Change:** `routeAfterAuthentication(..., amount)` already carried the typed fiat; `navigateToAdditionalVerificationCallback` now puts the same `amount` on **both** `RampAmountInput` and `RampAdditionalVerification` route params. **V2 Additional Verification** reads that param and passes **only** it into `navigateToKycWebview` (no `quote.fiatAmount` for this purpose). **Out of scope:** Order detail screens and stored order payloads are unchanged. The **Transak payment webview** URL is unchanged (no `fiatAmount` override in widget params); only in-app Build Quote / stack state matches the user’s input. ## **Changelog** CHANGELOG entry: Fixed Activity on-ramp (Orders) list showing V2 purchases from wallets other than the selected account group; fixed Transak unified buy flow so the fiat amount on Build Quote after additional verification matches the user-entered amount on the amount screen (in-app stack), without changing Transak’s payment widget totals. ### Tests - `selectRampsOrdersForSelectedAccountGroup` and ramp hook consumers (selector + hook unit tests). - `useTransakRouting`: IDPROOF / additional verification navigation with user `amount` set and omitted. - V2 `AdditionalVerification`: continue passes route `amount` into `navigateToKycWebview`. ## **Related issues** Refs: [TRAM-3361](https://consensyssoftware.atlassian.net/browse/TRAM-3361) ## **Manual testing steps** ```gherkin Feature: Activity on-ramp orders and Transak amount (TRAM-3361) Scenario: Orders tab shows only selected account group’s V2 on-ramp orders Given the user has two wallets (or account groups) with separate on-ramp purchase history And unified ramps V2 orders exist for more than one wallet address When the user selects account group A and opens Activity → Orders Then only on-ramp orders whose destination wallet belongs to account group A are listed When the user switches to account group B Then the Orders tab lists only orders for account group B Scenario: Custom fiat amount survives Transak additional verification on Build Quote Given the user is in unified Buy with Transak (native) as provider And the user enters a custom fiat amount (e.g. 25) on the amount screen When the user continues through flows that require additional verification And the user taps Continue on the additional verification screen Then Build Quote under the KYC/payment sheet still shows the entered amount (e.g. 25), not only the quote total When the user closes the webview or goes back from the flow Then the amount screen still shows the same entered amount (e.g. 25) ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/b26bb3cb-8219-48a3-8128-7c79026fdd18 ### **After** https://github.com/user-attachments/assets/3e1929e3-7e1a-42c9-98bd-ea8f7bc9b1eb https://github.com/user-attachments/assets/ef6d14ed-6533-4e04-a5a4-8cbd44477170 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [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] > **Medium Risk** > Moderate UX/data-scoping change: filters which on-ramp orders appear based on multichain account selection and adjusts Transak navigation params, which could hide expected history or affect flow state if account/address resolution is off. > > **Overview** > Fixes unified ramp UI to **scope V2 `RampsController` orders to the selected account group**, preventing purchases from other wallets from showing up in Activity-derived surfaces. This introduces `selectRampsOrdersForSelectedAccountGroup` (address-matched via `areAddressesEqual`) and switches key consumers (`useRampsOrders`, `useRampsProviders`, `useRampsButtonClickData`, `ProviderSelectionModal`) from the unfiltered selector. > > Updates the Transak additional-verification flow to **preserve the user-entered fiat amount** through stack resets: `useTransakRouting` now carries `amount` into `RampAdditionalVerification` params, and `AdditionalVerification` uses that param (not `quote.fiatAmount`) when opening the KYC webview. Selector and routing behavior are covered with expanded unit tests. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 39d5861d79d45c9f742c809c6a95aa1ef2aff902. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [4661cdb](https://github.com/MetaMask/metamask-mobile/commit/4661cdba2d91fcd672861ab330f91f134451c111) --------- Co-authored-by: Yu-Gi-Oh! <54774811+imyugioh@users.noreply.github.com> --- .../ProviderSelectionModal.tsx | 6 +- .../AdditionalVerification.test.tsx | 5 +- .../NativeFlow/AdditionalVerification.tsx | 9 +- .../UI/Ramp/hooks/useRampsButtonClickData.ts | 6 +- .../UI/Ramp/hooks/useRampsOrders.test.ts | 70 ++++- .../UI/Ramp/hooks/useRampsOrders.ts | 4 +- .../UI/Ramp/hooks/useRampsProviders.ts | 6 +- .../UI/Ramp/hooks/useTransakRouting.test.ts | 59 +++- .../UI/Ramp/hooks/useTransakRouting.ts | 4 +- app/selectors/rampsController/index.test.ts | 256 ++++++++++++++++++ app/selectors/rampsController/index.ts | 34 ++- 11 files changed, 436 insertions(+), 23 deletions(-) diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx index b6ef67590ed..8a1d0b81bb7 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx @@ -17,7 +17,7 @@ import { useRampsController } from '../../../hooks/useRampsController'; import { useRampsQuotes } from '../../../hooks/useRampsQuotes'; import useRampAccountAddress from '../../../hooks/useRampAccountAddress'; import { getOrdersProviders } from '../../../../../../reducers/fiatOrders'; -import { selectRampsOrders } from '../../../../../../selectors/rampsController'; +import { selectRampsOrdersForSelectedAccountGroup } from '../../../../../../selectors/rampsController'; import { completedOrdersFromRampsOrders } from '../../../utils/determinePreferredProvider'; import { useStyles } from '../../../../../hooks/useStyles'; import styleSheet from './ProviderSelectionModal.styles'; @@ -59,7 +59,9 @@ function ProviderSelectionModal() { } = useRampsController(); const legacyOrdersProviders = useSelector(getOrdersProviders); - const controllerOrders = useSelector(selectRampsOrders); + const controllerOrders = useSelector( + selectRampsOrdersForSelectedAccountGroup, + ); const ordersProviders = useMemo(() => { const v2ProviderIds = completedOrdersFromRampsOrders(controllerOrders).map( diff --git a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx index 6375f3e7a3e..481c0cfa3c3 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.test.tsx @@ -36,9 +36,10 @@ jest.mock('../../../../../util/navigation/navUtils', () => ({ (..._args: unknown[]) => (params: unknown) => ['MockRoute', params], useParams: () => ({ - quote: { quoteId: 'test-quote-id', fiatAmount: 100 }, + quote: { quoteId: 'test-quote-id', fiatAmount: 127.37 }, kycUrl: 'https://kyc.example.com', workFlowRunId: 'wf-123', + amount: 25, }), })); @@ -71,7 +72,7 @@ describe('V2AdditionalVerification', () => { expect(mockNavigateToKycWebview).toHaveBeenCalledWith({ kycUrl: 'https://kyc.example.com', - amount: 100, + amount: 25, }); }); diff --git a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx index 190666ee128..00a4377856f 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/AdditionalVerification.tsx @@ -23,11 +23,14 @@ interface V2AdditionalVerificationParams { quote: TransakBuyQuote; kycUrl: string; workFlowRunId: string; + /** From BuildQuote route; keeps stack amount in sync when opening KYC webview. */ + amount?: number; } const V2AdditionalVerification = () => { const navigation = useNavigation(); - const { kycUrl, quote } = useParams(); + const { kycUrl, amount: userEnteredAmount } = + useParams(); const { styles, theme } = useStyles(styleSheet, {}); @@ -46,8 +49,8 @@ const V2AdditionalVerification = () => { }, [navigation, theme]); const handleContinuePress = useCallback(() => { - navigateToKycWebview({ kycUrl, amount: quote?.fiatAmount }); - }, [navigateToKycWebview, kycUrl, quote?.fiatAmount]); + navigateToKycWebview({ kycUrl, amount: userEnteredAmount }); + }, [navigateToKycWebview, kycUrl, userEnteredAmount]); return ( diff --git a/app/components/UI/Ramp/hooks/useRampsButtonClickData.ts b/app/components/UI/Ramp/hooks/useRampsButtonClickData.ts index 1868700aa6c..4b73ecb6ddf 100644 --- a/app/components/UI/Ramp/hooks/useRampsButtonClickData.ts +++ b/app/components/UI/Ramp/hooks/useRampsButtonClickData.ts @@ -5,7 +5,7 @@ import { getRampRoutingDecision, UnifiedRampRoutingType, } from '../../../../reducers/fiatOrders'; -import { selectRampsOrders } from '../../../../selectors/rampsController'; +import { selectRampsOrdersForSelectedAccountGroup } from '../../../../selectors/rampsController'; import { getProviderToken } from '../Deposit/utils/ProviderTokenVault'; import { completedOrdersFromFiatOrders, @@ -21,7 +21,9 @@ export interface RampsButtonClickData { export function useRampsButtonClickData(): RampsButtonClickData { const orders = useSelector(getOrders); - const controllerOrders = useSelector(selectRampsOrders); + const controllerOrders = useSelector( + selectRampsOrdersForSelectedAccountGroup, + ); const rampRoutingDecision = useSelector(getRampRoutingDecision); const [isAuthenticated, setIsAuthenticated] = useState(false); diff --git a/app/components/UI/Ramp/hooks/useRampsOrders.test.ts b/app/components/UI/Ramp/hooks/useRampsOrders.test.ts index 0c5048f6ba5..6f1b6557191 100644 --- a/app/components/UI/Ramp/hooks/useRampsOrders.test.ts +++ b/app/components/UI/Ramp/hooks/useRampsOrders.test.ts @@ -2,9 +2,23 @@ import { renderHook, act } from '@testing-library/react-native'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import React from 'react'; +import { AccountGroupType } from '@metamask/account-api'; import { RampsOrderStatus, type RampsOrder } from '@metamask/ramps-controller'; +import { createMockInternalAccount } from '../../../../util/test/accountsControllerTestUtils'; import { useRampsOrders } from './useRampsOrders'; +const RAMP_HOOKS_TEST_WALLET_ID = 'keyring:use-ramps-orders-test' as const; +const RAMP_HOOKS_TEST_GROUP_ID = + `${RAMP_HOOKS_TEST_WALLET_ID}/ethereum` as const; +const RAMP_HOOKS_TEST_ACCOUNT_ID = 'account-rh-1'; +/** Must be a valid EVM address (20 bytes) so `areAddressesEqual` treats it as EVM. */ +const RAMP_HOOKS_TEST_ADDRESS = '0x2990079bcdee240329a520d2444386fc119da21a'; + +const rampHooksTestInternalAccount = { + ...createMockInternalAccount(RAMP_HOOKS_TEST_ADDRESS, 'Test'), + id: RAMP_HOOKS_TEST_ACCOUNT_ID, +}; + const mockAddOrder = jest.fn(); const mockAddPrecreatedOrder = jest.fn(); const mockRemoveOrder = jest.fn(); @@ -35,7 +49,7 @@ const createMockOrder = (overrides: Partial = {}): RampsOrder => ({ createdAt: Date.now(), totalFeesFiat: 5, txHash: '0xabc', - walletAddress: '0x123', + walletAddress: RAMP_HOOKS_TEST_ADDRESS, status: RampsOrderStatus.Completed, network: { name: 'Ethereum', chainId: 'eip155:1' }, canBeUpdated: false, @@ -54,6 +68,45 @@ const createMockStore = (orders: RampsOrder[] = []) => RampsController: { orders, }, + AccountTreeController: { + accountTree: { + wallets: { + [RAMP_HOOKS_TEST_WALLET_ID]: { + id: RAMP_HOOKS_TEST_WALLET_ID, + metadata: { name: 'Test wallet' }, + groups: { + [RAMP_HOOKS_TEST_GROUP_ID]: { + id: RAMP_HOOKS_TEST_GROUP_ID, + type: AccountGroupType.SingleAccount, + accounts: [RAMP_HOOKS_TEST_ACCOUNT_ID], + metadata: { name: 'Test Group' }, + }, + }, + }, + }, + selectedAccountGroup: RAMP_HOOKS_TEST_GROUP_ID, + }, + }, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + enableMultichainAccounts: { + enabled: true, + featureVersion: '1', + minimumVersion: '1.0.0', + }, + }, + }, + AccountsController: { + internalAccounts: { + accounts: { + [RAMP_HOOKS_TEST_ACCOUNT_ID]: rampHooksTestInternalAccount, + }, + selectedAccount: RAMP_HOOKS_TEST_ACCOUNT_ID, + }, + }, + KeyringController: { + keyrings: [], + }, }, }), }, @@ -78,7 +131,7 @@ describe('useRampsOrders', () => { expect(result.current.orders).toEqual([]); }); - it('returns orders from the store', () => { + it('returns orders from the store when walletAddress matches the selected account group', () => { const order = createMockOrder(); const store = createMockStore([order]); const { result } = renderHook(() => useRampsOrders(), { @@ -88,6 +141,19 @@ describe('useRampsOrders', () => { expect(result.current.orders).toEqual([order]); }); + it('excludes orders whose walletAddress is not in the selected account group', () => { + const foreignOrder = createMockOrder({ + providerOrderId: 'foreign-order', + walletAddress: '0x0000000000000000000000000000000000000001', + }); + const store = createMockStore([foreignOrder]); + const { result } = renderHook(() => useRampsOrders(), { + wrapper: wrapper(store), + }); + + expect(result.current.orders).toEqual([]); + }); + it('finds an order by providerOrderId', () => { const order1 = createMockOrder({ providerOrderId: 'order-1' }); const order2 = createMockOrder({ providerOrderId: 'order-2' }); diff --git a/app/components/UI/Ramp/hooks/useRampsOrders.ts b/app/components/UI/Ramp/hooks/useRampsOrders.ts index 72f58dd8198..48a78cb0480 100644 --- a/app/components/UI/Ramp/hooks/useRampsOrders.ts +++ b/app/components/UI/Ramp/hooks/useRampsOrders.ts @@ -3,7 +3,7 @@ import { useSelector } from 'react-redux'; import type { RampsOrder } from '@metamask/ramps-controller'; import { extractOrderCode } from '../utils/extractOrderCode'; import Engine from '../../../../core/Engine'; -import { selectRampsOrders } from '../../../../selectors/rampsController'; +import { selectRampsOrdersForSelectedAccountGroup } from '../../../../selectors/rampsController'; export interface AddPrecreatedOrderParams { orderId: string; @@ -31,7 +31,7 @@ export interface UseRampsOrdersResult { } export function useRampsOrders(): UseRampsOrdersResult { - const orders = useSelector(selectRampsOrders); + const orders = useSelector(selectRampsOrdersForSelectedAccountGroup); const getOrderById = useCallback( (providerOrderId: string) => { diff --git a/app/components/UI/Ramp/hooks/useRampsProviders.ts b/app/components/UI/Ramp/hooks/useRampsProviders.ts index 255aeff8af3..017e6f626ec 100644 --- a/app/components/UI/Ramp/hooks/useRampsProviders.ts +++ b/app/components/UI/Ramp/hooks/useRampsProviders.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { selectProviders, - selectRampsOrders, + selectRampsOrdersForSelectedAccountGroup, } from '../../../../selectors/rampsController'; import { type Provider } from '@metamask/ramps-controller'; import Engine from '../../../../core/Engine'; @@ -55,7 +55,9 @@ export function useRampsProviders(): UseRampsProvidersResult { } = useSelector(selectProviders); const legacyOrders = useSelector(getOrders); - const controllerOrders = useSelector(selectRampsOrders); + const controllerOrders = useSelector( + selectRampsOrdersForSelectedAccountGroup, + ); const completedOrders = useMemo( () => [ diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts index bde067dce25..708fcd90549 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts @@ -284,7 +284,7 @@ describe('useTransakRouting', () => { 'test-ott', mockQuote, MOCK_WALLET_ADDRESS, - expect.any(Object), + { theme: 'light' }, ); expect(mockReset).toHaveBeenCalledWith( expect.objectContaining({ @@ -482,10 +482,7 @@ describe('useTransakRouting', () => { const { result } = renderHook(() => useTransakRouting()); await act(async () => { - await result.current.routeAfterAuthentication( - mockQuote as never, - mockQuote.fiatAmount, - ); + await result.current.routeAfterAuthentication(mockQuote as never, 25); }); expect(mockReset).toHaveBeenCalledWith( @@ -494,7 +491,56 @@ describe('useTransakRouting', () => { routes: [ expect.objectContaining({ name: 'RampAmountInput', - params: { amount: mockQuote.fiatAmount }, + params: { amount: 25 }, + }), + expect.objectContaining({ + name: 'RampAdditionalVerification', + params: expect.objectContaining({ + quote: mockQuote, + kycUrl: 'https://kyc.example.com', + workFlowRunId: 'wf-123', + amount: 25, + }), + }), + ], + }), + ); + }); + + it('handles ADDITIONAL_FORMS_REQUIRED with IDPROOF when user amount is omitted', async () => { + mockGetUserDetails.mockResolvedValue({ + firstName: 'John', + address: {}, + }); + mockGetKycRequirement.mockResolvedValue({ + status: 'ADDITIONAL_FORMS_REQUIRED', + kycType: 'STANDARD', + }); + mockGetAdditionalRequirements.mockResolvedValue({ + formsRequired: [ + { + type: 'IDPROOF', + metadata: { + kycUrl: 'https://kyc.example.com', + workFlowRunId: 'wf-123', + }, + }, + ], + }); + + const { result } = renderHook(() => useTransakRouting()); + + await act(async () => { + await result.current.routeAfterAuthentication(mockQuote as never); + }); + + expect(mockReset).toHaveBeenCalledWith( + expect.objectContaining({ + index: 1, + routes: [ + expect.objectContaining({ + name: 'RampAmountInput', + params: { amount: undefined }, }), expect.objectContaining({ name: 'RampAdditionalVerification', @@ -502,6 +548,7 @@ describe('useTransakRouting', () => { quote: mockQuote, kycUrl: 'https://kyc.example.com', workFlowRunId: 'wf-123', + amount: undefined, }), }), ], diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.ts b/app/components/UI/Ramp/hooks/useTransakRouting.ts index 661863ddf24..342e15b0441 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.ts @@ -40,6 +40,8 @@ interface RampStackParamList { quote: TransakBuyQuote; kycUrl: string; workFlowRunId: string; + /** User-entered fiat from BuildQuote; used when resetting stack so amount screen keeps the typed value. */ + amount?: number; }; RampKycProcessing: { quote: TransakBuyQuote }; RampEnterEmail: undefined; @@ -258,7 +260,7 @@ export const useTransakRouting = (_config?: UseTransakRoutingConfig) => { }, { name: Routes.RAMP.ADDITIONAL_VERIFICATION, - params: { quote, kycUrl, workFlowRunId }, + params: { quote, kycUrl, workFlowRunId, amount }, }, ], }); diff --git a/app/selectors/rampsController/index.test.ts b/app/selectors/rampsController/index.test.ts index dd17c1ed4ca..4d179e49b44 100644 --- a/app/selectors/rampsController/index.test.ts +++ b/app/selectors/rampsController/index.test.ts @@ -6,6 +6,13 @@ import { type Country, type PaymentMethod, } from '@metamask/ramps-controller'; +import { AccountGroupType } from '@metamask/account-api'; +import { AccountId } from '@metamask/accounts-controller'; +import { TrxAccountType } from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import { InternalAccount } from '@metamask/keyring-internal-api'; +import { createMockInternalAccount } from '../../util/test/accountsControllerTestUtils'; +import { mockSolanaAddress } from '../../util/test/keyringControllerTestUtils'; import { selectUserRegion, selectProviders, @@ -14,6 +21,7 @@ import { selectPaymentMethods, selectRampsControllerState, selectRampsOrders, + selectRampsOrdersForSelectedAccountGroup, selectTransak, } from './index'; @@ -31,6 +39,7 @@ type RampsControllerStateOverride = Partial; const createMockState = ( rampsController: RampsControllerStateOverride = {}, + extraBackgroundState: Record = {}, ): RootState => ({ engine: { @@ -58,10 +67,65 @@ const createMockState = ( }, ...rampsController, }, + KeyringController: { + keyrings: [], + }, + ...extraBackgroundState, }, }, }) as unknown as RootState; +const WALLET_ID = 'keyring:ramps-selector-test' as const; +const GROUP_ID = `${WALLET_ID}/ethereum` as const; + +function createStateWithSelectedAccountGroup( + rampsController: RampsControllerStateOverride, + internalAccount: InternalAccount, + accountId: string, +): RootState { + return createMockState(rampsController, { + AccountTreeController: { + accountTree: { + wallets: { + [WALLET_ID]: { + id: WALLET_ID, + metadata: { name: 'Test wallet' }, + groups: { + [GROUP_ID]: { + id: GROUP_ID, + type: AccountGroupType.SingleAccount, + accounts: [accountId], + metadata: { name: 'Test Group' }, + }, + }, + }, + }, + selectedAccountGroup: GROUP_ID, + }, + }, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + enableMultichainAccounts: { + enabled: true, + featureVersion: '1', + minimumVersion: '1.0.0', + }, + }, + }, + AccountsController: { + internalAccounts: { + accounts: { + [accountId]: internalAccount, + }, + selectedAccount: accountId, + }, + }, + KeyringController: { + keyrings: [], + }, + }); +} + const mockUserRegion: UserRegion = { country: { isoCode: 'US', @@ -314,6 +378,198 @@ describe('RampsController Selectors', () => { }); }); + describe('selectRampsOrdersForSelectedAccountGroup', () => { + const accountId = 'account-ramps-1'; + const walletAddrLower = '0x2990079bcdee240329a520d2444386fc119da21a'; + const internalAccount = { + ...createMockInternalAccount(walletAddrLower, 'Account 1'), + id: accountId, + }; + + it('returns empty array when no selected account group addresses', () => { + const mockOrders = [ + { + providerOrderId: 'order-1', + walletAddress: walletAddrLower, + status: 'COMPLETED', + createdAt: 1000, + }, + ]; + const state = createMockState({ + orders: mockOrders, + } as never); + + expect(selectRampsOrdersForSelectedAccountGroup(state)).toEqual([]); + }); + + it('keeps orders whose walletAddress matches a selected group address (case-insensitive for EVM)', () => { + const mockOrders = [ + { + providerOrderId: 'order-match', + walletAddress: '0x2990079BCDEE240329A520D2444386FC119DA21A', + status: 'COMPLETED', + createdAt: 1000, + }, + { + providerOrderId: 'order-other', + walletAddress: '0x0000000000000000000000000000000000000001', + status: 'COMPLETED', + createdAt: 2000, + }, + ]; + const state = createStateWithSelectedAccountGroup( + { orders: mockOrders } as never, + internalAccount, + accountId, + ); + + const result = selectRampsOrdersForSelectedAccountGroup(state); + expect(result).toEqual([mockOrders[0]]); + }); + + it('excludes orders with missing walletAddress', () => { + const mockOrders = [ + { + providerOrderId: 'order-no-wallet', + status: 'COMPLETED', + createdAt: 1000, + }, + ]; + const state = createStateWithSelectedAccountGroup( + { orders: mockOrders } as never, + internalAccount, + accountId, + ); + + expect(selectRampsOrdersForSelectedAccountGroup(state)).toEqual([]); + }); + + it('keeps orders whose walletAddress matches a Solana account in the selected group', () => { + const solanaAccountId = 'account-ramps-solana' as AccountId; + const otherSolanaAddress = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM'; + const solanaInternalAccount: InternalAccount = { + id: solanaAccountId, + address: mockSolanaAddress, + type: 'solana:dataAccount' as InternalAccount['type'], + options: {}, + methods: [], + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + metadata: { + name: 'Solana Account', + importTime: Date.now(), + keyring: { + type: 'Snap Keyring', + }, + }, + }; + const mockOrders = [ + { + providerOrderId: 'order-sol-match', + walletAddress: mockSolanaAddress, + status: 'COMPLETED', + createdAt: 1000, + }, + { + providerOrderId: 'order-sol-other', + walletAddress: otherSolanaAddress, + status: 'COMPLETED', + createdAt: 2000, + }, + ]; + const state = createStateWithSelectedAccountGroup( + { orders: mockOrders } as never, + solanaInternalAccount, + solanaAccountId, + ); + + expect(selectRampsOrdersForSelectedAccountGroup(state)).toEqual([ + mockOrders[0], + ]); + }); + + it('keeps orders whose walletAddress matches a Bitcoin account in the selected group', () => { + const bitcoinAccountId = 'account-ramps-bitcoin' as AccountId; + const bitcoinAddress = 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh'; + const otherBitcoinAddress = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'; + const bitcoinInternalAccount: InternalAccount = { + id: bitcoinAccountId, + address: bitcoinAddress, + type: 'bip122:p2wpkh' as InternalAccount['type'], + options: {}, + methods: [], + scopes: ['bip122:000000000019d6689c085ae165831e93'], + metadata: { + name: 'Bitcoin Account', + importTime: Date.now(), + keyring: { + type: 'Snap Keyring', + }, + }, + }; + const mockOrders = [ + { + providerOrderId: 'order-btc-match', + walletAddress: bitcoinAddress, + status: 'COMPLETED', + createdAt: 1000, + }, + { + providerOrderId: 'order-btc-other', + walletAddress: otherBitcoinAddress, + status: 'COMPLETED', + createdAt: 2000, + }, + ]; + const state = createStateWithSelectedAccountGroup( + { orders: mockOrders } as never, + bitcoinInternalAccount, + bitcoinAccountId, + ); + + expect(selectRampsOrdersForSelectedAccountGroup(state)).toEqual([ + mockOrders[0], + ]); + }); + + it('keeps orders whose walletAddress matches a Tron account in the selected group', () => { + const tronAccountId = 'account-ramps-tron' as AccountId; + const tronAddress = 'TXYZopYRdj2D9XRtbPoJZ1CuXLNaoEBgD'; + const otherTronAddress = 'TN3W4H6rK2ce4vX9YnFQHw8ENXNA9s8rPH'; + const tronInternalAccount: InternalAccount = { + ...createMockInternalAccount( + tronAddress, + 'Tron Account', + KeyringTypes.snap, + TrxAccountType.Eoa, + ), + id: tronAccountId, + }; + const mockOrders = [ + { + providerOrderId: 'order-tron-match', + walletAddress: tronAddress, + status: 'COMPLETED', + createdAt: 1000, + }, + { + providerOrderId: 'order-tron-other', + walletAddress: otherTronAddress, + status: 'COMPLETED', + createdAt: 2000, + }, + ]; + const state = createStateWithSelectedAccountGroup( + { orders: mockOrders } as never, + tronInternalAccount, + tronAccountId, + ); + + expect(selectRampsOrdersForSelectedAccountGroup(state)).toEqual([ + mockOrders[0], + ]); + }); + }); + describe('selectTransak', () => { it('returns transak state when nativeProviders.transak is set', () => { const mockTransakState = { diff --git a/app/selectors/rampsController/index.ts b/app/selectors/rampsController/index.ts index aa30fd4b8aa..1c86fea1d88 100644 --- a/app/selectors/rampsController/index.ts +++ b/app/selectors/rampsController/index.ts @@ -11,6 +11,9 @@ import { type RampsOrder, } from '@metamask/ramps-controller'; import { RootState } from '../../reducers'; +import { areAddressesEqual } from '../../util/address'; +import { createDeepEqualSelector } from '../util'; +import { selectSelectedAccountGroupWithInternalAccountsAddresses } from '../multichainAccounts/accountTreeController'; /** * Selects the RampsController state from Redux. @@ -90,13 +93,42 @@ export const selectPaymentMethods = createSelector( ); /** - * Selects V2 orders from RampsController state. + * Selects all V2 orders from RampsController state (unfiltered). + * For UI scoped to the selected account group, use + * `selectRampsOrdersForSelectedAccountGroup` instead. */ export const selectRampsOrders = createSelector( selectRampsControllerState, (rampsControllerState): RampsOrder[] => rampsControllerState?.orders ?? [], ); +/** + * V2 on-ramp orders whose `walletAddress` belongs to the selected account group. + * Matches legacy `getOrders` scoping for fiat orders. + */ +export const selectRampsOrdersForSelectedAccountGroup = createDeepEqualSelector( + [selectRampsOrders, selectSelectedAccountGroupWithInternalAccountsAddresses], + (orders, addresses): RampsOrder[] => { + if (addresses.length === 0) { + return []; + } + return orders.filter((order) => { + const walletAddress = order.walletAddress; + if (!walletAddress) { + return false; + } + return addresses.some( + (addr) => addr != null && areAddressesEqual(walletAddress, addr), + ); + }); + }, + { + devModeChecks: { + identityFunctionCheck: 'never', + }, + }, +); + /** * Selects the transak native provider state (isAuthenticated, userDetails, buyQuote, kycRequirement). */ From 94e6bcb0478d73c80c0c0b59871f557eea6e880f Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Tue, 24 Mar 2026 14:52:45 +0000 Subject: [PATCH 27/54] [skip ci] Bump version number to 4172 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 8f01fbabd04..cb4881fdc9b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4171 + versionCode 4172 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 8331c859bd7..8bf6cc69f9c 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4171 + VERSION_NUMBER: 4172 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4171 + FLASK_VERSION_NUMBER: 4172 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 4fc1bf7cc4b..7d0636490fc 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4171; + CURRENT_PROJECT_VERSION = 4172; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4171; + CURRENT_PROJECT_VERSION = 4172; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4171; + CURRENT_PROJECT_VERSION = 4172; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4171; + CURRENT_PROJECT_VERSION = 4172; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4171; + CURRENT_PROJECT_VERSION = 4172; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4171; + CURRENT_PROJECT_VERSION = 4172; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 77eca9c5a2c8cc00ead352a163d6d7c3301b9f5e Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:18:20 +0100 Subject: [PATCH 28/54] chore(runway): cherry-pick feat: legacy-ios-feature-flag cp-7.71.0 (#27876) - feat: legacy-ios-feature-flag cp-7.71.0 (#27848) ## **Description** Support webcredential for ios google login Part 2/4 - Add feature flag This pr add feature flag for the ios google login PR list Part 1/ 4 - https://github.com/MetaMask/metamask-mobile/pull/27741 Part 2/ 4 - https://github.com/MetaMask/metamask-mobile/pull/27848 Part 3/ 4 - https://github.com/MetaMask/metamask-mobile/pull/27850 Part 4/ 4 - TBA ## **Changelog** CHANGELOG entry: added legacyIosGoogleConfigEnabled feature flag ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk: adds a new remote feature flag and selector with env override, without changing authentication flow yet; main risk is misconfiguration since the selector defaults to enabled. > > **Overview** > Adds a new remote feature flag, `legacyIosGoogleConfigEnabled`, including registry metadata and a dedicated selector `selectLegacyIosGoogleConfigEnabled` (defaulting to `true`) that can be force-overridden via `MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED`. > > Includes unit tests covering default/remote/env override behavior, and updates `babel.config.tests.js` to avoid inlining env vars for the new selector and its tests. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ca7e8132c44d06ba009029b1f83eb4412e10006f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [6ae4c95](https://github.com/MetaMask/metamask-mobile/commit/6ae4c95e880b7f2f71eb4bd415b75347610aa236) Co-authored-by: ieow <4881057+ieow@users.noreply.github.com> --- app/constants/featureFlags.ts | 1 + .../legacyIosGoogleConfig/index.test.ts | 56 +++++++++++++++++++ .../legacyIosGoogleConfig/index.ts | 26 +++++++++ babel.config.tests.js | 2 + tests/feature-flags/feature-flag-registry.ts | 8 +++ 5 files changed, 93 insertions(+) create mode 100644 app/selectors/featureFlagController/legacyIosGoogleConfig/index.test.ts create mode 100644 app/selectors/featureFlagController/legacyIosGoogleConfig/index.ts diff --git a/app/constants/featureFlags.ts b/app/constants/featureFlags.ts index c443b3f9fd6..a439f406657 100644 --- a/app/constants/featureFlags.ts +++ b/app/constants/featureFlags.ts @@ -15,6 +15,7 @@ export enum FeatureFlagNames { tokenDetailsV2Buttons = 'tokenDetailsV2Buttons', tokenDetailsV2ButtonLayout = 'tokenDetailsV2ButtonLayout', complianceEnabled = 'complianceEnabled', + legacyIosGoogleConfigEnabled = 'legacyIosGoogleConfigEnabled', } export const DEFAULT_FEATURE_FLAG_VALUES: Partial< diff --git a/app/selectors/featureFlagController/legacyIosGoogleConfig/index.test.ts b/app/selectors/featureFlagController/legacyIosGoogleConfig/index.test.ts new file mode 100644 index 00000000000..3a507ff875a --- /dev/null +++ b/app/selectors/featureFlagController/legacyIosGoogleConfig/index.test.ts @@ -0,0 +1,56 @@ +import { + DEFAULT_LEGACY_IOS_GOOGLE_CONFIG_ENABLED, + selectLegacyIosGoogleConfigEnabled, +} from '.'; +import { FeatureFlagNames } from '../../../constants/featureFlags'; + +describe('Legacy iOS Google Config Feature Flag Selector', () => { + const originalEnv = process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED; + + beforeEach(() => { + delete process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED; + }); + + afterAll(() => { + if (originalEnv === undefined) { + delete process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED; + return; + } + + process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED = originalEnv; + }); + + it('returns the default value when the remote flag is missing', () => { + const result = selectLegacyIosGoogleConfigEnabled.resultFunc({}); + + expect(result).toBe(DEFAULT_LEGACY_IOS_GOOGLE_CONFIG_ENABLED); + }); + + it('returns the remote flag value when present', () => { + const result = selectLegacyIosGoogleConfigEnabled.resultFunc({ + [FeatureFlagNames.legacyIosGoogleConfigEnabled]: false, + }); + + expect(result).toBe(false); + }); + + it('allows the local env var to force enable the legacy config', () => { + process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED = 'true'; + + const result = selectLegacyIosGoogleConfigEnabled.resultFunc({ + [FeatureFlagNames.legacyIosGoogleConfigEnabled]: false, + }); + + expect(result).toBe(true); + }); + + it('allows the local env var to force disable the legacy config', () => { + process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED = 'false'; + + const result = selectLegacyIosGoogleConfigEnabled.resultFunc({ + [FeatureFlagNames.legacyIosGoogleConfigEnabled]: true, + }); + + expect(result).toBe(false); + }); +}); diff --git a/app/selectors/featureFlagController/legacyIosGoogleConfig/index.ts b/app/selectors/featureFlagController/legacyIosGoogleConfig/index.ts new file mode 100644 index 00000000000..be844dc3bd6 --- /dev/null +++ b/app/selectors/featureFlagController/legacyIosGoogleConfig/index.ts @@ -0,0 +1,26 @@ +import { hasProperty } from '@metamask/utils'; +import { createSelector } from 'reselect'; +import { FeatureFlagNames } from '../../../constants/featureFlags'; +import { getFeatureFlagValue } from '../env'; +import { selectRemoteFeatureFlags } from '..'; + +export const DEFAULT_LEGACY_IOS_GOOGLE_CONFIG_ENABLED = true; + +export const selectLegacyIosGoogleConfigEnabled = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + const remoteValue = hasProperty( + remoteFeatureFlags, + FeatureFlagNames.legacyIosGoogleConfigEnabled, + ) + ? Boolean( + remoteFeatureFlags[FeatureFlagNames.legacyIosGoogleConfigEnabled], + ) + : DEFAULT_LEGACY_IOS_GOOGLE_CONFIG_ENABLED; + return getFeatureFlagValue( + // Use direct env access so Babel can inline this value in app builds. + process.env.MM_LEGACY_IOS_GOOGLE_CONFIG_ENABLED, + remoteValue, + ); + }, +); diff --git a/babel.config.tests.js b/babel.config.tests.js index bf4dfe1bd1f..292e3151ff1 100644 --- a/babel.config.tests.js +++ b/babel.config.tests.js @@ -37,6 +37,8 @@ const newOverrides = [ 'app/components/UI/Ramp/hooks/useRampTokens.test.ts', 'app/components/Views/confirmations/hooks/pay/useTransactionPayWithdraw.ts', 'app/components/Views/confirmations/hooks/pay/useTransactionPayWithdraw.test.ts', + 'app/selectors/featureFlagController/legacyIosGoogleConfig/index.ts', + 'app/selectors/featureFlagController/legacyIosGoogleConfig/index.test.ts', 'app/util/environment.ts', 'app/util/environment.test.ts', 'app/core/Engine/controllers/rewards-controller/utils/rewards-api-url.ts', diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index bb05b3aa658..2c696a8020c 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -2829,6 +2829,14 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + legacyIosGoogleConfigEnabled: { + name: 'legacyIosGoogleConfigEnabled', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: true, + status: FeatureFlagStatus.Active, + }, + metalCardCheckoutEnabled: { name: 'metalCardCheckoutEnabled', type: FeatureFlagType.Remote, From b22241e6f6d22afcabb065641dce5b5c6e7a795a Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Tue, 24 Mar 2026 17:20:17 +0000 Subject: [PATCH 29/54] [skip ci] Bump version number to 4173 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index cb4881fdc9b..d91fbcd5d44 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4172 + versionCode 4173 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 8bf6cc69f9c..a82f6f06ee2 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4172 + VERSION_NUMBER: 4173 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4172 + FLASK_VERSION_NUMBER: 4173 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 7d0636490fc..6b9e475e392 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4172; + CURRENT_PROJECT_VERSION = 4173; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4172; + CURRENT_PROJECT_VERSION = 4173; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4172; + CURRENT_PROJECT_VERSION = 4173; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4172; + CURRENT_PROJECT_VERSION = 4173; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4172; + CURRENT_PROJECT_VERSION = 4173; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4172; + CURRENT_PROJECT_VERSION = 4173; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 3bfa916203555dedf92448b4d96ce017bdaf2e7c Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:19:52 +0100 Subject: [PATCH 30/54] chore(runway): cherry-pick fix(perps): validate TP/SL prices, clear stale config, and block invalid orders cp-7.71.0 (#27874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(perps): validate TP/SL prices, clear stale config, and block invalid orders cp-7.71.0 (#27791) ## **Description** Fixes three related bugs in the Perps order form involving Take Profit (TP) and Stop Loss (SL) prices that were restored from a previous session's pending trade configuration: 1. **Stale TP/SL persisted after order submission**: The `pendingTradeConfiguration` was not cleared after a successful order, causing previously set TP/SL values to reappear on the next order form visit — even auto-submitting a stop loss the user never intended. 2. **TP/SL displayed as "off" despite being set**: When the RoE calculation clamped to zero (e.g., the TP/SL price was on the wrong side of the current market price), the "Auto close" summary row showed "off" instead of the actual price. The TP/SL edit form, however, showed the correct value — a confusing inconsistency. 3. **No validation or blocking for invalid TP/SL direction**: A restored TP/SL price that ended up on the wrong side of the market (e.g., take profit below entry for a long) was silently accepted and could be submitted, leading to immediate execution or unexpected behavior. ### Changes - Call `clearPendingTradeConfiguration` after successful order execution to prevent stale TP/SL restoration. - Display the formatted price in the "Auto close" row when RoE rounds to zero, instead of showing "off". - Validate TP/SL prices against current market price and trade direction using existing `isValidTakeProfitPrice` / `isValidStopLossPrice` utilities. - Show inline error warnings when TP or SL is on the wrong side of the current price. - Disable the "Place order" button while TP/SL is invalid. ## **Changelog** CHANGELOG entry: Fixed a bug where stale Take Profit and Stop Loss prices could persist across orders and display incorrectly in the Perps order form ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/27793 ## **Manual testing steps** ```gherkin Feature: Perps order TP/SL validation Scenario: stale TP/SL is cleared after placing an order Given the user has a Perps position open with TP and SL set And the user navigates to the order form When the user places the order successfully And the user returns to the order form for the same asset Then the TP and SL fields should be empty (not restored from previous order) Scenario: TP/SL on the wrong side shows warning and blocks submission Given the user is on the Perps order form for a Long position And the user sets a Take Profit price below the current market price When the order form validates the TP price Then a warning is displayed: "Take profit must be above current price. Update or clear it to place the order." And the "Place order" button is disabled Scenario: TP/SL on the wrong side for Short position Given the user is on the Perps order form for a Short position And the user sets a Stop Loss price below the current market price When the order form validates the SL price Then a warning is displayed: "Stop loss must be above current price. Update or clear it to place the order." And the "Place order" button is disabled Scenario: TP/SL with zero RoE displays price instead of "off" Given the user is on the Perps order form with a TP or SL set And the TP/SL price results in an RoE that rounds to 0% When the "Auto close" summary row renders Then it displays the formatted price value instead of "off" ``` ## **Screenshots/Recordings** N/A — validation logic and text changes only; no layout or visual design changes. ### **Before** N/A ### **After** Simulator Screenshot - iPhone 17
Pro Max - 2026-03-23 at 11 46 16 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes the Perps order submission/validation path to block orders when TP/SL is on the wrong side and clears stored pending trade config after submission, which could affect order placement behavior. Risk is mitigated by added unit coverage for market vs limit reference price and button-disabled states. > > **Overview** > Prevents Perps orders from being submitted with **invalid TP/SL trigger prices** by validating TP/SL against the appropriate reference price (*current* for market orders, *entry/limit* for limit orders), showing inline warnings, and disabling the **Place order** button when TP/SL is wrong-side. > > Fixes TP/SL display inconsistencies by showing the formatted TP/SL *price* in the summary row when computed RoE clamps to `0%` instead of rendering `off`, and clears `PerpsController.clearPendingTradeConfiguration(asset)` after successful submission to avoid restoring stale TP/SL on subsequent visits. > > Adds new i18n strings for the wrong-side TP/SL warnings and expands `PerpsOrderView` tests to cover wrong-side validation, limit-order reference pricing, and the monochrome A/B button variant disablement. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 7c10dc8724422403fe1505f7b22ef7caff833648. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [ef3a973](https://github.com/MetaMask/metamask-mobile/commit/ef3a973c798e5132e860ec4063f54b0dc3fa3441) Co-authored-by: Michal Szorad --- .../PerpsOrderView/PerpsOrderView.test.tsx | 435 ++++++++++++++++++ .../Views/PerpsOrderView/PerpsOrderView.tsx | 78 +++- locales/languages/en.json | 2 + 3 files changed, 513 insertions(+), 2 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index 81b8e3a67c1..87536d54e85 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -113,6 +113,10 @@ jest.mock('../../../../../../locales/i18n', () => ({ 'Size must be a positive number', 'perps.tpsl.stop_loss_order_view_warning': 'Stop loss is {{direction}} liquidation price', + 'perps.tpsl.take_profit_wrong_side_warning': + 'Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.', + 'perps.tpsl.stop_loss_wrong_side_warning': + 'Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.', 'perps.tpsl.below': 'below', 'perps.tpsl.above': 'above', 'perps.points': 'Points', @@ -535,6 +539,16 @@ jest.mock('../../../../../core/Engine', () => ({ }, })); +// Mock usePerpsABTest hook (controllable per-test) +const mockUsePerpsABTest = jest.fn(() => ({ + variantName: 'control', + variant: { long: 'green', short: 'red' }, + isEnabled: false, +})); +jest.mock('../../utils/abTesting/usePerpsABTest', () => ({ + usePerpsABTest: () => mockUsePerpsABTest(), +})); + // Mock useTooltipModal hook jest.mock('../../../../hooks/useTooltipModal', () => ({ __esModule: true, @@ -1970,6 +1984,427 @@ describe('PerpsOrderView', () => { }); }); + describe('TP/SL wrong-side price validation', () => { + const orderContextWithTPSL = (overrides: { + direction: 'long' | 'short'; + takeProfitPrice?: string; + stopLossPrice?: string; + }) => ({ + orderForm: { + asset: 'ETH', + amount: '100', + leverage: 10, + direction: overrides.direction, + type: 'market' as const, + limitPrice: undefined, + takeProfitPrice: overrides.takeProfitPrice, + stopLossPrice: overrides.stopLossPrice, + balancePercent: 10, + }, + setAmount: jest.fn(), + setLeverage: jest.fn(), + setTakeProfitPrice: jest.fn(), + setStopLossPrice: jest.fn(), + setLimitPrice: jest.fn(), + setOrderType: jest.fn(), + handlePercentageAmount: jest.fn(), + handleMaxAmount: jest.fn(), + handleMinAmount: jest.fn(), + optimizeOrderAmount: jest.fn(), + maxPossibleAmount: 1000, + balanceForValidation: 1000, + calculations: { + marginRequired: '10', + positionSize: '0.033', + }, + }); + + it('shows warning and disables button for long position with TP below current price', async () => { + // Arrange: TP at 2000 is below current price 3000 → invalid for long + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ direction: 'long', takeProfitPrice: '2000' }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: warning visible + await waitFor(() => { + expect( + screen.getByText( + 'Take profit must be above current price. Update or clear it to place the order.', + ), + ).toBeDefined(); + }); + + // Assert: button disabled + const placeOrderButton = await screen.findByTestId( + PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON, + ); + expect(placeOrderButton.props.accessibilityState?.disabled).toBeTruthy(); + }); + + it('shows warning and disables button for long position with SL above current price', async () => { + // Arrange: SL at 3500 is above current price 3000 → invalid for long + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ direction: 'long', stopLossPrice: '3500' }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: warning visible + await waitFor(() => { + expect( + screen.getByText( + 'Stop loss must be below current price. Update or clear it to place the order.', + ), + ).toBeDefined(); + }); + + // Assert: button disabled + const placeOrderButton = await screen.findByTestId( + PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON, + ); + expect(placeOrderButton.props.accessibilityState?.disabled).toBeTruthy(); + }); + + it('shows warning for short position with TP above current price', async () => { + // Arrange: TP at 3500 is above current price 3000 → invalid for short + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ direction: 'short', takeProfitPrice: '3500' }), + ); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: warning uses "below" for short + await waitFor(() => { + expect( + screen.getByText( + 'Take profit must be below current price. Update or clear it to place the order.', + ), + ).toBeDefined(); + }); + }); + + it('shows warning for short position with SL below current price', async () => { + // Arrange: SL at 2000 is below current price 3000 → invalid for short + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ direction: 'short', stopLossPrice: '2000' }), + ); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: warning uses "above" for short + await waitFor(() => { + expect( + screen.getByText( + 'Stop loss must be above current price. Update or clear it to place the order.', + ), + ).toBeDefined(); + }); + }); + + it('does not show wrong-side warnings when TP/SL prices are valid', async () => { + // Arrange: valid TP above and SL below current price for long + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ + direction: 'long', + takeProfitPrice: '3500', + stopLossPrice: '2500', + }), + ); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: component renders (TP/SL summary with valid percentages) + await waitFor(() => { + expect(screen.getByText('Leverage')).toBeDefined(); + }); + + // Assert: no wrong-side warnings + expect(screen.queryByText(/Take profit must be/)).toBeNull(); + expect(screen.queryByText(/Stop loss must be.*current price/)).toBeNull(); + }); + + it('disables button when both TP and SL are on wrong side', async () => { + // Arrange: both invalid for long (TP below, SL above current price) + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ + direction: 'long', + takeProfitPrice: '2000', + stopLossPrice: '3500', + }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: both warnings shown + await waitFor(() => { + expect(screen.getByText(/Take profit must be above/)).toBeDefined(); + expect(screen.getByText(/Stop loss must be below/)).toBeDefined(); + }); + + // Assert: button disabled + const placeOrderButton = await screen.findByTestId( + PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON, + ); + expect(placeOrderButton.props.accessibilityState?.disabled).toBeTruthy(); + }); + + it('disables monochrome button variant when TP/SL is invalid', async () => { + // Arrange: monochrome A/B test variant + invalid TP + mockUsePerpsABTest.mockReturnValue({ + variantName: 'monochrome', + variant: { long: 'white', short: 'white' }, + isEnabled: true, + }); + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextWithTPSL({ direction: 'long', takeProfitPrice: '2000' }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + // Act + render(, { wrapper: TestWrapper }); + + // Assert: warning visible (proves hasInvalidTPSL is true in monochrome path) + await waitFor(() => { + expect(screen.getByText(/Take profit must be above/)).toBeDefined(); + }); + + // Assert: monochrome button rendered and receives isDisabled prop + const placeOrderButton = await screen.findByTestId( + PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON, + ); + expect(placeOrderButton).toBeDefined(); + }); + + describe('limit order TP/SL validates against entry price, not market price', () => { + const orderContextForLimitOrder = (overrides: { + direction: 'long' | 'short'; + limitPrice: string; + takeProfitPrice?: string; + stopLossPrice?: string; + }) => ({ + orderForm: { + asset: 'ETH', + amount: '100', + leverage: 10, + direction: overrides.direction, + type: 'limit' as const, + limitPrice: overrides.limitPrice, + takeProfitPrice: overrides.takeProfitPrice, + stopLossPrice: overrides.stopLossPrice, + balancePercent: 10, + }, + setAmount: jest.fn(), + setLeverage: jest.fn(), + setTakeProfitPrice: jest.fn(), + setStopLossPrice: jest.fn(), + setLimitPrice: jest.fn(), + setOrderType: jest.fn(), + handlePercentageAmount: jest.fn(), + handleMaxAmount: jest.fn(), + handleMinAmount: jest.fn(), + optimizeOrderAmount: jest.fn(), + maxPossibleAmount: 1000, + balanceForValidation: 1000, + calculations: { + marginRequired: '10', + positionSize: '0.04', + }, + }); + + it('accepts TP above limit price for long limit order even when TP is below market price', async () => { + // Scenario: market at $3000, long limit buy at $2500, TP at $2700 + // TP $2700 is valid relative to $2500 entry but below $3000 market price + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextForLimitOrder({ + direction: 'long', + limitPrice: '2500', + takeProfitPrice: '2700', + }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + render(, { wrapper: TestWrapper }); + + await waitFor(() => { + expect(screen.getByText('Leverage')).toBeDefined(); + }); + + // TP at $2700 is above the $2500 limit (entry) price, so no warning should appear + expect(screen.queryByText(/Take profit must be/)).toBeNull(); + }); + + it('accepts SL below limit price for long limit order even when SL is below market price', async () => { + // Scenario: market at $3000, long limit buy at $2500, SL at $2300 + // SL $2300 is valid relative to $2500 entry + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextForLimitOrder({ + direction: 'long', + limitPrice: '2500', + stopLossPrice: '2300', + }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + render(, { wrapper: TestWrapper }); + + await waitFor(() => { + expect(screen.getByText('Leverage')).toBeDefined(); + }); + + // SL at $2300 is below the $2500 limit (entry) price, so no warning should appear + expect(screen.queryByText(/Stop loss must be/)).toBeNull(); + }); + + it('rejects TP below limit price for long limit order', async () => { + // Scenario: market at $3000, long limit buy at $2500, TP at $2400 + // TP $2400 is below the $2500 entry → invalid + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextForLimitOrder({ + direction: 'long', + limitPrice: '2500', + takeProfitPrice: '2400', + }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + render(, { wrapper: TestWrapper }); + + await waitFor(() => { + expect( + screen.getByText(/Take profit must be above entry price/), + ).toBeDefined(); + }); + }); + + it('accepts TP below limit price for short limit order even when TP is above market price', async () => { + // Scenario: market at $3000, short limit sell at $3500, TP at $3200 + // TP $3200 is below $3500 entry (valid for short) but above $3000 market + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextForLimitOrder({ + direction: 'short', + limitPrice: '3500', + takeProfitPrice: '3200', + }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + render(, { wrapper: TestWrapper }); + + await waitFor(() => { + expect(screen.getByText('Leverage')).toBeDefined(); + }); + + // TP at $3200 is below $3500 limit entry, valid for short + expect(screen.queryByText(/Take profit must be/)).toBeNull(); + }); + + it('accepts SL above limit price for short limit order', async () => { + // Scenario: market at $3000, short limit sell at $3500, SL at $3700 + // SL $3700 is above $3500 entry (valid for short) + (usePerpsOrderContext as jest.Mock).mockReturnValue( + orderContextForLimitOrder({ + direction: 'short', + limitPrice: '3500', + stopLossPrice: '3700', + }), + ); + (usePerpsOrderValidation as jest.Mock).mockReturnValue({ + isValid: true, + errors: [], + isValidating: false, + }); + (usePerpsOrderExecution as jest.Mock).mockReturnValue({ + placeOrder: jest.fn(), + isPlacing: false, + }); + + render(, { wrapper: TestWrapper }); + + await waitFor(() => { + expect(screen.getByText('Leverage')).toBeDefined(); + }); + + // SL at $3700 is above $3500 limit entry, valid for short + expect(screen.queryByText(/Stop loss must be/)).toBeNull(); + }); + }); + }); + describe('TP/SL limit price validation', () => { it('shows toast and prevents TP/SL bottom sheet from opening on limit order without limit price', async () => { // Clear all mocks to ensure clean state diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index a57a9f81cd8..94830b2df9d 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -47,6 +47,7 @@ import Text, { } from '../../../../../component-library/components/Texts/Text'; import useTooltipModal from '../../../../../components/hooks/useTooltipModal'; import Routes from '../../../../../constants/navigation/Routes'; +import Engine from '../../../../../core/Engine'; import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; import { useTheme } from '../../../../../util/theme'; import { TraceName } from '../../../../../util/trace'; @@ -139,6 +140,8 @@ import { willFlipPosition } from '../../utils/orderUtils'; import { calculateRoEForPrice, isStopLossSafeFromLiquidation, + isValidStopLossPrice, + isValidTakeProfitPrice, } from '../../utils/tpslValidation'; import createStyles from './PerpsOrderView.styles'; import { PerpsPayRow } from './PerpsPayRow'; @@ -672,7 +675,11 @@ const PerpsOrderViewContentBase: React.FC = ({ ); const absRoE = Math.abs(parseFloat(tpRoE || '0')); tpDisplay = - absRoE > 0 ? `${absRoE.toFixed(0)}%` : strings('perps.order.off'); + absRoE > 0 + ? `${absRoE.toFixed(0)}%` + : formatPerpsFiat(orderForm.takeProfitPrice, { + ranges: PRICE_RANGES_UNIVERSAL, + }); } if (orderForm.stopLossPrice && price > 0 && orderForm.leverage) { @@ -689,7 +696,11 @@ const PerpsOrderViewContentBase: React.FC = ({ ); const absRoE = Math.abs(parseFloat(slRoE || '0')); slDisplay = - absRoE > 0 ? `${absRoE.toFixed(0)}%` : strings('perps.order.off'); + absRoE > 0 + ? `${absRoE.toFixed(0)}%` + : formatPerpsFiat(orderForm.stopLossPrice, { + ranges: PRICE_RANGES_UNIVERSAL, + }); } return `${strings('perps.order.tp')} ${tpDisplay}, ${strings( @@ -1079,6 +1090,12 @@ const PerpsOrderViewContentBase: React.FC = ({ } else { await executeOrder(orderParams); } + + // Clear pending trade config after successful submission to prevent + // stale TP/SL values from being restored on the next order form visit + Engine.context.PerpsController?.clearPendingTradeConfiguration( + orderForm.asset, + ); } finally { // Always reset submission flag isSubmittingRef.current = false; @@ -1204,6 +1221,35 @@ const PerpsOrderViewContentBase: React.FC = ({ ), ); + const isLimitWithPrice = + orderForm.type === 'limit' && Boolean(orderForm.limitPrice); + + const validationReferencePrice = isLimitWithPrice + ? parseFloat(String(orderForm.limitPrice)) + : assetData.price; + + const tpslPriceType = isLimitWithPrice ? 'entry' : 'current'; + + const isTakeProfitPriceInvalid = Boolean( + orderForm.takeProfitPrice?.trim() && + validationReferencePrice > 0 && + !isValidTakeProfitPrice(orderForm.takeProfitPrice, { + currentPrice: validationReferencePrice, + direction: orderForm.direction, + }), + ); + + const isStopLossPriceInvalid = Boolean( + orderForm.stopLossPrice?.trim() && + validationReferencePrice > 0 && + !isValidStopLossPrice(orderForm.stopLossPrice, { + currentPrice: validationReferencePrice, + direction: orderForm.direction, + }), + ); + + const hasInvalidTPSL = isTakeProfitPriceInvalid || isStopLossPriceInvalid; + let rewardAnimationState = RewardAnimationState.Idle; if (rewardsState.isLoading) { rewardAnimationState = RewardAnimationState.Loading; @@ -1421,6 +1467,32 @@ const PerpsOrderViewContentBase: React.FC = ({ )} + {!hideTPSL && isTakeProfitPriceInvalid && ( + + + {strings('perps.tpsl.take_profit_wrong_side_warning', { + direction: + orderForm.direction === 'long' + ? strings('perps.tpsl.above') + : strings('perps.tpsl.below'), + priceType: tpslPriceType, + })} + + + )} + {!hideTPSL && isStopLossPriceInvalid && ( + + + {strings('perps.tpsl.stop_loss_wrong_side_warning', { + direction: + orderForm.direction === 'long' + ? strings('perps.tpsl.below') + : strings('perps.tpsl.above'), + priceType: tpslPriceType, + })} + + + )} )} @@ -1652,6 +1724,7 @@ const PerpsOrderViewContentBase: React.FC = ({ !orderValidation.isValid || isPlacingOrder || doesStopLossRiskLiquidation || + hasInvalidTPSL || isAtOICap || shouldBlockBecauseOfFeesLoading } @@ -1672,6 +1745,7 @@ const PerpsOrderViewContentBase: React.FC = ({ !orderValidation.isValid || isPlacingOrder || doesStopLossRiskLiquidation || + hasInvalidTPSL || isAtOICap || shouldBlockBecauseOfFeesLoading } diff --git a/locales/languages/en.json b/locales/languages/en.json index 830f8db5ce7..a30519785e2 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1471,6 +1471,8 @@ "stop_loss_invalid_price": "Stop loss must be {{direction}} {{priceType}} price", "stop_loss_beyond_liquidation_error": "Stop loss must be {{direction}} liquidation price", "stop_loss_order_view_warning": "Stop loss is {{direction}} liquidation price", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "above", "below": "below", "done": "Done", From 39889c8a4f7bb16406ef30d03a17603542909ee4 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Wed, 25 Mar 2026 08:21:31 +0000 Subject: [PATCH 31/54] [skip ci] Bump version number to 4179 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index d91fbcd5d44..afaaa7fdfc9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4173 + versionCode 4179 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index a82f6f06ee2..0b2537e8a05 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4173 + VERSION_NUMBER: 4179 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4173 + FLASK_VERSION_NUMBER: 4179 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 6b9e475e392..8d4ef29b463 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4173; + CURRENT_PROJECT_VERSION = 4179; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4173; + CURRENT_PROJECT_VERSION = 4179; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4173; + CURRENT_PROJECT_VERSION = 4179; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4173; + CURRENT_PROJECT_VERSION = 4179; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4173; + CURRENT_PROJECT_VERSION = 4179; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4173; + CURRENT_PROJECT_VERSION = 4179; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 0e4683f2726eaac590a2fd0a1e835ed71069220b Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:06:45 +0100 Subject: [PATCH 32/54] chore(runway): cherry-pick fix: disable Branch test instance and debug mode in branch.json cp-7.71.0 (#27889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: disable Branch test instance and debug mode in branch.json cp-7.71.0 (#27879) ## **Description** `branch.json` had `useTestInstance: true` and `debugMode: true`, which forced **all builds** — including production RC — to initialize the Branch SDK against the **test environment**. Branch short links (e.g. `metamask.app.link/1WkF6GmE40b`) are created in the **live** Branch dashboard, so the test-instance SDK could never resolve them: the test and live environments are separate databases. This was the root cause of the "This page doesn't exist" error when opening Branch deepview short links (e.g. from Twitter/X). The SDK returned `+clicked_branch_link: false` and `+non_branch_link` with the raw URL because the test environment had no record of live links. The fix sets both values to `false` so the SDK uses the live key from the native configuration (`Info.plist` / `AndroidManifest.xml`), matching the environment where links are actually created. **Note:** The same `branch.json` content is also present at `android/app/src/main/assets/branch.json` — both platforms are affected. This PR fixes the root config; the Android copy should also be verified/updated. ## **Changelog** CHANGELOG entry: Fixed Branch.io deep links not resolving by switching SDK from test to live environment ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Branch short link deep linking Background: Given I have a production RC build installed And the app is using the live Branch key Scenario: user opens a Branch short link from cold start Given the app is not running When user taps a Branch deepview link (e.g. https://metamask.app.link/1WkF6GmE40b) Then the app should open And the user should be navigated to the intended destination (e.g. Trending page) And the "This page doesn't exist" modal should NOT appear Scenario: user opens a Branch short link from warm start (app backgrounded) Given the app is running in the background When user taps a Branch deepview link Then the app should come to foreground And the user should be navigated to the intended destination Scenario: user opens a direct universal link (non-Branch) Given the app is installed When user taps https://link.metamask.io/trending Then the Trending page should open normally And behavior should be unchanged from before this PR ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [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] > **Low Risk** > Low risk config-only change, but it affects deep link behavior across builds by switching Branch initialization away from the test environment. > > **Overview** > Disables `debugMode` and `useTestInstance` in `branch.json`, ensuring the Branch SDK initializes against the **live** environment rather than the test instance. > > This should restore proper resolution of production Branch short links/deepviews that previously failed when the app was forced to use the test Branch database. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 33fcef144d583a1c9368a13cf57d8e03d887dd98. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [75bef55](https://github.com/MetaMask/metamask-mobile/commit/75bef55fcaa5061fcc0178d2348a48babd483914) Co-authored-by: tommasini <46944231+tommasini@users.noreply.github.com> --- branch.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/branch.json b/branch.json index d64e8345b90..ecceb6254d5 100644 --- a/branch.json +++ b/branch.json @@ -1,6 +1,6 @@ { - "debugMode": true, - "useTestInstance": true, + "debugMode": false, + "useTestInstance": false, "delayInitToCheckForSearchAds": false, "appleSearchAdsDebugMode": false } From 6c47666bbb67ac1d606a8e9ce11cca2251df86ec Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Wed, 25 Mar 2026 09:08:27 +0000 Subject: [PATCH 33/54] [skip ci] Bump version number to 4181 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index afaaa7fdfc9..7dd8cbe46af 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4179 + versionCode 4181 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 0b2537e8a05..6c8ae8f3fc6 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4179 + VERSION_NUMBER: 4181 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4179 + FLASK_VERSION_NUMBER: 4181 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 8d4ef29b463..6ad772a2681 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4179; + CURRENT_PROJECT_VERSION = 4181; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4179; + CURRENT_PROJECT_VERSION = 4181; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4179; + CURRENT_PROJECT_VERSION = 4181; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4179; + CURRENT_PROJECT_VERSION = 4181; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4179; + CURRENT_PROJECT_VERSION = 4181; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4179; + CURRENT_PROJECT_VERSION = 4181; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 74b93996fdb49881d87a4502c88be05b1e4d9576 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:25:35 +0100 Subject: [PATCH 34/54] chore(runway): cherry-pick fix: hardware wallet eip 7702 issue () (#27892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: hardware wallet eip 7702 issue (cp-7.71.0) (#27615) ## **Description** This PR will provide a fix for hardware wallet to gas free network like Monad and Sei. Due to currently Hardware wallet is not supported for EIP 7702 gas sponsorship, and Swap feature is not working for hardware wallet user. This fix will fall back the Gasless transaction to User pay gas previous model so that user can still do the swap and sign transaction like bfore. This is temporately fix for current version of extensions, and we will do a proper support in the future. Similar to extension PR: https://github.com/MetaMask/metamask-extension/pull/40915 Ticket: https://consensyssoftware.atlassian.net/jira/software/c/projects/NEB/boards/3738/backlog?selectedIssue=NEB-767 ## **Changelog** CHANGELOG entry: Hardware wallet user will fall back to use `User pay gas` for those Gasless network due to hardware wallet not supported in Gasless network like Sei and Monad. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Gas sponsorship disabled for hardware wallet accounts Scenario: Hardware wallet user does not use gas sponsorship on sponsored network Given the user has added a hardware wallet account (Ledger or QR-based) And the hardware wallet account is selected as the active account And the user has added a gas-sponsored network (e.g. Monad) When the user attempts to perform a swap a dapp interaction or send a transaction on the sponsored network Then the transaction should not use gas sponsorship And the UI should not display any gas sponsorship labels (e.g. "No network fee", "Paid by MetaMask") And the user should see the normal network gas fee And the transaction should follow the standard user-pays-gas flow ``` ## **Screenshots/Recordings** ### **Before** > With HW account: Network list: Screenshot 2026-03-18 at 16 05 48 Tx flow: Screenshot 2026-03-18 at 15 53 04 Screenshot 2026-03-18 at 15 55 20 Screenshot 2026-03-18 at 15 55 47 ### **After** > With HW account: Network list: Screenshot 2026-03-18 at 16 06 19 Tx flow: Screenshot 2026-03-18 at 15 49 55 Screenshot 2026-03-18 at 15 50 29 ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Touches gasless sponsorship and transaction publishing paths (including 7702 delegation), which can affect whether transactions are sponsored vs user-paid and could change behavior on supported chains. Changes are scoped to hardware-wallet detection gates with added tests, reducing regression risk. > > **Overview** > Hardware wallet accounts now **opt out of gasless / EIP-7702 sponsorship**, forcing swaps/bridge and confirmations to use the normal *user-pays-gas* path. > > This adds an `accountSupports7702` gate to `TransactionControllerInit` so `Delegation7702PublishHook` and `isEIP7702GasFeeTokensEnabled` only activate for keyrings that support 7702, and updates `useIsGaslessSupported`/`useIsGasIncluded7702Supported` (via new `useIsHardwareWalletForBridge`) to report unsupported for hardware signers. > > Network selection UI (`NetworkSelector`, `NetworkMultiSelectorList`, `CustomNetwork`) now hides the “No network fee” sponsored label for hardware wallets, and a patched `@metamask/bridge-status-controller` waits for approval tx confirmation when required. Tests were added/updated to cover the new hardware-wallet gating behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 83f66fc1de28d1f8f4be8a455844a9bbf39fba4c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: metamaskbot Co-authored-by: Julien Fontanel Co-authored-by: Frederic HENG Co-authored-by: Arafet (CN - Hong Kong) <52028926+arafetbenmakhlouf@users.noreply.github.com> Co-authored-by: tommasini <46944231+tommasini@users.noreply.github.com> [c4b93de](https://github.com/MetaMask/metamask-mobile/commit/c4b93deca90a841b826848947600d8e4a8f4867f) --------- Co-authored-by: khanti42 Co-authored-by: metamaskbot Co-authored-by: Julien Fontanel Co-authored-by: Frederic HENG Co-authored-by: Arafet (CN - Hong Kong) <52028926+arafetbenmakhlouf@users.noreply.github.com> Co-authored-by: tommasini <46944231+tommasini@users.noreply.github.com> --- ...tus-controller-npm-68.1.0-8a2c809398.patch | 38 ++++++ ...tus-controller-npm-69.0.0-ec19aeeecf.patch | 38 ++++++ .../useBridgeQuoteRequest.test.ts | 44 +++++++ .../useIsGasIncluded7702Supported/index.ts | 8 +- .../useIsGasIncluded7702Supported.test.ts | 37 ++++++ .../index.test.ts | 56 +++++++++ .../useIsHardwareWalletForBridge/index.ts | 18 +++ app/components/UI/Bridge/utils/transaction.ts | 4 +- .../NetworkMultiSelectorList.test.tsx | 21 ++++ .../NetworkMultiSelectorList.tsx | 26 +++- .../Views/NetworkSelector/NetworkSelector.tsx | 15 ++- .../CustomNetworkView/CustomNetwork.tsx | 11 +- .../gas-fee-details-row.tsx | 3 +- .../hooks/gas/useIsGaslessSupported.ts | 10 +- .../transaction-controller-init.test.ts | 21 ++++ .../transaction-controller-init.ts | 23 +++- .../account-supports-7702.test.ts | 115 ++++++++++++++++++ .../transactions/account-supports-7702.ts | 51 ++++++++ package.json | 4 +- yarn.lock | 52 +++++++- 20 files changed, 575 insertions(+), 20 deletions(-) create mode 100644 .yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch create mode 100644 .yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch create mode 100644 app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.test.ts create mode 100644 app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.ts create mode 100644 app/util/transactions/account-supports-7702.test.ts create mode 100644 app/util/transactions/account-supports-7702.ts diff --git a/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch b/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch new file mode 100644 index 00000000000..d841b6a6b85 --- /dev/null +++ b/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch @@ -0,0 +1,38 @@ +diff --git a/dist/bridge-status-controller.cjs b/dist/bridge-status-controller.cjs +index b787174f2c8c448ed1ad9c8884204c5c8b6858be..af058623871badb4891564c003693ea19d0aa676 100644 +--- a/dist/bridge-status-controller.cjs ++++ b/dist/bridge-status-controller.cjs +@@ -834,7 +834,13 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll + ? quoteResponse.approval + : undefined, quoteResponse.resetApproval, requireApproval); + approvalTxId = approvalTxMeta?.id; +- await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ if (requireApproval && approvalTxMeta) { ++ await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ await __classPrivateFieldGet(this, _BridgeStatusController_waitForTxConfirmation, "f").call(this, approvalTxMeta.id); ++ } ++ else { ++ await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ } + // Generate actionId for pre-submission history (non-batch EVM only) + const actionId = (0, transaction_1.generateActionId)().toString(); + // Add pre-submission history keyed by actionId +diff --git a/dist/bridge-status-controller.mjs b/dist/bridge-status-controller.mjs +index 2fe71bdd2caf4d62f7946e9466b31367d360cd7c..fb9c0bc45abf88873452667b85ef2ea0cdfd929c 100644 +--- a/dist/bridge-status-controller.mjs ++++ b/dist/bridge-status-controller.mjs +@@ -831,7 +831,13 @@ export class BridgeStatusController extends StaticIntervalPollingController() { + ? quoteResponse.approval + : undefined, quoteResponse.resetApproval, requireApproval); + approvalTxId = approvalTxMeta?.id; +- await handleMobileHardwareWalletDelay(requireApproval); ++ if (requireApproval && approvalTxMeta) { ++ await handleMobileHardwareWalletDelay(requireApproval); ++ await __classPrivateFieldGet(this, _BridgeStatusController_waitForTxConfirmation, "f").call(this, approvalTxMeta.id); ++ } ++ else { ++ await handleMobileHardwareWalletDelay(requireApproval); ++ } + // Generate actionId for pre-submission history (non-batch EVM only) + const actionId = generateActionId().toString(); + // Add pre-submission history keyed by actionId diff --git a/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch b/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch new file mode 100644 index 00000000000..d308173b6b2 --- /dev/null +++ b/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch @@ -0,0 +1,38 @@ +diff --git a/dist/bridge-status-controller.cjs b/dist/bridge-status-controller.cjs +index ec19aeeecfa32a3cdf955ccc1152829ee4ddfd8f..d9b427f9f0f4b05238d79c731fc81566634a7c25 100644 +--- a/dist/bridge-status-controller.cjs ++++ b/dist/bridge-status-controller.cjs +@@ -855,7 +855,13 @@ + ? quoteResponse.approval + : undefined, quoteResponse.resetApproval, requireApproval); + approvalTxId = approvalTxMeta?.id; +- await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ if (requireApproval && approvalTxMeta) { ++ await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ await __classPrivateFieldGet(this, _BridgeStatusController_waitForTxConfirmation, "f").call(this, approvalTxMeta.id); ++ } ++ else { ++ await (0, transaction_1.handleMobileHardwareWalletDelay)(requireApproval); ++ } + // Generate actionId for pre-submission history (non-batch EVM only) + const actionId = (0, transaction_1.generateActionId)().toString(); + // Add pre-submission history keyed by actionId +diff --git a/dist/bridge-status-controller.mjs b/dist/bridge-status-controller.mjs +index a5661d63c35b5ad3526c1804936dc0e189c90c29..86efc019968599662466e643dae7002ebf5f5014 100644 +--- a/dist/bridge-status-controller.mjs ++++ b/dist/bridge-status-controller.mjs +@@ -852,7 +852,13 @@ + ? quoteResponse.approval + : undefined, quoteResponse.resetApproval, requireApproval); + approvalTxId = approvalTxMeta?.id; +- await handleMobileHardwareWalletDelay(requireApproval); ++ if (requireApproval && approvalTxMeta) { ++ await handleMobileHardwareWalletDelay(requireApproval); ++ await __classPrivateFieldGet(this, _BridgeStatusController_waitForTxConfirmation, "f").call(this, approvalTxMeta.id); ++ } ++ else { ++ await handleMobileHardwareWalletDelay(requireApproval); ++ } + // Generate actionId for pre-submission history (non-batch EVM only) + const actionId = generateActionId().toString(); + // Add pre-submission history keyed by actionId diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts index ecf661c8351..eb9c0b82cfd 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteRequest/useBridgeQuoteRequest.test.ts @@ -484,6 +484,50 @@ describe('useBridgeQuoteRequest', () => { }); }); + describe('hardware wallet accounts', () => { + it('sends gasIncluded and gasIncluded7702 false when useIsGasIncluded7702Supported dispatches false for hardware wallet', async () => { + // useIsGasIncluded7702Supported now incorporates the HW wallet check and + // dispatches isGasIncluded7702Supported=false for hardware wallets. + // useIsGasIncludedSTXSendBundleSupported already dispatches false for HW + // wallets via selectShouldUseSmartTransaction. + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + isGasIncludedSTXSendBundleSupported: false, + isGasIncluded7702Supported: false, + sourceToken: { + address: '0xSourceToken', + chainId: '0x1', + decimals: 18, + symbol: 'SRC', + }, + destToken: { + address: '0xDestToken', + chainId: '0x1', + decimals: 18, + symbol: 'DEST', + }, + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteRequest(), { + state: testState, + }); + + await act(async () => { + await result.current(); + jest.advanceTimersByTime(DEBOUNCE_WAIT); + }); + + expect(spyUpdateBridgeQuoteRequestParams).toHaveBeenCalledWith( + expect.objectContaining({ + gasIncluded: false, + gasIncluded7702: false, + }), + undefined, + ); + }); + }); + describe('insufficientBal parameter', () => { it('includes insufficientBal false when balance is sufficient', async () => { mockUseIsInsufficientBalance.mockReturnValue(false); diff --git a/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/index.ts b/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/index.ts index bafe1d3eac6..c3488f1cea1 100644 --- a/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/index.ts +++ b/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/index.ts @@ -8,13 +8,15 @@ import { formatChainIdToHex, isNonEvmChainId, } from '@metamask/bridge-controller'; +import { useIsHardwareWalletForBridge } from '../useIsHardwareWalletForBridge'; /** * Hook that determines if 7702 gasless support is available for bridge/swap. * Should be used at the page level (e.g., BridgeView) to avoid repeated calculations. * - * Requirement for 7702: + * Requirements for 7702: * - Relay must be supported (for 7702 delegation) + * - Source wallet must not be a hardware wallet * * @param chainId - The chain ID to check (can be Hex, CAIP, or other format) - only EVM chains are supported */ @@ -40,9 +42,11 @@ export const useIsGasIncluded7702Supported = ( return isRelaySupported(evmChainId as Hex); }, [evmChainId]); + const isHardwareWallet = useIsHardwareWalletForBridge(); + // 7702 is available when ALL conditions are met const isGasIncluded7702Supported = Boolean( - evmChainId && !!isRelaySupportedForChain, + evmChainId && !!isRelaySupportedForChain && !isHardwareWallet, ); useEffect(() => { diff --git a/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/useIsGasIncluded7702Supported.test.ts b/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/useIsGasIncluded7702Supported.test.ts index 7c31e0e6dd7..1a3d7c3a8de 100644 --- a/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/useIsGasIncluded7702Supported.test.ts +++ b/app/components/UI/Bridge/hooks/useIsGasIncluded7702Supported/useIsGasIncluded7702Supported.test.ts @@ -7,8 +7,18 @@ import configureStore from '../../../../../util/test/configureStore'; // Mock dependencies jest.mock('../../../../../util/transactions/transaction-relay'); +jest.mock('../useIsHardwareWalletForBridge', () => ({ + useIsHardwareWalletForBridge: jest.fn().mockReturnValue(false), +})); const mockIsRelaySupported = jest.mocked(isRelaySupported); +const { useIsHardwareWalletForBridge } = jest.requireMock( + '../useIsHardwareWalletForBridge', +); +const mockUseIsHardwareWalletForBridge = + useIsHardwareWalletForBridge as jest.MockedFunction< + typeof useIsHardwareWalletForBridge + >; describe('useIsGasIncluded7702Supported', () => { const MAINNET_CHAIN_ID = '0x1' as Hex; @@ -28,6 +38,7 @@ describe('useIsGasIncluded7702Supported', () => { beforeEach(() => { jest.clearAllMocks(); mockIsRelaySupported.mockResolvedValue(false); + mockUseIsHardwareWalletForBridge.mockReturnValue(false); }); afterEach(() => { @@ -147,6 +158,32 @@ describe('useIsGasIncluded7702Supported', () => { }); }); + describe('when source wallet is a hardware account', () => { + it('updates isGasIncluded7702Supported to false even when relay is supported', async () => { + mockIsRelaySupported.mockResolvedValue(true); + mockUseIsHardwareWalletForBridge.mockReturnValue(true); + + const { store } = renderHookWithProvider( + () => useIsGasIncluded7702Supported(MAINNET_CHAIN_ID), + { state: {} }, + ); + + await expectGasIncluded7702State(store, false); + }); + + it('updates isGasIncluded7702Supported to false for hardware wallet regardless of chain', async () => { + mockIsRelaySupported.mockResolvedValue(true); + mockUseIsHardwareWalletForBridge.mockReturnValue(true); + + const { store } = renderHookWithProvider( + () => useIsGasIncluded7702Supported('eip155:59144'), // Linea + { state: {} }, + ); + + await expectGasIncluded7702State(store, false); + }); + }); + describe('edge cases', () => { it('handles case-insensitive chainId matching', async () => { mockIsRelaySupported.mockResolvedValue(true); diff --git a/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.test.ts b/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.test.ts new file mode 100644 index 00000000000..f1082ff5e73 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.test.ts @@ -0,0 +1,56 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import { useIsHardwareWalletForBridge } from './index'; +import { isHardwareAccount } from '../../../../../util/address'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../../util/address', () => ({ + isHardwareAccount: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockIsHardwareAccount = isHardwareAccount as jest.MockedFunction< + typeof isHardwareAccount +>; + +describe('useIsHardwareWalletForBridge', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockReturnValue(undefined); + mockIsHardwareAccount.mockReturnValue(false); + }); + + it('returns false when source wallet address is undefined', () => { + mockUseSelector.mockReturnValue(undefined); + + const { result } = renderHook(() => useIsHardwareWalletForBridge()); + + expect(result.current).toBe(false); + expect(mockIsHardwareAccount).not.toHaveBeenCalled(); + }); + + it('returns true when source wallet is a hardware account', () => { + const address = '0x1234567890123456789012345678901234567890'; + mockUseSelector.mockReturnValue(address); + mockIsHardwareAccount.mockReturnValue(true); + + const { result } = renderHook(() => useIsHardwareWalletForBridge()); + + expect(result.current).toBe(true); + expect(mockIsHardwareAccount).toHaveBeenCalledWith(address); + }); + + it('returns false when source wallet is not a hardware account', () => { + const address = '0x1234567890123456789012345678901234567890'; + mockUseSelector.mockReturnValue(address); + mockIsHardwareAccount.mockReturnValue(false); + + const { result } = renderHook(() => useIsHardwareWalletForBridge()); + + expect(result.current).toBe(false); + expect(mockIsHardwareAccount).toHaveBeenCalledWith(address); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.ts b/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.ts new file mode 100644 index 00000000000..c4ba9df5dee --- /dev/null +++ b/app/components/UI/Bridge/hooks/useIsHardwareWalletForBridge/index.ts @@ -0,0 +1,18 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { selectSourceWalletAddress } from '../../../../../selectors/bridge'; +import { isHardwareAccount } from '../../../../../util/address'; + +/** + * Returns whether the current bridge source account is a hardware wallet. + * Used to omit gas-included / 7702 params from bridge quote requests so responses + * are non-sponsored for hardware signers. + */ +export function useIsHardwareWalletForBridge(): boolean { + const walletAddress = useSelector(selectSourceWalletAddress); + + return useMemo( + () => Boolean(walletAddress && isHardwareAccount(walletAddress)), + [walletAddress], + ); +} diff --git a/app/components/UI/Bridge/utils/transaction.ts b/app/components/UI/Bridge/utils/transaction.ts index 77f981e8156..300a6fefb56 100644 --- a/app/components/UI/Bridge/utils/transaction.ts +++ b/app/components/UI/Bridge/utils/transaction.ts @@ -11,7 +11,9 @@ export const getIsBridgeTransaction = (txMeta: TransactionMeta) => { return ( origin === ORIGIN_METAMASK && (txMeta.type === TransactionType.bridgeApproval || - txMeta.type === TransactionType.bridge) + txMeta.type === TransactionType.bridge || + txMeta.type === TransactionType.swap || + txMeta.type === TransactionType.swapApproval) ); }; diff --git a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx index 8d97ae24468..329ec5984d1 100644 --- a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx +++ b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.test.tsx @@ -31,6 +31,27 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); +// Avoid loading keyring-utils, keyring-api, and the network/Engine chain in this test +jest.mock('../../../selectors/accountsController', () => ({ + selectSelectedInternalAccountFormattedAddress: jest.fn(), +})); + +jest.mock('../../../util/address', () => ({ + isHardwareAccount: jest.fn(() => false), +})); + +jest.mock('@metamask/keyring-api', () => ({ + EntropySourceId: {}, + BtcMethod: {}, + EthMethod: {}, + SolAccountType: {}, + SolMethod: {}, + TrxMethod: {}, + isEvmAccountType: jest.fn(), + KeyringAccountType: {}, + EthScope: {}, +})); + jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: jest.fn(), })); diff --git a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx index 9a3afef91ee..0ef4472b59f 100644 --- a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx +++ b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx @@ -59,6 +59,8 @@ import { selectEvmChainId } from '../../../selectors/networkController'; import { formatChainIdToCaip } from '@metamask/bridge-controller'; import { NETWORK_MULTI_SELECTOR_TEST_IDS } from '../NetworkMultiSelector/NetworkMultiSelector.constants'; import { getGasFeesSponsoredNetworkEnabled } from '../../../selectors/featureFlagController/gasFeesSponsored/index.ts'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; +import { isHardwareAccount } from '../../../util/address'; import { strings } from '../../../../locales/i18n'; import TagColored, { TagColor, @@ -106,6 +108,12 @@ const NetworkMultiSelectList = ({ const isGasFeesSponsoredNetworkEnabled = useSelector( getGasFeesSponsoredNetworkEnabled, ); + const selectedAddress = useSelector( + selectSelectedInternalAccountFormattedAddress, + ); + const isHardwareWallet = Boolean( + selectedAddress && isHardwareAccount(selectedAddress), + ); const { styles } = useStyles(styleSheet, {}); @@ -269,7 +277,8 @@ const NetworkMultiSelectList = ({ const isDisabled = isLoading || isSelectionDisabled; const showButtonIcon = Boolean(networkTypeOrRpcUrl); - const isGasSponsored = isGasFeesSponsoredNetworkEnabled(chainId); + const isGasSponsored = + !isHardwareWallet && isGasFeesSponsoredNetworkEnabled(chainId); return ( @@ -342,6 +351,7 @@ const NetworkMultiSelectList = ({ isSelectAllNetworksSection, openRpcModal, isGasFeesSponsoredNetworkEnabled, + isHardwareWallet, styles.centeredNetworkCell, styles.noNetworkFeeContainer, ], @@ -351,11 +361,17 @@ const NetworkMultiSelectList = ({ if (!networks.length || !isAutoScrollEnabled) return; if (networksLengthRef.current !== networks.length) { const selectedNetwork = networks.find(({ isSelected }) => isSelected); - networkListRef?.current?.scrollToOffset({ - offset: selectedNetwork?.yOffset ?? 0, - animated: false, - }); + const offset = selectedNetwork?.yOffset ?? 0; networksLengthRef.current = networks.length; + // Defer scroll so FlashList has time to lay out items and avoid "index out of bounds" + requestAnimationFrame(() => { + if (networkListRef?.current?.scrollToOffset) { + networkListRef.current.scrollToOffset({ + offset, + animated: false, + }); + } + }); } }, [networks, isAutoScrollEnabled]); diff --git a/app/components/Views/NetworkSelector/NetworkSelector.tsx b/app/components/Views/NetworkSelector/NetworkSelector.tsx index f1a34a6fed8..12351d22ead 100644 --- a/app/components/Views/NetworkSelector/NetworkSelector.tsx +++ b/app/components/Views/NetworkSelector/NetworkSelector.tsx @@ -107,6 +107,8 @@ import { removeItemFromChainIdList } from '../../../util/metrics/MultichainAPI/n import { analytics } from '../../../util/analytics/analytics'; import { NETWORK_SELECTOR_SOURCES } from '../../../constants/networkSelector'; import { getGasFeesSponsoredNetworkEnabled } from '../../../selectors/featureFlagController/gasFeesSponsored'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; +import { isHardwareAccount } from '../../../util/address'; import TagColored, { TagColor, } from '../../../component-library/components-temp/TagColored'; @@ -137,6 +139,12 @@ const NetworkSelector = ({ route }: NetworkSelectorProps) => { const isGasFeesSponsoredNetworkEnabled = useSelector( getGasFeesSponsoredNetworkEnabled, ); + const selectedAddress = useSelector( + selectSelectedInternalAccountFormattedAddress, + ); + const isHardwareWallet = Boolean( + selectedAddress && isHardwareAccount(selectedAddress), + ); const networkConfigurations = useSelector( selectEvmNetworkConfigurationsByChainId, @@ -559,7 +567,8 @@ const NetworkSelector = ({ route }: NetworkSelectorProps) => { {name} - {isGasFeesSponsoredNetworkEnabled(chainId) ? ( + {!isHardwareWallet && + isGasFeesSponsoredNetworkEnabled(chainId) ? ( { ) } tertiaryText={ - isSendFlow && isGasFeesSponsoredNetworkEnabled(chainId) + isSendFlow && + !isHardwareWallet && + isGasFeesSponsoredNetworkEnabled(chainId) ? strings('networks.no_network_fee') : undefined } diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/CustomNetworkView/CustomNetwork.tsx b/app/components/Views/Settings/NetworksSettings/NetworkSettings/CustomNetworkView/CustomNetwork.tsx index 16aca39e32b..1edd8ab10f8 100644 --- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/CustomNetworkView/CustomNetwork.tsx +++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/CustomNetworkView/CustomNetwork.tsx @@ -34,6 +34,8 @@ import Icon, { } from '../../../../../../component-library/components/Icons/Icon'; import { selectAdditionalNetworksBlacklistFeatureFlag } from '../../../../../../selectors/featureFlagController/networkBlacklist'; import { getGasFeesSponsoredNetworkEnabled } from '../../../../../../selectors/featureFlagController/gasFeesSponsored'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../../../../selectors/accountsController'; +import { isHardwareAccount } from '../../../../../../util/address'; import TagColored, { TagColor, } from '../../../../../../component-library/components-temp/TagColored'; @@ -65,6 +67,12 @@ const CustomNetwork = ({ const isGasFeesSponsoredNetworkEnabled = useSelector( getGasFeesSponsoredNetworkEnabled, ); + const selectedAddress = useSelector( + selectSelectedInternalAccountFormattedAddress, + ); + const isHardwareWallet = Boolean( + selectedAddress && isHardwareAccount(selectedAddress), + ); const { safeChains } = useSafeChains(); const blacklistedChainIds = useSelector( selectAdditionalNetworksBlacklistFeatureFlag, @@ -181,7 +189,8 @@ const CustomNetwork = ({ {networkConfiguration.nickname} - {isGasFeesSponsoredNetworkEnabled( + {!isHardwareWallet && + isGasFeesSponsoredNetworkEnabled( networkConfiguration.chainId, ) ? ( { const handleTransactionAddedEventForMetricsMock = jest.mocked( handleTransactionAddedEventForMetrics, ); + const accountSupports7702Mock = jest.mocked(accountSupports7702); const isSendBundleSupportedMock = jest.mocked(isSendBundleSupported); const selectMetaMaskPayFlagsMock = jest.mocked(selectMetaMaskPayFlags); const payHookClassMock = jest.mocked(TransactionPayPublishHook); @@ -422,6 +425,7 @@ describe('Transaction Controller Init', () => { let mockDelegation7702Hook: jest.MockedFn; beforeEach(() => { + accountSupports7702Mock.mockResolvedValue(true); payHookMock.mockResolvedValue({ transactionHash: undefined }); mockDelegation7702Hook = jest .fn() @@ -434,6 +438,20 @@ describe('Transaction Controller Init', () => { ); }); + it('skips Delegation7702PublishHook for hardware wallet accounts', async () => { + accountSupports7702Mock.mockResolvedValue(false); + selectShouldUseSmartTransactionMock.mockReturnValue(false); + + const hooks = testConstructorOption('hooks'); + await hooks?.publish?.({ + ...MOCK_TRANSACTION_META, + chainId: '0x13', + }); + + expect(Delegation7702PublishHookMock).not.toHaveBeenCalled(); + expect(mockDelegation7702Hook).not.toHaveBeenCalled(); + }); + it('falls back to Delegation7702PublishHook when smart transactions are disabled', async () => { selectShouldUseSmartTransactionMock.mockReturnValue(false); const hooks = testConstructorOption('hooks'); @@ -718,6 +736,7 @@ describe('Transaction Controller Init', () => { }); it('returns true if isExternalSign', async () => { + accountSupports7702Mock.mockResolvedValue(true); const mockTransactionMeta = { id: '123', status: 'approved', @@ -732,6 +751,7 @@ describe('Transaction Controller Init', () => { }); it('calls getNonceLock and releaseLock via Delegation7702PublishHook getNextNonce', async () => { + accountSupports7702Mock.mockResolvedValue(true); const releaseLockMock = jest.fn(); const getNonceLockMock = jest.fn().mockResolvedValue({ nextNonce: 99, @@ -773,6 +793,7 @@ describe('Transaction Controller Init', () => { }); it('calls 7702 publish hook if isExternalSign', async () => { + accountSupports7702Mock.mockResolvedValue(true); const delegation7702Mock: jest.MockedFn = jest.fn(); jest.mocked(Delegation7702PublishHook).mockImplementation( diff --git a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts index b61e47c745c..a758e7066b4 100644 --- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts +++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts @@ -52,6 +52,7 @@ import { } from '@metamask/transaction-pay-controller'; import { selectMetaMaskPayFlags } from '../../../../selectors/featureFlagController/confirmations'; import { trace } from '../../../../util/trace'; +import { accountSupports7702 } from '../../../../util/transactions/account-supports-7702'; import { Delegation7702PublishHook } from '../../../../util/transactions/hooks/delegation-7702-publish'; import { isSendBundleSupported } from '../../../../util/transactions/sentinel-api'; import { NetworkClientId } from '@metamask/network-controller'; @@ -110,6 +111,7 @@ export const TransactionControllerInit: ControllerInitFunction< publishHook({ transactionMeta, getState, + keyringController, transactionController, smartTransactionsController, initMessenger, @@ -134,6 +136,15 @@ export const TransactionControllerInit: ControllerInitFunction< isFirstTimeInteractionEnabled: () => isFirstTimeInteractionEnabled(preferencesController), isEIP7702GasFeeTokensEnabled: async (transactionMeta) => { + if ( + !(await accountSupports7702( + transactionMeta.txParams?.from, + keyringController as Parameters[1], + )) + ) { + return false; + } + const { chainId, isExternalSign } = transactionMeta; const state = getState(); @@ -191,6 +202,7 @@ async function getNextNonce( async function publishHook({ transactionMeta, getState, + keyringController, transactionController, smartTransactionsController, initMessenger, @@ -198,6 +210,7 @@ async function publishHook({ }: { transactionMeta: TransactionMeta; getState: () => RootState; + keyringController: Parameters[1]; transactionController: TransactionController; smartTransactionsController: SmartTransactionsController; initMessenger: TransactionControllerInitMessenger; @@ -224,7 +237,15 @@ async function publishHook({ const { isExternalSign } = transactionMeta; - if (!shouldUseSmartTransaction || !sendBundleSupport || isExternalSign) { + const keyringSupports7702 = await accountSupports7702( + transactionMeta.txParams?.from, + keyringController, + ); + + if ( + keyringSupports7702 && + (!shouldUseSmartTransaction || !sendBundleSupport || isExternalSign) + ) { const hook = new Delegation7702PublishHook({ isAtomicBatchSupported: transactionController.isAtomicBatchSupported.bind( transactionController, diff --git a/app/util/transactions/account-supports-7702.test.ts b/app/util/transactions/account-supports-7702.test.ts new file mode 100644 index 00000000000..cb9402df523 --- /dev/null +++ b/app/util/transactions/account-supports-7702.test.ts @@ -0,0 +1,115 @@ +import ExtendedKeyringTypes from '../../constants/keyringTypes'; +import { accountSupports7702 } from './account-supports-7702'; + +const SAMPLE_ADDRESS = '0x0000000000000000000000000000000000000001'; + +function createMockKeyringController(keyring: unknown): { + getKeyringForAccount: jest.Mock; +} { + return { + getKeyringForAccount: jest.fn().mockResolvedValue(keyring), + }; +} + +describe('accountSupports7702', () => { + it('returns true when address is undefined', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.ledger, + }); + await expect(accountSupports7702(undefined, controller)).resolves.toBe( + true, + ); + expect(controller.getKeyringForAccount).not.toHaveBeenCalled(); + }); + + it('returns true when address is empty', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.ledger, + }); + await expect(accountSupports7702('', controller)).resolves.toBe(true); + expect(controller.getKeyringForAccount).not.toHaveBeenCalled(); + }); + + it('returns true for HD Key Tree keyring', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.hd, + }); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + true, + ); + expect(controller.getKeyringForAccount).toHaveBeenCalledWith( + SAMPLE_ADDRESS, + ); + }); + + it('returns true for Simple Key Pair keyring', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.simple, + }); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + true, + ); + }); + + it('returns false for Ledger hardware keyring', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.ledger, + }); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + false, + ); + }); + + it('returns false for QR hardware keyring', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.qr, + }); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + false, + ); + }); + + it('returns false when keyring type is not in the allowlist', async () => { + const controller = createMockKeyringController({ + type: 'Snap Keyring', + }); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + false, + ); + }); + + it('returns false when keyring has no string type', async () => { + const controller = createMockKeyringController({ type: 123 }); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + false, + ); + }); + + it('returns false when keyring is null', async () => { + const controller = createMockKeyringController(null); + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + false, + ); + }); + + it('returns true when getKeyringForAccount throws', async () => { + const controller = { + getKeyringForAccount: jest.fn().mockRejectedValue(new Error('not found')), + }; + await expect(accountSupports7702(SAMPLE_ADDRESS, controller)).resolves.toBe( + true, + ); + }); + + it('resolves the controller from a getter when a function is passed', async () => { + const controller = createMockKeyringController({ + type: ExtendedKeyringTypes.hd, + }); + await expect( + accountSupports7702(SAMPLE_ADDRESS, () => controller), + ).resolves.toBe(true); + expect(controller.getKeyringForAccount).toHaveBeenCalledWith( + SAMPLE_ADDRESS, + ); + }); +}); diff --git a/app/util/transactions/account-supports-7702.ts b/app/util/transactions/account-supports-7702.ts new file mode 100644 index 00000000000..e04243fd8b9 --- /dev/null +++ b/app/util/transactions/account-supports-7702.ts @@ -0,0 +1,51 @@ +import ExtendedKeyringTypes from '../../constants/keyringTypes'; + +/** Minimal shape; KeyringController.getKeyringForAccount is typed as Promise. */ +interface KeyringControllerLike { + getKeyringForAccount: (address: string) => Promise; +} + +/** + * Keyring types that support EIP-7702 (Setup Smart Account). + * Only HD (entropy) and simple (private key) accounts support this; hardware and snap do not. + */ +const KEYRING_TYPES_SUPPORTING_7702: string[] = [ + ExtendedKeyringTypes.hd, + ExtendedKeyringTypes.simple, +]; + +/** + * Returns whether the given account's keyring supports EIP-7702 gas fee tokens. + * Used to avoid requesting 7702 from sentinel for hardware and other unsupported keyrings. + * + * @param address - Account address (e.g. request.from or transactionMeta.txParams?.from). + * @param keyringControllerOrGetter - KeyringController instance or a function that returns it. + * @returns True if the account supports 7702 (or address is missing / lookup fails; assume supported). + */ +export async function accountSupports7702( + address: string | undefined, + keyringControllerOrGetter: + | KeyringControllerLike + | (() => KeyringControllerLike), +): Promise { + if (!address) { + return true; + } + const keyringController = + typeof keyringControllerOrGetter === 'function' + ? keyringControllerOrGetter() + : keyringControllerOrGetter; + try { + const keyring = await keyringController.getKeyringForAccount(address); + const keyringType = + keyring && + typeof keyring === 'object' && + 'type' in keyring && + typeof (keyring as { type: unknown }).type === 'string' + ? (keyring as { type: string }).type + : ''; + return KEYRING_TYPES_SUPPORTING_7702.includes(keyringType); + } catch { + return true; + } +} diff --git a/package.json b/package.json index 1fd95d9ba02..cda2f712c30 100644 --- a/package.json +++ b/package.json @@ -184,8 +184,10 @@ "viem": "2.31.3", "@metamask/accounts-controller": "37.0.0", "@metamask/core-backend": "^5.0.0", + "@metamask/bridge-status-controller@npm:^69.0.0": "patch:@metamask/bridge-status-controller@npm%3A69.0.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch", "bn.js@npm:4.11.6": "4.12.3", "bn.js@npm:5.2.1": "5.2.3", + "@metamask/bridge-status-controller@npm:^68.1.0": "patch:@metamask/bridge-status-controller@npm%3A68.1.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch", "expo-web-browser@npm:~14.0.2": "patch:expo-web-browser@npm%3A14.0.2#~/.yarn/patches/expo-web-browser-npm-14.0.2-98d00ce880.patch" }, "dependencies": { @@ -216,7 +218,7 @@ "@metamask/base-controller": "^9.0.0", "@metamask/bitcoin-wallet-snap": "^1.10.0", "@metamask/bridge-controller": "^69.1.1", - "@metamask/bridge-status-controller": "^68.1.0", + "@metamask/bridge-status-controller": "patch:@metamask/bridge-status-controller@npm%3A68.1.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch", "@metamask/chain-agnostic-permission": "^1.3.0", "@metamask/compliance-controller": "^1.0.1", "@metamask/connectivity-controller": "^0.1.0", diff --git a/yarn.lock b/yarn.lock index c98b0ac0f33..3a709443f69 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7888,7 +7888,7 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-status-controller@npm:^68.1.0": +"@metamask/bridge-status-controller@npm:68.1.0": version: 68.1.0 resolution: "@metamask/bridge-status-controller@npm:68.1.0" dependencies: @@ -7911,7 +7911,7 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-status-controller@npm:^69.0.0": +"@metamask/bridge-status-controller@npm:69.0.0": version: 69.0.0 resolution: "@metamask/bridge-status-controller@npm:69.0.0" dependencies: @@ -7934,6 +7934,52 @@ __metadata: languageName: node linkType: hard +"@metamask/bridge-status-controller@patch:@metamask/bridge-status-controller@npm%3A68.1.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch": + version: 68.1.0 + resolution: "@metamask/bridge-status-controller@patch:@metamask/bridge-status-controller@npm%3A68.1.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch::version=68.1.0&hash=358095" + dependencies: + "@metamask/accounts-controller": "npm:^37.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/bridge-controller": "npm:^69.1.0" + "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/gas-fee-controller": "npm:^26.0.3" + "@metamask/keyring-controller": "npm:^25.1.0" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/polling-controller": "npm:^16.0.3" + "@metamask/profile-sync-controller": "npm:^28.0.0" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/transaction-controller": "npm:^62.21.0" + "@metamask/utils": "npm:^11.9.0" + bignumber.js: "npm:^9.1.2" + uuid: "npm:^8.3.2" + checksum: 10/e2fb1a7667030e5d486e0c70c5f673223a1f48b488490ee4fbcf662116111936596c447bddbcc75b166d58d3c726c5e5892578213db8b9206891e78a4f03c136 + languageName: node + linkType: hard + +"@metamask/bridge-status-controller@patch:@metamask/bridge-status-controller@npm%3A69.0.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch": + version: 69.0.0 + resolution: "@metamask/bridge-status-controller@patch:@metamask/bridge-status-controller@npm%3A69.0.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-69.0.0-ec19aeeecf.patch::version=69.0.0&hash=41006d" + dependencies: + "@metamask/accounts-controller": "npm:^37.0.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/bridge-controller": "npm:^69.1.1" + "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/gas-fee-controller": "npm:^26.1.0" + "@metamask/keyring-controller": "npm:^25.1.0" + "@metamask/network-controller": "npm:^30.0.0" + "@metamask/polling-controller": "npm:^16.0.3" + "@metamask/profile-sync-controller": "npm:^28.0.0" + "@metamask/snaps-controllers": "npm:^17.2.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/transaction-controller": "npm:^63.0.0" + "@metamask/utils": "npm:^11.9.0" + bignumber.js: "npm:^9.1.2" + uuid: "npm:^8.3.2" + checksum: 10/da79a48e1fbae222f682aedfe008f6c0ef1213c78ff0faa85e57d59258e517477456a2cab3ef09a68b44cefa68bd515c0b4b46a06294998bebeec269b40920d7 + languageName: node + linkType: hard + "@metamask/browser-passworder@npm:^5.0.0": version: 5.0.0 resolution: "@metamask/browser-passworder@npm:5.0.0" @@ -35536,7 +35582,7 @@ __metadata: "@metamask/base-controller": "npm:^9.0.0" "@metamask/bitcoin-wallet-snap": "npm:^1.10.0" "@metamask/bridge-controller": "npm:^69.1.1" - "@metamask/bridge-status-controller": "npm:^68.1.0" + "@metamask/bridge-status-controller": "patch:@metamask/bridge-status-controller@npm%3A68.1.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-68.1.0-8a2c809398.patch" "@metamask/browser-passworder": "npm:^5.0.0" "@metamask/browser-playground": "npm:0.3.0" "@metamask/build-utils": "npm:^3.0.0" From 9f24f30385eaac9384f8b159fb4fe550e3d028bf Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Wed, 25 Mar 2026 11:27:13 +0000 Subject: [PATCH 35/54] [skip ci] Bump version number to 4182 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 7dd8cbe46af..aa3754c6484 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4181 + versionCode 4182 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 6c8ae8f3fc6..f6eefc3c406 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4181 + VERSION_NUMBER: 4182 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4181 + FLASK_VERSION_NUMBER: 4182 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 6ad772a2681..a68c253dcf2 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4181; + CURRENT_PROJECT_VERSION = 4182; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4181; + CURRENT_PROJECT_VERSION = 4182; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4181; + CURRENT_PROJECT_VERSION = 4182; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4181; + CURRENT_PROJECT_VERSION = 4182; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4181; + CURRENT_PROJECT_VERSION = 4182; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4181; + CURRENT_PROJECT_VERSION = 4182; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 901b9ae509c9a3cf4dea656583fa0cd8d75cc05a Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:49:45 +0000 Subject: [PATCH 36/54] chore(runway): cherry-pick fix(perps): fix HIP-3 asset ID lookup failure from dual-cache desync cp-7.70.1 (#27912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(perps): fix HIP-3 asset ID lookup failure from dual-cache desync cp-7.70.1 (#27854) ## **Description** Fix HIP-3 asset ID lookup failure (`"Asset ID not found for xyz:BRENTOIL"`) that blocked trading on HIP-3 markets when navigating via the old Perps tab layout. **Root cause**: Dual-cache desync between `#cachedValidatedDexs` (string DEX names) and `#cachedAllPerpDexs` (raw API objects for `perpDexIndex` computation). The standalone preload path (`#getStandaloneValidatedDexs`) populated one cache but not the other. When `#buildAssetMapping` later ran, it found "xyz" in `dexsToMap` but couldn't compute its `perpDexIndex` because `#cachedAllPerpDexs` was null. **Why old Perps tab vs new Homepage Sections**: Both layouts sit inside `Wallet/index.tsx`, which calls `startMarketDataPreload()` on mount. This fires standalone HTTP calls that populate `#cachedValidatedDexs` but not `#cachedAllPerpDexs`. - **New homepage sections**: `PerpsSectionWithProvider` mounts immediately. Stream hooks fire `ensureReady()` before or concurrently with the standalone preload. Since `#cachedValidatedDexs` is often still null, `fetchValidatedDexsInternal` runs fresh and sets **both** caches correctly. - **Old tab layout**: The Perps tab doesn't mount until the user taps it. By that time, `startMarketDataPreload()` has already completed → `#cachedValidatedDexs` is populated by standalone. When the tab mounts → `getValidatedDexs()` → **cache hit** → `fetchValidatedDexsInternal` is never called → `#cachedAllPerpDexs` stays null → `buildAssetMapping` can't find "xyz". **Changes (1 file, 3 sites)**: 1. **Root cause fix**: `#getStandaloneValidatedDexs` now sets `this.#cachedAllPerpDexs = allDexs` after a successful `perpDexs()` call, keeping both caches in sync. 2. **Cache poisoning fix**: Removed `this.#cachedAllPerpDexs = this.#cachedAllPerpDexs ?? [null]` from the catch block in `#buildAssetMapping`. 3. **Cache poisoning fix**: Replaced persistent `if (!cache) { cache = [null] }` with local `const allPerpDexs = cache ?? [null]` — consumers read the cache, only the owner writes it. ## **Changelog** CHANGELOG entry: Fixed a bug where closing positions on HIP-3 markets (e.g., xyz:BRENTOIL) failed with "Asset ID not found" when navigating via the Perps tab ## **Related issues** Fixes: HIP-3 asset ID lookup failure on old Perps tab layout ## **Manual testing steps** ```gherkin Feature: HIP-3 position management via Perps tab Scenario: user closes a HIP-3 position from the old Perps tab Given user has an open position on a HIP-3 market (e.g., xyz:BRENTOIL) And user is using the old tab layout (homepage redesign v1 disabled) When user navigates to the Perps tab And user taps close on the xyz:BRENTOIL position Then the position closes successfully without "Asset ID not found" error Scenario: user opens a HIP-3 position from the old Perps tab Given user is on the Perps tab (old layout) When user navigates to xyz:BRENTOIL market and places a market order Then the order executes successfully with correct asset ID routing ``` ## **Screenshots/Recordings** ### **Before** Metro logs show the desync: ``` getValidatedDexs CACHE HIT {"cachedAllNull": true, "dexs": [null, "xyz"]} buildAssetMapping state {"allPerpDexsLen": 1, "cachedAllNull": true} Could not find perpDexIndex for DEX xyz Asset ID not found for xyz:BRENTOIL ``` ### **After** Metro logs show both caches in sync: ``` buildAssetMapping state {"allPerpDexsLen": 8, "cachedAllNull": false, "dexsToMap": [null, "xyz"]} Asset map state at order time {"assetExistsInMap": true, "hip3AssetsCount": 54, "totalAssetsInMap": 283} Resolved DEX-specific asset ID {"assetId": 110049, "coin": "xyz:BRENTOIL"} usePerpsClosePosition: Close result {"success": true, "orderId": "359617825254"} ``` ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Touches HIP-3 market routing/asset-ID mapping in `HyperLiquidProvider`, so a mistake could break trading on some perps markets; scope is small and localized to cache population/fallback behavior. > > **Overview** > Fixes a HIP-3 asset mapping failure where `#cachedValidatedDexs` could be populated via the standalone preload path while `#cachedAllPerpDexs` stayed `null`, leading to missing `perpDexIndex` during `#buildAssetMapping`. > > `#getStandaloneValidatedDexs()` now also populates `#cachedAllPerpDexs` after a successful `perpDexs()` call, and `#buildAssetMapping()` no longer “poisons” the shared cache with a persistent `[null]` fallback (it uses a local fallback instead). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c925609ab6e324afaf50556d96abf4acca2460ee. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [2898ec8](https://github.com/MetaMask/metamask-mobile/commit/2898ec839aa04320d74dc6bc4ea9b7ec24668d17) Co-authored-by: abretonc7s <107169956+abretonc7s@users.noreply.github.com> --- .../perps/providers/HyperLiquidProvider.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/app/controllers/perps/providers/HyperLiquidProvider.ts b/app/controllers/perps/providers/HyperLiquidProvider.ts index 764f4e42ea1..480ac72df40 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.ts @@ -2119,16 +2119,13 @@ export class HyperLiquidProvider implements PerpsProvider { '[buildAssetMapping] getValidatedDexs failed, falling back to main DEX', { error: String(dexError) }, ); - this.#cachedAllPerpDexs = this.#cachedAllPerpDexs ?? [null]; dexsToMap = [null]; } - // Use cached perpDexs array (populated by getValidatedDexs) - // Defensive: ensure non-null even if getValidatedDexs had an unexpected issue - if (!this.#cachedAllPerpDexs) { - this.#cachedAllPerpDexs = [null]; - } - const allPerpDexs = this.#cachedAllPerpDexs; + // Local fallback only — never write [null] into #cachedAllPerpDexs here. + // That cache is owned exclusively by #fetchValidatedDexsInternal; writing a + // fallback here would prevent subsequent callers from retrying perpDexs(). + const allPerpDexs = this.#cachedAllPerpDexs ?? [null]; this.#deps.debugLogger.log( 'HyperLiquidProvider: Starting asset mapping rebuild', @@ -4772,6 +4769,12 @@ export class HyperLiquidProvider implements PerpsProvider { return [null]; } + // Populate #cachedAllPerpDexs so buildAssetMapping can compute perpDexIndex. + // Without this, getValidatedDexs returns from #cachedValidatedDexs (string names) + // but #cachedAllPerpDexs (raw objects for index computation) stays null, + // causing "Could not find perpDexIndex for DEX xyz" failures. + this.#cachedAllPerpDexs = allDexs; + // Extract HIP-3 DEX names (filter out null which represents main DEX) const availableHip3Dexs: string[] = []; allDexs.forEach((dex) => { From 56b23328395123b7c2fa745c6a91511573b6429b Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Wed, 25 Mar 2026 13:51:32 +0000 Subject: [PATCH 37/54] [skip ci] Bump version number to 4183 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index aa3754c6484..69dff97f8e7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4182 + versionCode 4183 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index f6eefc3c406..95d7f6c7568 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4182 + VERSION_NUMBER: 4183 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4182 + FLASK_VERSION_NUMBER: 4183 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index a68c253dcf2..5430c17781f 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4182; + CURRENT_PROJECT_VERSION = 4183; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4182; + CURRENT_PROJECT_VERSION = 4183; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4182; + CURRENT_PROJECT_VERSION = 4183; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4182; + CURRENT_PROJECT_VERSION = 4183; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4182; + CURRENT_PROJECT_VERSION = 4183; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4182; + CURRENT_PROJECT_VERSION = 4183; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 57b7cfa384998a838ec03a7f94564f6f1e71a075 Mon Sep 17 00:00:00 2001 From: chloeYue <105063779+chloeYue@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:52:18 +0100 Subject: [PATCH 38/54] chore: Stable sync release 7.71.0 (7.70.1) (#27915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Sync `stable` into `release/7.71.0` so the release branch includes everything merged to stable through **7.70.1** (hotfixes and changelog from [#27824](https://github.com/MetaMask/metamask-mobile/pull/27824)) ## Changelog CHANGELOG entry: null --- Made with [Cursor](https://cursor.com) --- > [!NOTE] > **Low Risk** > Low risk: only updates release metadata (CHANGELOG links/entries and `OTA_VERSION`) with no functional code changes beyond versioning. > > **Overview** > Adds a `7.70.1` section to `CHANGELOG.md` (including two perps-related fixes) and updates the compare links so *Unreleased* starts from `v7.70.1`. > > Bumps `OTA_VERSION` in `app/constants/ota.ts` from `v7.65.1` to `v7.70.1` to match the hotfix release. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b8fba18be76a80a37e87f5049592090bcabb684d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: metamaskbot Co-authored-by: João Loureiro <175489935+joaoloureirop@users.noreply.github.com> Co-authored-by: runway-github[bot] <73448015+runway-github[bot]@users.noreply.github.com> Co-authored-by: abretonc7s <107169956+abretonc7s@users.noreply.github.com> --- CHANGELOG.md | 10 +++++++++- app/constants/ota.ts | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc62e6a36e9..29a67c3c648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.70.1] + +### Fixed + +- Fixed stale perpetuals data and missing 24h price change after returning from background (#27530) +- Fixed a bug where closing positions on HIP-3 markets (e.g., xyz:BRENTOIL) failed with "Asset ID not found" when navigating via the Perps tab (#27854) + ## [7.70.0] ### Added @@ -11008,7 +11015,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#957](https://github.com/MetaMask/metamask-mobile/pull/957): fix timeouts (#957) - [#954](https://github.com/MetaMask/metamask-mobile/pull/954): Bugfix: onboarding navigation (#954) -[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.0...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.1...HEAD +[7.70.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.0...v7.70.1 [7.70.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.1...v7.70.0 [7.69.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.0...v7.69.1 [7.69.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.68.3...v7.69.0 diff --git a/app/constants/ota.ts b/app/constants/ota.ts index 70e0dd691f3..ec21ae8bdf2 100644 --- a/app/constants/ota.ts +++ b/app/constants/ota.ts @@ -6,7 +6,7 @@ import otaConfig from '../../ota.config.js'; * Reset to v0 when releasing a new native build * We keep this OTA_VERSION here to because changes in ota.config.js will affect the fingerprint and break the workflow in Github Actions */ -export const OTA_VERSION: string = 'v7.65.1'; +export const OTA_VERSION: string = 'v7.70.1'; export const RUNTIME_VERSION = otaConfig.RUNTIME_VERSION; export const PROJECT_ID = otaConfig.PROJECT_ID; export const UPDATE_URL = otaConfig.UPDATE_URL; From 6599c4ad5fb8f69026d901869f1a86143b5fb756 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Wed, 25 Mar 2026 14:53:55 +0000 Subject: [PATCH 39/54] [skip ci] Bump version number to 4184 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 69dff97f8e7..fa91bcdba45 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4183 + versionCode 4184 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 95d7f6c7568..81122210301 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4183 + VERSION_NUMBER: 4184 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4183 + FLASK_VERSION_NUMBER: 4184 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 5430c17781f..93003ea86e0 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4183; + CURRENT_PROJECT_VERSION = 4184; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4183; + CURRENT_PROJECT_VERSION = 4184; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4183; + CURRENT_PROJECT_VERSION = 4184; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4183; + CURRENT_PROJECT_VERSION = 4184; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4183; + CURRENT_PROJECT_VERSION = 4184; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4183; + CURRENT_PROJECT_VERSION = 4184; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 4876bad56a2ad8684446eef9a6a3d24a04e30862 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:18:07 +0100 Subject: [PATCH 40/54] chore(runway): cherry-pick chore: New Crowdin translations by Github Action cp-7.71.0 (#27934) - chore: New Crowdin translations by Github Action cp-7.71.0 (#27496) Co-authored-by: metamaskbot [0b1f7be](https://github.com/MetaMask/metamask-mobile/commit/0b1f7befc4eedeb9cedc5d6a179151b469e6e552) Co-authored-by: MetaMask Bot <37885440+metamaskbot@users.noreply.github.com> Co-authored-by: metamaskbot --- locales/languages/de.json | 299 +++++++++++++++++++++++++++++--------- locales/languages/el.json | 297 ++++++++++++++++++++++++++++--------- locales/languages/es.json | 297 ++++++++++++++++++++++++++++--------- locales/languages/fr.json | 297 ++++++++++++++++++++++++++++--------- locales/languages/hi.json | 299 +++++++++++++++++++++++++++++--------- locales/languages/id.json | 297 ++++++++++++++++++++++++++++--------- locales/languages/ja.json | 299 +++++++++++++++++++++++++++++--------- locales/languages/ko.json | 299 +++++++++++++++++++++++++++++--------- locales/languages/pt.json | 297 ++++++++++++++++++++++++++++--------- locales/languages/ru.json | 299 +++++++++++++++++++++++++++++--------- locales/languages/tl.json | 293 +++++++++++++++++++++++++++++-------- locales/languages/tr.json | 299 +++++++++++++++++++++++++++++--------- locales/languages/vi.json | 297 ++++++++++++++++++++++++++++--------- locales/languages/zh.json | 297 ++++++++++++++++++++++++++++--------- 14 files changed, 3252 insertions(+), 914 deletions(-) diff --git a/locales/languages/de.json b/locales/languages/de.json index 4fc909b68e6..9310e98c947 100644 --- a/locales/languages/de.json +++ b/locales/languages/de.json @@ -20,6 +20,12 @@ "update": "Update" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Benachrichtigung", @@ -120,8 +126,8 @@ "title": "Senden von Assets an die Burn-Adresse" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Token-Contract-Warnung", + "message": "Die Empfängeradresse unterstützt womöglich keine direkten Token-Übertragungen, was zu Geldverlusten führen kann. Fahren Sie nur fort, wenn Sie sicher sind, dass dieser Contract Ihre Übertragung empfangen kann." }, "gas_sponsorship_reserve_balance": { "message": "Gas-Sponsoring ist für diese Transaktion nicht verfügbar. Sie benötigen mindestens %{minBalance} %{nativeTokenSymbol} auf Ihrem Konto.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Name konnte nicht aufgelöst werden", "invalid_address": "Ungültige Adresse", "contractAddressError": "Sie senden Tokens an die Kontraktadresse des Tokens. Dies kann zum Verlust dieser Tokens führen.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Smart-Contract-Adresse", + "smart_contract_address_warning": "Die Empfängeradresse unterstützt womöglich keine direkten Token-Übertragungen, was zu Geldverlusten führen kann. Fahren Sie nur fort, wenn Sie sicher sind, dass dieser Contract Ihre Übertragung empfangen kann.", "i_understand": "Ich verstehe", "cancel": "Stornieren" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Stop-Loss muss {{direction}} {{priceType}} Preis sein", "stop_loss_beyond_liquidation_error": "Stop-Loss muss {{direction}} Liquidationspreis sein", "stop_loss_order_view_warning": "Stop-Loss ist {{direction}} Liquidationspreis", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "Über", "below": "Unter", "done": "Fertig", @@ -2086,14 +2094,15 @@ "a_closer_look": "Ein genauerer Blick", "whats_being_said": "Was gesagt wird", "footer_disclaimer": "KI-Zusammenfassung nur zu Informationszwecken", - "trade_button": "Trade", + "swap_button": "Tauschen", + "buy_button": "Kaufen", "sources_count": "+{{count}} Quellen", "sources_title": "Nachrichtenquellen", "feedback_submitted": "Feedback eingereicht", "helpful_prompt": "War dies hilfreich?", "feedback": { "title": "Feedback", - "description": "Helfen Sie uns, unsere KI-generierten Markteinblicke zu erweitern.", + "description": "Ihre Antwort trägt zur Verbesserung unserer KI-Zusammenfassungen bei.", "not_relevant": "Nicht relevant", "not_accurate": "Nicht genau", "hard_to_understand": "Schwer zu verstehen", @@ -2162,7 +2171,7 @@ "sell_position": "Position verkaufen", "cash_out": "Auszahlung", "cash_out_info": "Gelder werden Ihrem verfügbaren Guthaben hinzugefügt", - "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", + "buy_preview_outcome_at_price": "{{outcome}} zum {{price}}", "at_price_per_share": "Verkauf von {{size}}-Aktien zu {{price}}", "cashout_info": "{{amount}} bei {{outcome}} zu {{initialPrice}}", "cashout_info_multiple": "{{amount}} bei {{outcomeGroupTitle}} • {{outcome}} zu {{initialPrice}}", @@ -2206,7 +2215,7 @@ "available_balance": "Verfügbares Guthaben", "claim_amount_text": "{{amount}} $ einfordern", "claim_winnings_text": "Gewinne einfordern", - "claiming_text": "Claiming...", + "claiming_text": "Einfordern ...", "unrealized_pnl_label": "Nicht realisierte GuV", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Laden nicht möglich", @@ -2287,7 +2296,7 @@ "try_again": "Erneut versuchen" }, "in_progress": { - "title": "Claim already in progress" + "title": "Einforderung bereits in Bearbeitung" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "An die Börse oder den Markt entrichtete Gebühr", "total_incl_fees": "inkl. Gebühren", "close": "Schließen", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Die angegebenen Preise setzen voraus, dass Ihre Order vollständig ausgeführt wird. Die tatsächlichen Beträge können abweichen, wenn die Order nur teilweise ausgeführt wird.", + "deposit_fee": "Einzahlungsgebühr", + "deposit_fee_description": "Gebühr für die Einzahlung von Geldern auf Ihr Prognosesaldo" }, "error": { "title": "Verbindung zu Prognosen nicht möglich", @@ -3059,6 +3068,7 @@ "networks_no_results": "Keine Netzwerke gefunden", "network_name_label": "Netzwerkname", "network_name_placeholder": "Netzwerkname (optional)", + "required": "Benötigt", "network_rpc_url_label": "RPC-URL", "network_rpc_name_label": "RPC-Name", "network_rpc_placeholder": "Neues RPC-Netzwerk", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Diese Funktion warnt Sie durch die aktive Überprüfung von Transaktions- und Signaturanfragen vor bösartigen Aktivitäten.", "security_alerts": "Sicherheitsbenachrichtigungen", "security_alerts_desc": "Diese Funktion warnt Sie vor böswilligen Aktivitäten, indem sie Ihre Transaktions- und Signaturanfragen lokal überprüft. Führen Sie immer Ihre eigene Prüfung durch, bevor Sie eine Anfrage genehmigen. Es gibt keine Garantie dafür, dass diese Funktion alle bösartigen Aktivitäten erkennt. Mit der Aktivierung dieser Funktion erklären Sie sich mit den Nutzungsbedingungen des Anbieters einverstanden.", + "smart_account_dapp_requests_heading": "Anfragen für Smart-Konten von Dapps", + "smart_account_dapp_requests_desc": "Lassen Sie Dapps die Smart-Konto-Funktionen für Standardkonten anfordern. Dies wirkt sich nicht auf bereits bestehende Smart-Konten aus.", "smart_transactions_opt_in_heading": "Smart Transactions", "smart_transactions_opt_in_desc_supported_networks": "Schalten Sie Smart Transactions für zuverlässigere und sicherere Transaktionen auf unterstützten Netzwerken ein.", "smart_transactions_learn_more": "Mehr erfahren", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}}-Aktivität", "disclaimer": "Die Marktdaten werden von Drittquellen wie CoinGecko bereitgestellt. Diese Daten dienen lediglich zu Informationszwecken. MetaMask übernimmt keinerlei Verantwortung für deren Richtigkeit." }, + "security_trust": { + "title": "Sicherheit und Vertrauen", + "malicious": "Bösartig", + "risky": "Riskant", + "malicious_token_title": "Bösartiges Token", + "malicious_token_description": "{{symbol}} ist ein bösartiger Token. Vermeiden Sie die Interaktion mit diesem Token oder dessen Handel.", + "verified_token_title": "Verifizierter Token", + "verified_token_description": "{{symbol}} wird aktiv gehandelt und ist weitgehend anerkannt. Die Verifizierung ist keine Empfehlung von MetaMask.", + "risky_token_title": "Riskanter Token", + "risky_token_description": "Warnsignale für {{symbol}} erkannt. Recherchieren Sie sorgfältig, bevor Sie diesen Token handeln.", + "malicious_token_sheet_description": "Ernsthafte Risikosignale für {{symbol}} erkannt. Wir empfehlen, diesen Token nicht zu handeln.", + "got_it": "Verstanden", + "proceed": "Fortfahren", + "cancel": "Stornieren", + "data_unavailable": "Sicherheitsdaten nicht verfügbar", + "subtitle_known": "Keine Risikosignale erkannt. Recherchieren Sie vor dem Handel stets jedes Asset.", + "subtitle_no_issues": "Keine Risikosignale erkannt. Recherchieren Sie vor dem Handel stets jedes Asset.", + "subtitle_suspicious": "Prüfen Sie die gekennzeichneten Punkte sorgfältig, bevor Sie dieses Asset handeln.", + "subtitle_malicious": "Ernsthafte Risikosignale erkannt. Wir empfehlen, dieses Asset zu meiden.", + "subtitle_unavailable": "Die Sicherheitsanalyse konnte für dieses Token nicht geladen werden.", + "token_distribution": "Verteilung von Token", + "total_supply": "Gesamtvorrat", + "top_10_holders": "Top 10 Inhaber", + "other": "Sonstiges", + "no_hidden_fees_detected": "Keine versteckten Gebühren erkannt", + "buy_sell_tax": "Kauf-/Verkaufssteuer", + "buy_tax": "Kaufsteuer", + "sell_tax": "Verkaufssteuer", + "transfer": "Übertragung", + "token_info": "Token-Infos", + "created": "Erstellt", + "token_age": "Token-Alter", + "network": "Netzwerk", + "type": "Geben Sie ein,", + "official_links": "Offizielle Links", + "website": "Website", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "N. z.", + "verified": "Verifiziert", + "no_issues": "Keine Probleme", + "suspicious": "Verdächtig", + "malicious_label": "Bösartig", + "more": "Mehr", + "evaluation_disclaimer": "Diese Sicherheitsüberprüfung dient nur der Bewertung und stellt keine Empfehlung zum Handel dar." + }, "account_details": { "title": "Kontodetails", "share_account": "Teilen", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Anspruchsberechtigter Bonus", "claim_bonus": "Bonus einfordern", "claim_bonus_subtitle": "Der Bonus wird auf {{networkName}} ausgezahlt.", + "percentage_bonus_on_linea": "{{percentage}} % Bonus auf Linea", + "claim": "Einfordern", + "sounds_good": "Klingt gut", + "claimable_bonus_tooltip_with_percentage": "{{percentage}} % jährlicher Bonus, den Sie für das Halten von mUSD verdient haben. Ihr Bonus kann täglich auf Linea eingefordert werden.", "empty_state_cta": { "heading": "Verleihen Sie {{tokenSymbol}} und verdienen Sie", "body": "Verleihen Sie Ihre {{tokenSymbol}} mit {{protocol}} und verdienen Sie", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Ihre Stablecoins" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Verdienen", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Sie haben nicht genügend Ressourcen, um diese Aktion durchzuführen." }, - "trx_unstaking_in_progress": "Unstaken {{amount}} TRX in Bearbeitung. Das Unstaken erfordert 14 Tage.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Unstaken von {{amount}} TRX in Bearbeitung", + "description": "Das Unstaken erfordert 14 Tage" + }, + "unstaked_banner": { + "title": "Unstaken von {{amount}} TRX abgeschlossen", + "description": "Ihr unstaken TRX kann jetzt ausgezahlt werden", + "button": "Auszahlen", + "error": "Auszahlung fehlgeschlagen" + } }, "stake_eth": "ETH staken", "unstake_eth": "ETH unstaken", @@ -6376,7 +6498,8 @@ "approve": "Anfrage genehmigen", "perps_deposit": "Gelder hinzufügen", "predict_deposit": "Prognosegelder hinzufügen", - "predict_withdraw": "Auszahlen" + "predict_withdraw": "Auszahlen", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Diese Website möchte die Genehmigung, Ihre Tokens auszugeben.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Transaktion {{index}}", "transaction": "Transaktion", "available_balance": "Verfügbares Guthaben: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Fortfahren", "deposit_edit_amount_done": "Gelder hinzufügen", "deposit_edit_amount_predict_withdraw": "Auszahlen", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Hardware-Wallets werden noch nicht unterstützt. Verwenden Sie eine Hot Wallet, um fortzufahren.", "hardware_wallet_not_supported_solana": "Hardware-Wallets werden für Solana noch nicht unterstützt. Verwenden Sie ein Hot Wallet, um fortzufahren.", "price_impact_info_title": "Preiseinfluss", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Auf diese Weise verändert Ihr Handel den Marktpreis eines Tokens. Dies ist abhängig von der Handelsgröße, der verfügbaren Liquidität und den Gebühren des Anbieters. MetaMask hat keinen Einfluss auf den Preis.", "price_impact_info_gasless_description": "Die Preisauswirkung spiegelt wider, wie Ihre Swap-Order den Marktpreis des Assets beeinflusst. Falls Sie nicht über genügend Gelder für Gas halten, wird ein Teil Ihres Quelltokens automatisch zur Deckung der Gebühren verwendet, was die Preisauswirkung erhöht. MetaMask hat keinerlei Kontrolle über die Preisauswirkung.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Aufgrund Ihrer Handelsgröße und der verfügbaren Liquidität erhalten Sie etwa {{priceImpact}} unter dem Marktpreis. Dies ist bereits in Ihrem Angebot berücksichtigt.", "price_impact_high": "Hohe Preisauswirkungen", "price_impact_execution_description": "Sie verlieren bei diesem Swap etwa {{priceImpact}} des Wertes Ihres Tokens. Versuchen Sie, den Betrag zu senken oder eine liquidere Route zu wählen.", "proceed": "Fortfahren", @@ -6627,8 +6751,8 @@ "total_cost": "Gesamtkosten", "got_it": "Verstanden", "price_impact_warning_title": "Hohe Preisauswirkungen", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Sehr hohe Preisauswirkungen", + "price_impact_error_description": "Sie verlieren bei diesem Swap etwa {{priceImpact}} den Marktpreis Ihres Tokens. Versuchen Sie einen kleineren Handel oder eine liquidere Route, um Ihren Kurs zu verbessern." }, "quote_expired_modal": { "title": "Neue Angebote sind verfügbar", @@ -6940,7 +7064,7 @@ "upgrade_title": "Upgrade auf Metall", "continue_button": "Fortfahren", "virtual_card": { - "name": "Virtual Card", + "name": "Virtuelle Karte", "price": "Kostenlos", "feature_1": "Virtuelle Karte für Apple Pay und Google Pay", "feature_2": "Bezahlen Sie mit Kryptowährung (USDC, USDT, WETH und mehr)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Metallkarte", "price": "199 $/Jahr", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Alles in virtuell, plus:", + "feature_1": "Erstklassige gravierte Metallkarte", + "feature_2": "3 % Cashback auf die ersten 10.000 $/Jahr", "feature_3": "Keine Auslandstransaktionsgebühren" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Verdienen Sie jährlich bis zu 300 $ an Cashback", + "upgrade_to_metal_label": "Oder upgraden Sie auf Metall für das 3-fache an Belohnungen" }, "review_order": { "title": "Prüfen Sie Ihre Bestellung", @@ -7104,7 +7228,7 @@ "ssn_description": "Vom Kartenaussteller gefordert. Es wird keine Bonitätsprüfung durchgeführt.", "invalid_ssn": "Ungültige SSN", "invalid_date_of_birth": "Ungültiges Geburtsdatum. Sie müssen mindestens 18 Jahre alt sein.", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Vor- und Nachname müssen mit Ihrer verifizierten Identität übereinstimmen" }, "physical_address": { "title": "Fügen Sie Ihre Adresse hinzu", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Sie sind Ihrem Ausgabenlimit nahe", "description": "Aktualisieren Sie, um Ablehnungen zu vermeiden", - "confirm_button_label": "Neues Limit festlegen" + "confirm_button_label": "Neues Limit festlegen", + "dismiss_button_label": "Verwerfen" }, "need_delegation": { "title": "Sie müssen Ihre Karte aktivieren", @@ -7301,7 +7426,6 @@ "dismiss": "Verwerfen", "update_success": "Ausgabenlimit erfolgreich aktualisiert", "update_error": "Aktualisierung des Ausgabenlimits fehlgeschlagen", - "solana_not_supported": "Aktivieren Sie Solana-Token auf card.metamask.io", "select_token": "Token auswählen", "loading": "Verfügbare Tokens werden geladen ...", "load_error": "Tokens können nicht geladen werden. Bitte versuchen Sie es erneut.", @@ -7343,9 +7467,7 @@ "limited": "Limitiert", "not_enabled": "Nicht aktiviert", "update_success": "Ausgabenpriorität erfolgreich aktualisiert", - "update_error": "Aktualisierung der Ausgabenpriorität fehlgeschlagen", - "solana_not_supported_button_title": "Andere Token auf Solana", - "solana_not_supported_button_description": "Auf card.metamask.io aktivieren" + "update_error": "Aktualisierung der Ausgabenpriorität fehlgeschlagen" }, "card_authentication": { "title": "Melden Sie sich bei Ihrem Kartenkonto an", @@ -7443,6 +7565,11 @@ "title": "Anmeldung fehlgeschlagen", "description": "Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut." }, + "version_guard": { + "title": "Aktualisierung erforderlich", + "description": "Für die Nutzung von Belohnungen ist eine neuere Version von MetaMask erforderlich. Bitte aktualisieren Sie, um fortzufahren.", + "update_button": "MetaMask aktualisieren" + }, "season_error": { "error_fetching_title": "Saison konnten nicht geladen werden", "error_fetching_description": "Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.", @@ -7525,7 +7652,6 @@ "main_title": "Belohnungen", "referral_title": "Empfehlungen", "tab_overview_title": "Übersicht", - "tab_snapshots_title": "Schnappschüsse", "tab_activity_title": "Aktivität", "referral_stats_earned_from_referrals": "Durch Empfehlungen verdient", "referral_stats_referrals": "Empfehlungen", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Sie haben in dieser Saison keine Belohnungen erhalten, aber es gibt ja immer ein nächstes Mal.", "verifying_rewards": "Wir stellen sicher, dass alles korrekt ist, bevor Sie Ihre Belohnungen einfordern." }, + "previous_season_view": { + "title": "Vorherige Saison" + }, "season_status": { "points_earned": "Verdiente Punkte" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Aktive Boosts", "season_1": "Saison 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "mUSD-Bonus-Rechner", + "description": "Finden Sie heraus, wie viel Sie durch die Konvertierung Ihrer Stablecoins in mUSD verdienen können.", + "amount_label": "Konvertierter Betrag", + "estimated_bonus": "Geschätzter jährlicher Bonus: bis zu 3 %", + "initial_amount": "Anfangsbetrag", + "daily_bonus": "Täglich einforderbarer Bonus", + "annualized_bonus": "Jährlicher Bonus", + "disclaimer": "Dies ist nur eine Schätzung. Der Bonus kann sich noch ändern.", "buy_button": "mUSD kaufen", - "swap_button": "Swap to mUSD" + "swap_button": "Swap zu mUSD" }, "upcoming_rewards": { "title": "Gesperrte Belohnungen", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Laden fehlgeschlagen" }, - "snapshot": { + "campaign": { "starts_date": "Beginnt am {{date}}", "ends_date": "Endet am {{date}}", - "results_coming_soon": "Ergebnisse folgen in Kürze", - "tokens_on_the_way": "Tokens sind unterwegs", + "ended_date": "Ended {{date}}", "pill_up_next": "Als Nächstes", - "pill_live_now": "Jetzt live", - "pill_calculating": "Berechnungsvorgang", - "pill_results_ready": "Ergebnisse bereit", - "pill_complete": "Abgeschlossen" - }, - "snapshots_section": { - "title": "Schnappschüsse", - "error_title": "Schnappschüsse konnten nicht geladen werden", - "error_description": "Die Schnappschüsse konnten nicht geladen werden. Bitte versuchen Sie es erneut.", - "retry_button": "Erneut versuchen" - }, - "snapshots_tab": { + "pill_active": "Live", + "pill_complete": "Abgeschlossen", + "enter_now": "Jetzt eingeben", + "entered": "Eingegeben", + "participant_count": "Nr. {{count}}", + "opt_in_cta": "Anmelden", + "opt_in_sheet_title": "An der Kampagne teilnehmen", + "opt_in_sheet_description_pre_link": "Indem Sie auf „Anmelden“ klicken, stimmen Sie MetaMask-Belohnungen zu", + "opt_in_sheet_link_text": "Ergänzende Nutzungsbedingungen und Datenschutzhinweis", + "opt_in_sheet_description_post_link": "Wir verfolgen die Onchain-Aktivitäten und belohnen Sie automatisch.", + "geo_restriction_banner_title": "In Ihrer Region nicht verfügbar", + "geo_restriction_banner_description": "Diese Kampagne ist in Ihrer Region aufgrund lokaler Bestimmungen nicht verfügbar." + }, + "campaign_mechanics": { + "title": "Mechaniken" + }, + "campaign_details": { + "start_date": "Beginnt am: {{date}}", + "end_date": "Endet am: {{date}}", + "opt_in": "Anmelden", + "opting_in": "Anmeldevorgang ...", + "opted_in": "Sie haben sich für diese Kampagne angemeldet", + "opt_in_error": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.", + "join_campaign": "An der Kampagne teilnehmen", + "checking_opt_in_status": "Überprüfung des Anmeldestatus", + "swap": "Tauschen", + "how_it_works": "Wie es funktioniert" + }, + "campaigns_preview": { + "title": "Kampagnen", + "coming_soon": "Demnächst verfügbar", + "notify_me": "Mich benachrichtigen" + }, + "earn_rewards": { + "title": "Belohnungen verdienen", + "musd_title": "Bis zu 3 % Bonus auf Stablecoins", + "musd_subtitle": "Berechnen Sie Ihren mUSD-Bonus", + "card_title": "Bis zu 3 % Cashback", + "card_subtitle": "Sichern Sie sich jetzt Ihre MetaMask Card", + "card_subtitle_cardholder": "Nutzen Sie die Vorteile Ihrer MetaMask Card" + }, + "campaigns_view": { + "title": "Kampagnen", "active_title": "Aktiv", "upcoming_title": "Bevorstehend", "previous_title": "Vorherige", - "empty_state": "Keine Schnappschüsse verfügbar", - "error_title": "Schnappschüsse konnten nicht geladen werden", - "error_description": "Die Schnappschüsse konnten nicht geladen werden. Bitte versuchen Sie es erneut.", + "empty_state": "Keine Kampagnen verfügbar", + "error_title": "Kampagnen konnten nicht geladen werden", + "error_description": "Kampagnen konnten nicht geladen werden. Bitte versuchen Sie es erneut.", "retry_button": "Erneut versuchen", "refreshing": "Aktualisierung ..." } @@ -7953,13 +8112,12 @@ "continue": "Fortfahren" }, "connecting": { - "title": "Verbinden Sie Ihr {{device}}", + "title": "Ihr {{device}} wird verbunden ...", "searching": "Suche nach {{device}} ...", - "tips_header": "Um fortzufahren, stellen Sie Folgendes sicher:", + "tips_header": "Stellen Sie Folgendes sicher:", "tip_unlock": "Ihr {{device}} ist freigeschaltet", "tip_open_app": "Die Ethereum-App ist geöffnet", "tip_enable_bluetooth": "Bluetooth ist eingeschaltet", - "tip_dnd_off": "Nicht stören ist ausgeschaltet", "tip_bluetooth_permission": "Standort- und Bluetooth-Berechtigung werden erteilt", "tip_bluetooth_permission_v12": "Berechtigung für Geräte in der Nähe wird erteilt", "tip_stay_close": "Ihr Gerät bleibt in der Nähe Ihres Telefons" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Berechtigung für Geräte in der Nähe ist erforderlich", "bluetooth_off": "Bitte schalten Sie Bluetooth ein, um eine Verbindung mit Ihrem Gerät herzustellen", "bluetooth_scan_failed": "Scannen nach Geräten fehlgeschlagen. Bitte versuchen Sie es erneut", - "bluetooth_connection_failed": "Aktivieren Sie Bluetooth auf Ihrem Gerät, um fortzufahren", + "bluetooth_connection_failed": "Die Verbindung zu Ihrem Gerät ist fehlgeschlagen. Bitte versuchen Sie es erneut", "not_supported": "Dieser Vorgang wird nicht unterstützt", "unknown_error": "Stellen Sie sicher, dass Ihr {{device}} mit der geheimen Wiederherstellungsphrase oder Passphrase für dieses Konto eingerichtet ist" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Bargeld", + "cash_empty_description": "Sie haben noch keine mUSD. Konvertieren Sie Stablecoins in mUSD im Bereich „Bargeld“ auf der Startseite.", + "cash_empty_description_network_filter": "Kein mUSD in diesem Netzwerk. Wechseln Sie das Netzwerk, um Ihre mUSD einzusehen.", "tokens": "Token", "perpetuals": "Perpetuals", "predictions": "Prognosen", + "whats_happening": "Was ist passiert?", + "whats_happening_categories": { + "geopolitical": "Geopolitisch", + "macro": "Makro", + "regulatory": "Regulatorisch", + "technical": "Technisch", + "social": "Sozial", + "other": "Sonstiges" + }, "defi": "DeFi", "nfts": "NFTs", "import_nfts": "NFTs importieren", diff --git a/locales/languages/el.json b/locales/languages/el.json index 0c241834aac..9fc2fc4e2cc 100644 --- a/locales/languages/el.json +++ b/locales/languages/el.json @@ -20,6 +20,12 @@ "update": "Ενημέρωση" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Ειδοποίηση", @@ -120,8 +126,8 @@ "title": "Αποστολή περιουσιακών στοιχείων σε διεύθυνση καύσης" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Προειδοποίηση συμβολαίου token", + "message": "Η διεύθυνση παραλήπτη ενδέχεται να μην υποστηρίζει άμεσες μεταφορές tokens, γεγονός που μπορεί να οδηγήσει σε απώλεια κεφαλαίων. Συνεχίστε μόνο εάν είστε βέβαιοι ότι το συγκεκριμένο συμβόλαιο μπορεί να λάβει τη μεταφορά." }, "gas_sponsorship_reserve_balance": { "message": "Η κάλυψη των τελών δεν είναι διαθέσιμη για αυτή τη συναλλαγή. Θα χρειαστεί να διατηρείτε τουλάχιστον %{minBalance} %{nativeTokenSymbol} στον λογαριασμό σας.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Δεν ήταν δυνατή η επίλυση του ονόματος", "invalid_address": "Μη έγκυρη διεύθυνση", "contractAddressError": "Πρόκειται να στείλετε tokens στη διεύθυνση συμβολαίου του token. Αυτό μπορεί να οδηγήσει σε απώλεια των token.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Διεύθυνση έξυπνου συμβολαίου", + "smart_contract_address_warning": "Η διεύθυνση παραλήπτη ενδέχεται να μην υποστηρίζει άμεσες μεταφορές tokens, γεγονός που μπορεί να οδηγήσει σε απώλεια κεφαλαίων. Συνεχίστε μόνο εάν είστε βέβαιοι ότι το συγκεκριμένο συμβόλαιο μπορεί να λάβει τη μεταφορά.", "i_understand": "Κατανοώ", "cancel": "Άκυρο" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Η τιμή περιορισμού ζημιάς πρέπει να είναι {{direction}} από την τιμή {{priceType}}", "stop_loss_beyond_liquidation_error": "Η τιμή περιορισμού ζημιάς πρέπει να είναι {{direction}} από την τιμή ρευστοποίησης", "stop_loss_order_view_warning": "Η τιμή περιορισμού ζημιάς είναι {{direction}} από την τιμή ρευστοποίησης", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "πάνω", "below": "κάτω", "done": "Τέλος", @@ -2086,14 +2094,15 @@ "a_closer_look": "Μια πιο προσεκτική ματιά", "whats_being_said": "Τι συζητιέται", "footer_disclaimer": "Περίληψη από ΤΝ, μόνο για ενημερωτικούς σκοπούς", - "trade_button": "Συναλλαγές", + "swap_button": "Ανταλλαγή", + "buy_button": "Αγορά", "sources_count": "+{{count}} πηγές", "sources_title": "Πηγές ειδήσεων", "feedback_submitted": "Το σχόλιό σας υποβλήθηκε", "helpful_prompt": "Ήταν χρήσιμο;", "feedback": { "title": "Ανατροφοδότηση", - "description": "Βοηθήστε μας να βελτιώσουμε τις αναλύσεις αγοράς που δημιουργεί η τεχνητή νοημοσύνη.", + "description": "Οι απαντήσεις σας βοηθούν στη βελτίωση των περιλήψεών μας με AI.", "not_relevant": "Δεν είναι σχετικό", "not_accurate": "Δεν είναι ακριβές", "hard_to_understand": "Δύσκολο στην κατανόηση", @@ -2206,7 +2215,7 @@ "available_balance": "Διαθέσιμο υπόλοιπο", "claim_amount_text": "Εξαργυρώστε ${{amount}}", "claim_winnings_text": "Εξαργύρωση κερδών", - "claiming_text": "Claiming...", + "claiming_text": "Λήψη…", "unrealized_pnl_label": "Μη οριστικοποιημένα κέρδη & ζημίες", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Δεν ήταν δυνατή η φόρτωση", @@ -2287,7 +2296,7 @@ "try_again": "Προσπαθήστε ξανά" }, "in_progress": { - "title": "Claim already in progress" + "title": "Η διαδικασία λήψης βρίσκεται ήδη σε εξέλιξη" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Τέλη που καταβάλλονται στην πλατφόρμα συναλλαγών ή στην αγορά", "total_incl_fees": "συμπερ. τέλη", "close": "Κλείσιμο", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Οι τιμές που εμφανίζονται προϋποθέτουν ότι η εντολή σας θα εκτελεστεί πλήρως. Τα πραγματικά ποσά ενδέχεται να διαφέρουν εάν η εντολή εκτελεστεί μόνο μερικώς.", + "deposit_fee": "Τέλη κατάθεσης", + "deposit_fee_description": "Τέλη που επιβάλλονται για την κατάθεση χρημάτων στο υπόλοιπο ππροβλέψεών σας" }, "error": { "title": "Δεν ήταν δυνατή η σύνδεση με την πλατφόρμα προβλέψεων", @@ -3059,6 +3068,7 @@ "networks_no_results": "Δεν βρέθηκαν δίκτυα", "network_name_label": "Όνομα δικτύου", "network_name_placeholder": "Όνομα δικτύου (προαιρετικά)", + "required": "Απαιτείται", "network_rpc_url_label": "Διεύθυνση URL του RPC", "network_rpc_name_label": "Όνομα RPC", "network_rpc_placeholder": "Νέο δίκτυο RPC", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Αυτή η λειτουργία σας προειδοποιεί για κακόβουλη δραστηριότητα, καθώς ελέγχει ενεργά τα αιτήματα συναλλαγών και υπογραφών.", "security_alerts": "Ειδοποιήσεις ασφαλείας", "security_alerts_desc": "Αυτή η λειτουργία σας ειδοποιεί για κακόβουλη δραστηριότητα, ελέγχοντας τοπικά τα αιτήματα συναλλαγών και υπογραφών σας. Κάνετε πάντα τη δική σας ενδελεχή έρευνα πριν εγκρίνετε οποιαδήποτε αιτήματα. Δεν υπάρχει καμία εγγύηση ότι αυτή η λειτουργία θα ανιχνεύσει όλες τις κακόβουλες δραστηριότητες. Ενεργοποιώντας αυτή τη λειτουργία, συμφωνείτε με τους όρους χρήσης του παρόχου.", + "smart_account_dapp_requests_heading": "Αιτήματα έξυπνου λογαριασμού από αποκεντρωμένες εφαρμογές (dapps)", + "smart_account_dapp_requests_desc": "Επιτρέψτε στις αποκεντρωμένες εφαρμογές (dapps) να ζητούν λειτουργίες έξυπνου λογαριασμού για τυπικούς λογαριασμούς. Δεν θα επηρεαστούν οι λογαριασμοί που είναι ήδη έξυπνοι λογαριασμοί.", "smart_transactions_opt_in_heading": "Έξυπνες Συναλλαγές", "smart_transactions_opt_in_desc_supported_networks": "Ενεργοποιήστε τις Έξυπνες Συναλλαγές για πιο αξιόπιστες και ασφαλείς συναλλαγές σε υποστηριζόμενα δίκτυα.", "smart_transactions_learn_more": "Μάθετε περισσότερα", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}} δραστηριότητα", "disclaimer": "Τα δεδομένα αγοράς παρέχονται από τρίτους, όπως το CoinGecko. Τα δεδομένα προσφέρονται μόνο για ενημερωτικούς σκοπούς. Το MetaMask δεν φέρει ευθύνη για την ακρίβειά τους." }, + "security_trust": { + "title": "Ασφάλεια και εμπιστοσύνη", + "malicious": "Κακόβουλο", + "risky": "Επικίνδυνο", + "malicious_token_title": "Κακόβουλο token", + "malicious_token_description": "Το {{symbol}} είναι κακόβουλο token. Αποφύγετε την αλληλεπίδραση μαζί του ή να κάνετε συναλλαγές.", + "verified_token_title": "Επαληθευμένο token", + "verified_token_description": "Το {{symbol}} έχει υψηλή αναγνωρισιμότητα και είναι ευρέως γνωστό. Η επαλήθευση δεν αποτελεί έγκριση από το MetaMask.", + "risky_token_title": "Επικίνδυνο token", + "risky_token_description": "Εντοπίστηκαν προειδοποιητικές ενδείξεις για το {{symbol}}. Κάντε προσεκτική έρευνα πριν προχωρήσετε σε συναλλαγές με αυτό το token.", + "malicious_token_sheet_description": "Εντοπίστηκαν σοβαρές ενδείξεις κινδύνου για το {{symbol}}. Συνιστούμε να μην κάνετε συναλλαγές με αυτό το token.", + "got_it": "Κατανοητό", + "proceed": "Συνεχίστε", + "cancel": "Ακύρωση", + "data_unavailable": "Τα δεδομένα ασφαλείας δεν είναι διαθέσιμα", + "subtitle_known": "Δεν εντοπίστηκαν ενδείξεις κινδύνου. Να ερευνάτε πάντα κάθε ψηφιακό περιουσιακό στοιχείο πριν κάνετε συναλλαγές.", + "subtitle_no_issues": "Δεν εντοπίστηκαν ενδείξεις κινδύνου. Να ερευνάτε πάντα κάθε ψηφιακό περιουσιακό στοιχείο πριν κάνετε συναλλαγές.", + "subtitle_suspicious": "Εντοπίστηκαν προειδοποιητικές ενδείξεις. Ελέγξτε προσεκτικά τα ζητήματα που έχουν επισημανθεί πριν κάνετε συναλλαγές με αυτό το ψηφιακό περιουσιακό στοιχείο.", + "subtitle_malicious": "Εντοπίστηκαν σοβαρές ενδείξεις κινδύνου. Συνιστούμε να αποφύγετε αυτό το ψηφιακό περιουσιακό στοιχείο.", + "subtitle_unavailable": "Δεν ήταν δυνατή η φόρτωση της ανάλυσης ασφαλείας για αυτό το token.", + "token_distribution": "Κατανομή του token", + "total_supply": "Συνολική προσφορά", + "top_10_holders": "Κορυφαίοι 10 χρήστες", + "other": "Άλλο", + "no_hidden_fees_detected": "Δεν εντοπίστηκαν κρυφές χρεώσεις", + "buy_sell_tax": "Φόρος αγοράς/πώλησης", + "buy_tax": "Φόρος αγοράς", + "sell_tax": "Φόρος πώλησης", + "transfer": "Μεταφορά", + "token_info": "Πληροφορίες για το token", + "created": "Δημιουργήθηκε", + "token_age": "Ηλικία του token", + "network": "Δίκτυο", + "type": "Πληκτρολογήστε", + "official_links": "Επίσημοι σύνδεσμοι", + "website": "Ιστότοπος", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "Μη διαθέσιμη", + "verified": "Επαληθευμένο", + "no_issues": "Δεν υπάρχουν ζητήματα", + "suspicious": "Ύποπτο", + "malicious_label": "Κακόβουλο", + "more": "περισσότερα", + "evaluation_disclaimer": "Αυτή η αξιολόγηση ασφαλείας παρέχεται αποκλειστικά για ενημερωτικούς σκοπούς και δεν αποτελεί έγκριση ή σύσταση για συναλλαγές." + }, "account_details": { "title": "Στοιχεία λογαριασμού", "share_account": "Κοινοποίηση", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Μπόνους προς εξαργύρωση", "claim_bonus": "Εξαργύρωση του μπόνους", "claim_bonus_subtitle": "Το μπόνους θα καταβληθεί στο δίκτυο {{networkName}}.", + "percentage_bonus_on_linea": "{{percentage}}% μπόνους στο δίκτυο Linea", + "claim": "Εξαργύρωση", + "sounds_good": "Εντάξει", + "claimable_bonus_tooltip_with_percentage": "Έχετε κερδίσει {{percentage}}% ετήσιο μπόνους επειδή έχετε mUSD. Το μπόνους σας μπορείτε να το εξαργυρώσετε καθημερινά στο δίκτυο Linea.", "empty_state_cta": { "heading": "Δανείστε {{tokenSymbol}} και κερδίστε", "body": "Δανείστε τα {{tokenSymbol}} μέσω του {{protocol}} και κερδίστε", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Τα stablecoins σας" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Κερδίστε", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Δεν έχετε αρκετό υπόλοιπο πόρων για να εκτελέσετε αυτή την ενέργεια." }, - "trx_unstaking_in_progress": "Η αποδέσμευση {{amount}} TRX βρίσκεται σε εξέλιξη. Η διαδικασία διαρκεί 14 ημέρες.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Αποδέσμευση {{amount}} TRX σε εξέλιξη", + "description": "Η διαδικασία αποδέσμευσης διαρκεί 14 ημέρες" + }, + "unstaked_banner": { + "title": "Η αποδέσμευση {{amount}} TRX ολοκληρώθηκε", + "description": "Τα TRX που αποδεσμεύσατε είναι πλέον διαθέσιμα για ανάληψη", + "button": "Ανάληψη", + "error": "Η ανάληψη απέτυχε" + } }, "stake_eth": "Ποντάρισμα σε ETH", "unstake_eth": "Ακύρωση πονταρίσματος σε ETH", @@ -6376,7 +6498,8 @@ "approve": "Έγκριση αιτήματος", "perps_deposit": "Προσθήκη κεφαλαίων", "predict_deposit": "Προσθήκη κεφαλαίων για Προβλέψεις", - "predict_withdraw": "Ανάληψη" + "predict_withdraw": "Ανάληψη", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Αυτός ο ιστότοπος θέλει άδεια για να δαπανήσει τα tokens σας.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Συναλλαγή {{index}}", "transaction": "Προστασία", "available_balance": "Διαθέσιμο υπόλοιπο: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Συνεχίστε", "deposit_edit_amount_done": "Προσθήκη κεφαλαίων", "deposit_edit_amount_predict_withdraw": "Ανάληψη", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Τα πορτοφόλια υλικού δεν υποστηρίζονται ακόμη. Χρησιμοποιήστε ένα θερμό πορτοφόλι για να συνεχίσετε.", "hardware_wallet_not_supported_solana": "Τα πορτοφόλια υλικού δεν υποστηρίζουν ακόμα την Solana. Χρησιμοποιήστε ένα θερμό πορτοφόλι για να συνεχίσετε.", "price_impact_info_title": "Αντίκτυπος στην τιμή", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Έτσι επηρεάζει η συναλλαγή σας την τιμή αγοράς ενός token. Εξαρτάται από το μέγεθος της συναλλαγής, τη διαθέσιμη ρευστότητα και τις χρεώσεις των παρόχων. Το MetaMask δεν ελέγχει την επίδραση στην τιμή.", "price_impact_info_gasless_description": "Η επίδραση στην τιμή αντικατοπτρίζει το πώς η εντολή ανταλλαγής σας επηρεάζει την τιμή αγοράς του περιουσιακού στοιχείου. Αν δεν διαθέτετε αρκετά κεφάλαια για τα τέλη συναλλαγής, μέρος του αρχικού σας token κατανέμεται αυτόματα για την κάλυψη των χρεώσεων, γεγονός που αυξάνει την επίδραση στην τιμή. Το MetaMask δεν επηρεάζει ούτε ελέγχει την επίδραση στην τιμή.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Λόγω του μεγέθους της συναλλαγής σας και της διαθέσιμης ρευστότητας, θα λάβετε περίπου {{priceImpact}} λιγότερο από την τιμή αγοράς. Αυτό έχει ήδη ληφθεί υπόψη στην προσφορά σας.", "price_impact_high": "Υψηλή επίδραση στην τιμή", "price_impact_execution_description": "Θα χάσετε περίπου {{priceImpact}} από την αξία του token σας σε αυτή την ανταλλαγή. Προσπαθήστε να μειώσετε το ποσό ή να επιλέξετε ένα κανάλι με μεγαλύτερη ρευστότητα.", "proceed": "Συνεχίστε", @@ -6627,8 +6751,8 @@ "total_cost": "Συνολικό κόστος", "got_it": "Κατανοητό", "price_impact_warning_title": "Υψηλή επίδραση στην τιμή", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Πολύ μεγάλη επίδραση στην τιμή", + "price_impact_error_description": "Θα χάσετε περίπου {{priceImpact}} από την τιμή αγοράς του token σε αυτή την ανταλλαγή. Δοκιμάστε με μικρότερο ποσό ή ένα κανάλι με μεγαλύτερη ρευστότητα για να βελτιώσετε την τιμή." }, "quote_expired_modal": { "title": "Υπάρχουν διαθέσιμες νέες προσφορές", @@ -6940,7 +7064,7 @@ "upgrade_title": "Αναβάθμιση σε Metal", "continue_button": "Συνεχίστε", "virtual_card": { - "name": "Virtual Card", + "name": "Εικονική κάρτα", "price": "Δωρεάν", "feature_1": "Εικονική κάρτα για Apple Pay και Google Pay", "feature_2": "Πληρώστε με κρυπτονομίσματα (USDC, USDT, WETH και άλλα)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Metal Card", "price": "$199/έτος", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Όλα όσα περιλαμβάνει η εικονική κάρτα, καθώς και:", + "feature_1": "Μεταλλική κάρτα υψηλής ποιότητας με χάραξη", + "feature_2": "3% επιστροφή χρημάτων στα πρώτα $10.000/έτος", "feature_3": "Χωρίς χρεώσεις για διεθνείς συναλλαγές" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Κερδίστε έως και $300 επιστροφή χρημάτων κάθε χρόνο", + "upgrade_to_metal_label": "Ή κάντε αναβάθμιση σε Μεταλλική κάρτα για 3× ανταμοιβές" }, "review_order": { "title": "Έλεγχος εντολής", @@ -7104,7 +7228,7 @@ "ssn_description": "Απαιτείται από τον εκδότη της κάρτας. Δεν θα πραγματοποιηθεί έλεγχος πιστοληπτικής ικανότητας.", "invalid_ssn": "Μη έγκυρος αριθμός κοινωνικής ασφάλισης (ΑΜΚΑ)", "invalid_date_of_birth": "Μη έγκυρη ημερομηνία γέννησης. Πρέπει να είστε τουλάχιστον 18 ετών", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Το όνομα και το επώνυμό σας πρέπει να ταιριάζουν με την επαληθευμένη ταυτότητά σας" }, "physical_address": { "title": "Προσθέστε τη διεύθυνσή σας", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Έχετε σχεδόν φτάσει το όριο δαπανών σας", "description": "Ενημερώστε για να αποφύγετε απορρίψεις\t", - "confirm_button_label": "Ορισμός νέου ορίου" + "confirm_button_label": "Ορισμός νέου ορίου", + "dismiss_button_label": "Απόρριψη" }, "need_delegation": { "title": "Πρέπει να ενεργοποιήσετε την κάρτα σας", @@ -7301,7 +7426,6 @@ "dismiss": "Απόρριψη", "update_success": "Το όριο δαπανών ενημερώθηκε με επιτυχία", "update_error": "Απέτυχε η ενημέρωση του ορίου δαπανών", - "solana_not_supported": "Ενεργοποιήστε τα tokens στη Solana στο card.metamask.io", "select_token": "Επιλέξτε token", "loading": "Φόρτωση διαθέσιμων tokens...", "load_error": "Δεν ήταν δυνατή η φόρτωση των tokens. Παρακαλούμε δοκιμάστε ξανά.", @@ -7343,9 +7467,7 @@ "limited": "Περιορισμένο", "not_enabled": "Δεν είναι ενεργοποιημένο", "update_success": "Η προτεραιότητα δαπανών ενημερώθηκε με επιτυχία", - "update_error": "Απέτυχε η ενημέρωση της προτεραιότητας δαπανών", - "solana_not_supported_button_title": "Άλλα tokens στη Solana", - "solana_not_supported_button_description": "Ενεργοποίηση στο card.metamask.io" + "update_error": "Απέτυχε η ενημέρωση της προτεραιότητας δαπανών" }, "card_authentication": { "title": "Συνδεθείτε στον λογαριασμό της κάρτας σας", @@ -7443,6 +7565,11 @@ "title": "Αποτυχία συμμετοχής", "description": "Ελέγξτε τη σύνδεσή σας και προσπαθήστε ξανά." }, + "version_guard": { + "title": "Απαιτείται ενημέρωση", + "description": "Απαιτείται νεότερη έκδοση του MetaMask για να χρησιμοποιήσετε τις Ανταμοιβές. Παρακαλούμε ενημερώστε την εφαρμογή για να συνεχίσετε.", + "update_button": "Ενημερώστε το MetaMask" + }, "season_error": { "error_fetching_title": "Αδυναμία φόρτωσης της περιόδου", "error_fetching_description": "Ελέγξτε τη σύνδεσή σας και προσπαθήστε ξανά.", @@ -7525,7 +7652,6 @@ "main_title": "Ανταμοιβές", "referral_title": "Συστάσεις", "tab_overview_title": "Επισκόπηση", - "tab_snapshots_title": "Στιγμιότυπα", "tab_activity_title": "Δραστηριότητα", "referral_stats_earned_from_referrals": "Κερδίσατε από συστάσεις", "referral_stats_referrals": "Συστάσεις", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Δεν κερδίσατε ανταμοιβές αυτή την περίοδο, αλλά υπάρχει πάντα η επόμενη φορά.", "verifying_rewards": "Βεβαιωνόμαστε ότι όλα είναι σωστά πριν διεκδικήσετε τις ανταμοιβές σας." }, + "previous_season_view": { + "title": "Προηγούμενη περίοδος" + }, "season_status": { "points_earned": "Πόντοι που κερδίσατε" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Ενεργές ενισχύσεις", "season_1": "Περίοδος 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "Εκτίμηση του μπόνους σε mUSD", + "description": "Δείτε πόσα μπορείτε να κερδίσετε μετατρέποντας τα stablecoins σας σε mUSD.", + "amount_label": "Το ποσό μετατράπηκε", + "estimated_bonus": "Εκτιμώμενο ετήσιο μπόνους: έως 3%", + "initial_amount": "Αρχικό ποσό", + "daily_bonus": "Ημερήσιο διαθέσιμο μπόνους", + "annualized_bonus": "Ετήσιο μπόνους", + "disclaimer": "Πρόκειται μόνο για εκτίμηση. Το μπόνους ενδέχεται να αλλάξει.", "buy_button": "Αγοράστε mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Ανταλλαγή σε mUSD" }, "upcoming_rewards": { "title": "Κλειδωμένες ανταμοιβές", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Δεν ήταν δυνατή η φόρτωση" }, - "snapshot": { + "campaign": { "starts_date": "Αρχίζει {{date}}", "ends_date": "Λήγει {{date}}", - "results_coming_soon": "Τα αποτελέσματα έρχονται σύντομα", - "tokens_on_the_way": "Token καθ' οδόν", + "ended_date": "Ended {{date}}", "pill_up_next": "Επόμενο", - "pill_live_now": "Ζωντανά τώρα", - "pill_calculating": "Υπολογισμός", - "pill_results_ready": "Τα αποτελέσματα είναι έτοιμα", - "pill_complete": "Ολοκληρώθηκε" - }, - "snapshots_section": { - "title": "Στιγμιότυπα", - "error_title": "Δεν είναι δυνατή η φόρτωση στιγμιότυπων", - "error_description": "Δεν ήταν δυνατή η φόρτωση των στιγμιότυπων. Παρακαλούμε δοκιμάστε ξανά.", - "retry_button": "Επανάληψη" - }, - "snapshots_tab": { + "pill_active": "Ζωντανά", + "pill_complete": "Ολοκληρώθηκε", + "enter_now": "Συμμετάσχετε τώρα", + "entered": "Έχετε συμμετάσχει", + "participant_count": "#{{count}}", + "opt_in_cta": "Εγγραφή", + "opt_in_sheet_title": "Συμμετάσχετε στην καμπάνια", + "opt_in_sheet_description_pre_link": "Κάνοντας κλικ στο ‘Εγγραφή’, αποδέχεστε τις Ανταμοιβές του MetaMask", + "opt_in_sheet_link_text": "Συμπληρωματικοί Όροι Χρήσης και Δήλωση Απορρήτου", + "opt_in_sheet_description_post_link": "Θα παρακολουθούμε τη δραστηριότητά σας στο blockchain ώστε να σας ανταμείβουμε αυτόματα.", + "geo_restriction_banner_title": "Δεν είναι διαθέσιμη στην περιοχή σας", + "geo_restriction_banner_description": "Αυτή η καμπάνια δεν είναι διαθέσιμη στην περιοχή σας λόγω τοπικών κανονισμών." + }, + "campaign_mechanics": { + "title": "Κανόνες" + }, + "campaign_details": { + "start_date": "Ξεκινά: {{date}}", + "end_date": "Λήγει: {{date}}", + "opt_in": "Εγγραφή", + "opting_in": "Εγγραφή...", + "opted_in": "Έχετε εγγραφεί σε αυτή την καμπάνια", + "opt_in_error": "Η εγγραφή απέτυχε. Προσπαθήστε ξανά.", + "join_campaign": "Συμμετάσχετε στην καμπάνια", + "checking_opt_in_status": "Έλεγχος κατάστασης εγγραφής", + "swap": "Ανταλλαγή", + "how_it_works": "Πώς λειτουργεί" + }, + "campaigns_preview": { + "title": "Καμπάνιες", + "coming_soon": "Προσεχώς", + "notify_me": "Ειδοποιήστε με" + }, + "earn_rewards": { + "title": "Κερδίστε ανταμοιβές", + "musd_title": "Έως 3% μπόνους σε stables", + "musd_subtitle": "Υπολογίστε το μπόνους σας σε mUSD", + "card_title": "Έως 3% επιστροφή μετρητών", + "card_subtitle": "Αποκτήστε τώρα την MetaMask Card", + "card_subtitle_cardholder": "Δείτε τα προνόμια της MetaMask Card" + }, + "campaigns_view": { + "title": "Καμπάνιες", "active_title": "Ενεργό", "upcoming_title": "Επερχόμενο", "previous_title": "Προηγούμενο", - "empty_state": "Δεν υπάρχουν διαθέσιμα στιγμιότυπα", - "error_title": "Δεν είναι δυνατή η φόρτωση στιγμιότυπων", - "error_description": "Δεν ήταν δυνατή η φόρτωση των στιγμιότυπων. Παρακαλούμε δοκιμάστε ξανά.", + "empty_state": "Δεν υπάρχουν διαθέσιμες καμπάνιες", + "error_title": "Δεν ήταν δυνατή η φόρτωση των καμπανιών", + "error_description": "Δεν μπορέσαμε να φορτώσουμε τις καμπάνιες. Προσπαθήστε ξανά.", "retry_button": "Επανάληψη", "refreshing": "Ανανεώνεται..." } @@ -7953,13 +8112,12 @@ "continue": "Συνεχίστε" }, "connecting": { - "title": "Συνδέστε τη συσκευή σας {{device}}", + "title": "Σύνδεση με το {{device}}...", "searching": "Αναζήτηση για το {{device}}...", - "tips_header": "Για να συνεχίσετε, βεβαιωθείτε ότι:", + "tips_header": "Βεβαιωθείτε:", "tip_unlock": "Η συσκευή σας {{device}} είναι ξεκλείδωτη", "tip_open_app": "Η εφαρμογή Ethereum είναι ανοιχτή", "tip_enable_bluetooth": "Το Bluetooth είναι ενεργοποιημένο", - "tip_dnd_off": "Η λειτουργία Μην Ενοχλείτε είναι απενεργοποιημένη", "tip_bluetooth_permission": "Η άδεια τοποθεσίας και Bluetooth έχει δοθεί", "tip_bluetooth_permission_v12": "Η άδεια για κοντινές συσκευές έχει δοθεί", "tip_stay_close": "Η συσκευή σας πρέπει να βρίσκεται κοντά στο τηλέφωνό σας" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Απαιτείται άδεια πρόσβασης σε κοντινές συσκευές", "bluetooth_off": "Ενεργοποιήστε το Bluetooth για να συνδεθεί με τη συσκευή σας", "bluetooth_scan_failed": "Δεν ήταν δυνατή η σάρωση συσκευών. Προσπαθήστε ξανά", - "bluetooth_connection_failed": "Ενεργοποιήστε το Bluetooth στη συσκευή σας για να συνεχίσετε", + "bluetooth_connection_failed": "Η σύνδεση με τη συσκευή σας απέτυχε. Προσπαθήστε ξανά", "not_supported": "Αυτή η λειτουργία δεν υποστηρίζεται", "unknown_error": "Βεβαιωθείτε ότι το {{device}} σας έχει ρυθμιστεί με τη Μυστική Φράση Ανάκτησης ή τη φράση πρόσβασης για αυτόν τον λογαριασμό" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Μετρητά", + "cash_empty_description": "Δεν έχετε ακόμη mUSD. Μετατρέψτε τα stablecoins σας σε mUSD από την ενότητα Μετρητά στην αρχική σελίδα.", + "cash_empty_description_network_filter": "Δεν έχετε mUSD σε αυτό το δίκτυο. Αλλάξτε δίκτυο για να δείτε τα mUSD σας.", "tokens": "Token", "perpetuals": "Συμβόλαια αορίστου διάρκειας", "predictions": "Προβλέψεις", + "whats_happening": "Τι συμβαίνει", + "whats_happening_categories": { + "geopolitical": "Γεωπολιτικά", + "macro": "Μακροοικονομικά", + "regulatory": "Κανονισμοί", + "technical": "Τεχνικά", + "social": "Κοινωνικά", + "other": "Άλλο" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "Εισαγωγή NFT", diff --git a/locales/languages/es.json b/locales/languages/es.json index 0aed1f163aa..3e30408f7e2 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -20,6 +20,12 @@ "update": "Actualizar" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Alerta", @@ -120,8 +126,8 @@ "title": "Envío de activos a la dirección de quema" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Advertencia sobre el contrato de tokens", + "message": "Es posible que la dirección del destinatario no admita transferencias directas de tokens, lo que podría provocar la pérdida de fondos. Continúa solo si estás seguro de que este contrato puede recibir tu transferencia." }, "gas_sponsorship_reserve_balance": { "message": "El patrocinio de gas no está disponible para esta transacción. Deberás mantener al menos %{minBalance} %{nativeTokenSymbol} en tu cuenta.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "No se pudo resolver el nombre", "invalid_address": "Dirección no válida", "contractAddressError": "Estás enviando tokens a la dirección del contrato del token. Esto podría resultar en la pérdida de estos tokens.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Dirección del contrato inteligente", + "smart_contract_address_warning": "Es posible que la dirección del destinatario no admita transferencias directas de tokens, lo que podría provocar la pérdida de fondos. Continúa solo si estás seguro de que este contrato puede recibir tu transferencia.", "i_understand": "Comprendo", "cancel": "Cancelar" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "El límite de pérdidas debe estar por {{direction}} del precio {{priceType}}", "stop_loss_beyond_liquidation_error": "El límite de pérdidas debe estar por {{direction}} del precio de liquidación", "stop_loss_order_view_warning": "El límite de pérdidas está por {{direction}} del precio de liquidación", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "por encima", "below": "por debajo", "done": "Hecho", @@ -2086,14 +2094,15 @@ "a_closer_look": "Un vistazo más de cerca", "whats_being_said": "Qué se dice", "footer_disclaimer": "Resumen generado por IA solo con fines informativos", - "trade_button": "Operar", + "swap_button": "Canjear", + "buy_button": "Comprar", "sources_count": "+{{count}} fuentes", "sources_title": "Fuentes de noticias", "feedback_submitted": "Comentarios enviados", "helpful_prompt": "¿Te resultó útil?", "feedback": { "title": "Comentarios", - "description": "Ayuda a mejorar nuestra información de mercado generada por IA.", + "description": "Tu respuesta ayuda a mejorar nuestros resúmenes generados por IA.", "not_relevant": "No es relevante", "not_accurate": "No es precisa", "hard_to_understand": "Difícil de entender", @@ -2206,7 +2215,7 @@ "available_balance": "Saldo disponible", "claim_amount_text": "Reclamar ${{amount}}", "claim_winnings_text": "Reclama tus ganancias", - "claiming_text": "Claiming...", + "claiming_text": "Reclamando...", "unrealized_pnl_label": "P&L no realizado", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "No se puede cargar", @@ -2287,7 +2296,7 @@ "try_again": "Inténtalo de nuevo" }, "in_progress": { - "title": "Claim already in progress" + "title": "Reclamación ya en curso" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Tarifa pagada al cambio o al mercado", "total_incl_fees": "tarifas incl.", "close": "Cerrar", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Los precios indicados se basan en que tu orden se ejecute en su totalidad. Los montos reales pueden variar si la orden solo se ejecuta parcialmente.", + "deposit_fee": "Tarifa de depósito", + "deposit_fee_description": "Tarifa que se cobra por depositar fondos en tu saldo de predicción" }, "error": { "title": "No se puede conectar a las predicciones", @@ -3059,6 +3068,7 @@ "networks_no_results": "No se encontraron redes", "network_name_label": "Nombre de la red", "network_name_placeholder": "Nombre de la red (opcional)", + "required": "Requerido", "network_rpc_url_label": "URL de RPC", "network_rpc_name_label": "Nombre de RPC", "network_rpc_placeholder": "Nueva red RPC", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Esta función le alerta sobre actividades maliciosas al revisar activamente las solicitudes de transacciones y firmas.", "security_alerts": "Alertas de seguridad", "security_alerts_desc": "Esta función le alerta sobre actividad maliciosa al revisar localmente sus solicitudes de transacción y firma. Haga siempre su propia diligencia debida antes de aprobar cualquier solicitud. No hay garantía de que esta función detecte toda la actividad maliciosa. Al activar esta función, acepta los términos de uso del proveedor.", + "smart_account_dapp_requests_heading": "Solicitudes de cuentas inteligentes desde dapps", + "smart_account_dapp_requests_desc": "Permite que las dapps soliciten funciones de cuentas inteligentes para cuentas estándar. Esto no afectará a las cuentas que ya son cuentas inteligentes.", "smart_transactions_opt_in_heading": "Transacciones inteligentes", "smart_transactions_opt_in_desc_supported_networks": "Active las transacciones inteligentes para realizar transacciones más confiables y seguras en las redes compatibles.", "smart_transactions_learn_more": "Conozca más", @@ -3566,6 +3578,53 @@ "activity": "Actividad de {{symbol}}", "disclaimer": "Los datos de mercado provienen de fuentes externas como CoinGecko. Su uso es meramente informativo. MetaMask no se responsabiliza de su exactitud." }, + "security_trust": { + "title": "Seguridad y confianza", + "malicious": "Malicioso", + "risky": "Riesgoso", + "malicious_token_title": "Token malicioso", + "malicious_token_description": "{{symbol}} es un token malicioso. Evita interactuar o realizar operaciones con él.", + "verified_token_title": "Token verificado", + "verified_token_description": "{{symbol}} opera activamente y goza de amplio reconocimiento. La verificación no implica el respaldo de MetaMask.", + "risky_token_title": "Token riesgoso", + "risky_token_description": "Se detectaron señales de precaución para {{symbol}}. Investiga bien antes de operar con este token.", + "malicious_token_sheet_description": "Se detectaron señales de riesgo grave para {{symbol}}. Te recomendamos no operar con este token.", + "got_it": "Entendido", + "proceed": "Continuar", + "cancel": "Cancelar", + "data_unavailable": "Datos de seguridad no disponibles", + "subtitle_known": "No se detectaron señales de riesgo. Siempre investiga cualquier activo antes de operar.", + "subtitle_no_issues": "No se detectaron señales de riesgo. Siempre investiga cualquier activo antes de operar.", + "subtitle_suspicious": "Se detectaron señales de precaución. Revisa cuidadosamente los problemas señalados antes de operar con este activo.", + "subtitle_malicious": "Se detectaron señales de riesgo grave. Te recomendamos evitar este activo.", + "subtitle_unavailable": "No se pudo cargar el análisis de seguridad de este token.", + "token_distribution": "Distribución de tokens", + "total_supply": "Suministro total", + "top_10_holders": "Los 10 principales titulares", + "other": "Otro", + "no_hidden_fees_detected": "No se detectaron tarifas ocultas", + "buy_sell_tax": "Impuesto de compra/venta", + "buy_tax": "Impuesto de compra", + "sell_tax": "Impuesto de venta", + "transfer": "Transferir", + "token_info": "Información del token", + "created": "Creado", + "token_age": "Antigüedad del token", + "network": "Red", + "type": "Tipo", + "official_links": "Enlaces oficiales", + "website": "Sitio web", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "N/A", + "verified": "Verificado", + "no_issues": "Sin problemas", + "suspicious": "Sospechoso", + "malicious_label": "Malicioso", + "more": "más", + "evaluation_disclaimer": "Esta revisión de seguridad es solo para fines de evaluación y no constituye un respaldo ni una recomendación para operar." + }, "account_details": { "title": "Detalles de la cuenta", "share_account": "Compartir", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Bonificación reclamable", "claim_bonus": "Reclamar bono", "claim_bonus_subtitle": "El bono se pagará en {{networkName}}.", + "percentage_bonus_on_linea": "Bono del {{percentage}} % en Linea", + "claim": "Reclamar", + "sounds_good": "Me parece bien", + "claimable_bonus_tooltip_with_percentage": "Bono anualizado del {{percentage}} % que has ganado por mantener mUSD. Puedes reclamar tu bono diariamente en Linea.", "empty_state_cta": { "heading": "Presta {{tokenSymbol}} y gana", "body": "Presta tu {{tokenSymbol}} con {{protocol}} y gana", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Tus monedas estables" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Ganar", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "No tienes suficiente saldo de recursos para realizar esta acción." }, - "trx_unstaking_in_progress": "Unstaking de {{amount}} n curso. El unstaking tarda 14 días.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Unstaking de {{amount}} TRX en curso", + "description": "El unstaking tarda 14 días" + }, + "unstaked_banner": { + "title": "Unstaking de {{amount}} TRX finalizado", + "description": "Ya puedes retirar tus TRX sin staking", + "button": "Retirar", + "error": "Retiro fallido" + } }, "stake_eth": "Hacer staking de ETH", "unstake_eth": "Dejar de hacer staking de ETH", @@ -6376,7 +6498,8 @@ "approve": "Aprobar la solicitud", "perps_deposit": "Agregar fondos", "predict_deposit": "Añadir fondos de Predicciones", - "predict_withdraw": "Retirar" + "predict_withdraw": "Retirar", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Este sitio necesita permiso para gastar sus tokens.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "{{index}} de transacción", "transaction": "Transacción", "available_balance": "Saldo disponible: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Continuar", "deposit_edit_amount_done": "Agregar fondos", "deposit_edit_amount_predict_withdraw": "Retirar", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Las billeteras físicas aún no son compatibles. Usa una billetera en caliente para continuar.", "hardware_wallet_not_supported_solana": "Las billeteras físicas aún no son compatibles con Solana. Usa una billetera en caliente para continuar.", "price_impact_info_title": "Impacto sobre el precio", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Así es como tu operación modifica el precio de mercado de un token. Depende del volumen de la operación, la liquidez disponible y las tarifas del proveedor. MetaMask no controla el impacto en el precio.", "price_impact_info_gasless_description": "El impacto en el precio refleja cómo tu orden de canje afecta el precio de mercado del activo. Si no tienes suficientes fondos para gas, parte de tu token de origen se asigna automáticamente para cubrir las tarifas, lo que aumenta el impacto en el precio. MetaMask no influye ni controla el impacto en el precio.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Debido al volumen de tu operación y a la liquidez disponible, obtendrás aproximadamente {{priceImpact}} menos que el precio de mercado. Esto ya está incluido en tu cotización.", "price_impact_high": "Alto impacto en el precio", "price_impact_execution_description": "Perderás aproximadamente {{priceImpact}} del valor de tu token en este canje. Intenta reducir el monto o elegir una ruta más líquida.", "proceed": "Continuar", @@ -6627,8 +6751,8 @@ "total_cost": "Costo total", "got_it": "Entendido", "price_impact_warning_title": "Alto impacto en el precio", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Impacto muy alto en el precio", + "price_impact_error_description": "Perderás aproximadamente {{priceImpact}} del precio de mercado de tu token en este canje. Prueba con una operación más pequeña o una ruta con mayor liquidez para mejorar tu tasa." }, "quote_expired_modal": { "title": "Hay nuevas cotizaciones disponibles", @@ -6940,7 +7064,7 @@ "upgrade_title": "Actualiza a Metal", "continue_button": "Continuar", "virtual_card": { - "name": "Virtual Card", + "name": "Tarjeta Virtual", "price": "Gratis", "feature_1": "Tarjeta virtual para Apple Pay y Google Pay", "feature_2": "Paga con criptomonedas (USDC, USDT, WETH y más)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Tarjeta Metal", "price": "$199/año", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Todo lo que ofrece la versión virtual, más:", + "feature_1": "Tarjeta metálica grabada premium", + "feature_2": "3 % de cashback en los primeros $10.000/año", "feature_3": "Sin tarifas por transacciones internacionales" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Gana hasta $300 en cashback al año", + "upgrade_to_metal_label": "O actualiza a Metal para obtener 3 veces más recompensas" }, "review_order": { "title": "Revisa tu orden", @@ -7104,7 +7228,7 @@ "ssn_description": "Requerido por el emisor de la tarjeta. No se realizará ninguna verificación de crédito.", "invalid_ssn": "SSN no válido", "invalid_date_of_birth": "Fecha de nacimiento no válida. Debes tener al menos 18 años", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "El nombre y apellido deben coincidir con tu identidad verificada" }, "physical_address": { "title": "Agrega tu dirección", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Estás cerca de tu límite de gasto", "description": "Actualiza para evitar rechazos", - "confirm_button_label": "Establecer nuevo límite" + "confirm_button_label": "Establecer nuevo límite", + "dismiss_button_label": "Ignorar" }, "need_delegation": { "title": "Debes habilitar tu tarjeta", @@ -7301,7 +7426,6 @@ "dismiss": "Ignorar", "update_success": "Límite de gasto actualizado correctamente", "update_error": "No se pudo actualizar el límite de gasto", - "solana_not_supported": "Habilita los tokens de Solana en card.metamask.io", "select_token": "Selecciona un token", "loading": "Cargando tokens disponibles...", "load_error": "No se pueden cargar los tokens. Inténtalo de nuevo.", @@ -7343,9 +7467,7 @@ "limited": "Limitado", "not_enabled": "No habilitado", "update_success": "Prioridad de gasto actualizada correctamente", - "update_error": "No se pudo actualizar la prioridad de gasto", - "solana_not_supported_button_title": "Otros tokens en Solana", - "solana_not_supported_button_description": "Habilitar en card.metamask.io" + "update_error": "No se pudo actualizar la prioridad de gasto" }, "card_authentication": { "title": "Inicia sesión en tu cuenta de tarjeta", @@ -7443,6 +7565,11 @@ "title": "Error al registrarte", "description": "Comprueba tu conexión y vuelve a intentarlo." }, + "version_guard": { + "title": "Se requiere una actualización", + "description": "Se requiere una versión más reciente de MetaMask para usar Recompensas. Actualiza para continuar.", + "update_button": "Actualizar MetaMask" + }, "season_error": { "error_fetching_title": "No se pudo cargar la temporada", "error_fetching_description": "Comprueba tu conexión y vuelve a intentarlo.", @@ -7525,7 +7652,6 @@ "main_title": "Recompensas", "referral_title": "Referidos", "tab_overview_title": "Resumen general", - "tab_snapshots_title": "Instantáneas", "tab_activity_title": "Actividad", "referral_stats_earned_from_referrals": "Ganancias por referidos", "referral_stats_referrals": "Recomendados", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "No ganaste recompensas esta temporada, pero siempre habrá una próxima vez.", "verifying_rewards": "Nos aseguramos de que todo sea correcto antes de que reclames tus recompensas." }, + "previous_season_view": { + "title": "Temporada anterior" + }, "season_status": { "points_earned": "Puntos ganados" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Potenciadores activos", "season_1": "Temporada 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "Calculadora del bono en mUSD", + "description": "Descubre cuánto podrías ganar al convertir tus monedas estables a mUSD.", + "amount_label": "Monto convertido", + "estimated_bonus": "Bono anualizado estimado: hasta 3 %", + "initial_amount": "Monto inicial", + "daily_bonus": "Bono diario reclamable", + "annualized_bonus": "Bono anualizado", + "disclaimer": "Esto es solo una estimación. El bono está sujeto a cambios.", "buy_button": "Comprar mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Canjear por mUSD" }, "upcoming_rewards": { "title": "Recompensas bloqueadas", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "No se pudo cargar" }, - "snapshot": { + "campaign": { "starts_date": "Empieza el {{date}}", "ends_date": "Termina el {{date}}", - "results_coming_soon": "Resultados próximamente", - "tokens_on_the_way": "Los tokens están llegando", + "ended_date": "Ended {{date}}", "pill_up_next": "A continuación", - "pill_live_now": "En vivo ahora", - "pill_calculating": "Calculando", - "pill_results_ready": "Resultados listos", - "pill_complete": "Completado" - }, - "snapshots_section": { - "title": "Instantáneas", - "error_title": "No se pueden cargar las instantáneas", - "error_description": "No pudimos cargar las instantáneas. Inténtalo de nuevo.", - "retry_button": "Reintentar" - }, - "snapshots_tab": { + "pill_active": "En vivo", + "pill_complete": "Completado", + "enter_now": "Participa ahora", + "entered": "Ya participas", + "participant_count": "#{{count}}", + "opt_in_cta": "Participar", + "opt_in_sheet_title": "Únete a la campaña", + "opt_in_sheet_description_pre_link": "Al hacer clic en \"Participar\", aceptas las Recompensas de MetaMask", + "opt_in_sheet_link_text": "Términos de uso complementarios y aviso de privacidad", + "opt_in_sheet_description_post_link": "Realizaremos un seguimiento de la actividad en la cadena para recompensarte automáticamente.", + "geo_restriction_banner_title": "No está disponible en tu región", + "geo_restriction_banner_description": "Esta campaña no está disponible en tu región debido a regulaciones locales." + }, + "campaign_mechanics": { + "title": "Mecánica" + }, + "campaign_details": { + "start_date": "Inicia: {{date}}", + "end_date": "Finaliza: {{date}}", + "opt_in": "Participar", + "opting_in": "Activando participación...", + "opted_in": "Ya estás participando en esta campaña", + "opt_in_error": "Error al activar la participación. Inténtalo de nuevo.", + "join_campaign": "Únete a la campaña", + "checking_opt_in_status": "Comprobando estado de participación", + "swap": "Canjear", + "how_it_works": "Cómo funciona" + }, + "campaigns_preview": { + "title": "Campañas", + "coming_soon": "Próximamente", + "notify_me": "Notificarme" + }, + "earn_rewards": { + "title": "Gana recompensas", + "musd_title": "Bono de hasta el 3 % en monedas estables", + "musd_subtitle": "Calcula tu bono en mUSD", + "card_title": "Hasta 3% de cashback", + "card_subtitle": "Obtén tu tarjeta MetaMask ahora", + "card_subtitle_cardholder": "Accede a los beneficios de tu tarjeta MetaMask" + }, + "campaigns_view": { + "title": "Campañas", "active_title": "Activa", "upcoming_title": "Próxima", "previous_title": "Previa", - "empty_state": "No hay instantáneas disponibles", - "error_title": "No se pueden cargar las instantáneas", - "error_description": "No pudimos cargar las instantáneas. Inténtalo de nuevo.", + "empty_state": "No hay campañas disponibles", + "error_title": "No se pueden cargar las campañas", + "error_description": "No pudimos cargar las campañas. Inténtalo de nuevo.", "retry_button": "Reintentar", "refreshing": "Actualizando..." } @@ -7953,13 +8112,12 @@ "continue": "Continuar" }, "connecting": { - "title": "Conecta tu {{device}}", + "title": "Conectando tu {{device}}...", "searching": "Buscando {{device}}...", - "tips_header": "Para continuar, asegúrate de que:", + "tips_header": "Asegúrate de que:", "tip_unlock": "Tu {{device}} esté desbloqueado", "tip_open_app": "La aplicación de Ethereum esté abierta", "tip_enable_bluetooth": "El Bluetooth esté activado", - "tip_dnd_off": "El modo \"No molestar\" esté desactivado", "tip_bluetooth_permission": "Se hayan concedido permisos de ubicación y Bluetooth", "tip_bluetooth_permission_v12": "Se haya concedido permiso para dispositivos cercanos", "tip_stay_close": "Tu dispositivo se mantenga cerca de tu teléfono" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Se requiere permiso para dispositivos cercanos", "bluetooth_off": "Activa el Bluetooth para conectar tu dispositivo", "bluetooth_scan_failed": "Error al buscar dispositivos. Inténtalo de nuevo", - "bluetooth_connection_failed": "Activa el Bluetooth en tu dispositivo para continuar", + "bluetooth_connection_failed": "Falló la conexión con tu dispositivo. Inténtalo de nuevo", "not_supported": "No se admite esta operación", "unknown_error": "Asegúrate de que tu {{device}} esté configurado con la frase secreta de recuperación o la frase de contraseña de esta cuenta" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Efectivo", + "cash_empty_description": "Aún no tienes mUSD. Convierte monedas estables a mUSD desde la sección Efectivo en la página de inicio.", + "cash_empty_description_network_filter": "No hay mUSD en esta red. Cambia de red para ver tus mUSD.", "tokens": "Tokens", "perpetuals": "Contratos perpetuos", "predictions": "Predicciones", + "whats_happening": "Qué está pasando", + "whats_happening_categories": { + "geopolitical": "Geopolítico", + "macro": "Macro", + "regulatory": "Regulatorio", + "technical": "Técnico", + "social": "Social", + "other": "Otro" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "Importar NFT", diff --git a/locales/languages/fr.json b/locales/languages/fr.json index edebc02df75..4771a1895c8 100644 --- a/locales/languages/fr.json +++ b/locales/languages/fr.json @@ -20,6 +20,12 @@ "update": "Mettre à jour" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Alerte", @@ -120,8 +126,8 @@ "title": "Envoi d’actifs vers une adresse de destruction" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Avertissement concernant le contrat de jetons", + "message": "L’adresse du destinataire ne prend peut-être pas en charge les transferts directs de jetons, ce qui pourrait entraîner une perte de fonds. Ne poursuivez que si vous êtes certain que ce contrat est capable de recevoir votre transfert." }, "gas_sponsorship_reserve_balance": { "message": "Le parrainage de gaz n’est pas disponible pour cette transaction. Vous devrez conserver au moins %{minBalance} %{nativeTokenSymbol} sur votre compte.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Impossible de résoudre le nom", "invalid_address": "Adresse non valide", "contractAddressError": "Vous envoyez des jetons à l’adresse du contrat du jeton. Cela peut entraîner la perte de ces jetons.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Adresse du contrat intelligent", + "smart_contract_address_warning": "L’adresse du destinataire ne prend peut-être pas en charge les transferts directs de jetons, ce qui pourrait entraîner une perte de fonds. Ne poursuivez que si vous êtes certain que ce contrat est capable de recevoir votre transfert.", "i_understand": "Je comprends", "cancel": "Annuler" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Le stop loss doit être {{direction}} au prix {{priceType}}", "stop_loss_beyond_liquidation_error": "Le stop loss doit être {{direction}} au prix de liquidation", "stop_loss_order_view_warning": "Le stop loss est {{direction}} au prix de liquidation", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "supérieur", "below": "inférieur", "done": "Terminé", @@ -2086,14 +2094,15 @@ "a_closer_look": "Regarder de plus près", "whats_being_said": "Ce qu’on en dit", "footer_disclaimer": "Résumé généré par l’IA fourni à titre informatif uniquement", - "trade_button": "Trader", + "swap_button": "Échanger", + "buy_button": "Acheter", "sources_count": "+{{count}} sources", "sources_title": "Sources d’information", "feedback_submitted": "Commentaire soumis", "helpful_prompt": "Cela vous a-t-il été utile ?", "feedback": { "title": "Commentaires", - "description": "Aidez-nous à améliorer nos analyses de marché générées par l’IA.", + "description": "Votre réponse nous aidera à améliorer nos résumés générés par l’IA.", "not_relevant": "Pas pertinent", "not_accurate": "Inexact", "hard_to_understand": "Difficile à comprendre", @@ -2206,7 +2215,7 @@ "available_balance": "Solde disponible", "claim_amount_text": "Réclamer {{amount}} $", "claim_winnings_text": "Réclamer vos gains", - "claiming_text": "Claiming...", + "claiming_text": "En cours de réclamation…", "unrealized_pnl_label": "Profits et pertes non réalisés", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Impossible de charger", @@ -2287,7 +2296,7 @@ "try_again": "Réessayez" }, "in_progress": { - "title": "Claim already in progress" + "title": "Réclamation déjà en cours" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Frais payés à la bourse ou au marché", "total_incl_fees": "frais inclus", "close": "Fermer", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Les prix indiqués sont basés sur une exécution entière de l’ordre. Les montants réels peuvent varier si l’ordre n’est que partiellement exécuté.", + "deposit_fee": "Frais de dépôt", + "deposit_fee_description": "Frais facturés pour déposer des fonds sur votre solde de prédiction" }, "error": { "title": "Impossible de se connecter aux marchés de prédiction", @@ -3059,6 +3068,7 @@ "networks_no_results": "Aucun réseau trouvé", "network_name_label": "Nom du réseau", "network_name_placeholder": "Nom du réseau (facultatif)", + "required": "Requis", "network_rpc_url_label": "URL de l’appel de procédure à distance", "network_rpc_name_label": "Nom du RPC", "network_rpc_placeholder": "Nouveau réseau RPC", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Cette fonctionnalité vous avertit de toute activité malveillante en examinant activement les demandes de transaction et de signature.", "security_alerts": "Alertes de sécurité", "security_alerts_desc": "Cette fonctionnalité vous avertit de toute activité malveillante en examinant localement vos demandes de transaction et de signature. Vous devez faire preuve de diligence raisonnable avant d’approuver toute demande. Rien ne garantit que toutes les activités malveillantes seront détectées par cette fonctionnalité. En l’activant, vous acceptez les conditions d’utilisation du fournisseur.", + "smart_account_dapp_requests_heading": "Demandes de comptes intelligents émanant de dapps", + "smart_account_dapp_requests_desc": "Autoriser les dapps à demander des fonctionnalités de compte intelligent pour les comptes standards. Cela n’affectera pas les comptes qui sont déjà des comptes intelligents.", "smart_transactions_opt_in_heading": "Transactions intelligentes", "smart_transactions_opt_in_desc_supported_networks": "Activez les transactions intelligentes pour profiter de transactions plus fiables et plus sûres sur les réseaux pris en charge.", "smart_transactions_learn_more": "En savoir plus", @@ -3566,6 +3578,53 @@ "activity": "Activité du {{symbol}}", "disclaimer": "Les données du marché sont fournies par des sources tierces telles que CoinGecko. Ces données sont fournies à titre d’information uniquement. MetaMask n’est pas responsable de leur exactitude." }, + "security_trust": { + "title": "Sécurité et confiance", + "malicious": "Malveillant", + "risky": "À risque", + "malicious_token_title": "Jeton malveillant", + "malicious_token_description": "{{symbol}} est un jeton malveillant. Évitez toute interaction avec ce jeton ou de le négocier.", + "verified_token_title": "Jeton vérifié", + "verified_token_description": "{{symbol}} fait l’objet d’un trading actif et est largement reconnu. Cette vérification ne constitue pas une recommandation de la part de MetaMask.", + "risky_token_title": "Jeton à risque", + "risky_token_description": "Signaux d’alerte détectés concernant le jeton {{symbol}}. Renseignez-vous soigneusement avant de négocier ce jeton.", + "malicious_token_sheet_description": "De sérieux signaux d’alerte ont été détectés concernant le jeton {{symbol}}. Nous vous recommandons de ne pas négocier ce jeton.", + "got_it": "J’ai compris", + "proceed": "Continuer", + "cancel": "Annuler", + "data_unavailable": "Données de sécurité non disponibles", + "subtitle_known": "Aucun signal d’alerte détecté. Vous devez faire preuve de diligence raisonnable avant de négocier tout actif.", + "subtitle_no_issues": "Aucun signal d’alerte détecté. Vous devez faire preuve de diligence raisonnable avant de négocier tout actif.", + "subtitle_suspicious": "Signaux d’alerte détectés. Examinez attentivement les problèmes signalés avant de négocier cet actif.", + "subtitle_malicious": "De sérieux signaux d’alerte ont été détectés. Nous vous recommandons d’éviter cet actif.", + "subtitle_unavailable": "L’analyse de sécurité n’a pas pu être chargée pour ce jeton.", + "token_distribution": "Répartition des jetons", + "total_supply": "Offre totale", + "top_10_holders": "Les 10 principaux détenteurs", + "other": "Autre", + "no_hidden_fees_detected": "Aucuns frais cachés détectés", + "buy_sell_tax": "Taxe à l’achat/à la vente", + "buy_tax": "Taxe à l’achat", + "sell_tax": "Taxe à la vente", + "transfer": "Transférer", + "token_info": "Informations sur le jeton", + "created": "Créé", + "token_age": "Âge du jeton", + "network": "Réseau", + "type": "Type", + "official_links": "Liens officiels", + "website": "Site web", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "S.O.", + "verified": "Vérifié", + "no_issues": "Aucun problème", + "suspicious": "Suspect", + "malicious_label": "Malveillant", + "more": "plus", + "evaluation_disclaimer": "Cette analyse de sécurité est fournie à titre indicatif uniquement et ne constitue ni une recommandation ni une incitation à négocier ce jeton." + }, "account_details": { "title": "Détails du compte", "share_account": "Partager", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Bonus réclamable", "claim_bonus": "Réclamer le bonus", "claim_bonus_subtitle": "Le bonus sera versé sur {{networkName}}.", + "percentage_bonus_on_linea": "Bonus de {{percentage}} % sur Linea", + "claim": "Réclamer", + "sounds_good": "Ça a l’air intéressant", + "claimable_bonus_tooltip_with_percentage": "Bonus annualisé de {{percentage}} % que vous avez gagné en détenant des mUSD. Vous pouvez réclamer votre bonus quotidiennement sur Linea.", "empty_state_cta": { "heading": "Prêtez des {{tokenSymbol}} et gagnez", "body": "Prêtez vos {{tokenSymbol}} avec {{protocol}} et touchez un revenu", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Vos stablecoins" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Gagner", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Vous ne disposez pas d’un solde suffisant pour effectuer cette action." }, - "trx_unstaking_in_progress": "Déstaking de {{amount}} TRX en cours. Le déstaking prend 14 jours.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Déstaking de {{amount}} TRX en cours", + "description": "Le déstaking prendra 14 jours" + }, + "unstaked_banner": { + "title": "Déstaking de {{amount}} TRX terminé", + "description": "Vous pouvez désormais retirer vos TRX déstakés", + "button": "Retirer", + "error": "Le retrait a échoué" + } }, "stake_eth": "Staker des ETH", "unstake_eth": "Déstaker des ETH", @@ -6376,7 +6498,8 @@ "approve": "Approuver la demande", "perps_deposit": "Ajouter des fonds", "predict_deposit": "Ajouter des fonds de prédiction", - "predict_withdraw": "Retirer" + "predict_withdraw": "Retirer", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Ce site demande l’autorisation de dépenser vos jetons.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Transaction {{index}}", "transaction": "Protection des", "available_balance": "Solde disponible: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Continuer", "deposit_edit_amount_done": "Ajouter des fonds", "deposit_edit_amount_predict_withdraw": "Retirer", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Les portefeuilles matériels ne sont pas encore pris en charge. Utilisez un portefeuille connecté pour continuer.", "hardware_wallet_not_supported_solana": "Les portefeuilles matériels ne sont pas encore pris en charge pour Solana. Utilisez un portefeuille connecté pour continuer.", "price_impact_info_title": "Impact sur les prix", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Voici comment votre transaction modifie le prix de marché d’un jeton. Cela dépend de la taille de la transaction, de la liquidité disponible et des frais facturés par le fournisseur. MetaMask n’exerce aucun contrôle sur cet impact.", "price_impact_info_gasless_description": "L’impact sur le prix reflète la manière dont votre ordre de swap affecte le prix de l’actif sur le marché. Si vous ne disposez pas de fonds suffisants pour payer les frais de gaz, une partie de votre jeton source est automatiquement allouée pour couvrir ces frais, ce qui augmente l’impact sur le prix. MetaMask n’influence ni ne contrôle l’impact sur le prix.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "En raison de la taille de votre transaction et de la liquidité disponible, vous obtiendrez environ {{priceImpact}} de moins que le prix de marché. Cette différence de prix est déjà reflétée dans votre devis.", "price_impact_high": "Impact élevé sur le prix", "price_impact_execution_description": "Vous perdrez environ {{priceImpact}} de la valeur de vos jetons lors de cet échange. Essayez de réduire le montant ou de choisir une voie plus liquide.", "proceed": "Continuer", @@ -6627,8 +6751,8 @@ "total_cost": "Coût total", "got_it": "J’ai compris", "price_impact_warning_title": "Impact élevé sur le prix", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Impact très élevé sur le prix", + "price_impact_error_description": "En effectuant cet échange, vous perdrez environ {{priceImpact}} du prix de marché de votre jeton. Essayez un montant plus petit ou une voie plus liquide pour améliorer votre taux de change." }, "quote_expired_modal": { "title": "De nouvelles cotations sont disponibles", @@ -6940,7 +7064,7 @@ "upgrade_title": "Passer à Metal", "continue_button": "Continuer", "virtual_card": { - "name": "Virtual Card", + "name": "Carte virtuelle", "price": "Gratuit", "feature_1": "Carte virtuelle pour Apple Pay et Google Pay", "feature_2": "Payer avec des cryptomonnaies (USDC, USDT, WETH, etc.)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Carte Metal", "price": "199 $/an", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Tout ce qu’offre la carte virtuelle, avec en plus :", + "feature_1": "Une carte métallique gravée haut de gamme", + "feature_2": "3 % de cashback sur les premiers 10 000 $ que vous dépensez par an", "feature_3": "Pas de frais de transaction à l’étranger" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Gagnez jusqu’à 300 $ de cashback par an", + "upgrade_to_metal_label": "Ou passez à la carte Metal pour tripler vos récompenses" }, "review_order": { "title": "Vérifiez votre commande", @@ -7104,7 +7228,7 @@ "ssn_description": "Requis par l’émetteur de la carte. Aucune vérification de solvabilité ne sera effectuée.", "invalid_ssn": "Numéro de sécurité sociale non valide", "invalid_date_of_birth": "Date de naissance non valide. Vous devez être âgé d’au moins 18 ans", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Veuillez saisir votre nom et votre prénom tels qu’ils figurent sur la pièce d’identité que vous avez fournie pour la vérification de votre identité" }, "physical_address": { "title": "Ajoutez votre adresse", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Vous avez presque atteint votre limite de dépenses", "description": "Veuillez modifier votre limite de dépenses pour éviter tout refus de paiement", - "confirm_button_label": "Fixer une nouvelle limite" + "confirm_button_label": "Fixer une nouvelle limite", + "dismiss_button_label": "Ignorer" }, "need_delegation": { "title": "Vous devez activer votre carte", @@ -7301,7 +7426,6 @@ "dismiss": "Ignorer", "update_success": "Limite de dépenses mise à jour avec succès", "update_error": "Échec de la mise à jour de la limite de dépenses", - "solana_not_supported": "Activez les jetons Solana sur card.metamask.io", "select_token": "Sélectionnez un jeton", "loading": "Chargement des jetons disponibles…", "load_error": "Impossible de charger les jetons. Veuillez réessayer.", @@ -7343,9 +7467,7 @@ "limited": "Limité", "not_enabled": "Non activé", "update_success": "Priorité de dépense mise à jour avec succès", - "update_error": "Échec de la mise à jour de la priorité de dépense", - "solana_not_supported_button_title": "Autres jetons sur Solana", - "solana_not_supported_button_description": "Activer sur card.metamask.io" + "update_error": "Échec de la mise à jour de la priorité de dépense" }, "card_authentication": { "title": "Connectez-vous à votre compte Card", @@ -7443,6 +7565,11 @@ "title": "Échec de l’inscription", "description": "Vérifiez votre connexion et réessayez." }, + "version_guard": { + "title": "Mise à jour requise", + "description": "Une version plus récente de MetaMask est nécessaire pour utiliser « Récompenses ». Veuillez effectuer la mise à jour pour continuer.", + "update_button": "Mettre à jour MetaMask" + }, "season_error": { "error_fetching_title": "Impossible de charger les informations sur la période des récompenses", "error_fetching_description": "Vérifiez votre connexion et réessayez.", @@ -7525,7 +7652,6 @@ "main_title": "Récompenses", "referral_title": "Parrainages", "tab_overview_title": "Aperçu", - "tab_snapshots_title": "Snapshots", "tab_activity_title": "Activité", "referral_stats_earned_from_referrals": "Gagnés grâce aux parrainages", "referral_stats_referrals": "Parrainages", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Vous n’avez pas gagné de récompenses cette saison, mais il y aura toujours une prochaine fois.", "verifying_rewards": "Nous nous assurons que tout est correct avant que vous ne réclamiez vos récompenses." }, + "previous_season_view": { + "title": "Saison précédente" + }, "season_status": { "points_earned": "Points gagnés" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Boosts actifs", "season_1": "Saison 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "Calculateur du bonus en mUSD", + "description": "Découvrez combien vous pourriez gagner en convertissant vos stablecoins en mUSD.", + "amount_label": "Montant converti", + "estimated_bonus": "Bonus annualisé estimé : jusqu’à 3 %", + "initial_amount": "Montant initial", + "daily_bonus": "Bonus quotidien pouvant être réclamé", + "annualized_bonus": "Bonus annualisé", + "disclaimer": "Il ne s’agit que d’une estimation. Le montant du bonus peut varier.", "buy_button": "Acheter des mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Échanger en mUSD" }, "upcoming_rewards": { "title": "Récompenses verrouillées", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Impossible de charger" }, - "snapshot": { + "campaign": { "starts_date": "Débute le {{date}}", "ends_date": "Se termine le {{date}}", - "results_coming_soon": "Résultats bientôt disponibles", - "tokens_on_the_way": "Jetons en cours d’envoi", + "ended_date": "Ended {{date}}", "pill_up_next": "À venir", - "pill_live_now": "Disponible dès maintenant", - "pill_calculating": "Calcul en cours", - "pill_results_ready": "Résultats disponibles", - "pill_complete": "Terminé" - }, - "snapshots_section": { - "title": "Snapshots", - "error_title": "Impossible de charger les snapshots", - "error_description": "Impossible de charger les snapshots. Veuillez réessayer.", - "retry_button": "Réessayer" - }, - "snapshots_tab": { + "pill_active": "Active", + "pill_complete": "Terminé", + "enter_now": "S’inscrire maintenant", + "entered": "Déjà inscrit", + "participant_count": "n° {{count}}", + "opt_in_cta": "S’inscrire", + "opt_in_sheet_title": "Rejoindre la campagne", + "opt_in_sheet_description_pre_link": "En cliquant sur « S’inscrire », vous acceptez les conditions générales du programme de fidélité « Récompenses MetaMask »", + "opt_in_sheet_link_text": "Conditions d’utilisation supplémentaires et avis de confidentialité de MetaMask Rewards", + "opt_in_sheet_description_post_link": "Nous suivrons votre activité sur la chaîne pour vous récompenser automatiquement.", + "geo_restriction_banner_title": "Non disponible dans votre région", + "geo_restriction_banner_description": "Cette campagne n’est pas disponible dans votre région en raison de la réglementation locale." + }, + "campaign_mechanics": { + "title": "Déroulement" + }, + "campaign_details": { + "start_date": "Débute le : {{date}}", + "end_date": "Se termine le : {{date}}", + "opt_in": "S’inscrire", + "opting_in": "Inscription en cours…", + "opted_in": "Vous êtes inscrit à cette campagne", + "opt_in_error": "Échec de l’inscription. Veuillez réessayer.", + "join_campaign": "Rejoindre la campagne", + "checking_opt_in_status": "Vérification du statut d’inscription", + "swap": "Échanger", + "how_it_works": "Comment ça marche " + }, + "campaigns_preview": { + "title": "Campagnes", + "coming_soon": "Bientôt disponible", + "notify_me": "M’avertir" + }, + "earn_rewards": { + "title": "Gagner des récompenses", + "musd_title": "Jusqu’à 3 % de bonus sur les stablecoins", + "musd_subtitle": "Calculer votre bonus en mUSD", + "card_title": "Jusqu’à 3 % de cashback", + "card_subtitle": "Obtenez votre carte MetaMask Card dès maintenant", + "card_subtitle_cardholder": "Accédez aux avantages de votre carte MetaMask Card" + }, + "campaigns_view": { + "title": "Campagnes", "active_title": "Actif", "upcoming_title": "À venir", "previous_title": "Précédent", - "empty_state": "Aucun snapshot disponible", - "error_title": "Impossible de charger les snapshots", - "error_description": "Impossible de charger les snapshots. Veuillez réessayer.", + "empty_state": "Aucune campagne disponible", + "error_title": "Impossible de charger les campagnes", + "error_description": "Nous n’avons pas pu charger les campagnes. Veuillez réessayer.", "retry_button": "Réessayer", "refreshing": "Actualisation en cours…" } @@ -7953,13 +8112,12 @@ "continue": "Continuer" }, "connecting": { - "title": "Connectez votre {{device}}", + "title": "Connexion de votre {{device}}…", "searching": "Recherche de {{device}}…", - "tips_header": "Pour continuer, assurez-vous que :", + "tips_header": "Assurez-vous que :", "tip_unlock": "Votre {{device}} est déverrouillé", "tip_open_app": "L’application Ethereum est ouverte", "tip_enable_bluetooth": "Le Bluetooth est activé", - "tip_dnd_off": "Le mode « Ne pas déranger » est désactivé", "tip_bluetooth_permission": "Les autorisations de localisation et de connexion Bluetooth sont accordées", "tip_bluetooth_permission_v12": "L’autorisation d’accès aux appareils à proximité est accordée", "tip_stay_close": "Votre appareil reste à proximité de votre téléphone" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "L’autorisation d’accès aux appareils à proximité est requise", "bluetooth_off": "Veuillez activer le Bluetooth pour établir une connexion avec votre appareil", "bluetooth_scan_failed": "Échec de la recherche d’appareils. Veuillez réessayer", - "bluetooth_connection_failed": "Activez le Bluetooth sur votre appareil pour continuer", + "bluetooth_connection_failed": "La connexion à votre appareil a échoué. Veuillez réessayer", "not_supported": "Cette opération n’est pas prise en charge", "unknown_error": "Assurez-vous que votre {{device}} est configuré avec la phrase de récupération secrète ou la phrase secrète de ce compte" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Liquidités", + "cash_empty_description": "Vous n’avez pas encore de mUSD. Convertissez vos stablecoins en mUSD depuis la section « Liquidités » de la page d’accueil.", + "cash_empty_description_network_filter": "Pas de mUSD sur ce réseau. Changez de réseau pour consulter votre solde de mUSD.", "tokens": "Jetons", "perpetuals": "Contrats perpétuels", "predictions": "Prédictions", + "whats_happening": "Actualités", + "whats_happening_categories": { + "geopolitical": "Géopolitique", + "macro": "Macroéconomie", + "regulatory": "Réglementation", + "technical": "Technique", + "social": "Réseaux sociaux", + "other": "Autre" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "Importer des NFT", diff --git a/locales/languages/hi.json b/locales/languages/hi.json index 363fd6273cf..c99f36605fa 100644 --- a/locales/languages/hi.json +++ b/locales/languages/hi.json @@ -20,6 +20,12 @@ "update": "अपडेट करें" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "एलर्ट", @@ -120,8 +126,8 @@ "title": "एसेट्स को बर्न एड्रेस पर भेजना" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "टोकन कॉन्ट्रैक्ट वार्निंग", + "message": "हो सकता है कि पाने वाले का पता सीधे टोकन ट्रांसफर को सपोर्ट न करे, जिससे फंड का नुकसान हो सकता है। तभी आगे बढ़ें जब आपको पक्का हो कि यह कॉन्ट्रैक्ट आपका ट्रांसफर प्राप्त कर सकता है।" }, "gas_sponsorship_reserve_balance": { "message": "इस ट्रांसेक्शन के लिए गैस स्पॉन्सरशिप उपलब्ध नहीं है। आपको अपने अकाउंट में कम से कम %{minBalance} %{nativeTokenSymbol} रखना होगा।", @@ -694,8 +700,8 @@ "could_not_resolve_name": "नाम हल नहीं किया जा सका", "invalid_address": "एड्रेस ग़लत है", "contractAddressError": "आप टोकन को टोकन के कॉन्ट्रैक्ट एड्रेस पर भेज रहे हैं। इससे इन टोकन को खोने की संभावना है।", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "स्मार्ट कॉन्ट्रैक्ट एड्रेस", + "smart_contract_address_warning": "हो सकता है कि पाने वाले का पता सीधे टोकन ट्रांसफर को सपोर्ट न करे, जिससे फंड का नुकसान हो सकता है। तभी आगे बढ़ें जब आपको पक्का हो कि यह कॉन्ट्रैक्ट आपका ट्रांसफर प्राप्त कर सकता है।", "i_understand": "मैं समझता हूं", "cancel": "कैंसिल करें" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "स्टॉप लॉस {{direction}} {{priceType}} प्राइस का होना चाहिए", "stop_loss_beyond_liquidation_error": "स्टॉप लॉस {{direction}} लिक्विडेशन प्राइस का होना चाहिए", "stop_loss_order_view_warning": "स्टॉप लॉस {{direction}} लिक्विडेशन प्राइस का है", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "ऊपर", "below": "नीचे", "done": "पूरा हुआ", @@ -2086,14 +2094,15 @@ "a_closer_look": "एक करीबी निगाह", "whats_being_said": "क्या कहा जा रहा है", "footer_disclaimer": "AI सारांश केवल जानकारी के लिए है", - "trade_button": "ट्रेड करें", + "swap_button": "स्वैप करें", + "buy_button": "खरीदें", "sources_count": "+{{count}} सोर्स", "sources_title": "समाचार सूत्र", "feedback_submitted": "फीडबैक सबमिट किया गया", "helpful_prompt": "क्या यह सहायक था?", "feedback": { "title": "फीडबैक", - "description": "हमारे AI-जनित मार्केट इनसाइट्स को बेहतर बनाने में सहायता करें।", + "description": "आपका जवाब हमारी AI समरी को बेहतर बनाने में मदद करता है।", "not_relevant": "संबंधित नहीं", "not_accurate": "सही नहीं", "hard_to_understand": "समझने में मुश्किल", @@ -2162,7 +2171,7 @@ "sell_position": "सैल पोज़िशन", "cash_out": "कैश आउट करें", "cash_out_info": "फ़ंड आपके उपलब्ध बैलेंस में जोड़े जाएंगे", - "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", + "buy_preview_outcome_at_price": "{{price}} पर {{outcome}}", "at_price_per_share": "{{size}} शेयरों को {{price}} पर बेचा जा रहा है", "cashout_info": "{{initialPrice}} पर {{outcome}} पर {{amount}}", "cashout_info_multiple": "{{outcomeGroupTitle}} पर {{amount}} • {{initialPrice}} पर {{outcome}}", @@ -2206,7 +2215,7 @@ "available_balance": "उपलब्ध बैलेंस", "claim_amount_text": "${{amount}} क्लेम करें", "claim_winnings_text": "जीत का ईनाम क्लेम करें", - "claiming_text": "Claiming...", + "claiming_text": "क्लेम किया जा रहा है...", "unrealized_pnl_label": "अनरियलाइज्ड P&L", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "लोड करने में असमर्थ", @@ -2287,7 +2296,7 @@ "try_again": "फिर से प्रयास करें" }, "in_progress": { - "title": "Claim already in progress" + "title": "क्लेम प्रोग्रेस में है" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "एक्सचेंज या मार्केट को दिया गया शुल्क", "total_incl_fees": "शुल्क सहित", "close": "बंद करें", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "दिखाई गई कीमतें यह मानकर चलती हैं कि आपका ऑर्डर पूरी तरह से भरा हुआ है। अगर ऑर्डर थोड़ा भरा हुआ है, तो असल रकम अलग हो सकती है।", + "deposit_fee": "डिपॉज़िट फीस", + "deposit_fee_description": "आपके प्रेडिक्शन बैलेंस में फंड डिपॉज़िट करने के लिए फीस ली जाती है" }, "error": { "title": "प्रीडिक्शंस से कनेक्ट नहीं हो पाया", @@ -3059,6 +3068,7 @@ "networks_no_results": "कोई नेटवर्क नहीं मिला", "network_name_label": "नेटवर्क का नाम", "network_name_placeholder": "नेटवर्क का नाम (वैकल्पिक)", + "required": "जरुरी", "network_rpc_url_label": "RPC URL", "network_rpc_name_label": "RPC का नाम", "network_rpc_placeholder": "नया RPC नेटवर्क", @@ -3298,6 +3308,8 @@ "blockaid_desc": "यह फीचर सक्रिय रूप से ट्रांसेक्शन और सिग्नेचर अनुरोधों की समीक्षा करके आपको बुरी नीयत वाली गतिविधि के प्रति एलर्ट करती है।", "security_alerts": "सुरक्षा चेतावनियाँ", "security_alerts_desc": "यह सुविधा स्थानीय रूप से आपके ट्रांसेक्शन और हस्ताक्षर अनुरोधों की समीक्षा करके आपको बुरी नीयत वाली गतिविधि के प्रति एलर्ट करती है। किसी भी अनुरोध को मंजूरी देने से पहले हमेशा पूरी जांच-पड़ताल ज़रूर करें। इस बात की कोई गारंटी नहीं है कि यह सुविधा सभी बुरी नीयत वाली गतिविधि का पता लगा लेगी। इस सुविधा को सक्षम करके आप प्रदाता की उपयोग की शर्तों से सहमत होते हैं।", + "smart_account_dapp_requests_heading": "dapps से स्मार्ट अकाउंट रिक्वेस्ट", + "smart_account_dapp_requests_desc": "dapps को स्टैंडर्ड अकाउंट के लिए स्मार्ट अकाउंट फ़ीचर रिक्वेस्ट करने दें। इससे उन अकाउंट पर कोई असर नहीं पड़ेगा जो पहले से ही स्मार्ट अकाउंट हैं।", "smart_transactions_opt_in_heading": "स्मार्ट ट्रांसेक्शन", "smart_transactions_opt_in_desc_supported_networks": "समर्थित नेटवर्क पर अधिक विश्वसनीय और सुरक्षित ट्रांसेक्शन के लिए स्मार्ट ट्रांसेक्शन चालू करें।", "smart_transactions_learn_more": "ज़्यादा जानें", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}} गतिविधि", "disclaimer": "मार्केट डेटा CoinGecko जैसे थर्ड-पार्टी सोर्स से मिलता है। डेटा सिर्फ़ जानकारी के लिए है। इसकी सटीकता के लिए MetaMask ज़िम्मेदार नहीं है।" }, + "security_trust": { + "title": "सुरक्षा और भरोसा", + "malicious": "बुरी नीयत वाला", + "risky": "जोखिम से भरा", + "malicious_token_title": "बुरी नीयत वाला टोकन", + "malicious_token_description": "{{symbol}} एक बुरी नीयत वाला टोकन है। इसके साथ इंटरैक्ट करने या इसे ट्रेड करने से बचें।", + "verified_token_title": "सत्यापित टोकन", + "verified_token_description": "{{symbol}} एक्टिवली ट्रेड किया जाता है और यह काफ़ी जाना-माना है। वेरिफ़िकेशन को MetaMask मेटामास्क द्वारा समर्थन नहीं है।", + "risky_token_title": "जोखिम भरा टोकन", + "risky_token_description": "{{symbol}} पर चेतावनी के संकेत मिले हैं। इस टोकन में ट्रेड करने से पहले ध्यान से रिसर्च करें।", + "malicious_token_sheet_description": "{{symbol}} पर गंभीर जोखिम के संकेत मिले हैं। हम इस टोकन में ट्रेड न करने की सलाह देते हैं।", + "got_it": "समझ गए", + "proceed": "आगे बढ़ें", + "cancel": "कैंसिल करें", + "data_unavailable": "सुरक्षा डेटा उपलब्ध नहीं है", + "subtitle_known": "जोखिम का कोई संकेत नहीं मिला। ट्रेड करने से पहले किसी भी एसेट में रिसर्च ज़रूर करें।", + "subtitle_no_issues": "जोखिम का कोई संकेत नहीं मिला। ट्रेड करने से पहले किसी भी एसेट में रिसर्च ज़रूर करें।", + "subtitle_suspicious": "चेतावनी के संकेत मिले हैं। इस एसेट में ट्रेड करने से पहले फ़्लैग किए गए मुद्दों को ध्यान से देखें।", + "subtitle_malicious": "गंभीर जोखिम के संकेत मिले हैं। हम इस एसेट से बचने की सलाह देते हैं।", + "subtitle_unavailable": "इस टोकन के लिए सिक्योरिटी एनालिसिस लोड नहीं किया जा सका।", + "token_distribution": "टोकन डिस्ट्रीब्यूशन", + "total_supply": "कुल आपूर्ति", + "top_10_holders": "टॉप 10 होल्डर", + "other": "अन्य", + "no_hidden_fees_detected": "कोई छिपी हुई फ़ीस नहीं दिखी", + "buy_sell_tax": "टैक्स खरीदें/बेचें", + "buy_tax": "टैक्स खरीदें", + "sell_tax": "टैक्स बेचें", + "transfer": "स्थानांतरण", + "token_info": "टोकन जानकारी", + "created": "बनाया गया", + "token_age": "टोकन की उम्र", + "network": "नेटवर्क", + "type": "यह पुष्टि करने के लिए टाइप करें", + "official_links": "ऑफिशियल लिंक", + "website": "वेबसाइट", + "twitter_x": "ट्विटर", + "telegram": "टेलीग्राम", + "etherscan": "Etherscan", + "na": "लागू नहीं", + "verified": "वेरीफाई किया गया", + "no_issues": "कोई समस्या नहीं", + "suspicious": "संदिग्ध", + "malicious_label": "बुरी नीयत वाला", + "more": "अधिक", + "evaluation_disclaimer": "यह सिक्योरिटी रिव्यू सिर्फ़ इवैल्यूएशन के लिए है और यह ट्रेड के लिए एंडोर्समेंट या रिकमेंडेशन नहीं है।" + }, "account_details": { "title": "अकाउंट का विवरण", "share_account": "साझा करें", @@ -5934,6 +5993,10 @@ "claimable_bonus": "क्लेम करने योग्य बोनस", "claim_bonus": "बोनस क्लेम करें", "claim_bonus_subtitle": "बोनस {{networkName}} पर दिया जाएगा।", + "percentage_bonus_on_linea": "Linea पर {{percentage}}% बोनस", + "claim": "क्लेम करें", + "sounds_good": "सही लगता है", + "claimable_bonus_tooltip_with_percentage": "mUSD होल्ड करने पर आपको मिला {{percentage}}% सालाना बोनस। आपका बोनस Linea पर रोज़ाना क्लेम किया जा सकता है।", "empty_state_cta": { "heading": "{{tokenSymbol}} उधार दें और कमाएं", "body": "{{protocol}} के साथ अपना {{tokenSymbol}} उधार दें और सालाना", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "आपके स्टेबलकॉइन" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "कमाएं", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "आपके पास यह काम करने के लिए पर्याप्त रिसोर्स बैलेंस नहीं है।" }, - "trx_unstaking_in_progress": "{{amount}} TRX का अनस्टेकिंग जारी है। अनस्टेकिंग में 14 दिन लगते हैं।", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "{{amount}} TRX की अनस्टेकिंग प्रोग्रेस में है", + "description": "अनस्टेकिंग में 14 दिन लगेंगे" + }, + "unstaked_banner": { + "title": "{{amount}} TRX की अनस्टेकिंग पूरी हुई", + "description": "आपका अनस्टेक्ड TRX अब विदड्रॉ किया जा सकता है", + "button": "निकालें", + "error": "विदड्रॉवल नहीं हो पाया" + } }, "stake_eth": "ETH स्टेक करें", "unstake_eth": "ETH अनस्टेक करें", @@ -6376,7 +6498,8 @@ "approve": "अनुरोध एप्रूव करें", "perps_deposit": "फंड जोड़ें", "predict_deposit": "प्रिडिक्शन फ़ंड जोड़ें", - "predict_withdraw": "निकालें" + "predict_withdraw": "निकालें", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "यह साइट आपके टोकन खर्च करने की अनुमति चाहती है।", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "ट्रांसेक्शन {{index}}", "transaction": "ट्रांसेक्शन", "available_balance": "उपलब्ध बैलेंस: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "जारी रखें", "deposit_edit_amount_done": "फंड जोड़ें", "deposit_edit_amount_predict_withdraw": "निकालें", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "हार्डवेयर वॉलेट अभी तक सपोर्टेड नहीं हैं। जारी रखने के लिए हॉट वॉलेट का उपयोग करें।", "hardware_wallet_not_supported_solana": "Solana के लिए हार्डवेयर वॉलेट अभी तक सपोर्टेड नहीं हैं। जारी रखने के लिए हॉट वॉलेट का उपयोग करें।", "price_impact_info_title": "कीमत का प्रभाव", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "इस तरह आपका ट्रेड किसी टोकन की मार्केट प्राइस बदलता है। यह ट्रेड साइज़, उपलब्ध लिक्विडिटी और प्रोवाइडर फीस पर निर्भर करता है। MetaMask प्राइस इम्पैक्ट को कंट्रोल नहीं करता है।", "price_impact_info_gasless_description": "प्राइस इम्पैक्ट यह दर्शाता है कि आपका स्वैप ऑर्डर एसेट की मार्केट प्राइस को कैसे प्रभावित करता है। अगर आपके पास गैस के लिए पर्याप्त फंड नहीं हैं, तो आपके स्रोत टोकन का एक हिस्सा ऑटोमैटिकली फीस को कवर करने के लिए इस्तेमाल किया जाएगा, जिससे प्राइस इम्पैक्ट बढ़ जाता है। MetaMask प्राइस इम्पैक्ट को प्रभावित या नियंत्रित नहीं करता।", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "आपके ट्रेड साइज़ और उपलब्ध लिक्विडिटी की वजह से, आपको मार्केट प्राइस से लगभग {{priceImpact}} कम मिलेगा। यह आपके कोट में पहले से ही शामिल है।", "price_impact_high": "हाई प्राइस इम्पैक्ट", "price_impact_execution_description": "इस स्वैप में आप अपने टोकन के मूल्य का लगभग {{priceImpact}} खो देंगे। राशि कम करने की कोशिश करें या अधिक लिक्विड रूट चुनें।", "proceed": "आगे बढ़ें", @@ -6627,8 +6751,8 @@ "total_cost": "कुल लागत", "got_it": "समझ गए", "price_impact_warning_title": "हाई प्राइस इम्पैक्ट", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "वेरी हाई प्राइस इम्पैक्ट", + "price_impact_error_description": "इस स्वैप पर आपको अपने टोकन की मार्केट प्राइस का लगभग {{priceImpact}} का नुकसान होगा। अपना रेट बेहतर करने के लिए छोटा ट्रेड या ज़्यादा लिक्विड तरीका आज़माएँ।" }, "quote_expired_modal": { "title": "नये कोटेशन उपलब्ध हैं", @@ -6940,7 +7064,7 @@ "upgrade_title": "मेटल में अपग्रेड करें", "continue_button": "जारी रखें", "virtual_card": { - "name": "Virtual Card", + "name": "वर्चुअल कार्ड", "price": "मुफ्त", "feature_1": "Apple Pay और Google Pay के लिए वर्चुअल कार्ड", "feature_2": "क्रिप्टो से भुगतान करें (USDC, USDT, WETH और अन्य)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "मेटल कार्ड", "price": "$199/साल", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "सब कुछ वर्चुअल, साथ में:", + "feature_1": "प्रीमियम एनग्रेव्ड मेटल कार्ड", + "feature_2": "पहले $10,000/साल पर 3% कैशबैक", "feature_3": "कोई विदेशी ट्रांसेक्शन फीस नहीं" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "सालाना $300 तक कैशबैक कमाएं", + "upgrade_to_metal_label": "या 3x रिवॉर्ड के लिए मेटल में अपग्रेड करें" }, "review_order": { "title": "अपना ऑर्डर समीक्षा करें", @@ -7104,7 +7228,7 @@ "ssn_description": "कार्ड जारी करने वाले के लिए ज़रूरी है। कोई क्रेडिट चेक नहीं किया जाएगा।", "invalid_ssn": "SSN ग़लत है", "invalid_date_of_birth": "जन्मतिथि ग़लत है। आपकी आयु कम से कम 18 वर्ष होनी चाहिए", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "पहला और आखिरी नाम आपकी वेरिफाइड पहचान से मेल खाना चाहिए" }, "physical_address": { "title": "अपना एड्रेस जोड़ें", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "आप अपनी खर्च सीमा के करीब पहुँच चुके हैं", "description": "अस्वीकृति से बचने के लिए अपडेट करें", - "confirm_button_label": "नई सीमा निर्धारित करें" + "confirm_button_label": "नई सीमा निर्धारित करें", + "dismiss_button_label": "खारिज करें" }, "need_delegation": { "title": "आपको अपना कार्ड चालू करना होगा", @@ -7301,7 +7426,6 @@ "dismiss": "खारिज करें", "update_success": "खर्च सीमा सफलतापूर्वक अपडेट की गई", "update_error": "खर्च सीमा अपडेट करना नहीं हो पाया", - "solana_not_supported": "card.metamask.io पर Solana टोकन चालू करें", "select_token": "टोकन चुनें", "loading": "उपलब्ध टोकन लोड हो रहे हैं…", "load_error": "टोकन लोड नहीं हो पाए। कृपया फिर से प्रयास करें।", @@ -7343,9 +7467,7 @@ "limited": "सीमित", "not_enabled": "चालू नहीं किया गया", "update_success": "खर्च प्राथमिकता सफलतापूर्वक अपडेट की गई", - "update_error": "खर्च प्राथमिकता अपडेट करना नहीं हो पाया", - "solana_not_supported_button_title": "Solana पर अन्य टोकन", - "solana_not_supported_button_description": "card.metamask.io पर चालू करें" + "update_error": "खर्च प्राथमिकता अपडेट करना नहीं हो पाया" }, "card_authentication": { "title": "अपने कार्ड अकाउंट में लॉगिन करें", @@ -7443,6 +7565,11 @@ "title": "ऑप्ट-इन नहीं हो पाया", "description": "अपना कनेक्शन जांचें और फिर से प्रयास करें।" }, + "version_guard": { + "title": "अपडेट ज़रूरी है", + "description": "रिवॉर्ड्स इस्तेमाल करने के लिए MetaMask का नया वर्शन ज़रूरी है। जारी रखने के लिए कृपया अपडेट करें।", + "update_button": "MetaMask को अपडेट करें" + }, "season_error": { "error_fetching_title": "सीज़न लोड नहीं हो पाया", "error_fetching_description": "अपना कनेक्शन जांचें और फिर से प्रयास करें।", @@ -7525,7 +7652,6 @@ "main_title": "पुरस्कार", "referral_title": "रेफरल", "tab_overview_title": "ओवरव्यू", - "tab_snapshots_title": "स्नैपशॉट्स", "tab_activity_title": "गतिविधि", "referral_stats_earned_from_referrals": "रेफरल से अर्जित", "referral_stats_referrals": "रेफरल", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "इस सीज़न में भले ही आपको रिवॉर्ड नहीं मिले, लेकिन अगली बार मिल भी सकते हैं।", "verifying_rewards": "इससे पहले कि आप रिवॉर्ड क्लेम करें, हम पुष्टि कर रहे हैं कि सब कुछ सही है।" }, + "previous_season_view": { + "title": "पिछला सत्र" + }, "season_status": { "points_earned": "पॉइंट्स कमाए" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "सक्रिय बूस्ट्स", "season_1": "सीज़न 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "mUSD बोनस कैलकुलेटर", + "description": "देखें कि आप अपने स्टेबलकॉइन को mUSD में बदलकर कितना कमा सकते हैं।", + "amount_label": "कन्वर्ट किया गया अमाउंट", + "estimated_bonus": "अनुमानित सालाना बोनस: 3% तक", + "initial_amount": "शुरुआती अमाउंट", + "daily_bonus": "रोज़ाना क्लेम किया जाने वाला बोनस", + "annualized_bonus": "सालाना बोनस", + "disclaimer": "यह सिर्फ़ एक अनुमान है। बोनस बदल सकता है।", "buy_button": "mUSD खरीदें", - "swap_button": "Swap to mUSD" + "swap_button": "mUSD पर स्वैप करें" }, "upcoming_rewards": { "title": "लॉक किए हुए रिवॉर्ड्स", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "लोड नहीं हो सका" }, - "snapshot": { + "campaign": { "starts_date": "{{date}} को शुरू होता है", "ends_date": "{{date}} को समाप्त होता है", - "results_coming_soon": "रिज़ल्ट जल्द ही आ रहे हैं", - "tokens_on_the_way": "टोकन आने वाले हैं", + "ended_date": "Ended {{date}}", "pill_up_next": "आगे आने वाला है", - "pill_live_now": "अब लाइव है", - "pill_calculating": "गणना की जा रही है", - "pill_results_ready": "रिज़ल्ट तैयार हैं", - "pill_complete": "पूरा" - }, - "snapshots_section": { - "title": "स्नैपशॉट्स", - "error_title": "स्नैपशॉट्स लोड नहीं हो पा रहे हैं", - "error_description": "हम स्नैपशॉट्स लोड नहीं कर पाए। कृपया फिर से कोशिश करें।", - "retry_button": "फिर से प्रयास करें" - }, - "snapshots_tab": { + "pill_active": "लाइव", + "pill_complete": "पूरा", + "enter_now": "अभी एंटर करें", + "entered": "एंटर किया", + "participant_count": "#{{count}}", + "opt_in_cta": "ऑप्ट इन करें", + "opt_in_sheet_title": "कैंपेन जॉइन करें", + "opt_in_sheet_description_pre_link": "'ऑप्ट इन' पर क्लिक करके आप MetaMask रिवॉर्ड्स से सहमत होते हैं", + "opt_in_sheet_link_text": "के पूरक उपयोग की शर्तों और गोपनीयता नोटिस से सहमत होते हैं", + "opt_in_sheet_description_post_link": "हम आपको ऑटोमैटिकली रिवॉर्ड देने के लिए ऑनचेन एक्टिविटी को ट्रैक करेंगे।", + "geo_restriction_banner_title": "आपके इलाके में उपलब्ध नहीं है", + "geo_restriction_banner_description": "लोकल नियमों के कारण यह कैंपेन आपके इलाके में उपलब्ध नहीं है।" + }, + "campaign_mechanics": { + "title": "मैकेनिक्स" + }, + "campaign_details": { + "start_date": "इस दिन शुरू होता है: {{date}}", + "end_date": "इस दिन समाप्त होता है: {{date}}", + "opt_in": "ऑप्ट इन करें", + "opting_in": "ऑप्ट इन किया जा रहा है...", + "opted_in": "आपने इस कैंपेन में ऑप्ट इन किया है", + "opt_in_error": "ऑप्ट इन करना नहीं हो पाया। कृपया फिर से प्रयास करें।", + "join_campaign": "कैंपेन जॉइन करें", + "checking_opt_in_status": "ऑप्ट इन स्टेटस चेक किया जा रहा है", + "swap": "स्वैप करें", + "how_it_works": "ये कैसे काम करता है" + }, + "campaigns_preview": { + "title": "कैंपेन", + "coming_soon": "जल्द आ रहा है", + "notify_me": "मुझे सूचित करें" + }, + "earn_rewards": { + "title": "रिवॉर्ड्स कमाएं", + "musd_title": "स्टेबल्स पर 3% तक बोनस", + "musd_subtitle": "अपना mUSD बोनस कैलकुलेट करें", + "card_title": "3% तक कैश बैक", + "card_subtitle": "अपना MetaMask कार्ड अभी पाएं", + "card_subtitle_cardholder": "अपने MetaMask कार्ड के फ़ायदे पाएं" + }, + "campaigns_view": { + "title": "कैंपेन", "active_title": "एक्टिव", "upcoming_title": "आगे आने वाला है", "previous_title": "पिछला", - "empty_state": "कोई स्नैपशॉट उपलब्ध नहीं है", - "error_title": "स्नैपशॉट्स लोड नहीं हो पा रहे हैं", - "error_description": "हम स्नैपशॉट्स लोड नहीं कर पाए। कृपया फिर से कोशिश करें।", + "empty_state": "कोई कैंपेन उपलब्ध नहीं है", + "error_title": "कैंपेन लोड नहीं हो पा रहे हैं", + "error_description": "हम कैंपेन लोड नहीं कर पाए। कृपया फिर से कोशिश करें।", "retry_button": "फिर से प्रयास करें", "refreshing": "रिफ्रेश हो रहा है..." } @@ -7953,13 +8112,12 @@ "continue": "जारी रखें" }, "connecting": { - "title": "अपना {{device}} कनेक्ट करें", + "title": "आपका {{device}} कनेक्ट हो रहा है...", "searching": "{{device}} खोज रहे हैं…", - "tips_header": "आगे बढ़ने के लिए, सुनिश्चित करें:", + "tips_header": "यह ज़रूर करें:", "tip_unlock": "आपका {{device}} अनलॉक है", "tip_open_app": "Ethereum ऐप खुला है", "tip_enable_bluetooth": "ब्लूटूथ चालू है", - "tip_dnd_off": "डू नॉट डिस्टर्ब बंद है", "tip_bluetooth_permission": "लोकेशन और ब्लूटूथ की अनुमति दी गई है", "tip_bluetooth_permission_v12": "नज़दीकी डिवाइस की अनुमति दी गई है", "tip_stay_close": "आपका डिवाइस आपके फोन के पास रहता है" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "नज़दीकी डिवाइस की अनुमति आवश्यक है", "bluetooth_off": "कृपया अपने डिवाइस से कनेक्ट करने के लिए ब्लूटूथ चालू करें", "bluetooth_scan_failed": "डिवाइस स्कैन नहीं हो पाया। कृपया दोबारा प्रयास करें", - "bluetooth_connection_failed": "जारी रखने के लिए अपने डिवाइस पर ब्लूटूथ चालू करें", + "bluetooth_connection_failed": "आपके डिवाइस से कनेक्शन नहीं हो पाया। कृपया फिर से कोशिश करें", "not_supported": "यह ऑपरेशन सपोर्टेड नहीं है", "unknown_error": "सुनिश्चित करें कि आपका {{device}} इस अकाउंट के लिए सीक्रेट रिकवरी फ्रेज़ या पासफ़्रेज़ के साथ सेटअप किया गया है" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "कैश", + "cash_empty_description": "आपके पास अभी तक कोई mUSD नहीं है। होमपेज पर कैश सेक्शन से स्टेबलकॉइन को mUSD में कन्वर्ट करें।", + "cash_empty_description_network_filter": "इस नेटवर्क पर कोई mUSD नहीं है। अपना mUSD देखने के लिए नेटवर्क बदलें।", "tokens": "टोकन", "perpetuals": "परपेचुअल्स", "predictions": "प्रेडिक्शंस", + "whats_happening": "क्या हो रहा है", + "whats_happening_categories": { + "geopolitical": "जियोपॉलिटिकल", + "macro": "मैक्रो", + "regulatory": "रेगुलेटरी", + "technical": "टेक्निकल", + "social": "सोशल", + "other": "अन्य" + }, "defi": "DeFi", "nfts": "NFTs", "import_nfts": "NFTs इंपोर्ट करें", diff --git a/locales/languages/id.json b/locales/languages/id.json index 7aae4c37b0b..0acfe4afd19 100644 --- a/locales/languages/id.json +++ b/locales/languages/id.json @@ -20,6 +20,12 @@ "update": "Perbarui" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Peringatan", @@ -120,8 +126,8 @@ "title": "Mengirim aset ke alamat burn" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Peringatan kontrak token", + "message": "Alamat penerima mungkin tidak mendukung transfer token langsung, yang dapat mengakibatkan hilangnya dana. Lanjutkan hanya jika Anda yakin kontrak ini dapat menerima transfer." }, "gas_sponsorship_reserve_balance": { "message": "Sponsor gas tidak tersedia untuk transaksi ini. Anda perlu mempertahankan saldo minimal %{minBalance} %{nativeTokenSymbol} di akun Anda.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Tidak dapat menyelesaikan nama", "invalid_address": "Alamat tidak valid", "contractAddressError": "Anda mengirimkan token ke alamat kontrak token. Hal ini dapat mengakibatkan hilangnya token tersebut.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Alamat kontrak cerdas", + "smart_contract_address_warning": "Alamat penerima mungkin tidak mendukung transfer token langsung, yang dapat mengakibatkan hilangnya dana. Lanjutkan hanya jika Anda yakin kontrak ini dapat menerima transfer.", "i_understand": "Saya mengerti", "cancel": "Batal" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Stop loss harus dilakukan pada harga {{direction}} {{priceType}}", "stop_loss_beyond_liquidation_error": "Stop loss harus dilakukan pada harga likuidasi {{direction}}", "stop_loss_order_view_warning": "Stop loss merupakan harga likuidasi {{direction}}", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "di atas", "below": "di bawah", "done": "Selesai", @@ -2086,14 +2094,15 @@ "a_closer_look": "Melihat lebih dekat", "whats_being_said": "Yang sedang dibicarakan", "footer_disclaimer": "Ringkasan AI hanya untuk informasi", - "trade_button": "Berdagang", + "swap_button": "Swap", + "buy_button": "Beli", "sources_count": "+{{count}} sumber", "sources_title": "Sumber berita", "feedback_submitted": "Umpan balik telah dikirim", "helpful_prompt": "Apakah ini membantu?", "feedback": { "title": "Umpan balik", - "description": "Bantu tingkatkan wawasan pasar yang dihasilkan AI.", + "description": "Jawaban Anda membantu meningkatkan ringkasan AI kami.", "not_relevant": "Tidak relevan", "not_accurate": "Tidak akurat", "hard_to_understand": "Sulit dipahami", @@ -2206,7 +2215,7 @@ "available_balance": "Saldo tersedia", "claim_amount_text": "Klaim ${{amount}}", "claim_winnings_text": "Klaim kemenangan", - "claiming_text": "Claiming...", + "claiming_text": "Mengklaim...", "unrealized_pnl_label": "P&L Belum Terealisasi", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Tidak dapat memuat", @@ -2287,7 +2296,7 @@ "try_again": "Coba lagi" }, "in_progress": { - "title": "Claim already in progress" + "title": "Klaim sedang diproses" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Biaya yang dibayarkan ke bursa atau pasar", "total_incl_fees": "termasuk biaya", "close": "Tutup", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Harga yang tertera diasumsikan order Anda telah terpenuhi sepenuhnya. Jumlah aktual dapat bervariasi jika order hanya terpenuhi sebagian.", + "deposit_fee": "Biaya deposit", + "deposit_fee_description": "Biaya yang dikenakan untuk mendeposit dana ke saldo prediksi Anda" }, "error": { "title": "Tidak dapat terhubung ke prediksi", @@ -3059,6 +3068,7 @@ "networks_no_results": "Jaringan tidak ditemukan", "network_name_label": "Nama jaringan", "network_name_placeholder": "Nama jaringan (opsional)", + "required": "Diperlukan", "network_rpc_url_label": "URL RPC", "network_rpc_name_label": "Nama RPC", "network_rpc_placeholder": "Jaringan RPC baru", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Fitur ini memperingatkan Anda tentang aktivitas jahat dengan meninjau permintaan transaksi dan tanda tangan secara aktif.", "security_alerts": "Peringatan keamanan", "security_alerts_desc": "Fitur ini memperingatkan Anda tentang aktivitas berbahaya dengan meninjau permintaan transaksi dan tanda tangan secara lokal. Selalu lakukan uji tuntas sendiri sebelum menyetujui permintaan apa pun. Tidak ada jaminan bahwa fitur ini akan mendeteksi semua aktivitas berbahaya. Dengan mengaktifkan fitur ini, Anda menyetujui persyaratan penggunaan penyedia.", + "smart_account_dapp_requests_heading": "Permintaan akun cerdas dari dapp", + "smart_account_dapp_requests_desc": "Izinkan dapp meminta fitur akun cerdas untuk akun standar. Ini tidak akan memengaruhi akun yang sudah menjadi akun cerdas.", "smart_transactions_opt_in_heading": "Transaksi Pintar", "smart_transactions_opt_in_desc_supported_networks": "Aktifkan Transaksi Pintar untuk transaksi yang lebih andal dan aman pada jaringan yang didukung.", "smart_transactions_learn_more": "Pelajari selengkapnya", @@ -3566,6 +3578,53 @@ "activity": "Aktivitas {{symbol}}", "disclaimer": "Data pasar disediakan oleh sumber pihak ketiga seperti CoinGecko. Data hanya untuk tujuan informasi. MetaMask tidak bertanggung jawab atas akurasinya." }, + "security_trust": { + "title": "Keamanan dan kepercayaan", + "malicious": "Berbahaya", + "risky": "Berisiko", + "malicious_token_title": "Token berbahaya", + "malicious_token_description": "{{symbol}} adalah token berbahaya. Hindari berinteraksi dengan token ini atau memperdagangkannya.", + "verified_token_title": "Token terverifikasi", + "verified_token_description": "{{symbol}} diperdagangkan secara aktif dan dikenal luas. Verifikasi bukanlah bentuk dukungan dari MetaMask.", + "risky_token_title": "Token berisiko", + "risky_token_description": "Sinyal peringatan terdeteksi pada {{symbol}}. Lakukan riset dengan cermat sebelum memperdagangkan token ini.", + "malicious_token_sheet_description": "Sinyal risiko serius terdeteksi pada {{symbol}}. Sebaiknya jangan memperdagangkan token ini.", + "got_it": "Mengerti", + "proceed": "Lanjutkan", + "cancel": "Batalkan", + "data_unavailable": "Data keamanan tidak tersedia", + "subtitle_known": "Sinyal risiko tidak terdeteksi. Selalu lakukan riset terhadap aset apa pun sebelum melakukan perdagangan.", + "subtitle_no_issues": "Sinyal risiko tidak terdeteksi. Selalu lakukan riset terhadap aset apa pun sebelum melakukan perdagangan.", + "subtitle_suspicious": "Sinyal peringatan terdeteksi. Tinjau masalah yang ditandai dengan cermat sebelum memperdagangkan aset ini.", + "subtitle_malicious": "Sinyal risiko serius terdeteksi. Sebaiknya hindari aset ini.", + "subtitle_unavailable": "Analisis keamanan tidak dapat dimuat untuk token ini.", + "token_distribution": "Distribusi token", + "total_supply": "Total suplai", + "top_10_holders": "Top 10 pemilik", + "other": "Lainnya", + "no_hidden_fees_detected": "Tidak ditemukan biaya tersembunyi", + "buy_sell_tax": "Pajak Jual/Beli", + "buy_tax": "Pajak pembelian", + "sell_tax": "Pajak penjualan", + "transfer": "Transfer", + "token_info": "Informasi Token", + "created": "Dibuat", + "token_age": "Usia token", + "network": "Jaringan", + "type": "Jenis", + "official_links": "Tautan Resmi", + "website": "Situs web", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "T/A", + "verified": "Terverifikasi", + "no_issues": "Tidak ada masalah", + "suspicious": "Mencurigakan", + "malicious_label": "Berbahaya", + "more": "lainnya", + "evaluation_disclaimer": "Tinjauan keamanan ini hanya untuk tujuan evaluasi dan bukan merupakan dukungan atau rekomendasi untuk melakukan perdagangan." + }, "account_details": { "title": "Detail akun", "share_account": "Bagikan", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Bonus yang dapat diklaim", "claim_bonus": "Klaim bonus", "claim_bonus_subtitle": "Bonus akan dibayarkan melalui {{networkName}}.", + "percentage_bonus_on_linea": "Bonus {{percentage}}% di Linea", + "claim": "Klaim", + "sounds_good": "Kedengarannya bagus", + "claimable_bonus_tooltip_with_percentage": "Bonus tahunan sebesar {{percentage}}% yang Anda peroleh karena memiliki mUSD. Bonus dapat diklaim setiap hari di Linea.", "empty_state_cta": { "heading": "Pinjamkan {{tokenSymbol}} dan hasilkan", "body": "Pinjamkan {{tokenSymbol}} Anda dengan {{protocol}} dan dapatkan", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Stablecoin Anda" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Dapatkan", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Anda tidak memiliki cukup saldo sumber daya untuk melakukan tindakan ini." }, - "trx_unstaking_in_progress": "Proses pembatalan stake {{amount}} TRX sedang berlangsung. Proses ini membutuhkan waktu 14 hari.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Pembatalan stake {{amount}} TRX sedang berlangsung", + "description": "Proses pembatalan stake akan memakan waktu 14 hari" + }, + "unstaked_banner": { + "title": "Pembatalan stake {{amount}} TRX selesai", + "description": "TRX yang batal di-stake kini dapat ditarik", + "button": "Tarik", + "error": "Penarikan gagal" + } }, "stake_eth": "Stake ETH", "unstake_eth": "Batalkan stake ETH", @@ -6376,7 +6498,8 @@ "approve": "Setujui permintaan", "perps_deposit": "Tambahkan dana", "predict_deposit": "Tambahkan dana Prediction", - "predict_withdraw": "Tarik" + "predict_withdraw": "Tarik", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Situs ini meminta izin untuk menggunakan token Anda.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Transaksi {{index}}", "transaction": "Transaksi", "available_balance": "Saldo tersedia: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Lanjutkan", "deposit_edit_amount_done": "Tambahkan dana", "deposit_edit_amount_predict_withdraw": "Tarik", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Dompet perangkat keras belum didukung. Gunakan hot wallet untuk melanjutkan.", "hardware_wallet_not_supported_solana": "Dompet perangkat keras belum didukung untuk Solana. Gunakan hot wallet untuk melanjutkan.", "price_impact_info_title": "Dampak harga", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Beginilah cara perdagangan Anda mengubah harga pasar suatu token. Hal ini bergantung pada ukuran perdagangan, likuiditas yang tersedia, dan biaya penyedia. MetaMask tidak mengontrol dampak harga tersebut.", "price_impact_info_gasless_description": "Dampak harga mencerminkan bagaimana perintah swap Anda memengaruhi harga pasar aset. Jika Anda tidak memiliki cukup dana untuk gas, sebagian token sumber akan dialokasikan secara otomatis untuk menutupi biaya, yang meningkatkan dampak harga. MetaMask tidak memengaruhi atau mengendalikan dampak harga.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Karena ukuran perdagangan Anda dan likuiditas yang tersedia, Anda akan mendapatkan sekitar {{priceImpact}} lebih rendah dari harga pasar. Hal ini sudah diperhitungkan dalam kuotasi Anda.", "price_impact_high": "Dampak harga tinggi", "price_impact_execution_description": "Anda akan kehilangan sekitar {{priceImpact}} dari nilai token Anda pada swap ini. Cobalah untuk mengurangi jumlahnya atau pilih rute yang lebih likuid.", "proceed": "Lanjutkan", @@ -6627,8 +6751,8 @@ "total_cost": "Biaya Total", "got_it": "Mengerti", "price_impact_warning_title": "Dampak harga tinggi", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Dampak harga sangat tinggi", + "price_impact_error_description": "Anda akan kehilangan sekitar {{priceImpact}} dari harga pasar token Anda pada swap ini. Cobalah perdagangan yang lebih kecil atau rute yang lebih likuid untuk meningkatkan tarif Anda." }, "quote_expired_modal": { "title": "Kuotasi baru tersedia", @@ -6940,7 +7064,7 @@ "upgrade_title": "Upgrade ke Logam", "continue_button": "Lanjutkan", "virtual_card": { - "name": "Virtual Card", + "name": "Kartu Virtual", "price": "Gratis", "feature_1": "Kartu virtual untuk Apple Pay dan Google Pay", "feature_2": "Bayar dengan kripto (USDC, USDT, WETH, dan lainnya)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Kartu Logam", "price": "$199/tahun", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Semuanya serba virtual, ditambah:", + "feature_1": "Kartu logam berukir premium", + "feature_2": "Cashback 3% untuk $10.000 pertama/tahun", "feature_3": "Tidak ada biaya transaksi luar negeri" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Dapatkan cashback hingga $300 setiap tahunnya", + "upgrade_to_metal_label": "Atau tingkatkan ke Metal untuk mendapatkan reward 3x" }, "review_order": { "title": "Tinjau order", @@ -7104,7 +7228,7 @@ "ssn_description": "Diperlukan oleh penerbit kartu. Tidak akan dilakukan pengecekan kredit.", "invalid_ssn": "NJS tidak valid", "invalid_date_of_birth": "Tanggal lahir tidak valid. Anda harus berusia minimal 18 tahun", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Nama depan dan nama belakang harus sesuai dengan identitas Anda yang telah diverifikasi" }, "physical_address": { "title": "Tambahkan alamat Anda", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Batas penggunaan hampir tercapai", "description": "Perbarui untuk menghindari penurunan", - "confirm_button_label": "Atur batas baru" + "confirm_button_label": "Atur batas baru", + "dismiss_button_label": "Lewatkan" }, "need_delegation": { "title": "Anda perlu mengaktifkan kartu", @@ -7301,7 +7426,6 @@ "dismiss": "Lewatkan", "update_success": "Batas penggunaan berhasil diperbarui", "update_error": "Gagal memperbarui batas penggunaan", - "solana_not_supported": "Aktifkan token Solana di card.metamask.io", "select_token": "Pilih token", "loading": "Memuat token yang tersedia...", "load_error": "Tidak dapat memuat token. coba lagi.", @@ -7343,9 +7467,7 @@ "limited": "Terbatas", "not_enabled": "Tidak diaktifkan", "update_success": "Prioritas penggunaan berhasil diperbarui", - "update_error": "Gagal memperbarui prioritas penggunaan", - "solana_not_supported_button_title": "Token lain di Solana", - "solana_not_supported_button_description": "Aktifkan di card.metamask.io" + "update_error": "Gagal memperbarui prioritas penggunaan" }, "card_authentication": { "title": "Masuk ke akun kartu Anda", @@ -7443,6 +7565,11 @@ "title": "Gagal berpartisipasi", "description": "Periksa koneksi Anda dan coba lagi." }, + "version_guard": { + "title": "Pembaruan diperlukan", + "description": "Versi MetaMask yang lebih baru diperlukan untuk menggunakan Reward. Perbarui untuk melanjutkan.", + "update_button": "Perbarui MetaMask" + }, "season_error": { "error_fetching_title": "Musim tidak dapat dimuat", "error_fetching_description": "Periksa koneksi Anda dan coba lagi.", @@ -7525,7 +7652,6 @@ "main_title": "Reward", "referral_title": "Rujukan", "tab_overview_title": "Ikhtisar", - "tab_snapshots_title": "Snapshot", "tab_activity_title": "Aktivitas", "referral_stats_earned_from_referrals": "Diperoleh dari rujukan", "referral_stats_referrals": "Rujukan", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Anda tidak mendapatkan reward musim ini, tetapi selalu ada kesempatan lain.", "verifying_rewards": "Kami memastikan semuanya benar sebelum Anda mengklaim reward." }, + "previous_season_view": { + "title": "Musim Sebelumnya" + }, "season_status": { "points_earned": "Poin yang diperoleh" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Peningkatan aktif", "season_1": "Musim 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "kalkulator bonus mUSD", + "description": "Lihat berapa banyak yang bisa diperoleh dengan mengonversi stablecoin Anda menjadi mUSD.", + "amount_label": "Jumlah yang dikonversi", + "estimated_bonus": "Estimasi bonus tahunan: hingga 3%", + "initial_amount": "Jumlah awal", + "daily_bonus": "Bonus yang dapat diklaim setiap hari", + "annualized_bonus": "Bonus tahunan", + "disclaimer": "Ini hanya estimasi. Bonus dapat berubah sewaktu-waktu.", "buy_button": "Beli mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Swap ke mUSD" }, "upcoming_rewards": { "title": "Reward terkunci", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Tidak dapat memuat" }, - "snapshot": { + "campaign": { "starts_date": "Mulai {{date}}", "ends_date": "Berakhir {{date}}", - "results_coming_soon": "Hasil akan segera diumumkan", - "tokens_on_the_way": "Token sedang dalam perjalanan", + "ended_date": "Ended {{date}}", "pill_up_next": "Selanjutnya", - "pill_live_now": "Sedang live", - "pill_calculating": "Menghitung", - "pill_results_ready": "Hasil Sudah Siap", - "pill_complete": "Selesaikan" - }, - "snapshots_section": { - "title": "Snapshot", - "error_title": "Tidak dapat memuat snapshot", - "error_description": "Kami tidak dapat memuat snapshot. Coba lagi.", - "retry_button": "Coba lagi" - }, - "snapshots_tab": { + "pill_active": "Langsung", + "pill_complete": "Selesaikan", + "enter_now": "Masuk sekarang", + "entered": "Masuk", + "participant_count": "#{{count}}", + "opt_in_cta": "Ikut serta", + "opt_in_sheet_title": "Gabung dalam kampanye ini", + "opt_in_sheet_description_pre_link": "Dengan mengklik 'Ikut serta', Anda menyetujui program Reward MetaMask", + "opt_in_sheet_link_text": "Ketentuan Penggunaan dan Pemberitahuan Privasi Tambahan", + "opt_in_sheet_description_post_link": "Kami akan melacak aktivitas onchain untuk memberikan reward secara otomatis.", + "geo_restriction_banner_title": "Tidak tersedia di wilayah Anda", + "geo_restriction_banner_description": "Kampanye ini tidak tersedia di wilayah Anda karena peraturan setempat." + }, + "campaign_mechanics": { + "title": "Mekanika" + }, + "campaign_details": { + "start_date": "Mulai: {{date}}", + "end_date": "Selesai: {{date}}", + "opt_in": "Ikut serta", + "opting_in": "Sedang ikut serta...", + "opted_in": "Anda telah memilih untuk ikut serta dalam kampanye ini", + "opt_in_error": "Gagal ikut serta. Coba lagi.", + "join_campaign": "Gabung dalam kampanye ini", + "checking_opt_in_status": "Memeriksa status keikutsertaan", + "swap": "Swap", + "how_it_works": "Cara kerjanya" + }, + "campaigns_preview": { + "title": "Kampanye", + "coming_soon": "Segera hadir", + "notify_me": "Beri tahu saya" + }, + "earn_rewards": { + "title": "Dapatkan reward", + "musd_title": "Bonus stabil hingga 3%", + "musd_subtitle": "Hitung bonus mUSD Anda", + "card_title": "Cashback hingga 3%", + "card_subtitle": "Dapatkan Kartu MetaMask sekarang", + "card_subtitle_cardholder": "Manfaatkan keuntungan Kartu MetaMask" + }, + "campaigns_view": { + "title": "Kampanye", "active_title": "Aktif", "upcoming_title": "Mendatang", "previous_title": "Sebelumnya", - "empty_state": "Snapshot tidak tersedia", - "error_title": "Tidak dapat memuat snapshot", - "error_description": "Kami tidak dapat memuat snapshot. Coba lagi.", + "empty_state": "Kampanye tidak tersedia", + "error_title": "Tidak dapat memuat kampanye", + "error_description": "Kami tidak dapat memuat kampanye. Coba lagi.", "retry_button": "Coba lagi", "refreshing": "Menyegarkan..." } @@ -7953,13 +8112,12 @@ "continue": "Lanjutkan" }, "connecting": { - "title": "Hubungkan {{device}} Anda", + "title": "Menghubungkan {{device}} Anda...", "searching": "Mencari {{device}}...", - "tips_header": "Untuk melanjutkan, pastikan:", + "tips_header": "Pastikan:", "tip_unlock": "{{device}} Anda tidak terkunci", "tip_open_app": "Aplikasi Ethereum sudah dibuka", "tip_enable_bluetooth": "Bluetooth diaktifkan", - "tip_dnd_off": "Jangan Ganggu dinonaktifkan", "tip_bluetooth_permission": "Izin lokasi dan Bluetooth telah diberikan", "tip_bluetooth_permission_v12": "Izin perangkat terdekat telah diberikan", "tip_stay_close": "Perangkat Anda tetap berada di dekat ponsel Anda" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Izin perangkat terdekat diperlukan", "bluetooth_off": "Aktifkan Bluetooth untuk terhubung ke perangkat Anda", "bluetooth_scan_failed": "Gagal memindai perangkat. Coba lagi", - "bluetooth_connection_failed": "Aktifkan Bluetooth di perangkat Anda untuk melanjutkan", + "bluetooth_connection_failed": "Koneksi ke perangkat Anda gagal. Coba lagi", "not_supported": "Operasi ini tidak didukung", "unknown_error": "Pastikan {{device}} Anda telah diatur dengan Frasa Pemulihan Rahasia atau passphrase untuk akun ini" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Uang tunai", + "cash_empty_description": "Anda belum memiliki mUSD. Konversikan stablecoin ke mUSD dari bagian Uang Tunai di halaman beranda.", + "cash_empty_description_network_filter": "Tidak ada mUSD di jaringan ini. Ganti jaringan untuk melihat mUSD milik Anda.", "tokens": "Token", "perpetuals": "Abadi", "predictions": "Prediksi", + "whats_happening": "Apa yang sedang terjadi", + "whats_happening_categories": { + "geopolitical": "Geopolitik", + "macro": "Makro", + "regulatory": "Peraturan", + "technical": "Teknis", + "social": "Sosial", + "other": "Lainnya" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "Impor NFT", diff --git a/locales/languages/ja.json b/locales/languages/ja.json index 32d9a9028f7..fdb8b7268e2 100644 --- a/locales/languages/ja.json +++ b/locales/languages/ja.json @@ -20,6 +20,12 @@ "update": "更新" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "アラート", @@ -120,8 +126,8 @@ "title": "バーンアドレスに資産を送ろうとしています" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "トークンコントラクトに関する警告", + "message": "この受取人のアドレスはトークンの直接送金に対応していない可能性があり、資金を失うおそれがあります。これが送金を受け取れるコントラクトであることを確信している場合のみ、続行してください。" }, "gas_sponsorship_reserve_balance": { "message": "この取引でガススポンサーシップはご利用いただけません。アカウントに%{minBalance} %{nativeTokenSymbol}以上の残高が必要です。", @@ -694,8 +700,8 @@ "could_not_resolve_name": "名前の解決ができませんでした", "invalid_address": "無効なアドレス", "contractAddressError": "トークンのコントラクトアドレスにトークンを送金しようとしています。これにより、当該トークンが失われる可能性があります。", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "スマートコントラクトアドレス", + "smart_contract_address_warning": "この受取人のアドレスはトークンの直接送金に対応していない可能性があり、資金を失うおそれがあります。これが送金を受け取れるコントラクトであることを確信している場合のみ、続行してください。", "i_understand": "理解しています", "cancel": "キャンセル" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "ストップロスは{{priceType}}価格よりも{{direction}}に設定する必要があります", "stop_loss_beyond_liquidation_error": "ストップロスは清算価格よりも{{direction}}に設定する必要があります", "stop_loss_order_view_warning": "ストップロスが清算価格よりも{{direction}}に設定されています", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "上", "below": "下", "done": "完了", @@ -2086,14 +2094,15 @@ "a_closer_look": "詳細", "whats_being_said": "市場の声", "footer_disclaimer": "AIによる要約は参考用です", - "trade_button": "取引", + "swap_button": "スワップ", + "buy_button": "購入", "sources_count": "他{{count}}件のソース", "sources_title": "ニュースソース", "feedback_submitted": "フィードバックが送信されました", "helpful_prompt": "この情報は役に立ちましたか?", "feedback": { "title": "フィードバック", - "description": "AIが生成した市場分析情報の改善にご協力ください。", + "description": "皆様からの回答は、当社のAIサマリーの改善に役立ちます。", "not_relevant": "関連性が低い", "not_accurate": "正確でない", "hard_to_understand": "わかりにくい", @@ -2162,7 +2171,7 @@ "sell_position": "ポジションを売却", "cash_out": "キャッシュアウト", "cash_out_info": "資金は利用可能残高に追加されます", - "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", + "buy_preview_outcome_at_price": "{{price}}で{{outcome}}", "at_price_per_share": "{{size}}株を{{price}}で売却中", "cashout_info": "{{outcome}}を{{initialPrice}}で{{amount}}", "cashout_info_multiple": "{{outcomeGroupTitle}} • {{outcome}}を{{initialPrice}}で{{amount}}", @@ -2206,7 +2215,7 @@ "available_balance": "利用可能残高", "claim_amount_text": "請求額 ${{amount}}", "claim_winnings_text": "報酬を請求", - "claiming_text": "Claiming...", + "claiming_text": "請求中...", "unrealized_pnl_label": "含み損益", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "読み込めません", @@ -2287,7 +2296,7 @@ "try_again": "再試行してください" }, "in_progress": { - "title": "Claim already in progress" + "title": "請求処理はすでに進行中です" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "取引所または市場に支払われる手数料", "total_incl_fees": "手数料込", "close": "閉じる", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "表示価格は、ご注文が完全に約定した場合の金額です。ご注文の一部のみが約定した場合は、実際の金額が異なることがあります。", + "deposit_fee": "デポジット手数料", + "deposit_fee_description": "予測残高に入金する際に発生する手数料" }, "error": { "title": "予想に接続できません", @@ -3059,6 +3068,7 @@ "networks_no_results": "ネットワークが見つかりません", "network_name_label": "ネットワーク名", "network_name_placeholder": "ネットワーク名 (オプション)", + "required": "必須", "network_rpc_url_label": "RPC URL", "network_rpc_name_label": "RPC名", "network_rpc_placeholder": "新しいRPCネットワーク", @@ -3298,6 +3308,8 @@ "blockaid_desc": "この機能は、トランザクションと署名要求を能動的に確認し、悪質なアクティビティに関するアラートを発します。", "security_alerts": "セキュリティアラート", "security_alerts_desc": "この機能は、トランザクションと署名要求をローカルで確認することで、悪質な行為に関するアラートを発します。要求を承認する前に、必ず独自のデューデリジェンスを行ってください。この機能がすべての悪質な行為を検出するという保証はありません。この機能を有効にすることで、プロバイダーの利用規約に同意したものとみなされます。", + "smart_account_dapp_requests_heading": "DAppからのスマートアカウントリクエスト", + "smart_account_dapp_requests_desc": "DAppによる、スタンダードアカウント用のスマートアカウント機能のリクエストを許可します。これにより、すでにスマートアカウントのアカウントに影響はありません。", "smart_transactions_opt_in_heading": "スマートトランザクション", "smart_transactions_opt_in_desc_supported_networks": "スマートトランザクションをオンにして、サポートされているネットワーク上でのトランザクションの信頼性と安全性を高めましょう。", "smart_transactions_learn_more": "詳細", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}}のアクティビティ", "disclaimer": "市場データは、CoinGeckoなどのサードパーティソースから提供されます。データは情報目的のみです。MetaMaskはこのデータの正確性に責任を持ちません。" }, + "security_trust": { + "title": "セキュリティと信頼", + "malicious": "悪意のある", + "risky": "リスクのある", + "malicious_token_title": "悪質なトークン", + "malicious_token_description": "{{symbol}}は悪意のあるトークンです。やり取りや取引は避けてください。", + "verified_token_title": "確認済みのトークン", + "verified_token_description": "{{symbol}}はアクティブに取引され、広く認識されています。検証はMetaMaskによる承認ではありません。", + "risky_token_title": "リスクのあるトークン", + "risky_token_description": "{{symbol}}で警告のシグナルが検出されました。このトークンを取引する前に、慎重に調査してください。", + "malicious_token_sheet_description": "{{symbol}}で深刻なリスクシグナルが検出されました。このトークンは取引しないことをお勧めします。", + "got_it": "了解", + "proceed": "先に進む", + "cancel": "キャンセル", + "data_unavailable": "セキュリティデータが利用できません", + "subtitle_known": "リスクシグナルは検出されませんでした。どの資産でも取引前に必ず調査してください。", + "subtitle_no_issues": "リスクシグナルは検出されませんでした。どの資産でも取引前に必ず調査してください。", + "subtitle_suspicious": "警告のシグナルが検出されました。この資産を取引する前に、フラグの付いた問題を慎重に確認してください。", + "subtitle_malicious": "深刻なリスクシグナルが検出されました。この資産は避けることをお勧めします。", + "subtitle_unavailable": "このトークンのセキュリティ分析情報を読み込めませんでした。", + "token_distribution": "トークンの流通", + "total_supply": "合計供給量", + "top_10_holders": "トップ10のトークン", + "other": "その他", + "no_hidden_fees_detected": "隠れた手数料は検出されませんでした", + "buy_sell_tax": "購入・売却税", + "buy_tax": "購入税", + "sell_tax": "売却税", + "transfer": "送金", + "token_info": "トークン情報", + "created": "作成されました", + "token_age": "トークンの年齢", + "network": "ネットワーク", + "type": "タイプ", + "official_links": "公式リンク", + "website": "Webサイト", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "該当なし", + "verified": "検証済み", + "no_issues": "問題なし", + "suspicious": "不審", + "malicious_label": "悪意のある", + "more": "さらに表示", + "evaluation_disclaimer": "このセキュリティレビューは評価目的のみのものであり、取引の承認または推奨とみなされるものではありません。" + }, "account_details": { "title": "アカウント情報", "share_account": "共有", @@ -5934,6 +5993,10 @@ "claimable_bonus": "獲得できるボーナス", "claim_bonus": "ボーナスを請求する", "claim_bonus_subtitle": "ボーナスは{{networkName}}上で支払われます。", + "percentage_bonus_on_linea": "Lineaでの{{percentage}}%ボーナス", + "claim": "請求", + "sounds_good": "いいですね", + "claimable_bonus_tooltip_with_percentage": "mUSDを保有することで獲得した年換算{{percentage}}%のボーナスです。ボーナスはLineaで毎日請求できます。", "empty_state_cta": { "heading": "{{tokenSymbol}}を貸して収益化", "body": "{{protocol}}で{{tokenSymbol}}を貸し付けて、", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "保有中のステーブルコイン" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "獲得", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "この操作を行うのに十分なリソース残高がありません。" }, - "trx_unstaking_in_progress": "{{amount}}TRXのステーキング解除を実行中です。ステーキング解除には14日かかります。", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "{{amount}} TRXのステーキングを解除しています", + "description": "ステーキングの解除には14日間かかります" + }, + "unstaked_banner": { + "title": "{{amount}} TRXのステーキングが解除されました", + "description": "ステーキングを解除したTRXが出金可能になりました", + "button": "出金", + "error": "出金失敗" + } }, "stake_eth": "ETHをステーキング", "unstake_eth": "ETHのステーキングを解除", @@ -6376,7 +6498,8 @@ "approve": "要求の承認", "perps_deposit": "資金を追加", "predict_deposit": "予測資金を追加", - "predict_withdraw": "出金" + "predict_withdraw": "出金", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "このサイトがトークンの使用許可を求めています。", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "トランザクション {{index}}", "transaction": "トランザクション", "available_balance": "利用可能残高: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "続行", "deposit_edit_amount_done": "資金を追加", "deposit_edit_amount_predict_withdraw": "出金", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "まだハードウェアウォレットに対応していません。続行するにはホットウォレットをご使用ください。", "hardware_wallet_not_supported_solana": "Solanaはまだハードウェアウォレットに対応していません。続行するにはホットウォレットをご使用ください。", "price_impact_info_title": "プライスインパクト", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "これは、取引によってトークンの市場価格がどのように変動するかを示しています。取引量、利用可能な流動性、プロバイダー手数料によって異なります。MetaMaskは価格への影響を制御することはできません。", "price_impact_info_gasless_description": "プライスインパクトは、スワップ注文がその資産の市場価格にどのように影響するかを反映します。ガス代の支払いに十分な資金を保有していない場合、交換前のトークンの一部が自動的に手数料の支払いに充当され、プライスインパクトが増大します。MetaMaskがプライスインパクトに影響を与えたりコントロールしたりすることはありません。", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "取引規模と利用可能な流動性により、市場価格よりも約{{priceImpact}}安くなります。これはすでに提示価格に反映されています。", "price_impact_high": "高プライスインパクト", "price_impact_execution_description": "このスワップにより、トークンの価値の約{{priceImpact}}が失われます。金額を下げるか、より流動性の高いルートを選択してください。", "proceed": "先に進む", @@ -6627,8 +6751,8 @@ "total_cost": "総コスト", "got_it": "了解", "price_impact_warning_title": "高プライスインパクト", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "非常に高いプライスインパクト", + "price_impact_error_description": "このスワップでは、トークンの市場価格の約{{priceImpact}}を失います。より良いレートを得るには、取引量を減らすか、流動性の高いルートをお試しください。" }, "quote_expired_modal": { "title": "新しい価格が利用可能です", @@ -6940,7 +7064,7 @@ "upgrade_title": "メタルにアップグレード", "continue_button": "続行", "virtual_card": { - "name": "Virtual Card", + "name": "バーチャルカード", "price": "無料", "feature_1": "Apple PayとGoogle Payで使えるバーチャルカード", "feature_2": "仮想通貨でお支払い (USDC、USDT、WETHなど)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "メタルカード", "price": "年間199ドル", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "すべてバーチャルで、さらに:", + "feature_1": "プレミアム刻印入りメタルカード", + "feature_2": "年間最初の$10,000に対して3%のキャッシュバック", "feature_3": "海外トランザクション手数料なし" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "年間最大$300のキャッシュバック獲得", + "upgrade_to_metal_label": "または、メタルカードにアップグレードして3倍の報酬を獲得" }, "review_order": { "title": "ご注文内容の確認", @@ -7104,7 +7228,7 @@ "ssn_description": "カード発行者により求められています。信用調査は行われません。", "invalid_ssn": "無効な社会保障番号です", "invalid_date_of_birth": "生年月日が無効です。18歳以上である必要があります。", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "氏名は本人確認済みの情報と一致している必要があります" }, "physical_address": { "title": "住所を追加", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "利用限度額に近づいています", "description": "支払い拒否を防ぐために更新してください", - "confirm_button_label": "新しい限度額を設定" + "confirm_button_label": "新しい限度額を設定", + "dismiss_button_label": "閉じる" }, "need_delegation": { "title": "カードを有効にする必要があります", @@ -7301,7 +7426,6 @@ "dismiss": "閉じる", "update_success": "使用上限が更新されました", "update_error": "使用上限の更新に失敗しました", - "solana_not_supported": "card.metamask.ioでSolanaトークンを有効にする", "select_token": "トークンを選択", "loading": "ご利用可能なトークンを読み込み中...", "load_error": "トークンを読み込めません。もう一度お試しください。", @@ -7343,9 +7467,7 @@ "limited": "制限付き", "not_enabled": "有効になっていません", "update_success": "使用優先順位が更新されました", - "update_error": "使用優先順位の更新に失敗しました", - "solana_not_supported_button_title": "Solanaの他のトークン", - "solana_not_supported_button_description": "card.metamask.ioで有効にします" + "update_error": "使用優先順位の更新に失敗しました" }, "card_authentication": { "title": "カードアカウントへのログイン", @@ -7443,6 +7565,11 @@ "title": "オプトインに失敗しました", "description": "接続を確認して、もう一度お試しください。" }, + "version_guard": { + "title": "アップデートが必要です", + "description": "Rewardsの利用には、MetaMaskの新しいバージョンが必要です。続けるにはアップデートしてください。", + "update_button": "MetaMaskをアップデート" + }, "season_error": { "error_fetching_title": "シーズンを読み込めませんでした", "error_fetching_description": "接続を確認して、もう一度お試しください。", @@ -7525,7 +7652,6 @@ "main_title": "報酬", "referral_title": "紹介", "tab_overview_title": "概要", - "tab_snapshots_title": "スナップショット", "tab_activity_title": "アクティビティ", "referral_stats_earned_from_referrals": "紹介して報酬を獲得", "referral_stats_referrals": "紹介", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "このセッションではリワードを獲得できませんでしたが、また次があります。", "verifying_rewards": "リワードを獲得する前に、情報がすべて正しいことを確認しています。" }, + "previous_season_view": { + "title": "以前のセッション" + }, "season_status": { "points_earned": "ポイント獲得" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "有効なブースト", "season_1": "シーズン1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "mUSDボーナス計算ツール", + "description": "ステーブルコインをmUSDに交換することで、どれだけの利益が得られるか確認しましょう。", + "amount_label": "変換金額", + "estimated_bonus": "推定年率ボーナス:最大3%", + "initial_amount": "初期金額", + "daily_bonus": "1日あたりに獲得可能なボーナス", + "annualized_bonus": "年率ボーナス", + "disclaimer": "これはあくまで目安です。ボーナスは変更される場合があります。", "buy_button": "mUSDを購入", - "swap_button": "Swap to mUSD" + "swap_button": "mUSDにスワップ" }, "upcoming_rewards": { "title": "ロックされているリワード", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "読み込ませんでした" }, - "snapshot": { + "campaign": { "starts_date": "{{date}}開始", "ends_date": "{{date}}終了", - "results_coming_soon": "間もなく結果が出ます", - "tokens_on_the_way": "トークンを送金中です", + "ended_date": "Ended {{date}}", "pill_up_next": "次", - "pill_live_now": "現在進行中", - "pill_calculating": "計算中", - "pill_results_ready": "結果が出ました", - "pill_complete": "完了" - }, - "snapshots_section": { - "title": "スナップショット", - "error_title": "スナップショットを読み込めません", - "error_description": "スナップショットを読み込めませんでした。もう一度お試しください。", - "retry_button": "再試行" - }, - "snapshots_tab": { + "pill_active": "ライブ", + "pill_complete": "完了", + "enter_now": "今すぐ応募", + "entered": "応募しました", + "participant_count": "#{{count}}", + "opt_in_cta": "オプトイン", + "opt_in_sheet_title": "キャンペーンに参加しましょう", + "opt_in_sheet_description_pre_link": "「オプトイン」をクリックすることで、MetaMask Rewardsに同意したものとみなされます", + "opt_in_sheet_link_text": "補足利用規約およびプライバシー通知", + "opt_in_sheet_description_post_link": "オンチェーンアクティビティを自動的に追跡し、リワードを付与します。", + "geo_restriction_banner_title": "お客様の地域では利用できません", + "geo_restriction_banner_description": "現地の規制により、お住いの地域ではこのキャンペーンにご参加いただけません。" + }, + "campaign_mechanics": { + "title": "仕組み" + }, + "campaign_details": { + "start_date": "開始: {{date}}", + "end_date": "終了: {{date}}", + "opt_in": "オプトイン", + "opting_in": "オプトインしています...", + "opted_in": "このキャンペーンにオプトインしました", + "opt_in_error": "オプトインに失敗しました。もう一度お試しください。", + "join_campaign": "キャンペーンに参加", + "checking_opt_in_status": "オプトインステータスを確認しています", + "swap": "スワップ", + "how_it_works": "報酬獲得の仕組み" + }, + "campaigns_preview": { + "title": "キャンペーン", + "coming_soon": "近日追加予定", + "notify_me": "通知を受ける" + }, + "earn_rewards": { + "title": "報酬を獲得しましょう", + "musd_title": "ステーブルで最大3%のボーナス", + "musd_subtitle": "mUSDボーナスの計算", + "card_title": "最大3%のキャッシュバック", + "card_subtitle": "今すぐMetaMaskカードを取得", + "card_subtitle_cardholder": "MetaMaskカードの特典を利用" + }, + "campaigns_view": { + "title": "キャンペーン", "active_title": "アクティブ", "upcoming_title": "今後", "previous_title": "以前", - "empty_state": "利用可能なスナップショットがありません", - "error_title": "スナップショットを読み込めません", - "error_description": "スナップショットを読み込めませんでした。もう一度お試しください。", + "empty_state": "参加できるキャンペーンがありません", + "error_title": "キャンペーンを読み込めません", + "error_description": "キャンペーンを読み込めませんでした。もう一度お試しください。", "retry_button": "再試行", "refreshing": "更新中..." } @@ -7953,13 +8112,12 @@ "continue": "続行" }, "connecting": { - "title": "{{device}}の接続", + "title": "{{device}}を接続しています...", "searching": "{{device}}を検索中...", - "tips_header": "続行するには、以下の点をご確認ください。", + "tips_header": "次の点を確認してください:", "tip_unlock": "{{device}}のロックが解除されている", "tip_open_app": "イーサリアムアプリが開いている", "tip_enable_bluetooth": "Bluetoothがオンになっている", - "tip_dnd_off": "サイレントモードがオフになっている", "tip_bluetooth_permission": "位置情報とBluetoothへのアクセス許可が付与されている", "tip_bluetooth_permission_v12": "付近のデバイスへのアクセス許可が付与されている", "tip_stay_close": "デバイスがスマートフォンの近くにある" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "付近のデバイスへのアクセス許可が必要です", "bluetooth_off": "デバイスに接続するには、Bluetoothをオンにしてください", "bluetooth_scan_failed": "デバイスを検索できませんでした。もう一度お試しください。", - "bluetooth_connection_failed": "続行するには、デバイスでBluetoothを有効にしてください", + "bluetooth_connection_failed": "デバイスへの接続に失敗しました。もう一度お試しください", "not_supported": "この操作はサポートされていません。", "unknown_error": "{{device}}がこのアカウント用のシークレットリカバリーフレーズまたはパスフレーズを使ってセットアップされていることを確認してください。" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "現金", + "cash_empty_description": "まだmUSDをお持ちではありません。ホームページの「現金」セクションからステーブルコインをmUSDに変換してください。", + "cash_empty_description_network_filter": "このネットワークにはmUSDがありません。ネットワークを切り替えてお持ちのmUSDをご確認ください。", "tokens": "トークン", "perpetuals": "パーペチュアル", "predictions": "予測", + "whats_happening": "現在起きていること", + "whats_happening_categories": { + "geopolitical": "地政学", + "macro": "マクロ", + "regulatory": "規制", + "technical": "技術", + "social": "社会", + "other": "その他" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "NFTをインポート", diff --git a/locales/languages/ko.json b/locales/languages/ko.json index 6d484d0a381..cd541c1ed55 100644 --- a/locales/languages/ko.json +++ b/locales/languages/ko.json @@ -20,6 +20,12 @@ "update": "업데이트" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "경고", @@ -120,8 +126,8 @@ "title": "소각 주소로 자산 전송" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "토큰 계약 경고", + "message": "수신자 주소가 직접 토큰 전송을 지원하지 않을 수 있습니다. 이 경우 자금이 손실될 수 있습니다. 이 계약이 전송되는 토큰을 받을 수 있다는 확신이 있을 때만 계속하세요." }, "gas_sponsorship_reserve_balance": { "message": "이 트랜잭션에는 가스 후원이 제공되지 않습니다. 계정에 최소 %{minBalance}의 %{nativeTokenSymbol} 토큰을 보유해야 합니다.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "이름을 확인할 수 없습니다", "invalid_address": "잘못된 주소", "contractAddressError": "토큰의 계약 주소로 토큰을 보내고 있습니다. 이로 인해 해당 토큰이 손실될 수 있습니다.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "스마트 계약 주소", + "smart_contract_address_warning": "수신자 주소가 직접 토큰 전송을 지원하지 않을 수 있습니다. 이 경우 자금이 손실될 수 있습니다. 이 계약이 전송되는 토큰을 받을 수 있다는 확신이 있을 때만 계속하세요.", "i_understand": "견적은 다음 기간 전에 만료됨을", "cancel": "취소" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "손절은 {{direction}} {{priceType}} 가격이어야 합니다", "stop_loss_beyond_liquidation_error": "손절은 {{direction}} 청산 가격이어야 합니다", "stop_loss_order_view_warning": "손절이 {{direction}} 청산 가격입니다", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": " 이상", "below": " 미만", "done": "완료", @@ -2086,14 +2094,15 @@ "a_closer_look": "자세히 보기", "whats_being_said": "시장 반응", "footer_disclaimer": "참고용 AI 요약", - "trade_button": "거래하기", + "swap_button": "스와프", + "buy_button": "매수", "sources_count": "출처 {{count}}개 이상", "sources_title": "뉴스 출처", "feedback_submitted": "피드백 제출됨", "helpful_prompt": "도움이 되셨나요?", "feedback": { "title": "피드백", - "description": "AI 기반 시장 인사이트를 개선할 수 있도록 도와주세요.", + "description": "귀하의 답변은 AI 요약 개선에 도움이 됩니다.", "not_relevant": "관련 없음", "not_accurate": "정확하지 않음", "hard_to_understand": "이해하기 어려움", @@ -2162,7 +2171,7 @@ "sell_position": "포지션 매도", "cash_out": "출금", "cash_out_info": "자금은 사용 가능한 잔액에 추가됩니다", - "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", + "buy_preview_outcome_at_price": "{{price}}에 {{outcome}}", "at_price_per_share": "{{price}}에 {{size}}주 매도", "cashout_info": "{{outcome}}에 {{amount}}(단가: {{initialPrice}})", "cashout_info_multiple": "{{outcomeGroupTitle}} - {{outcome}}에 {{amount}}(단가:{{initialPrice}}", @@ -2206,7 +2215,7 @@ "available_balance": "사용 가능한 잔액", "claim_amount_text": "${{amount}} 수령", "claim_winnings_text": "수익금 수령", - "claiming_text": "Claiming...", + "claiming_text": "청구 중...", "unrealized_pnl_label": "미실현 손익", "unrealized_pnl_value": "{{amount}}({{percent}})", "unrealized_pnl_error": "불러올 수 없습니다", @@ -2287,7 +2296,7 @@ "try_again": "다시 시도" }, "in_progress": { - "title": "Claim already in progress" + "title": "청구가 이미 진행 중입니다" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "거래소 또는 시장에 지불한 수수료", "total_incl_fees": "수수료 포함", "close": "닫기", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "표시된 가격은 주문이 전량 체결된다고 가정한 값입니다. 주문이 일부만 체결되면 실제 수량은 달라질 수 있습니다.", + "deposit_fee": "예치 수수료", + "deposit_fee_description": "예측 잔액에 자금을 예치할 때 부과되는 수수료" }, "error": { "title": "예측에 연결할 수 없습니다", @@ -3059,6 +3068,7 @@ "networks_no_results": "네트워크를 찾을 수 없습니다", "network_name_label": "네트워크 이름", "network_name_placeholder": "네트워크 이름(옵션)", + "required": "필수", "network_rpc_url_label": "RPC URL", "network_rpc_name_label": "RPC 이름", "network_rpc_placeholder": "신규 RPC 네트워크", @@ -3298,6 +3308,8 @@ "blockaid_desc": "이 기능은 트랜잭션과 서명 요청을 적극적으로 검토하여 악의적인 활동을 경고합니다.", "security_alerts": "보안 경고", "security_alerts_desc": "이 기능은 거래 및 서명 요청을 로컬에서 검토하여 악의적인 활동이 있는 경우 경고합니다. 요청을 승인하기 전에 항상 직접 검토하세요. 이 기능이 모든 악성 활동 탐지를 보장하지는 않습니다. 이 기능을 활성화하면 제공 업체의 이용 약관에 동의하는 것이 됩니다.", + "smart_account_dapp_requests_heading": "디앱의 스마트 계정 요청", + "smart_account_dapp_requests_desc": "디앱이 일반 계정에 스마트 계정 기능을 요청하도록 허용합니다. 이미 스마트 계정인 계정에는 영향을 미치지 않습니다.", "smart_transactions_opt_in_heading": "스마트 트랜잭션", "smart_transactions_opt_in_desc_supported_networks": "지원되는 네트워크에서 더 안정적이고 안전하게 트랜잭션을 진행하려면 스마트 트랜잭션을 활성화하세요.", "smart_transactions_learn_more": "더 보기", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}} 활동", "disclaimer": "시장 데이터는 CoinGecko와 같은 제3자 제공업체에서 제공합니다. 해당 데이터는 정보 제공 목적이며, MetaMask는 정확성을 보장하지 않습니다." }, + "security_trust": { + "title": "보안 및 신뢰", + "malicious": "악성", + "risky": "위험", + "malicious_token_title": "악성 토큰", + "malicious_token_description": "{{symbol}}은(는) 악성 토큰입니다. 이 토큰과 상호작용하거나 거래하지 마세요.", + "verified_token_title": "검증된 토큰", + "verified_token_description": "{{symbol}}은(는) 활발하게 거래되고 있으며 널리 알려져 있습니다. 검증은 MetaMask의 보증을 의미하지 않습니다.", + "risky_token_title": "위험한 토큰", + "risky_token_description": "{{symbol}}에 대해 주의가 필요한 신호가 감지되었습니다. 이 토큰을 거래하기 전에 충분히 조사하세요.", + "malicious_token_sheet_description": "{{symbol}}에 대해 심각한 위험 신호가 감지되었습니다. 이 토큰은 거래하지 않는 것이 좋습니다.", + "got_it": "컨펌", + "proceed": "진행", + "cancel": "취소", + "data_unavailable": "보안 데이터 없음", + "subtitle_known": "위험 신호가 감지되지 않았습니다. 거래하기 전에 항상 자산을 조사하세요.", + "subtitle_no_issues": "위험 신호가 감지되지 않았습니다. 거래하기 전에 항상 자산을 조사하세요.", + "subtitle_suspicious": "주의가 필요한 신호가 감지되었습니다. 이 자산을 거래하기 전에 표시된 문제를 주의 깊게 검토하세요.", + "subtitle_malicious": "심각한 위험 신호가 감지되었습니다. 이 자산은 피하는 것이 좋습니다.", + "subtitle_unavailable": "이 토큰의 보안 분석을 불러올 수 없습니다.", + "token_distribution": "토큰 분포", + "total_supply": "총 공급", + "top_10_holders": "상위 보유자 10인", + "other": "기타", + "no_hidden_fees_detected": "숨은 수수료 감지되지 않음", + "buy_sell_tax": "매수/매도 세금", + "buy_tax": "매수세", + "sell_tax": "매도세", + "transfer": "송금", + "token_info": "토큰 정보", + "created": "생성됨", + "token_age": "토큰 생성 후 경과 시간", + "network": "네트워크", + "type": "유형", + "official_links": "공식 링크", + "website": "웹사이트", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "이용 불가", + "verified": "인증 완료", + "no_issues": "문제 없음", + "suspicious": "의심됨", + "malicious_label": "악성", + "more": "더 보기", + "evaluation_disclaimer": "이 보안 검토는 평가용일 뿐이므로 거래에 대한 보증이나 권유로 받아들여서는 안 됩니다." + }, "account_details": { "title": "계정 세부 정보", "share_account": "공유", @@ -5934,6 +5993,10 @@ "claimable_bonus": "청구 가능한 보너스", "claim_bonus": "보너스 수령", "claim_bonus_subtitle": "보너스는 {{networkName}}에서 지급됩니다.", + "percentage_bonus_on_linea": "Linea에서 {{percentage}}% 보너스", + "claim": "청구", + "sounds_good": "좋아요", + "claimable_bonus_tooltip_with_percentage": "mUSD를 보유하여 {{percentage}}%의 연환산 보너스를 받았습니다. 보너스는 Linea에서 매일 청구할 수 있습니다.", "empty_state_cta": { "heading": "{{tokenSymbol}} 토큰을 빌려주고 수익을 올리세요", "body": "{{protocol}}에서 {{tokenSymbol}}을 예치하고 연간 이자를", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "사용자의 스테이블코인" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "수익 창출", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "이 작업을 수행하기에 리소스 잔액이 부족합니다." }, - "trx_unstaking_in_progress": "{{amount}} TRX 언스테이킹이 진행 중입니다. 언스테이킹에는 14일이 소요됩니다.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "{{amount}} TRX 언스테이킹 진행 중", + "description": "언스테이킹에는 14일이 소요됩니다" + }, + "unstaked_banner": { + "title": "{{amount}} TRX 언스테이킹 완료", + "description": "언스테이킹한 TRX를 이제 출금할 수 있습니다", + "button": "출금", + "error": "출금 실패" + } }, "stake_eth": "ETH 스테이크", "unstake_eth": "ETH 언스테이크", @@ -6376,7 +6498,8 @@ "approve": "요청 승인", "perps_deposit": "자금 추가", "predict_deposit": "예측 자금 추가", - "predict_withdraw": "출금" + "predict_withdraw": "출금", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "이 사이트에서 토큰 사용 권한을 요청합니다.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "트랜잭션 {{index}}", "transaction": "트랜잭션", "available_balance": "사용 가능한 잔액: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "계속", "deposit_edit_amount_done": "자금 추가", "deposit_edit_amount_predict_withdraw": "출금", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "하드웨어 지갑은 아직 지원되지 않습니다. 핫월렛을 사용하여 계속하세요.", "hardware_wallet_not_supported_solana": "솔라나는 아직 하드웨어 지갑이 지원하지 않습니다. 계속하려면 핫월렛을 사용하세요.", "price_impact_info_title": "가격 영향", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "이는 거래가 토큰의 시장 가격에 어떤 영향을 미치는지 보여줍니다. 거래 규모, 이용 가능한 유동성, 공급자 수수료에 따라 달라집니다. MetaMask는 가격 영향에 관여하지 않습니다.", "price_impact_info_gasless_description": "가격 영향은 사용자의 스왑 주문이 자산의 시장 가격에 미치는 영향을 의미합니다. 가스비를 지불할 충분한 자금이 없는 경우, 스왑할 토큰 일부가 자동으로 수수료로 사용되므로 가격 영향이 커질 수 있습니다. MetaMask는 가격 영향에 관여하지 않으며 이를 통제하지도 않습니다.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "거래 규모와 이용 가능한 유동성으로 인해 시장 가격보다 약 {{priceImpact}}만큼 낮은 가격을 받게 됩니다. 이 내용은 이미 견적에 반영되어 있습니다.", "price_impact_high": "높은 가격 영향", "price_impact_execution_description": "이 스왑으로 토큰 가치의 약 {{priceImpact}}을(를) 잃게 됩니다. 금액을 낮추거나 유동성이 더 많은 경로를 선택해 보세요.", "proceed": "진행", @@ -6627,8 +6751,8 @@ "total_cost": "총비용", "got_it": "컨펌", "price_impact_warning_title": "높은 가격 영향", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "매우 높은 가격 영향", + "price_impact_error_description": "이번 스왑으로 토큰의 시장 가격 대비 약 {{priceImpact}}만큼 손실이 발생합니다. 더 작은 규모로 거래하거나 유동성이 더 높은 경로를 선택하면 더 나은 비율을 받을 수 있습니다." }, "quote_expired_modal": { "title": "새로운 견적이 있습니다", @@ -6940,7 +7064,7 @@ "upgrade_title": "메탈 카드로 업그레이드", "continue_button": "계속", "virtual_card": { - "name": "Virtual Card", + "name": "가상 카드", "price": "수수료", "feature_1": "Apple Pay 및 Google Pay용 가상 카드", "feature_2": "암호화폐로 결제 (USDC, USDT, WETH 등)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "메탈 카드", "price": "연 $199", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "가상 카드의 모든 혜택에 더해, 다음 특전도 제공됩니다.", + "feature_1": "프리미엄 각인 메탈 카드", + "feature_2": "연간 첫 $10,000 사용액에 대해 3% 캐시백", "feature_3": "해외 결제 수수료 없음" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "연간 최대 $300 캐시백 적립", + "upgrade_to_metal_label": "또는 Metal로 업그레이드하고 3배 보상 적립" }, "review_order": { "title": "주문 검토", @@ -7104,7 +7228,7 @@ "ssn_description": "카드 발급사의 요구 사항입니다. 신용 조회는 진행되지 않습니다.", "invalid_ssn": "잘못된 SSN입니다", "invalid_date_of_birth": "유효하지 않은 생년월일입니다. 18세 이상이어야 합니다", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "이름과 성은 인증된 신원 정보와 일치해야 합니다" }, "physical_address": { "title": "주소 추가", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "이용한도가 얼마 남지 않았습니다", "description": "거절을 피하려면 업데이트하세요", - "confirm_button_label": "새 한도 설정" + "confirm_button_label": "새 한도 설정", + "dismiss_button_label": "닫기" }, "need_delegation": { "title": "카드를 활성화해야 합니다", @@ -7301,7 +7426,6 @@ "dismiss": "닫기", "update_success": "지출 한도 변경됨", "update_error": "지출 한도 변경 실패", - "solana_not_supported": "card.metamask.io에서 솔라나 토큰 활성화", "select_token": "토큰 선택", "loading": "사용 가능한 토큰 불러오는 중...", "load_error": "토큰을 불러올 수 없습니다. 다시 시도해 주세요.", @@ -7343,9 +7467,7 @@ "limited": "제한됨", "not_enabled": "활성화되지 않음", "update_success": "지출 우선순위 변경됨", - "update_error": "지출 우선순위 변경 실패", - "solana_not_supported_button_title": "솔라나 네트워크의 다른 토큰", - "solana_not_supported_button_description": "card.metamask.io에서 활성화" + "update_error": "지출 우선순위 변경 실패" }, "card_authentication": { "title": "카드 계정에 로그인", @@ -7443,6 +7565,11 @@ "title": "참여 실패", "description": "연결 상태를 확인하고 다시 시도하세요." }, + "version_guard": { + "title": "업데이트 필요", + "description": "보상을 사용하려면 더 최신 버전의 MetaMask가 필요합니다. 계속하려면 업데이트하세요.", + "update_button": "MetaMask 업데이트" + }, "season_error": { "error_fetching_title": "시즌을 불러올 수 없습니다", "error_fetching_description": "연결 상태를 확인하고 다시 시도하세요.", @@ -7525,7 +7652,6 @@ "main_title": "보상", "referral_title": "추천", "tab_overview_title": "개요", - "tab_snapshots_title": "스냅샷", "tab_activity_title": "활동", "referral_stats_earned_from_referrals": "추천을 통해 적립", "referral_stats_referrals": "추천", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "이번 시즌에는 보상을 받지 못하셨습니다. 다음 기회를 기다려 주세요.", "verifying_rewards": "회원님이 보상을 수령하기 전에 모든 정보가 정확한지 확인하고 있습니다." }, + "previous_season_view": { + "title": "이전 시즌" + }, "season_status": { "points_earned": "포인트 획득함" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "적용 중인 부스트", "season_1": "시즌 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "mUSD 보너스 계산기", + "description": "스테이블코인을 mUSD로 전환하면 얼마나 적립할 수 있는지 확인해 보세요.", + "amount_label": "전환 금액", + "estimated_bonus": "예상 연환산 보너스: 최대 3%", + "initial_amount": "초기 금액", + "daily_bonus": "매일 청구 가능 보너스", + "annualized_bonus": "연환산 보너스", + "disclaimer": "이는 추정치일 뿐입니다. 보너스는 변경될 수 있습니다.", "buy_button": "mUSD 구매", - "swap_button": "Swap to mUSD" + "swap_button": "mUSD로 스왑" }, "upcoming_rewards": { "title": "잠긴 리워드", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "불러올 수 없음" }, - "snapshot": { + "campaign": { "starts_date": "시작일: {{date}}", "ends_date": "종료일: {{date}}", - "results_coming_soon": "결과 곧 공개", - "tokens_on_the_way": "토큰 지급 예정", + "ended_date": "Ended {{date}}", "pill_up_next": "다음 일정", - "pill_live_now": "지금 진행 중", - "pill_calculating": "계산 중", - "pill_results_ready": "결과 준비 완료", - "pill_complete": "완료" - }, - "snapshots_section": { - "title": "스냅샷", - "error_title": "스냅샷을 불러올 수 없습니다", - "error_description": "스냅샷을 불러오지 못했습니다. 다시 시도해 주세요.", - "retry_button": "다시 시도" - }, - "snapshots_tab": { + "pill_active": "진행 중", + "pill_complete": "완료", + "enter_now": "지금 참가하기", + "entered": "참가 완료", + "participant_count": "#{{count}}", + "opt_in_cta": "참여하기", + "opt_in_sheet_title": "캠페인 참여", + "opt_in_sheet_description_pre_link": "'참여하기'를 클릭하면 MetaMask 보상 프로그램에 동의하는 것입니다", + "opt_in_sheet_link_text": "추가 이용 약관 및 개인정보 처리방침에 동의하는 것이 됩니다", + "opt_in_sheet_description_post_link": "MetaMask는 온체인 활동을 추적하여 보상을 자동으로 지급합니다.", + "geo_restriction_banner_title": "회원님의 지역에서 사용할 수 없습니다", + "geo_restriction_banner_description": "현지 규정으로 인해 이 캠페인은 거주 지역에서 사용할 수 없습니다." + }, + "campaign_mechanics": { + "title": "운영 방식" + }, + "campaign_details": { + "start_date": "시작일: {{date}}", + "end_date": "종료일: {{date}}", + "opt_in": "참여하기", + "opting_in": "참여 중...", + "opted_in": "이 캠페인에 참여했습니다", + "opt_in_error": "참여하지 못했습니다. 다시 시도하세요.", + "join_campaign": "캠페인 참여", + "checking_opt_in_status": "참여 상태 확인 중", + "swap": "스와프", + "how_it_works": "작동 방식" + }, + "campaigns_preview": { + "title": "캠페인", + "coming_soon": "곧 추가 예정", + "notify_me": "알림 받기" + }, + "earn_rewards": { + "title": "보상 받기", + "musd_title": "스테이블코인 최대 3% 보너스", + "musd_subtitle": "mUSD 보너스 계산하기", + "card_title": "최대 3% 캐시백", + "card_subtitle": "지금 MetaMask 카드를 받으세요", + "card_subtitle_cardholder": "MetaMask 카드 혜택을 이용하세요" + }, + "campaigns_view": { + "title": "캠페인", "active_title": "진행 중", "upcoming_title": "예정", "previous_title": "이전", - "empty_state": "사용 가능한 스냅샷 없음", - "error_title": "스냅샷을 불러올 수 없습니다", - "error_description": "스냅샷을 불러오지 못했습니다. 다시 시도해 주세요.", + "empty_state": "참여 가능한 캠페인이 없습니다", + "error_title": "캠페인을 불러올 수 없습니다", + "error_description": "캠페인을 불러오지 못했습니다. 다시 시도하세요.", "retry_button": "다시 시도", "refreshing": "새로 고침 중..." } @@ -7953,13 +8112,12 @@ "continue": "계속" }, "connecting": { - "title": "{{device}} 연결", + "title": "{{device}} 연결 중...", "searching": "{{device}} 찾는 중...", - "tips_header": "계속하려면 다음 사항을 확인하세요.", + "tips_header": "다음을 확인하세요:", "tip_unlock": "{{device}} 잠금이 해제되어 있어야 합니다", "tip_open_app": "이더리움 앱이 열려 있어야 합니다", "tip_enable_bluetooth": "블루투스가 켜져 있어야 합니다", - "tip_dnd_off": "방해 금지 모드는 꺼져 있어야 합니다", "tip_bluetooth_permission": "위치 및 블루투스 권한이 허용되어 있어야 합니다", "tip_bluetooth_permission_v12": "근처 장치 권한이 허용되어 있어야 합니다", "tip_stay_close": "장치와 휴대전화가 서로 가까이 있어야 합니다" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "근처 장치 권한이 필요합니다", "bluetooth_off": "장치에 연결하려면 블루투스를 켜세요", "bluetooth_scan_failed": "장치를 스캔하지 못했습니다. 다시 시도하세요", - "bluetooth_connection_failed": "장치의 블루투스를 활성화한 후 계속하세요", + "bluetooth_connection_failed": "기기 연결에 실패했습니다. 다시 시도하세요", "not_supported": "지원되지 않는 작업입니다", "unknown_error": "이 계정에 대한 비밀복구구문 또는 패스프레이즈로 {{device}}이(가) 설정되어 있는지 확인하세요" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "현금", + "cash_empty_description": "아직 mUSD가 없습니다. 홈페이지의 현금 섹션에서 스테이블코인을 mUSD로 전환하세요.", + "cash_empty_description_network_filter": "이 네트워크에는 mUSD가 없습니다. mUSD를 확인하려면 네트워크를 전환하세요.", "tokens": "토큰", "perpetuals": "영구계약", "predictions": "예측", + "whats_happening": "주요 동향", + "whats_happening_categories": { + "geopolitical": "지정학", + "macro": "거시 경제", + "regulatory": "규제", + "technical": "기술", + "social": "사회", + "other": "기타" + }, "defi": "디파이", "nfts": "NFT", "import_nfts": "NFT 가져오기", diff --git a/locales/languages/pt.json b/locales/languages/pt.json index a612b4b87de..3a2d1147550 100644 --- a/locales/languages/pt.json +++ b/locales/languages/pt.json @@ -20,6 +20,12 @@ "update": "Atualizar" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Alerta", @@ -120,8 +126,8 @@ "title": "Enviando ativos para endereço de queima" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Aviso sobre contrato de token", + "message": "O endereço do destinatário pode não aceitar transferências diretas de tokens, o que pode resultar na perda de fundos. Prossiga apenas se tiver certeza de que este contrato pode receber sua transferência." }, "gas_sponsorship_reserve_balance": { "message": "O patrocínio de gas não está disponível para esta transação. Você precisará manter pelo menos %{minBalance} %{nativeTokenSymbol} em sua conta.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Não foi possível resolver o nome", "invalid_address": "Endereço inválido", "contractAddressError": "Você está enviando tokens para o endereço do contrato do token. Isso pode levar à perda desses tokens.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Endereço de contrato inteligente", + "smart_contract_address_warning": "O endereço do destinatário pode não aceitar transferências diretas de tokens, o que pode resultar na perda de fundos. Prossiga apenas se tiver certeza de que este contrato pode receber sua transferência.", "i_understand": "Eu compreendo", "cancel": "Cancelar" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "O stop loss deve ser um preço {{direction}} {{priceType}}", "stop_loss_beyond_liquidation_error": "O stop loss deve ser um preço de liquidação {{direction}}", "stop_loss_order_view_warning": "O stop loss é um preço de liquidação {{direction}}", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "acima", "below": "abaixo", "done": "Pronto", @@ -2086,14 +2094,15 @@ "a_closer_look": "Uma análise mais detalhada", "whats_being_said": "O que as pessoas dizem", "footer_disclaimer": "Resumo de IA apenas para fins informativos", - "trade_button": "Negociar", + "swap_button": "Troca", + "buy_button": "Comprar", "sources_count": "+{{count}} fontes", "sources_title": "Fontes de notícias", "feedback_submitted": "Feedback enviado", "helpful_prompt": "Isso foi útil?", "feedback": { "title": "Comentário", - "description": "Ajude a aprimorar nossas análises de mercado geradas por IA.", + "description": "Sua resposta ajuda a melhorar nossos resumos de IA.", "not_relevant": "Não relevante", "not_accurate": "Inexato", "hard_to_understand": "Difícil de entender", @@ -2206,7 +2215,7 @@ "available_balance": "Saldo disponível", "claim_amount_text": "Resgatar $ {{amount}}", "claim_winnings_text": "Resgatar ganhos", - "claiming_text": "Claiming...", + "claiming_text": "Reivindicando...", "unrealized_pnl_label": "P&L não realizados", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Não foi possível carregar", @@ -2287,7 +2296,7 @@ "try_again": "Tentar novamente" }, "in_progress": { - "title": "Claim already in progress" + "title": "Reivindicação já em andamento" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Taxa paga à bolsa ou ao mercado", "total_incl_fees": "incluindo taxas", "close": "Fechar", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Os preços apresentados pressupõem que sua ordem seja totalmente executada. Os valores reais podem variar se a ordem for executada apenas parcialmente.", + "deposit_fee": "Taxa de depósito", + "deposit_fee_description": "Taxa cobrada para depositar fundos em seu saldo de previsões" }, "error": { "title": "Não foi possível conectar-se às previsões", @@ -3059,6 +3068,7 @@ "networks_no_results": "Nenhuma rede encontrada", "network_name_label": "Nome da rede", "network_name_placeholder": "Nome da rede (opcional)", + "required": "Obrigatório", "network_rpc_url_label": "URL da RPC", "network_rpc_name_label": "Nome da RPC", "network_rpc_placeholder": "Nova rede RPC", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Esse recurso alerta você sobre atividades mal-intencionadas analisando ativamente as solicitações de transações e assinaturas.", "security_alerts": "Alertas de segurança", "security_alerts_desc": "Esse recurso alerta sobre atividades mal-intencionadas por meio da análise local de solicitações de transações e assinaturas. Sempre realize sua própria devida diligência antes de aprovar solicitações. Não há garantia de que esse recurso detectará toda e qualquer atividade mal-intencionada. Ao ativar esse recurso, você concorda com os termos de uso do provedor.", + "smart_account_dapp_requests_heading": "Solicitações de conta inteligente vindas de dapps", + "smart_account_dapp_requests_desc": "Permite que dapps (aplicativos descentralizados) solicitem recursos de conta inteligente para contas padrão. Isso não afetará contas que já são contas inteligentes.", "smart_transactions_opt_in_heading": "Transações inteligentes", "smart_transactions_opt_in_desc_supported_networks": "Ative as transações inteligentes para fazer transações mais confiáveis e seguras nas redes suportadas.", "smart_transactions_learn_more": "Saiba mais", @@ -3566,6 +3578,53 @@ "activity": "Atividade do {{symbol}}", "disclaimer": "Os dados de mercado são fornecidos por fontes terceirizadas, como o CoinGecko. Os dados são apenas para fins informativos. A MetaMask não se responsabiliza por sua exatidão." }, + "security_trust": { + "title": "Segurança e confiança", + "malicious": "Malicioso", + "risky": "De risco", + "malicious_token_title": "Token malicioso", + "malicious_token_description": "{{symbol}} é um token malicioso. Evite interagir com ele ou negociá-lo.", + "verified_token_title": "Token verificado", + "verified_token_description": "{{symbol}} é ativamente negociado e amplamente reconhecido. A verificação não representa um endosso por parte da MetaMask.", + "risky_token_title": "Token de risco", + "risky_token_description": "Sinais de alerta detectados em {{symbol}}. Pesquise cuidadosamente antes de negociar este token.", + "malicious_token_sheet_description": "Sinais de risco graves foram detectados em {{symbol}}. Recomendamos não negociar este token.", + "got_it": "Entendi", + "proceed": "Prosseguir", + "cancel": "Cancelar", + "data_unavailable": "Dados de segurança não disponíveis", + "subtitle_known": "Nenhum sinal de risco detectado. Sempre pesquise qualquer ativo antes de negociar.", + "subtitle_no_issues": "Nenhum sinal de risco detectado. Sempre pesquise qualquer ativo antes de negociar.", + "subtitle_suspicious": "Sinais de alerta detectados. Analise cuidadosamente os problemas sinalizados antes de negociar este ativo.", + "subtitle_malicious": "Sinais de risco graves foram detectados. Recomendamos evitar este ativo.", + "subtitle_unavailable": "Não foi possível carregar a análise de segurança para este token.", + "token_distribution": "Distribuição de token", + "total_supply": "Fornecimento total", + "top_10_holders": "10 principais detentores", + "other": "Outro", + "no_hidden_fees_detected": "Nenhuma taxa oculta detectada", + "buy_sell_tax": "Imposto sobre compra/venda", + "buy_tax": "Imposto sobre compra", + "sell_tax": "Imposto sobre venda", + "transfer": "Transferir", + "token_info": "Informações do token", + "created": "Criado em", + "token_age": "Idade do token", + "network": "Rede", + "type": "Digite", + "official_links": "Links oficiais", + "website": "Website", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "N/D", + "verified": "Verificado", + "no_issues": "Sem problemas", + "suspicious": "Suspeito", + "malicious_label": "Malicioso", + "more": "mais", + "evaluation_disclaimer": "Esta análise de segurança tem caráter meramente avaliativo e não constitui um endosso ou recomendação de negociação." + }, "account_details": { "title": "Detalhes da conta", "share_account": "Compartilhar", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Bônus resgatável", "claim_bonus": "Resgatar bônus", "claim_bonus_subtitle": "O bônus será pago em {{networkName}}.", + "percentage_bonus_on_linea": "Bônus de {{percentage}}% na Linea", + "claim": "Resgatar", + "sounds_good": "Parece bom", + "claimable_bonus_tooltip_with_percentage": "Você ganhou {{percentage}}% de bônus anualizado por manter mUSD. Seu bônus pode ser resgatado diariamente na Linea.", "empty_state_cta": { "heading": "Empreste {{tokenSymbol}} e ganhe", "body": "Empreste seus {{tokenSymbol}} com {{protocol}} e ganhe", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Suas stablecoins" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Ganhe", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Você não possui saldo de recursos suficiente para realizar esta ação." }, - "trx_unstaking_in_progress": "Desfazer staking de {{amount}} TRX em andamento. O processo de desfazer staking leva 14 dias.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Retirada de staking de {{amount}} TRX em andamento", + "description": "O processo de retirada de staking levará 14 dias" + }, + "unstaked_banner": { + "title": "Retirada de staking de {{amount}} TRX concluída", + "description": "Seus TRX retirados de staking já podem ser sacados", + "button": "Sacar", + "error": "Falha ao sacar" + } }, "stake_eth": "Fazer staking de ETH", "unstake_eth": "Retirar ETH do staking", @@ -6376,7 +6498,8 @@ "approve": "Aprovar solicitação", "perps_deposit": "Adicionar fundos", "predict_deposit": "Adicionar fundos de previsão", - "predict_withdraw": "Sacar" + "predict_withdraw": "Sacar", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Este site quer permissão para gastar seus tokens.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Transação {{index}}", "transaction": "Transações", "available_balance": "Saldo disponível: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Continuar", "deposit_edit_amount_done": "Adicionar fundos", "deposit_edit_amount_predict_withdraw": "Sacar", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Ainda não oferecemos suporte a carteiras de hardware. Use uma hot wallet para continuar.", "hardware_wallet_not_supported_solana": "Carteiras de hardware ainda não são compatíveis com Solana. Use uma hot wallet para continuar.", "price_impact_info_title": "Impacto do preço", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "É assim que sua negociação altera o preço de mercado de um token. Ele depende do volume da negociação, da liquidez disponível e das taxas do fornecedor. A MetaMask não controla o impacto no preço.", "price_impact_info_gasless_description": "O impacto no preço reflete como sua ordem de troca afeta o preço de mercado do ativo. Se você não tiver fundos suficientes para o gás, parte do seu token de origem será automaticamente alocada para cobrir taxas, o que aumenta o impacto no preço. A MetaMask não influencia nem controla o impacto no preço.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Devido ao volume da sua negociação e à liquidez disponível, você receberá cerca de {{priceImpact}} a menos do que o preço de mercado. Isso já está incluído em sua cotação.", "price_impact_high": "Alto impacto no preço", "price_impact_execution_description": "Você perderá aproximadamente {{priceImpact}} do valor do seu token nesta troca. Tente reduzir o valor ou escolher uma rota com mais liquidez.", "proceed": "Prosseguir", @@ -6627,8 +6751,8 @@ "total_cost": "Custo total", "got_it": "Entendi", "price_impact_warning_title": "Alto impacto no preço", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Impacto de preço muito elevado", + "price_impact_error_description": "Você perderá aproximadamente {{priceImpact}} do valor de mercado do seu token neste swap. Tente uma negociação de valor menor ou uma rota com mais liquidez para melhorar sua taxa." }, "quote_expired_modal": { "title": "Novas cotações estão disponíveis", @@ -6940,7 +7064,7 @@ "upgrade_title": "Faça upgrade para Metal", "continue_button": "Continuar", "virtual_card": { - "name": "Virtual Card", + "name": "Cartão virtual", "price": "Gratuito", "feature_1": "Cartão virtual para Apple Pay e Google Pay", "feature_2": "Pague com criptomoedas (USDC, USDT, WETH e várias outras)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Cartão Metal", "price": "US$ 199/ano", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Tudo em formato virtual, além de:", + "feature_1": "Cartão premium de metal entalhado", + "feature_2": "3% de cashback nos primeiros US$ 10.000/ano", "feature_3": "Sem taxas de transação internacional" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Ganhe até US$ 300 em cashback anualmente", + "upgrade_to_metal_label": "Ou faça upgrade para Metal e ganhe 3x mais recompensas" }, "review_order": { "title": "Confira sua ordem", @@ -7104,7 +7228,7 @@ "ssn_description": "Exigido pela emissora do cartão. Nenhuma verificação de crédito será realizada.", "invalid_ssn": "NSS inválido", "invalid_date_of_birth": "Data de nascimento inválida. Você deve ter pelo menos 18 anos", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Nome e sobrenome devem corresponder à sua identidade verificada" }, "physical_address": { "title": "Insira seu endereço", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Você está próximo do seu limite de gastos", "description": "Atualize para evitar recusas", - "confirm_button_label": "Definir novo limite" + "confirm_button_label": "Definir novo limite", + "dismiss_button_label": "Ignorar" }, "need_delegation": { "title": "Você precisa habilitar seu cartão", @@ -7301,7 +7426,6 @@ "dismiss": "Ignorar", "update_success": "Limite de gastos atualizado com sucesso", "update_error": "Falha ao atualizar limite de gastos", - "solana_not_supported": "Habilite tokens Solana em card.metamask.io", "select_token": "Selecionar token", "loading": "Carregando tokens disponíveis...", "load_error": "Não foi possível carregar os tokens. Tente novamente.", @@ -7343,9 +7467,7 @@ "limited": "Limitado", "not_enabled": "Não ativado", "update_success": "Prioridade de gastos atualizada com sucesso", - "update_error": "Falha ao atualizar prioridade de gastos", - "solana_not_supported_button_title": "Outros tokens em Solana", - "solana_not_supported_button_description": "Ative em card.metamask.io" + "update_error": "Falha ao atualizar prioridade de gastos" }, "card_authentication": { "title": "Faça login na conta do seu cartão", @@ -7443,6 +7565,11 @@ "title": "A participação falhou", "description": "Verifique sua conexão e tente novamente." }, + "version_guard": { + "title": "Atualização necessária", + "description": "O uso do programa de Recompensas requer uma versão mais recente da MetaMask. Atualize para continuar.", + "update_button": "Atualizar MetaMask" + }, "season_error": { "error_fetching_title": "Não foi possível carregar a temporada", "error_fetching_description": "Verifique sua conexão e tente novamente.", @@ -7525,7 +7652,6 @@ "main_title": "Recompensas", "referral_title": "Indicações", "tab_overview_title": "Visão geral", - "tab_snapshots_title": "Capturas de tela", "tab_activity_title": "Atividade", "referral_stats_earned_from_referrals": "Ganho por meio de indicações", "referral_stats_referrals": "Indicações", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Você não ganhou recompensas nesta temporada, mas sempre haverá uma próxima vez.", "verifying_rewards": "Estamos verificando se tudo está correto antes de você resgatar suas recompensas." }, + "previous_season_view": { + "title": "Temporada anterior" + }, "season_status": { "points_earned": "Pontos ganhos" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Incrementos ativos", "season_1": "Temporada 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "Calculadora de bônus mUSD", + "description": "Veja quanto você pode ganhar convertendo suas stablecoins para mUSD.", + "amount_label": "Valor convertido", + "estimated_bonus": "Bônus anual estimado: até 3%", + "initial_amount": "Valor inicial", + "daily_bonus": "Bônus diário resgatável", + "annualized_bonus": "Bônus anualizado", + "disclaimer": "Este valor é apenas uma estimativa. O bônus está sujeito a alterações.", "buy_button": "Comprar mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Converter para mUSD" }, "upcoming_rewards": { "title": "Recompensas bloqueadas", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Não foi possível carregar" }, - "snapshot": { + "campaign": { "starts_date": "Começa em {{date}}", "ends_date": "Termina em {{date}}", - "results_coming_soon": "Resultados em breve", - "tokens_on_the_way": "Tokens a caminho", + "ended_date": "Ended {{date}}", "pill_up_next": "Em seguida", - "pill_live_now": "Ao vivo agora", - "pill_calculating": "Calculando", - "pill_results_ready": "Resultados prontos", - "pill_complete": "Concluído" - }, - "snapshots_section": { - "title": "Capturas de tela", - "error_title": "Não foi possível carregar capturas de tela", - "error_description": "Não foi possível carregar as capturas de tela. Tente novamente.", - "retry_button": "Tentar novamente" - }, - "snapshots_tab": { + "pill_active": "Em tempo real", + "pill_complete": "Concluído", + "enter_now": "Insira agora", + "entered": "Inserido", + "participant_count": "#{{count}}", + "opt_in_cta": "Participar", + "opt_in_sheet_title": "Participe da campanha", + "opt_in_sheet_description_pre_link": "Ao clicar em \"Participar\", você concorda com o programa de Recompensas da MetaMask", + "opt_in_sheet_link_text": "Termos de uso suplementares e aviso de privacidade", + "opt_in_sheet_description_post_link": "Rastrearemos a atividade onchain para recompensar você automaticamente.", + "geo_restriction_banner_title": "Não disponível em sua região", + "geo_restriction_banner_description": "Esta campanha não está disponível na sua região devido a regulamentos locais." + }, + "campaign_mechanics": { + "title": "Mecânica" + }, + "campaign_details": { + "start_date": "Início: {{date}}", + "end_date": "Término: {{date}}", + "opt_in": "Participar", + "opting_in": "Optando por participar...", + "opted_in": "Você optou por participar desta campanha", + "opt_in_error": "Falha ao optar por participar. Tente novamente.", + "join_campaign": "Participe da campanha", + "checking_opt_in_status": "Verificando status de participação", + "swap": "Troca", + "how_it_works": "Como funciona" + }, + "campaigns_preview": { + "title": "Campanhas", + "coming_soon": "Em breve", + "notify_me": "Avisar-me" + }, + "earn_rewards": { + "title": "Ganhe recompensas", + "musd_title": "Até 3% de bônus sobre stablecoins", + "musd_subtitle": "Calcule seu bônus em mUSD", + "card_title": "Até 3% em cashback", + "card_subtitle": "Peça já o seu Cartão MetaMask", + "card_subtitle_cardholder": "Acesse os benefícios do seu Cartão MetaMask" + }, + "campaigns_view": { + "title": "Campanhas", "active_title": "Ativo", "upcoming_title": "Próximo", "previous_title": "Anterior", - "empty_state": "Nenhuma captura de tela disponível", - "error_title": "Não foi possível carregar capturas de tela", - "error_description": "Não foi possível carregar as capturas de tela. Tente novamente.", + "empty_state": "Nenhuma campanha disponível", + "error_title": "Não foi possível carregar campanhas", + "error_description": "Não foi possível carregar as campanhas. Tente novamente.", "retry_button": "Tentar novamente", "refreshing": "Atualizando..." } @@ -7953,13 +8112,12 @@ "continue": "Continuar" }, "connecting": { - "title": "Conecte seu {{device}}", + "title": "Conectando seu {{device}}...", "searching": "Procurando {{device}}...", - "tips_header": "Para continuar, certifique-se de que:", + "tips_header": "Certifique-se de que:", "tip_unlock": "Seu {{device}} está desbloqueado", "tip_open_app": "O aplicativo Ethereum está aberto", "tip_enable_bluetooth": "O Bluetooth está ativado", - "tip_dnd_off": "O modo \"Não perturbe\" está desativado", "tip_bluetooth_permission": "Permissão de localização e Bluetooth estão concedidas", "tip_bluetooth_permission_v12": "A permissão para dispositivos próximos foi concedida", "tip_stay_close": "Seu dispositivo permanece próximo ao seu telefone" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "É necessário permissão para dispositivos próximos", "bluetooth_off": "Ative o Bluetooth para se conectar ao seu dispositivo", "bluetooth_scan_failed": "Falha ao procurar dispositivos. Tente novamente", - "bluetooth_connection_failed": "Ative o Bluetooth no seu dispositivo para continuar", + "bluetooth_connection_failed": "A conexão com seu dispositivo falhou. Tente novamente", "not_supported": "Esta operação não é suportada", "unknown_error": "Certifique-se de que seu {{device}} esteja configurado com a Frase de Recuperação Secreta ou com a senha para esta conta" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Dinheiro em espécie", + "cash_empty_description": "Você ainda não tem mUSD. Converta stablecoins para mUSD na seção \"Dinheiro\" da página inicial.", + "cash_empty_description_network_filter": "Não há mUSD nesta rede. Mude de rede para ver seus mUSD.", "tokens": "Tokens", "perpetuals": "Perpétuos", "predictions": "Previsões", + "whats_happening": "O que está acontecendo", + "whats_happening_categories": { + "geopolitical": "Geopolítica", + "macro": "Macro", + "regulatory": "Regulatório", + "technical": "Técnico", + "social": "Redes sociais", + "other": "Outro" + }, "defi": "DeFi", "nfts": "NFTs", "import_nfts": "Importar NFTs", diff --git a/locales/languages/ru.json b/locales/languages/ru.json index be640321f3f..5710fe027b2 100644 --- a/locales/languages/ru.json +++ b/locales/languages/ru.json @@ -20,6 +20,12 @@ "update": "Обновить" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Оповещение", @@ -120,8 +126,8 @@ "title": "Активы отправляются на адрес для сжигания" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Предупреждение о контракте токена", + "message": "Адрес получателя может не поддерживать прямые переводы токенов, что может привести к потере средств. Продолжайте только в том случае, если вы уверены, что этот контракт может получить ваш перевод." }, "gas_sponsorship_reserve_balance": { "message": "Для этой транзакции недоступна спонсорская оплата газа. Вам необходимо постоянно иметь на счету не менее %{minBalance} %{nativeTokenSymbol}.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Не удалось разрешить имя", "invalid_address": "Недействительный адрес", "contractAddressError": "Вы отправляете токены на адрес контракта токена. Это может привести к потере этих токенов.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Адрес смарт-контракта", + "smart_contract_address_warning": "Адрес получателя может не поддерживать прямые переводы токенов, что может привести к потере средств. Продолжайте только в том случае, если вы уверены, что этот контракт может получить ваш перевод.", "i_understand": "Я понимаю", "cancel": "Отмена" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Стоп-лосс должен быть по цене {{direction}} {{priceType}}", "stop_loss_beyond_liquidation_error": "Стоп-лосс должен быть равен цене ликвидации {{direction}}", "stop_loss_order_view_warning": "Стоп-лосс — это цена ликвидации {{direction}}", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "выше", "below": "ниже", "done": "Готово", @@ -2086,14 +2094,15 @@ "a_closer_look": "Подробный обзор", "whats_being_said": "Что говорят", "footer_disclaimer": "ИИ-сводка только для информации", - "trade_button": "Торговать", + "swap_button": "Обменять", + "buy_button": "Купить", "sources_count": "+{{count}} источника(-ов)", "sources_title": "Источники новостей", "feedback_submitted": "Отзыв отправлен", "helpful_prompt": "Это было полезно?", "feedback": { "title": "Отзыв", - "description": "Помогите улучшить наши обзоры рынка, созданные с помощью ИИ.", + "description": "Ваш ответ помогает улучшить наши сводки, создаваемые ИИ.", "not_relevant": "Неактуально", "not_accurate": "Неточно", "hard_to_understand": "Сложно для понимания", @@ -2162,7 +2171,7 @@ "sell_position": "Позиция на продажу", "cash_out": "Вывести деньги", "cash_out_info": "Средства будут зачислены на ваш доступный баланс.", - "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", + "buy_preview_outcome_at_price": "{{outcome}} по {{price}}", "at_price_per_share": "Продажа {{size}} акции(-ий) по цене {{price}}", "cashout_info": "{{amount}} при {{outcome}} за {{initialPrice}}", "cashout_info_multiple": "{{amount}} при {{outcomeGroupTitle}} • {{outcome}} по цене {{initialPrice}}", @@ -2206,7 +2215,7 @@ "available_balance": "Доступный баланс", "claim_amount_text": "Получить {{amount}} $", "claim_winnings_text": "Получить выигрыши", - "claiming_text": "Claiming...", + "claiming_text": "Получение...", "unrealized_pnl_label": "Нереализованные П/У", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Не удалось загрузить", @@ -2287,7 +2296,7 @@ "try_again": "Повторить попытку" }, "in_progress": { - "title": "Claim already in progress" + "title": "Запрос уже выполняется" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Комиссия, уплачиваемая бирже или рынку", "total_incl_fees": "вкл. комиссии", "close": "Закрыть", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Указанные цены действительны при условии полного выполнения вашего ордера. Фактические суммы могут отличаться, если ордер выполнен лишь частично.", + "deposit_fee": "Комиссия за депозит", + "deposit_fee_description": "Комиссия, взимаемая за внесение средств на ваш баланс прогнозов" }, "error": { "title": "Невозможно подключиться к прогнозам", @@ -3059,6 +3068,7 @@ "networks_no_results": "Сети не найдены", "network_name_label": "Имя сети", "network_name_placeholder": "Имя сети (необязательно)", + "required": "Требуется", "network_rpc_url_label": "URL-адрес RPC", "network_rpc_name_label": "Название RPC", "network_rpc_placeholder": "Новая сеть RPC", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Эта функция предупреждает вас о вредоносной активности, активно проверяя транзакции и запросы на подпись.", "security_alerts": "Оповещения безопасности", "security_alerts_desc": "Эта функция предупреждает вас о вредоносной активности, проверяя запросы транзакций и подписей локально. Всегда проводите комплексную проверку перед утверждением каких-либо запросов. Нет никакой гарантии, что эта функция обнаружит всю вредоносную активность. Включая эту функцию, вы соглашаетесь с условиями использования поставщика.", + "smart_account_dapp_requests_heading": "Запросы на создание смарт-счетов от dapps", + "smart_account_dapp_requests_desc": "Разрешите dapps запрашивать функции смарт-счетов для стандартных счетов. Это не повлияет на счета, которые уже являются смарт-счетами.", "smart_transactions_opt_in_heading": "Умные транзакции", "smart_transactions_opt_in_desc_supported_networks": "Включите функцию «Умные транзакции» для более надежных и безопасных транзакций в поддерживаемых сетях.", "smart_transactions_learn_more": "Подробнее", @@ -3566,6 +3578,53 @@ "activity": "Активность {{symbol}}", "disclaimer": "Рыночные данные предоставлены сторонними источниками, такими как CoinGecko. Данные носят исключительно информационный характер. MetaMask не несет ответственности за их точность." }, + "security_trust": { + "title": "Безопасность и доверие", + "malicious": "Вредоносный", + "risky": "Рискованный", + "malicious_token_title": "Вредоносный токен", + "malicious_token_description": "{{symbol}} — это вредоносный токен. Избегайте взаимодействия с ним или торговли им.", + "verified_token_title": "Проверенный токен", + "verified_token_description": "{{symbol}} активно торгуется и широко известен. Подтверждение не является одобрением со стороны MetaMask.", + "risky_token_title": "Рискованный токен", + "risky_token_description": "В отношении {{symbol}} обнаружены предупреждающие сигналы. Перед началом торговли этим токеном внимательно изучите информацию.", + "malicious_token_sheet_description": "Обнаружены серьезные сигналы риска для {{symbol}}. Мы рекомендуем воздержаться от торговли этим токеном.", + "got_it": "Понятно", + "proceed": "Продолжить", + "cancel": "Отмена", + "data_unavailable": "Данные о безопасности недоступны", + "subtitle_known": "Сигналы риска не обнаружены. Всегда проводите анализ любого актива перед совершением сделки.", + "subtitle_no_issues": "Сигналы риска не обнаружены. Всегда проводите анализ любого актива перед совершением сделки.", + "subtitle_suspicious": "Обнаружены предупреждающие сигналы. Внимательно изучите отмеченные проблемы, прежде чем совершать сделки с этим активом.", + "subtitle_malicious": "Обнаружены серьезные сигналы риска. Рекомендуем воздержаться от покупки этого актива.", + "subtitle_unavailable": "Не удалось загрузить анализ безопасности для этого токена.", + "token_distribution": "Распределение токенов", + "total_supply": "Общий запас", + "top_10_holders": "Топ-10 держателей", + "other": "Другое", + "no_hidden_fees_detected": "Скрытых платежей не обнаружено", + "buy_sell_tax": "Налог на куплю-продажу", + "buy_tax": "Налог на покупку", + "sell_tax": "Налог на продажу", + "transfer": "Перевести", + "token_info": "Информация о токене", + "created": "Создан", + "token_age": "Возраст токена", + "network": "Сеть", + "type": "Тип", + "official_links": "Официальные ссылки", + "website": "Веб-сайт", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "Н.Д.", + "verified": "Проверенный", + "no_issues": "Нет проблем", + "suspicious": "Подозрительный", + "malicious_label": "Вредоносный", + "more": "больше", + "evaluation_disclaimer": "Данный обзор безопасности носит исключительно ознакомительный характер и не является рекомендацией или одобрением для совершения сделок." + }, "account_details": { "title": "Сведения о счете", "share_account": "Поделиться", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Встребуемый бонус", "claim_bonus": "Получить бонус", "claim_bonus_subtitle": "Бонус будет выплачен в сети {{networkName}}.", + "percentage_bonus_on_linea": "Бонус {{percentage}}% на Linea", + "claim": "Получить", + "sounds_good": "Звучит отлично", + "claimable_bonus_tooltip_with_percentage": "Годовой бонус в размере {{percentage}}% от суммы вашего накопленного бонуса за владение mUSD. Ваш бонус можно получить ежедневно на Linea.", "empty_state_cta": { "heading": "Давайте взаймы {{tokenSymbol}} и зарабатывайте", "body": "Одолжите свой {{tokenSymbol}} с помощью {{protocol}} и зарабатывайте", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Ваши стейблкоины" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Заработать", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "У вас недостаточно ресурсов для выполнения этого действия." }, - "trx_unstaking_in_progress": "Выполняется вывод {{amount}} TRX из стейкинга. Он займет 14 дней.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Выполняется отмена стейкинга {{amount}} TRX", + "description": "Для отмены стейкинга потребуется 14 дней" + }, + "unstaked_banner": { + "title": "Отмена стейкинга {{amount}} TRX завершена", + "description": "Теперь вы можете вывести свои TRX, стейкинг которых отменили", + "button": "Вывести средства", + "error": "Ошибка вывода средств" + } }, "stake_eth": "Выполнить стейкинг ETH", "unstake_eth": "Отменить стейкинг ETH", @@ -6376,7 +6498,8 @@ "approve": "Одобрить запрос", "perps_deposit": "Внести средства", "predict_deposit": "Внести средства для прогнозирования", - "predict_withdraw": "Вывести средства" + "predict_withdraw": "Вывести средства", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Этот сайт запрашивает разрешение на трату ваших токенов.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Транзакция {{index}}", "transaction": "Защита", "available_balance": "Доступный баланс: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Продолжить", "deposit_edit_amount_done": "Внести средства", "deposit_edit_amount_predict_withdraw": "Вывести средства", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Аппаратные кошельки пока не поддерживаются. Используйте горячий кошелек, чтобы продолжить.", "hardware_wallet_not_supported_solana": "Аппаратные кошельки пока не поддерживаются для Solana. Используйте горячий кошелек, чтобы продолжить.", "price_impact_info_title": "Влияние на цену", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Вот как ваша сделка влияет на рыночную цену токена. Это зависит от размера сделки, доступной ликвидности и комиссий поставщика. MetaMask не контролирует влияние на цену.", "price_impact_info_gasless_description": "Влияние на цену отражает, как ваш ордер на своп влияет на рыночную цену актива. Если у вас недостаточно средств для оплаты газа, часть вашего исходного токена автоматически выделяется на покрытие комиссий, что увеличивает влияние на цену. MetaMask не влияет на воздействие цену и не контролирует его.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Из-за размера вашей сделки и доступной ликвидности вы получите примерно на {{priceImpact}} меньше рыночной цены. Это уже учтено в вашей котировке.", "price_impact_high": "Сильное влияние на цену", "price_impact_execution_description": "В результате этого обмена вы потеряете примерно {{priceImpact}} от стоимости вашего токена. Попробуйте уменьшить сумму или выбрать более ликвидный вариант.", "proceed": "Продолжить", @@ -6627,8 +6751,8 @@ "total_cost": "Общая стоимость", "got_it": "Понятно", "price_impact_warning_title": "Сильное влияние на цену", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Очень высокое влияние на цену", + "price_impact_error_description": "В результате этой сделки вы потеряете примерно {{priceImpact}} от рыночной цены вашего токена. Попробуйте совершить сделку меньшего размера или использовать более ликвидный путь, чтобы улучшить свой курс." }, "quote_expired_modal": { "title": "Доступны новые котировки", @@ -6940,7 +7064,7 @@ "upgrade_title": "Повысить уровень до Металлической", "continue_button": "Продолжить", "virtual_card": { - "name": "Virtual Card", + "name": "Виртуальная карта", "price": "Бесплатно", "feature_1": "Виртуальная карта для Apple Pay и Google Pay", "feature_2": "Оплата криптовалютой (USDC, USDT, WETH и другие)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Металлическая карта", "price": "$199/год", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Все то же, что и для виртуальной карты, плюс:", + "feature_1": "Премиальная металлическая карта с гравировкой", + "feature_2": "Кешбэк 3% на первые 10 000 $ в год", "feature_3": "Без комиссий за зарубежные транзакции" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Зарабатывайте до 300 $ кешбэка ежегодно", + "upgrade_to_metal_label": "Или перейдите на план Metal и получайте трехкратные бонусы" }, "review_order": { "title": "Проверьте свой заказ", @@ -7104,7 +7228,7 @@ "ssn_description": "Требуется эмитентом карты. Проверка кредитной истории проводиться не будет.", "invalid_ssn": "Недопустимый SSN", "invalid_date_of_birth": "Неверная дата рождения. Ваш возраст должен быть не менее 18 лет", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Имя и фамилия должны совпадать с вашими подтвержденными данными" }, "physical_address": { "title": "Введите свой адрес", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Вы приближаетесь к своему лимиту расходов", "description": "Обновите, чтобы избежать отказов", - "confirm_button_label": "Установить новый лимит" + "confirm_button_label": "Установить новый лимит", + "dismiss_button_label": "Отклонить" }, "need_delegation": { "title": "Вам нужно включить вашу карту", @@ -7301,7 +7426,6 @@ "dismiss": "Отклонить", "update_success": "Лимит расходов успешно обновлен", "update_error": "Не удалось обновить лимит расходов", - "solana_not_supported": "Включить токены Solana на card.metamask.io", "select_token": "Выбрать токен", "loading": "Загрузка доступных токенов…", "load_error": "Не удалось загрузить токены. Попробуйте еще раз.", @@ -7343,9 +7467,7 @@ "limited": "Ограниченный", "not_enabled": "Не включен", "update_success": "Приоритет расходов успешно обновлен", - "update_error": "Не удалось обновить приоритет расходов", - "solana_not_supported_button_title": "Другие токены на Solana", - "solana_not_supported_button_description": "Включить на card.metamask.io" + "update_error": "Не удалось обновить приоритет расходов" }, "card_authentication": { "title": "Войдите в счет своей карты", @@ -7443,6 +7565,11 @@ "title": "Не удалось согласиться", "description": "Проверьте соединение и повторите попытку." }, + "version_guard": { + "title": "Требуется обновление", + "description": "Для использования функции «Бонусы» требуется более новая версия MetaMask. Обновите приложение, чтобы продолжить.", + "update_button": "Обновить MetaMask" + }, "season_error": { "error_fetching_title": "Не удалось загрузить сезон", "error_fetching_description": "Проверьте соединение и повторите попытку.", @@ -7525,7 +7652,6 @@ "main_title": "Награды", "referral_title": "Рефералы", "tab_overview_title": "Обзор", - "tab_snapshots_title": "Снимки", "tab_activity_title": "Деятельность", "referral_stats_earned_from_referrals": "Заработано на рефералах", "referral_stats_referrals": "Рефералы", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "В этом сезоне вы не получали бонусы, но всегда можно получить их в следующий раз.", "verifying_rewards": "Мы проверяем правильность всех данных, прежде чем вы сможете получить свои бонусы." }, + "previous_season_view": { + "title": "Предыдущий сезон" + }, "season_status": { "points_earned": "Заработанные баллы" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Активные повышающие коэффициенты", "season_1": "Сезон 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "Калькулятор бонусов mUSD", + "description": "Посмотрите, сколько вы могли бы заработать, конвертировав ваши стейблкоины в mUSD.", + "amount_label": "Сконвертированная сумма", + "estimated_bonus": "Расчетный годовой бонус: до 3%", + "initial_amount": "Начальная сумма", + "daily_bonus": "Ежедневный бонус, доступный для получения", + "annualized_bonus": "Годовой бонус", + "disclaimer": "Это лишь приблизительная оценка. Размер бонуса может измениться.", "buy_button": "Купить mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Обменять на mUSD" }, "upcoming_rewards": { "title": "Заблокированные награды", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Ошибка загрузки" }, - "snapshot": { + "campaign": { "starts_date": "Начинается {{date}}", "ends_date": "Заканчивается {{date}}", - "results_coming_soon": "Скоро появятся результаты", - "tokens_on_the_way": "Токены в пути", + "ended_date": "Ended {{date}}", "pill_up_next": "Далее", - "pill_live_now": "Уже активно", - "pill_calculating": "Расчет", - "pill_results_ready": "Результаты готовы", - "pill_complete": "Завершено" - }, - "snapshots_section": { - "title": "Снимки", - "error_title": "Не удалось загрузить снимки", - "error_description": "Нам не удалось загрузить снимки. Попробуйте еще раз.", - "retry_button": "Повтор" - }, - "snapshots_tab": { + "pill_active": "Идет сейчас", + "pill_complete": "Завершено", + "enter_now": "Принять участие", + "entered": "Уже участвуете", + "participant_count": "#{{count}}", + "opt_in_cta": "Согласиться", + "opt_in_sheet_title": "Присоединиться к кампании", + "opt_in_sheet_description_pre_link": "Нажав на кнопку «Согласиться», вы соглашаетесь на участие в Бонусной программе MetaMask", + "opt_in_sheet_link_text": "Дополнительные условия использования и уведомление о конфиденциальности", + "opt_in_sheet_description_post_link": "Мы будем отслеживать активность в сети, чтобы автоматически начислять вам вознаграждения.", + "geo_restriction_banner_title": "Недоступно в вашем регионе", + "geo_restriction_banner_description": "Данная кампания недоступна в вашем регионе в связи с местными правилами." + }, + "campaign_mechanics": { + "title": "Механизм" + }, + "campaign_details": { + "start_date": "Начинается: {{date}}", + "end_date": "Заканчивается: {{date}}", + "opt_in": "Согласиться", + "opting_in": "Дача согласия...", + "opted_in": "Вы согласились на участие в этой кампании", + "opt_in_error": "Не удалось согласиться. Повторите попытку.", + "join_campaign": "Присоединиться к кампании", + "checking_opt_in_status": "Проверить статуса согласия на участие", + "swap": "Обменять", + "how_it_works": "Как это работает" + }, + "campaigns_preview": { + "title": "Кампании", + "coming_soon": "Скоро появятся", + "notify_me": "Уведомить меня" + }, + "earn_rewards": { + "title": "Заработать вознаграждения", + "musd_title": "Бонус до 3% на стейблкойны", + "musd_subtitle": "Рассчитайте свой бонус в mUSD", + "card_title": "До 3% кешбэка", + "card_subtitle": "Получите свою карту MetaMask прямо сейчас", + "card_subtitle_cardholder": "Воспользуйтесь преимуществами своей карты MetaMask" + }, + "campaigns_view": { + "title": "Кампании", "active_title": "Активно", "upcoming_title": "Далее", "previous_title": "Предыдущее", - "empty_state": "Нет доступных снимков", - "error_title": "Не удалось загрузить снимки", - "error_description": "Нам не удалось загрузить снимки. Попробуйте еще раз.", + "empty_state": "Нет доступных кампаний", + "error_title": "Не удалось загрузить кампании", + "error_description": "Нам не удалось загрузить кампании. Повторите попытку.", "retry_button": "Повтор", "refreshing": "Обновление..." } @@ -7953,13 +8112,12 @@ "continue": "Продолжить" }, "connecting": { - "title": "Подключите ваше {{device}}", + "title": "Подключение вашего {{device}}...", "searching": "Поиск {{device}}...", - "tips_header": "Чтобы продолжить, убедитесь, что:", + "tips_header": "Убедитесь:", "tip_unlock": "Ваше {{device}} разблокировано", "tip_open_app": "Приложение Ethereum открыто", "tip_enable_bluetooth": "Bluetooth включен", - "tip_dnd_off": "Режим «Не беспокоить» выключен", "tip_bluetooth_permission": "Разрешен доступ к геолокации и Bluetooth", "tip_bluetooth_permission_v12": "Разрешен доступ к устройствам поблизости", "tip_stay_close": "Ваше устройство находится рядом с телефоном" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Требуется разрешение на доступ к устройствам поблизости", "bluetooth_off": "Включите Bluetooth для подключения к вашему устройству", "bluetooth_scan_failed": "Не удалось выполнить поиск устройств. Повторите попытку", - "bluetooth_connection_failed": "Включите Bluetooth на вашем устройстве, чтобы продолжить", + "bluetooth_connection_failed": "Сбой подключения к вашему устройству. Повторите попытку", "not_supported": "Эта операция не поддерживается", "unknown_error": "Убедитесь, что ваш {{device}} настроен с помощью секретной фразой для восстановления или пароля для этого счета" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Наличные", + "cash_empty_description": "У вас пока нет mUSD. Конвертируйте стейблкоины в mUSD в разделе «Деньги» на главной странице.", + "cash_empty_description_network_filter": "В этой сети нет mUSD. Переключитесь на другую сеть, чтобы увидеть свои mUSD.", "tokens": "Токены", "perpetuals": "Бессрочные контракты", "predictions": "Прогнозы", + "whats_happening": "Что происходит", + "whats_happening_categories": { + "geopolitical": "Геополитика", + "macro": "Макро", + "regulatory": "Регулирование", + "technical": "Техника", + "social": "Социальные вопросы", + "other": "Другое" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "Импорт NFT", diff --git a/locales/languages/tl.json b/locales/languages/tl.json index 6a2b36089ce..9c064573120 100644 --- a/locales/languages/tl.json +++ b/locales/languages/tl.json @@ -20,6 +20,12 @@ "update": "I-update" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Alerto", @@ -120,8 +126,8 @@ "title": "Ipapadala ang mga asset sa burn address" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Babala sa kontrata ng token", + "message": "Maaaring hindi sinusuportahan ng address ng tatanggap ang direktang mga paglilipat ng token, na maaaring magresulta sa pagkalugi ng pondo. Magpatuloy lamang kung tiyak ka na matatanggap ng kontratang ito ang paglilipat mo." }, "gas_sponsorship_reserve_balance": { "message": "Hindi available ang pag-iisponsor ng gas para sa transaksyong ito. Kakailanganin mo ng hindi bababa sa %{minBalance} %{nativeTokenSymbol} sa account mo.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Hindi maresolba ang pangalan", "invalid_address": "Di-wastong address", "contractAddressError": "Nagpapadala ka ng mga token sa address ng kontrata ng token. Maaari itong magresulta sa pagkawala ng mga token na ito.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Address ng smart na kontrata", + "smart_contract_address_warning": "Maaaring hindi sinusuportahan ng address ng tatanggap ang direktang mga paglilipat ng token, na maaaring magresulta sa pagkalugi ng pondo. Magpatuloy lamang kung tiyak ka na matatanggap ng kontratang ito ang paglilipat mo.", "i_understand": "Nauunawaan ko", "cancel": "Kanselahin" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Ang stop loss ay dapat na {{direction}} {{priceType}} ang presyo", "stop_loss_beyond_liquidation_error": "Ang stop loss ay dapat na {{direction}} ang presyo ng liquidation", "stop_loss_order_view_warning": "Ang stop loss ay {{direction}} ang liquidation price", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "mas mataas", "below": "mas mababa", "done": "Tapos na", @@ -2086,14 +2094,15 @@ "a_closer_look": "Mas malalim na pagtingin", "whats_being_said": "Ano ang sinasabi", "footer_disclaimer": "Para sa impormasyon lang ang buod ng AI", - "trade_button": "Mag-trade", + "swap_button": "Mag-swap", + "buy_button": "Bumili", "sources_count": "+{{count}} (na) pinagmulan", "sources_title": "Mga mapagkukunan ng balita", "feedback_submitted": "Isinumite ang feedback", "helpful_prompt": "Nakatulong ba ito?", "feedback": { "title": "Feedback", - "description": "Tumulong na mapahusay ang mga pananaw sa market na gawa ng AI.", + "description": "Tinutulungan ng sagot mo na mapahusay ang mga buod ng aming AI.", "not_relevant": "Walang kaugnayan", "not_accurate": "Hindi tumpak", "hard_to_understand": "Mahirap maunawaan", @@ -2206,7 +2215,7 @@ "available_balance": "Available na balanse", "claim_amount_text": "I-claim ang ${{amount}}", "claim_winnings_text": "I-claim ang mga panalo", - "claiming_text": "Claiming...", + "claiming_text": "Kini-claim...", "unrealized_pnl_label": "Unrealized P&L", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Hindi mai-load", @@ -2287,7 +2296,7 @@ "try_again": "Subukang muli" }, "in_progress": { - "title": "Claim already in progress" + "title": "Isinasagawa na ang pagki-claim" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Bayad sa palitan o market", "total_incl_fees": "kasama ang mga bayarin", "close": "Isara", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Ipinagpapalagay sa mga ipinakitang presyo na ganap na puno ang order mo. Maaaring iba-iba ang aktwal na mga halaga kapag bahagyang puno lamang ang order.", + "deposit_fee": "Bayad sa deposito", + "deposit_fee_description": "Bayarin na sinisingil para magdeposito ng mga pondo sa iyong balanse ng hula" }, "error": { "title": "Hindi maikonekta sa mga prediksyon", @@ -3059,6 +3068,7 @@ "networks_no_results": "Walang nahanap na network", "network_name_label": "Pangalan ng network", "network_name_placeholder": "Pangalan ng network (opsyonal)", + "required": "Kinakailangan", "network_rpc_url_label": "RPC URL", "network_rpc_name_label": "Pangalan ng RPC", "network_rpc_placeholder": "Bagong RPC network", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Ang tampok na ito ay nag-aalerto sa iyo ng masamang aktibidad sa pamamagitan ng aktibong pagsusuri sa mga transaksyon at paghiling ng pirma.", "security_alerts": "Mga alerto sa seguridad", "security_alerts_desc": "Inaalertuhan ka ng tampok na ito sa mga aktibidad na may masamang hangarin sa pamamagitan ng lokal na pagsusuri sa iyong mga transaksyon at kahilingan sa paglagda. Palaging gumawa ng sarili mong pag-iingat bago aprubahan ang anumang mga kahilingan. Walang garantiya na made-detect ng tampok na ito ang lahat ng aktibidad na may masamang hangarin. Sa pagpapagana sa tampok na ito, sumasang-ayon ka sa mga tuntunin ng paggamit ng provider.", + "smart_account_dapp_requests_heading": "Mga kahilingan na smart account mula sa dapps", + "smart_account_dapp_requests_desc": "Hayaan ang dapps na hilingin ang mga feature ng smart account para sa mga standard na account. Hindi nito maaapektuhan ang mga smart account na.", "smart_transactions_opt_in_heading": "Mga Smart Transaction", "smart_transactions_opt_in_desc_supported_networks": "I-on ang mga Smart na Transaksyon para sa mas maaasahan at ligtas na mga transaksyon sa mga suportadong network.", "smart_transactions_learn_more": "Matuto pa", @@ -3566,6 +3578,53 @@ "activity": "Aktibidad ng {{symbol}}", "disclaimer": "Ibinibigay ang market data ng mga pinagmumulang third-party gaya ng CoinGecko. Ang data ay para lamang sa mga layuning pang-impormasyon. Hindi mananagot ang MetaMask para sa katumpakan nito." }, + "security_trust": { + "title": "Seguridad at tiwala", + "malicious": "Mapaminsala", + "risky": "Mapanganib", + "malicious_token_title": "Mapanganib na token", + "malicious_token_description": "Ang {{symbol}} ay isang mapaminsalang token. Iwasang makipag-ugnayan o i-trade ito.", + "verified_token_title": "Na-verify na token", + "verified_token_description": "Ang {{symbol}} ay aktibong tini-trade at malawakang kinikilala. Ang verification ay hindi pag-eendorso ng MetaMask.", + "risky_token_title": "Mapanganib na token", + "risky_token_description": "Natuklasan ang mga senyales ng pag-iingat sa {{symbol}}. Maingat na magsaliksik bago i-trade ang token na ito.", + "malicious_token_sheet_description": "Natuklasan ang mga senyales ng malubhang panganib sa {{symbol}}. Inirerekomenda namin na huwag i-trade ang token na ito.", + "got_it": "Nakuha ko", + "proceed": "Magpatuloy", + "cancel": "Kanselahin", + "data_unavailable": "Hindi available ang data ng seguridad", + "subtitle_known": "Walang natuklasang mga senyales ng panganib. Laging saliksikin ang anumang asset bago mag-trade.", + "subtitle_no_issues": "Walang natuklasang mga senyales ng panganib. Laging saliksikin ang anumang asset bago mag-trade.", + "subtitle_suspicious": "Natuklasan ang mga senyales ng pag-iingat sa. Maingat na suriin ang mga nai-flag na isyu bago i-trade ang asset na ito.", + "subtitle_malicious": "Natuklasan ang mga senyales ng malubhang panganib. Inirerekomenda namin na iwasan ang asset na ito.", + "subtitle_unavailable": "Hindi mai-load ang pagsusuri sa seguridad para sa token na ito.", + "token_distribution": "Pamamahagi ng token", + "total_supply": "Kabuuang supply", + "top_10_holders": "Nangungunang 10 na holder", + "other": "Iba pa", + "no_hidden_fees_detected": "Walang natuklasang mga nakatagong bayarin", + "buy_sell_tax": "Buwis sa Pagbili/Pagbebenta", + "buy_tax": "Buwis sa pagbili", + "sell_tax": "Buwis sa pagbebenta", + "transfer": "Maglipat", + "token_info": "Impormasyon ng Token", + "created": "Ginawa", + "token_age": "Tagal ng token", + "network": "Network", + "type": "Uri", + "official_links": "Mga Opisyal na Link", + "website": "Website", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "N/A", + "verified": "Na-verify", + "no_issues": "Walang mga isyu", + "suspicious": "Kahina-hinala", + "malicious_label": "Mapaminsala", + "more": "iba pa", + "evaluation_disclaimer": "Ang pagsusuri sa seguridad na ito ay para sa ebalwasyon lamang at hindi nangangahulugan ng pag-eendorso o rekomendasyon na mag-trade." + }, "account_details": { "title": "Mga detalye ng account", "share_account": "Ibahagi", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Naki-claim na bonus", "claim_bonus": "I-claim ang bonus", "claim_bonus_subtitle": "Ibibigay ang bonus sa {{networkName}}.", + "percentage_bonus_on_linea": "{{percentage}}% bonus sa Linea", + "claim": "I-claim", + "sounds_good": "Mukhang maganda", + "claimable_bonus_tooltip_with_percentage": "{{percentage}}% taunang bonus na kinita mo sa pagho-hold ng mUSD. Pwedeng i-claim ang bonus mo sa Linea araw-araw.", "empty_state_cta": { "heading": "Magpahiram ng {{tokenSymbol}} at kumita ng", "body": "Ipahiram ang iyong {{tokenSymbol}} sa {{protocol}} at kumita", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Mga stablecoin mo" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Mag-stake", "earn": "Kumita", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Wala kang sapat na balanse ng mapagkukunan para gawin ang aksyong ito." }, - "trx_unstaking_in_progress": "Kasalukuyang ina-unstake ang {{amount}} TRX. Umaabot nang 14 na araw ang pag-unstake.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Kasalukuyang nag-a-unstake ng {{amount}} TRX", + "description": "Tatagal ng 14 araw para sa pag-unstake" + }, + "unstaked_banner": { + "title": "Nakumpleto ang pag-unstake ng {{amount}} TRX", + "description": "Maaari ng ma-withdraw ang na-unstake mo na TRX", + "button": "Mag-withdraw", + "error": "Pumalya ang pag-withdraw" + } }, "stake_eth": "Mag-stake ng ETH", "unstake_eth": "Mag-unstake ng ETH", @@ -6376,7 +6498,8 @@ "approve": "Aprubahan ang kahilingan", "perps_deposit": "Magdagdag ng pondo", "predict_deposit": "Magdagdag ng mga pondo para sa Prediksyon", - "predict_withdraw": "Mag-withdraw" + "predict_withdraw": "Mag-withdraw", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Kailangan ng site na ito ng pahintulot para gastusin ang mga token mo.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Transaksyon {{index}}", "transaction": "Transaksyon", "available_balance": "Available na balanse: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Magpatuloy", "deposit_edit_amount_done": "Magdagdag ng pondo", "deposit_edit_amount_predict_withdraw": "Mag-withdraw", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Hindi pa sinusuportahan ang mga wallet na hardware. Gumamit ng hot wallet para magpatuloy.", "hardware_wallet_not_supported_solana": "Hindi pa sinusuportahan ang mga wallet na hardware sa Solana. Gumamit ng hot wallet para magpatuloy.", "price_impact_info_title": "Epekto ng presyo", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Ganito binabago ng mga trade mo ang market price ng isang token. Depende ito sa laki ng trade, available na liquidity, at mga bayarin sa provider. Hindi kontrolado ng MetaMask ang epekto sa presyo.", "price_impact_info_gasless_description": "Ipinapakita ng epekto sa presyo kung paano naaapektuhan ng iyong swap order ang market price ng asset. Kung wala kang sapat na pondo para sa gas, awtomatikong ilalaan ang isang bahagi ng source token mo para sa mga bayarin, na magdaragdag sa epekto ng presyo. Walang impluwensya o kontrol ang MetaMask sa epekto sa presyo.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Dahil sa laki ng trade mo at available na liquidity, makakakuha ka ng mga {{priceImpact}} na mas mababa sa market price. Sinukat na ito sa quote mo.", "price_impact_high": "Matinding epekto sa presyo", "price_impact_execution_description": "Mawawalan ka ng tinatayang {{priceImpact}} ng halaga ng token mo sa swap na ito. Subukang ibaba ang halaga o pumili ng mas liquid na ruta.", "proceed": "Magpatuloy", @@ -6627,8 +6751,8 @@ "total_cost": "Kabuuang Halaga", "got_it": "Nakuha ko", "price_impact_warning_title": "Matinding epekto sa presyo", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Napakatinding epekto sa presyo", + "price_impact_error_description": "Mawawalan ka ng tinatayang {{priceImpact}} ng market price ng token mo sa swap na ito. Subukan ang mas maliit na trade o mas liquid na ruta para pahusayin ang rate mo." }, "quote_expired_modal": { "title": "Available ang mga bagong quote", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Metal Card", "price": "$199/taon", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Virtual ang lahat, at:", + "feature_1": "Premium na nakaukit na metal card", + "feature_2": "3% cashback sa unang $10,000 kada taon", "feature_3": "Walang bayad sa transaksyon ang tagaibang bansa" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Kumita ng hanggang $300 sa cashback taun-taon", + "upgrade_to_metal_label": "O mag-upgrade sa Metal para sa 3x na mga reward" }, "review_order": { "title": "Suriin ang order mo", @@ -7104,7 +7228,7 @@ "ssn_description": "Kinakailangan ng taga-isyu ng card. Hindi magsasagawa ng credit check.", "invalid_ssn": "Di-wastong SSN", "invalid_date_of_birth": "Maling petsa ng kapanganakan. Dapat na hindi bababa sa 18 taong gulang ang edad mo", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Ang pangalan at apelyido ay dapat tumugma sa na-verify na pagkakakilanlan" }, "physical_address": { "title": "Ilagay ang address mo", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Malapit mo nang maabot ang limitasyon mo sa paggastos", "description": "Mag-update para maiwasang matanggihan", - "confirm_button_label": "Magtakda ng bagong limitasyon" + "confirm_button_label": "Magtakda ng bagong limitasyon", + "dismiss_button_label": "I-dismiss" }, "need_delegation": { "title": "Kailangan mong i-enable ang card mo", @@ -7301,7 +7426,6 @@ "dismiss": "I-dismiss", "update_success": "Matagumpay na na-update ang limit ng paggastos", "update_error": "Hindi na-update ang limit ng paggastos", - "solana_not_supported": "Paganahin ang mga token ng Solana sa card.metamask.io", "select_token": "Pumili ng token", "loading": "Naglo-load ng mga available na token...", "load_error": "Hindi makapag-load ng mga token. Pakisubukan muli.", @@ -7343,9 +7467,7 @@ "limited": "Limitado", "not_enabled": "Hindi gumagana", "update_success": "Matagumpay na na-update ang prayoridad", - "update_error": "Hindi na-update ang prayoridad sa paggastos", - "solana_not_supported_button_title": "Iba pang mga token sa Solana", - "solana_not_supported_button_description": "Paganahin sa card.metamask.io" + "update_error": "Hindi na-update ang prayoridad sa paggastos" }, "card_authentication": { "title": "Mag-log in sa card account mo", @@ -7443,6 +7565,11 @@ "title": "Nabigong mag-opt-in", "description": "Suriin ang iyong koneksyon at subukang muli." }, + "version_guard": { + "title": "Kinakailangan ng update", + "description": "Kinakailangan ang mas bagong bersyon ng MetaMask para magamit ang mga Reward. Paki-update para makapagpatuloy.", + "update_button": "I-update ang MetaMask" + }, "season_error": { "error_fetching_title": "Hindi mai-load ang season", "error_fetching_description": "Suriin ang iyong koneksyon at subukang muli.", @@ -7525,7 +7652,6 @@ "main_title": "Mga Reward", "referral_title": "Mga Referral", "tab_overview_title": "Overview", - "tab_snapshots_title": "Mga snapshot", "tab_activity_title": "Aktibidad", "referral_stats_earned_from_referrals": "Nakuha mula sa mga referral", "referral_stats_referrals": "Mga Referral", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Hindi ka nakakuha ng mga reward sa season na ito, pero mayroon pa namang ibang pagkakataon.", "verifying_rewards": "Sinisigurado namin na tama lahat bago mo i-claim ang mga reward mo." }, + "previous_season_view": { + "title": "Nakaraang Season" + }, "season_status": { "points_earned": "Mga point na nakuha" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Mga active boost", "season_1": "Season 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "bonus calculator ng mUSD", + "description": "Tingnan kung magkano ang maaari mong kitain sa pamamagitan ng pag-convert sa mga stablecoin mo sa mUSD.", + "amount_label": "Halagang na-convert", + "estimated_bonus": "Tinatayang taunang bonus: hanggang 3%", + "initial_amount": "Paunang halaga", + "daily_bonus": "Maki-claim na bonus araw-araw", + "annualized_bonus": "Taunang bonus", + "disclaimer": "Pagtataya lamang ito. Maaaring magbago ang bonus.", "buy_button": "Bumili ng mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "I-swap sa mUSD" }, "upcoming_rewards": { "title": "Naka-lock na mga reward", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Hindi mai-load" }, - "snapshot": { + "campaign": { "starts_date": "Magsisimula sa {{date}}", "ends_date": "Matatapos sa {{date}}", - "results_coming_soon": "Paparating na ang mga resulta", - "tokens_on_the_way": "Malapit na ang mga token", + "ended_date": "Ended {{date}}", "pill_up_next": "Susunod", - "pill_live_now": "Live ngayon", - "pill_calculating": "Kinakalkula", - "pill_results_ready": "Handa na ang mga Resulta", - "pill_complete": "Kumpleto na" - }, - "snapshots_section": { - "title": "Mga snapshot", - "error_title": "Hindi mai-load ang mga snapshot", - "error_description": "Hindi namin mai-load ang mga snapshot. Pakisubukan muli.", - "retry_button": "Subukang muli" - }, - "snapshots_tab": { + "pill_active": "Live", + "pill_complete": "Kumpleto na", + "enter_now": "Ilagay ngayon", + "entered": "Nailagay", + "participant_count": "#{{count}}", + "opt_in_cta": "Mag-opt in", + "opt_in_sheet_title": "Sumali sa campaign", + "opt_in_sheet_description_pre_link": "Sa pamamagitan ng pag-click sa ''Mag-opt in', sumasang-ayon ka sa Mga Reward ng MetaMask", + "opt_in_sheet_link_text": "Karagdagang Mga Tuntunin ng Paggamit at Abiso sa Privacy", + "opt_in_sheet_description_post_link": "Susubaybayan namin ang aktibidad sa onchain para agad kang mabigyan ng reward.", + "geo_restriction_banner_title": "Hindi available sa rehiyon mo", + "geo_restriction_banner_description": "Hindi available ang campaign na ito sa iyong rehiyon dahil sa mga lokal na regulasyon." + }, + "campaign_mechanics": { + "title": "Mechanics" + }, + "campaign_details": { + "start_date": "Magsisimula sa: {{date}}", + "end_date": "Matatapos sa: {{date}}", + "opt_in": "Mag-opt in", + "opting_in": "Nag-o-opt in...", + "opted_in": "Nag-opt in ka sa campaign na ito", + "opt_in_error": "Pumalaya ang pag-opt in. Pakisubukan muli.", + "join_campaign": "Sumali sa campaign", + "checking_opt_in_status": "Sinusuri ang katayuan ng pag-opt in", + "swap": "Mag-swap", + "how_it_works": "Paano ito gumagana" + }, + "campaigns_preview": { + "title": "Mga campaign", + "coming_soon": "Paparating na", + "notify_me": "Abisuhan ako" + }, + "earn_rewards": { + "title": "Kumita ng mga reward", + "musd_title": "Hanggang 3% bonus sa mga stable", + "musd_subtitle": "Kalkulahin ang iyong mUSD bonus", + "card_title": "Hanggang 3% cash back", + "card_subtitle": "Kunin ang iyong MetaMask Card ngayon", + "card_subtitle_cardholder": "I-access ang mga benepisyo ng iyong MetaMask Card" + }, + "campaigns_view": { + "title": "Mga campaign", "active_title": "Aktibo", "upcoming_title": "Paparating", "previous_title": "Nakaraan", - "empty_state": "Walang available na mga snapshot", - "error_title": "Hindi mai-load ang mga snapshot", - "error_description": "Hindi namin mai-load ang mga snapshot. Pakisubukan muli.", + "empty_state": "Walang mga campaign na available", + "error_title": "Hindi mai-load ang mga campaign", + "error_description": "Hindi namin mai-load ang mga campaign. Pakisubukan muli.", "retry_button": "Subukang muli", "refreshing": "Nire-refresh..." } @@ -7953,13 +8112,12 @@ "continue": "Magpatuloy" }, "connecting": { - "title": "Ikonekta ang {{device}} mo", + "title": "Ikinokonekta ang iyong {{device}}...", "searching": "Hinahanap ang {{device}}...", - "tips_header": "Para magpatuloy, siguraduhing:", + "tips_header": "Tiyakin na:", "tip_unlock": "Naka-unlock ang {{device}} mo", "tip_open_app": "Bukas ang Ethereum app", "tip_enable_bluetooth": "Naka-on ang bluetooth", - "tip_dnd_off": "Naka-off ang Do Not Disturb", "tip_bluetooth_permission": "Nagbigay ng pahintulot sa Lokasyon at Bluetooth", "tip_bluetooth_permission_v12": "Nagbigay ng pahintulot sa mga device sa paligid", "tip_stay_close": "Malapit ang device mo sa telepono mo" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Kinakailangan ang pahintulot sa mga device sa paligid", "bluetooth_off": "I-on ang Bluetooth para kumonekta sa device mo", "bluetooth_scan_failed": "Hindi nakapag-scan ng mga device. Subukan ulit", - "bluetooth_connection_failed": "I-enable ang Bluetooh sa device mo para magpatuloy", + "bluetooth_connection_failed": "Pumalya ang koneksyon sa iyong device. Pakisubukan muli", "not_supported": "Hindi sinusuportahan ang operasyong ito", "unknown_error": "Siguraduhing may naka-set up na Lihim na Parirala sa Pagbawi o passphrase ang {{device}} mo para sa account na ito" }, @@ -8034,11 +8192,20 @@ "homepage": { "sections": { "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash_empty_description": "Wala ka pang kahit anong mUSD. I-convert ang mga stablecoin papuntang mUSD mula sa seksyon ng Cash sa homepage.", + "cash_empty_description_network_filter": "Walang mUSD sa network na ito. Lumipat ng network para makita ang mUSD mo.", "tokens": "Mga Token", "perpetuals": "Perpetuals", "predictions": "Mga hula", + "whats_happening": "Ano ang nangyayari", + "whats_happening_categories": { + "geopolitical": "Heopolitikal", + "macro": "Makro", + "regulatory": "Regulatoryo", + "technical": "Teknikal", + "social": "Sosyal", + "other": "Iba pa" + }, "defi": "DeFi", "nfts": "Mga NFT", "import_nfts": "Mag-import ng mga NFT", diff --git a/locales/languages/tr.json b/locales/languages/tr.json index cc453b540d7..8295430842c 100644 --- a/locales/languages/tr.json +++ b/locales/languages/tr.json @@ -20,6 +20,12 @@ "update": "Güncelle" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Uyarı", @@ -120,8 +126,8 @@ "title": "Varlıklar yakım adresine gönderiliyor" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Token sözleşmesi uyarısı", + "message": "Alıcı adresi doğrudan token transferlerini desteklemiyor olabilir ve bu durum para kaybına neden olabilir. Yalnızca transferinizin bu sözleşmeye ulaşabileceğinden eminseniz devam edin." }, "gas_sponsorship_reserve_balance": { "message": "Gaz sponsorluğu bu işlem için kullanılamıyor. Hesabınızda en az %{minBalance} %{nativeTokenSymbol} tutmanız gerekecek.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Adı çözümlenemedi", "invalid_address": "Geçersiz adres", "contractAddressError": "Token'in sözleşme adresine token gönderiyorsunuz. Bu durum, bu token'lerin kaybedilmesine neden olabilir.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Akıllı sözleşme adresi", + "smart_contract_address_warning": "Alıcı adresi doğrudan token transferlerini desteklemiyor olabilir ve bu durum para kaybına neden olabilir. Yalnızca transferinizin bu sözleşmeye ulaşabileceğinden eminseniz devam edin.", "i_understand": "Anlıyorum", "cancel": "İptal" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Zararda durdur emri {{direction}} yönünde {{priceType}} fiyatında olmalıdır", "stop_loss_beyond_liquidation_error": "Zararda durdur emri {{direction}} yönünde likidasyon fiyatında olmalıdır", "stop_loss_order_view_warning": "Zararda durdur emri {{direction}} yönünde likidasyon fiyatında", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "üzerinde", "below": "altında", "done": "Bitti", @@ -2086,14 +2094,15 @@ "a_closer_look": "Daha yakın bir görünüm", "whats_being_said": "Neler söyleniyor", "footer_disclaimer": "Yapay zeka özeti yalnızca bilgi amaçlıdır", - "trade_button": "İşlem Yap", + "swap_button": "Takas", + "buy_button": "Al", "sources_count": "+{{count}} kaynak", "sources_title": "Haber kaynakları", "feedback_submitted": "Geri bildirim gönderildi", "helpful_prompt": "Bu faydalı oldu mu?", "feedback": { "title": "Geri Bildirim", - "description": "Yapay zeka ile oluşturulmuş piyasa içgörülerimizi iyileştirmemize yardımcı olun.", + "description": "Cevabınız yapay zeka özetlerimizi iyileştirmemize yardımcı olur.", "not_relevant": "İlgili değil", "not_accurate": "Doğru değil", "hard_to_understand": "Anlaşılması zor", @@ -2162,7 +2171,7 @@ "sell_position": "Sat pozisyonu", "cash_out": "Paraya çevir", "cash_out_info": "Fonlar kullanılabilir bakiyenize eklenecek", - "buy_preview_outcome_at_price": "{{outcome}} at {{price}}", + "buy_preview_outcome_at_price": "{{price}} için {{outcome}}", "at_price_per_share": "{{size}} hisse {{price}} fiyatından satılıyor", "cashout_info": "{{amount}} {{outcome}} için {{initialPrice}} fiyatıyla", "cashout_info_multiple": "{{amount}} {{outcomeGroupTitle}} • {{outcome}} için {{initialPrice}} fiyatıyla", @@ -2206,7 +2215,7 @@ "available_balance": "Kullanılabilir bakiye", "claim_amount_text": "{{amount}}$ al", "claim_winnings_text": "Kazançları al", - "claiming_text": "Claiming...", + "claiming_text": "Alınıyor...", "unrealized_pnl_label": "Gerçekleşmemiş K&Z", "unrealized_pnl_value": "({{percent}}) {{amount}}", "unrealized_pnl_error": "Yüklenemiyor", @@ -2287,7 +2296,7 @@ "try_again": "Tekrar dene" }, "in_progress": { - "title": "Claim already in progress" + "title": "Alım zaten sürüyor" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Borsaya veya piyasaya ödenen ücret", "total_incl_fees": "ücretler dahil", "close": "Kapat", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Gösterilen fiyatlar emrinizin tamamen gerçekleştiği varsayılarak hesaplanmıştır. Emrin sadece kısmen gerçekleşmesi durumunda gerçek tutarlar değişiklik gösterebilir.", + "deposit_fee": "Para yatırma ücreti", + "deposit_fee_description": "Tahmin bakiyenize para yatırma işlemi için alınan ücret" }, "error": { "title": "Tahminlere bağlanılamıyor", @@ -3059,6 +3068,7 @@ "networks_no_results": "Ağ bulunamadı", "network_name_label": "Ağ adı", "network_name_placeholder": "Ağ adı (isteğe bağlı)", + "required": "Gerekli", "network_rpc_url_label": "RPC URL adresi", "network_rpc_name_label": "RPC adı", "network_rpc_placeholder": "Yeni RPC ağı", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Bu özellik, işlem ve imza taleplerini aktif bir şekilde inceleyerek kötü amaçlı aktivite konusunda sizi uyarır.", "security_alerts": "Güvenlik uyarıları", "security_alerts_desc": "Bu özellik, işlem ve imza taleplerinizi yerel olarak incelerken gizliliğinizi koruyarak Ethereum Ana Ağındaki kötü amaçlı aktivitelere karşı sizi uyarır. Talepleri onaylamadan önce her zaman gereken özeni kendiniz gösterin. Bu özelliğin tüm kötü amaçlı faaliyetleri algılayacağına dair herhangi bir garanti bulunmamaktadır. Bu özelliği etkinleştirerek sağlayıcının kullanım koşullarını kabul etmiş olursunuz.", + "smart_account_dapp_requests_heading": "Dapp'lerden akıllı hesap talepleri", + "smart_account_dapp_requests_desc": "Dapp'lerin standart hesaplar için akıllı hesap özellikleri talep etmesine izin verin. Bu durum, halihazırda akıllı hesap olan hesapları etkilemez.", "smart_transactions_opt_in_heading": "Akıllı İşlemler", "smart_transactions_opt_in_desc_supported_networks": "Desteklenen ağlarda daha güvenilir ve güvenli işlemler için Akıllı İşlemleri açın.", "smart_transactions_learn_more": "Daha fazla bilgi edin", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}} aktivitesi", "disclaimer": "Piyasa verileri CoinGecko gibi üçüncü taraf kaynaklar tarafından sağlanır. Veriler yalnızca bilgi amaçlıdır. Verilerin doğruluğundan MetaMask sorumlu değildir." }, + "security_trust": { + "title": "Güvenlik ve güven", + "malicious": "Kötü amaçlı", + "risky": "Riskli", + "malicious_token_title": "Kötü amaçlı tokenlar", + "malicious_token_description": "{{symbol}} kötü amaçlı bir token. Bununla etkileşimde bulunmaktan veya işlem yapmaktan kaçının.", + "verified_token_title": "Doğrulanmış tokenlar", + "verified_token_description": "{{symbol}} aktif olarak işlem görmekte olup yaygın olarak tanınmaktadır. Doğrulama, MetaMask tarafından onaylandığı anlamına gelmez.", + "risky_token_title": "Riskli token", + "risky_token_description": "{{symbol}} üzerinde uyarı sinyalleri tespit edildi. Bu token ile işlem yapmadan önce dikkatli bir şekilde araştırın.", + "malicious_token_sheet_description": "{{symbol}} üzerinde ciddi risk sinyalleri tespit edildi. Bu token ile işlem yapmamanızı tavsiye ederiz.", + "got_it": "Anladım", + "proceed": "Devam et", + "cancel": "İptal et", + "data_unavailable": "Güvenlik verileri mevcut değil", + "subtitle_known": "Risk sinyali algılanmadı. Varlıklarla işlem yapmadan önce her zaman araştırma yapın.", + "subtitle_no_issues": "Risk sinyali algılanmadı. Varlıklarla işlem yapmadan önce her zaman araştırma yapın.", + "subtitle_suspicious": "Uyarı sinyalleri tespit edildi. Bu varlıkla işlem yapmadan önce işaretli sorunları dikkatli bir şekilde inceleyin.", + "subtitle_malicious": "Ciddi risk sinyalleri algılandı. Bu varlıktan kaçınmanızı tavsiye ederiz.", + "subtitle_unavailable": "Bu token için güvenlik analizi yüklenemedi.", + "token_distribution": "Token dağılımı", + "total_supply": "Toplam arz", + "top_10_holders": "En büyük 10 hissedar", + "other": "Diğer", + "no_hidden_fees_detected": "Gizli ücret algılanmadı", + "buy_sell_tax": "Alış/Satış Vergisi", + "buy_tax": "Alış vergisi", + "sell_tax": "Satış vergisi", + "transfer": "Transfer Et", + "token_info": "Token Bilgileri", + "created": "Oluşturuldu", + "token_age": "Token yaşı", + "network": "Ağ", + "type": "Bu snap'i kaldırmak istediğinizi onaylamak için", + "official_links": "Resmi Bağlantılar", + "website": "Web Sitesi", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "Yok", + "verified": "Doğrulandı", + "no_issues": "Sorun yok", + "suspicious": "Şüpheli", + "malicious_label": "Kötü amaçlı", + "more": "daha fazla", + "evaluation_disclaimer": "Bu güvenlik incelemesi yalnızca değerlendirme amaçlıdır ve onay veya işlem tavsiyesi teşkil etmez." + }, "account_details": { "title": "Hesap bilgileri", "share_account": "Paylaş", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Alınabilir bonus", "claim_bonus": "Bonusu al", "claim_bonus_subtitle": "Bonus, {{networkName}} üzerinde ödenecektir.", + "percentage_bonus_on_linea": "Linea üzerinde %{{percentage}} bonus", + "claim": "Al", + "sounds_good": "Kulağa hoş geliyor", + "claimable_bonus_tooltip_with_percentage": "mUSD tuttuğunuz için kazandığınız %{{percentage}} yıllıklandırılmış bonus. Bonusunuzu Linea üzerinde günlük olarak alabilirsiniz.", "empty_state_cta": { "heading": "{{tokenSymbol}} borç verin ve kazanın", "body": "{{protocol}} ile {{tokenSymbol}} token'ınızı borç verin ve", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Stabil kripto paralarınız" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Pay", "earn": "Kazan", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Bu eylemi gerçekleştirmek için yeterli kaynak bakiyeniz yok." }, - "trx_unstaking_in_progress": "{{amount}} TRX unstake işlemi devam ediyor. Unstake işlemi 14 gün sürer.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "{{amount}} TRX unstake işlemi sürüyor", + "description": "Unstake işlemi 14 gün sürecek" + }, + "unstaked_banner": { + "title": "{{amount}} TRX unstake işlemi tamamlandı", + "description": "Unstake edilen TRX'iniz şu anda çekilebilir", + "button": "Çek", + "error": "Para çekme işlemi başarısız" + } }, "stake_eth": "ETH Stake Et", "unstake_eth": "ETH Unstake Et", @@ -6376,7 +6498,8 @@ "approve": "Talebi onayla", "perps_deposit": "Fon ekle", "predict_deposit": "Tahmin fonu ekle", - "predict_withdraw": "Çek" + "predict_withdraw": "Çek", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Bu site token'larınızı harcamak için izin istiyor.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "{{index}} işlemi", "transaction": "İşlem", "available_balance": "Kullanılabilir bakiye: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Devam et", "deposit_edit_amount_done": "Fon ekle", "deposit_edit_amount_predict_withdraw": "Çek", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Donanım cüzdanları henüz desteklenmiyor. Devam etmek için sıcak cüzdan kullanın.", "hardware_wallet_not_supported_solana": "Donanım cüzdanları henüz Solana için desteklenmiyor. Devam etmek için sıcak cüzdan kullanın.", "price_impact_info_title": "Piyasa etkisi", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Bu, işleminizin bir tokenın piyasa fiyatını nasıl değiştirdiğini ifade eder. Bu durum; işlem boyutuna, mevcut likiditeye ve sağlayıcı ücretlerine bağlıdır. MetaMask fiyat etkisini kontrol etmez.", "price_impact_info_gasless_description": "Fiyat etkisi, takas emrinizin varlığın piyasa fiyatını nasıl etkilediğini yansıtır. Gaz için yeterli fon tutmazsanız kaynak token'inizin bir kısmı otomatik olarak ücretleri karşılamak üzere ayrılır, bu nedenle fiyat etkisi artar. MetaMask fiyat etkisini etkilemez veya kontrol etmez.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "İşleminizin büyüklüğü ve kullanılabilir likiditeden dolayı piyasa fiyatından yaklaşık {{priceImpact}} daha az alacaksınız. Bu zaten fiyat teklifinize dahil edilmiştir.", "price_impact_high": "Yüksek fiyat etkisi", "price_impact_execution_description": "Bu takas işleminde tokenınızın değerinden yaklaşık {{priceImpact}} kaybedeceksiniz. Miktarı düşürmeyi veya daha likit bir rota seçmeyi deneyin.", "proceed": "Devam et", @@ -6627,8 +6751,8 @@ "total_cost": "Toplam Maliyet", "got_it": "Anladım", "price_impact_warning_title": "Yüksek fiyat etkisi", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Çok yüksek fiyat etkisi", + "price_impact_error_description": "Bu takas işleminde tokenınızın piyasa fiyatından yaklaşık {{priceImpact}} kaybedeceksiniz. Oranınızı iyileştirmek için daha küçük bir işlem veya daha likit bir rota deneyin." }, "quote_expired_modal": { "title": "Yeni teklifler mevcut", @@ -6940,7 +7064,7 @@ "upgrade_title": "Metale Yükselt", "continue_button": "Devam et", "virtual_card": { - "name": "Virtual Card", + "name": "Sanal Kart", "price": "Ücretsiz", "feature_1": "Apple Pay ve Google Pay için sanal kart", "feature_2": "Kripto ile öde (USDC, USDT, WETH ve daha fazlası)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Metal Kart", "price": "199$/yıl", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Her şey sanal ortamdadır, ayrıca:", + "feature_1": "Premium gravürlü metal kart", + "feature_2": "İlk 10.000$/yıl için %3 para iadesi", "feature_3": "Yabancı işlem ücretleri yok" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Yılda 300$'a kadar para iadesi kazanın", + "upgrade_to_metal_label": "Veya 3 katı ödül için Metal karta yükseltin" }, "review_order": { "title": "Emrinizi inceleyin", @@ -7104,7 +7228,7 @@ "ssn_description": "Kart düzenleyicisi tarafından talep edilir. Kredi notu sorgulaması yapılmayacaktır.", "invalid_ssn": "Geçersiz Sosyal Güvenlik Numarası (SSN)", "invalid_date_of_birth": "Doğum tarihi geçersiz. En az 18 yaşında olmalısınız", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Adı ve soyadı, doğrulanmış kimliğinizle uyumlu olmalıdır" }, "physical_address": { "title": "Adresinizi ekleyin", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Harcama limitinize yakınsınız", "description": "Reddedilmeleri önlemek için güncelle", - "confirm_button_label": "Yeni limit ayarla" + "confirm_button_label": "Yeni limit ayarla", + "dismiss_button_label": "Yok say" }, "need_delegation": { "title": "Kartınızı etkinleştirmeniz gerekiyor", @@ -7301,7 +7426,6 @@ "dismiss": "Yok say", "update_success": "Harcama limiti başarılı bir şekilde güncellendi", "update_error": "Harcama limiti güncellenemedi", - "solana_not_supported": "card.metamask.io adresinde Solana token'larını etkinleştir", "select_token": "Token seç", "loading": "Kullanılabilir tokenler yükleniyor...", "load_error": "Tokenler yüklenemedi. Lütfen tekrar deneyin.", @@ -7343,9 +7467,7 @@ "limited": "Sınırlı", "not_enabled": "Etkinleştirilmedi", "update_success": "Harcama önceliği başarılı bir şekilde güncellendi", - "update_error": "Harcama önceliği güncellenemedi", - "solana_not_supported_button_title": "Solana üzerindeki diğer token'lar", - "solana_not_supported_button_description": "card.metamask.io adresinde etkinleştir" + "update_error": "Harcama önceliği güncellenemedi" }, "card_authentication": { "title": "Kart hesabınızda oturum açın", @@ -7443,6 +7565,11 @@ "title": "Katılım başarısız oldu", "description": "Bağlantınızı kontrol edin ve tekrar deneyin." }, + "version_guard": { + "title": "Güncelleme gerekli", + "description": "Ödüller programının kullanılabilmesi için MetaMask'in daha yeni bir sürümü gereklidir. Devam etmek için lütfen güncelleyin.", + "update_button": "MetaMask'i güncelle" + }, "season_error": { "error_fetching_title": "Sezon yüklenemedi", "error_fetching_description": "Bağlantınızı kontrol edin ve tekrar deneyin.", @@ -7525,7 +7652,6 @@ "main_title": "Ödüller", "referral_title": "Referanslar", "tab_overview_title": "Genel Bakış", - "tab_snapshots_title": "Anlık görüntüler", "tab_activity_title": "Aktivite", "referral_stats_earned_from_referrals": "Referanslardan kazanılan", "referral_stats_referrals": "Referanslar", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Bu sezon ödül kazanmadınız ancak her zaman bir sonraki sezon vardır.", "verifying_rewards": "Ödüllerinizi almadan önce her şeyin doğru olduğunu teyit ediyoruz." }, + "previous_season_view": { + "title": "Önceki Sezon" + }, "season_status": { "points_earned": "Puan kazanıldı" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Aktif yükseltmeler", "season_1": "Sezon 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "mUSD bonus hesaplayıcı", + "description": "Stabil kripto paralarınızı mUSD'ye dönüştürerek ne kadar kazanabileceğinize bakın.", + "amount_label": "Dönüştürülen miktar", + "estimated_bonus": "Tahmini yıllıklandırılmış bonus: %3'e kadar", + "initial_amount": "Başlangıç miktarı", + "daily_bonus": "Günlük alınabilir bonus", + "annualized_bonus": "Yıllıklandırılmış bonus", + "disclaimer": "Bu yalnızca bir tahmindir. Bonus değişikliğe tabidir.", "buy_button": "mUSD al", - "swap_button": "Swap to mUSD" + "swap_button": "mUSD'ye takas et" }, "upcoming_rewards": { "title": "Kilitli ödüller", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Yüklenemedi" }, - "snapshot": { + "campaign": { "starts_date": "Başlangıç tarihi {{date}}", "ends_date": "Bitiş tarihi {{date}}", - "results_coming_soon": "Sonuçlar çok yakında", - "tokens_on_the_way": "Tokenlar yolda", + "ended_date": "Ended {{date}}", "pill_up_next": "Sırada", - "pill_live_now": "Şimdi canlı", - "pill_calculating": "Hesaplanıyor", - "pill_results_ready": "Sonuçlar Hazır", - "pill_complete": "Tamamlandı" - }, - "snapshots_section": { - "title": "Anlık görüntüler", - "error_title": "Anlık görüntüler yüklenemiyor", - "error_description": "Anlık görüntüleri yükleyemedik. Lütfen tekrar deneyin.", - "retry_button": "Tekrar Dene" - }, - "snapshots_tab": { + "pill_active": "Canlı", + "pill_complete": "Tamamlandı", + "enter_now": "Şimdi giriş yapın", + "entered": "Giriş yapıldı", + "participant_count": "#{{count}}", + "opt_in_cta": "Katıl", + "opt_in_sheet_title": "Kampanyaya katıl", + "opt_in_sheet_description_pre_link": "'Katıl' düğmesine tıklayarak şunları kabul edersiniz: MetaMask Ödüller", + "opt_in_sheet_link_text": "Ek Kullanım Şartları ve Gizlilik Bildirimi", + "opt_in_sheet_description_post_link": "Sizi otomatik olarak ödüllendirmek için zincir üzeri etkinliğinizi takip edeceğiz.", + "geo_restriction_banner_title": "Bölgenizde kullanılamıyor", + "geo_restriction_banner_description": "Bu kampanya yerel düzenlemelerden dolayı bölgenizde kullanılamıyor." + }, + "campaign_mechanics": { + "title": "İşleyiş" + }, + "campaign_details": { + "start_date": "Başlangıç: {{date}}", + "end_date": "Bitiş: {{date}}", + "opt_in": "Katıl", + "opting_in": "Katılım sağlanıyor...", + "opted_in": "Bu kampanyaya katıldınız", + "opt_in_error": "Katılım sağlanamadı. Lütfen tekrar deneyin.", + "join_campaign": "Kampanyaya katıl", + "checking_opt_in_status": "Katılım durumu kontrol ediliyor", + "swap": "Takas", + "how_it_works": "Nasıl çalışır?" + }, + "campaigns_preview": { + "title": "Kampanyalar", + "coming_soon": "Çok yakında", + "notify_me": "Bana bildir" + }, + "earn_rewards": { + "title": "Ödül kazan", + "musd_title": "Stabil kripto paralarda %3'e kadar bonus", + "musd_subtitle": "mUSD bonusunuzu hesaplayın", + "card_title": "%3'e kadar para iadesi", + "card_subtitle": "MetaMask Card'ınızı hemen alın", + "card_subtitle_cardholder": "MetaMask Card faydalarınıza erişim sağlayın" + }, + "campaigns_view": { + "title": "Kampanyalar", "active_title": "Aktif", "upcoming_title": "Yaklaşan", "previous_title": "Önceki", - "empty_state": "Anlık görüntüler kullanılamıyor", - "error_title": "Anlık görüntüler yüklenemiyor", - "error_description": "Anlık görüntüleri yükleyemedik. Lütfen tekrar deneyin.", + "empty_state": "Kampanya mevcut değil", + "error_title": "Kampanyalar yüklenemiyor", + "error_description": "Kampanyaları yükleyemedik. Lütfen tekrar deneyin.", "retry_button": "Tekrar Dene", "refreshing": "Yenileniyor..." } @@ -7953,13 +8112,12 @@ "continue": "Devam et" }, "connecting": { - "title": "{{device}} cihazını bağla", + "title": "{{device}} cihazınız bağlanıyor...", "searching": "{{device}} aranıyor...", - "tips_header": "Devam etmek için şunlardan emin olun:", + "tips_header": "Şunlardan emin olun:", "tip_unlock": "{{device}} kilidi açık olmalı", "tip_open_app": "Ethereum uygulaması açık olmalı", "tip_enable_bluetooth": "Bluetooth açık olmalı", - "tip_dnd_off": "Rahatsız Etme modu kapalı olmalı", "tip_bluetooth_permission": "Konum ve Bluetooth izni verilmiş olmalı", "tip_bluetooth_permission_v12": "Yakındaki cihazlar izni verilmiş olmalı", "tip_stay_close": "Cihazınız telefonunuza yakın durmalı" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Yakındaki cihazlar izni gereklidir", "bluetooth_off": "Cihazınıza bağlanmak için lütfen Bluetooth'u açın", "bluetooth_scan_failed": "Cihazlar taranamadı. Lütfen tekrar deneyin", - "bluetooth_connection_failed": "Devam etmek için cihazınızda Bluetooth'u etkinleştirin", + "bluetooth_connection_failed": "Cihazınıza bağlanılamadı. Lütfen tekrar deneyin", "not_supported": "Bu işlem desteklenmiyor", "unknown_error": "{{device}} cihazınızda bu hesap için Gizli Kurtarma İfadesi veya parola kurulumunun yapıldığından emin olun" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Nakit", + "cash_empty_description": "Henüz mUSD'niz yok. Ana sayfada Nakit kısmından stabil kripto paraları mUSD'ye dönüştürün.", + "cash_empty_description_network_filter": "Bu ağda mUSD yok. mUSD'nizi görmek için ağ değiştirin.", "tokens": "tokenlar", "perpetuals": "Sürekli Vadeli İşlemler", "predictions": "Tahminler", + "whats_happening": "Neler oluyor", + "whats_happening_categories": { + "geopolitical": "Jeopolitik", + "macro": "Makro", + "regulatory": "Mevzuat", + "technical": "Teknik", + "social": "Sosyal", + "other": "Diğer" + }, "defi": "DeFi", "nfts": "NFT'ler", "import_nfts": "NFT'leri içe aktar", diff --git a/locales/languages/vi.json b/locales/languages/vi.json index 0ba27f0840b..31240aed98b 100644 --- a/locales/languages/vi.json +++ b/locales/languages/vi.json @@ -20,6 +20,12 @@ "update": "Cập nhật" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "Cảnh báo", @@ -120,8 +126,8 @@ "title": "Gửi tài sản đến địa chỉ đốt" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "Cảnh báo hợp đồng token", + "message": "Địa chỉ người nhận có thể không hỗ trợ chuyển khoản token trực tiếp, điều này có thể dẫn đến mất tiền. Chỉ tiếp tục nếu bạn chắc chắn hợp đồng này có thể nhận khoản chuyển của bạn." }, "gas_sponsorship_reserve_balance": { "message": "Tài trợ phí gas không khả dụng cho giao dịch này. Bạn cần giữ ít nhất %{minBalance} %{nativeTokenSymbol} trong tài khoản của mình.", @@ -694,8 +700,8 @@ "could_not_resolve_name": "Không thể giải mã tên", "invalid_address": "Địa chỉ không hợp lệ", "contractAddressError": "Bạn đang gửi token đến địa chỉ hợp đồng của token. Điều này có thể dẫn đến việc mất token đó.", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "Địa chỉ hợp đồng thông minh", + "smart_contract_address_warning": "Địa chỉ người nhận có thể không hỗ trợ chuyển khoản token trực tiếp, điều này có thể dẫn đến mất tiền. Chỉ tiếp tục nếu bạn chắc chắn hợp đồng này có thể nhận khoản chuyển của bạn.", "i_understand": "Tôi hiểu", "cancel": "Hủy" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "Giá cắt lỗ phải {{direction}} giá {{priceType}}", "stop_loss_beyond_liquidation_error": "Giá cắt lỗ phải {{direction}} giá thanh lý", "stop_loss_order_view_warning": "Giá cắt lỗ {{direction}} giá thanh lý", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "cao hơn", "below": "thấp hơn", "done": "Hoàn tất", @@ -2086,14 +2094,15 @@ "a_closer_look": "Xem chi tiết hơn", "whats_being_said": "Những gì đang được nói", "footer_disclaimer": "Tóm tắt AI chỉ mang tính chất cung cấp thông tin", - "trade_button": "Giao dịch", + "swap_button": "Hoán đổi", + "buy_button": "Mua", "sources_count": "+{{count}} nguồn", "sources_title": "Nguồn tin tức", "feedback_submitted": "Đã gửi phản hồi", "helpful_prompt": "Thông tin này có hữu ích không?", "feedback": { "title": "Phản hồi", - "description": "Giúp cải thiện thông tin thị trường do AI tạo ra.", + "description": "Câu trả lời của bạn giúp cải thiện các bản tóm tắt bằng AI của chúng tôi.", "not_relevant": "Không liên quan", "not_accurate": "Không chính xác", "hard_to_understand": "Khó hiểu", @@ -2206,7 +2215,7 @@ "available_balance": "Số dư khả dụng", "claim_amount_text": "Nhận ${{amount}}", "claim_winnings_text": "Nhận tiền thắng", - "claiming_text": "Claiming...", + "claiming_text": "Đang nhận...", "unrealized_pnl_label": "Lãi/Lỗ chưa thực hiện", "unrealized_pnl_value": "{{amount}} ({{percent}})", "unrealized_pnl_error": "Không thể tải", @@ -2287,7 +2296,7 @@ "try_again": "Thử lại" }, "in_progress": { - "title": "Claim already in progress" + "title": "Yêu cầu nhận đang được xử lý" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "Phí trả cho sàn giao dịch hoặc thị trường", "total_incl_fees": "bao gồm phí", "close": "Đóng", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "Giá hiển thị dựa trên giả định rằng lệnh của bạn đã khớp hoàn toàn. Số tiền thực tế có thể khác nếu lệnh chỉ khớp một phần.", + "deposit_fee": "Phí gửi tiền", + "deposit_fee_description": "Phí được tính để gửi tiền vào số dư dự đoán của bạn" }, "error": { "title": "Không thể kết nối với thị trường dự đoán", @@ -3059,6 +3068,7 @@ "networks_no_results": "Không tìm thấy mạng nào", "network_name_label": "Tên mạng", "network_name_placeholder": "Tên mạng (không bắt buộc)", + "required": "Bắt buộc", "network_rpc_url_label": "URL RPC", "network_rpc_name_label": "Tên RPC", "network_rpc_placeholder": "Mạng RPC mới", @@ -3298,6 +3308,8 @@ "blockaid_desc": "Tính năng này sẽ cảnh báo bạn khi có hoạt động độc hại bằng cách chủ động xem xét các yêu cầu giao dịch và chữ ký.", "security_alerts": "Cảnh báo bảo mật", "security_alerts_desc": "Tính năng này sẽ cảnh báo bạn về hoạt động độc hại bằng cách xem xét cục bộ các yêu cầu giao dịch và chữ ký của bạn. Hãy luôn tự mình thực hiện quy trình thẩm định trước khi chấp thuận bất kỳ yêu cầu nào. Không có gì đảm bảo rằng tính năng này sẽ phát hiện được tất cả các hoạt động độc hại. Bằng cách bật tính năng này, bạn đồng ý với các điều khoản sử dụng của nhà cung cấp.", + "smart_account_dapp_requests_heading": "Yêu cầu tài khoản thông minh từ dapp", + "smart_account_dapp_requests_desc": "Cho phép dapp yêu cầu các tính năng tài khoản thông minh cho tài khoản tiêu chuẩn. Điều này sẽ không ảnh hưởng đến các tài khoản đã là tài khoản thông minh.", "smart_transactions_opt_in_heading": "Giao dịch thông minh", "smart_transactions_opt_in_desc_supported_networks": "Bật Giao dịch thông minh để có các giao dịch đáng tin cậy và an toàn hơn trên các mạng được hỗ trợ.", "smart_transactions_learn_more": "Tìm hiểu thêm", @@ -3566,6 +3578,53 @@ "activity": "Hoạt động {{symbol}}", "disclaimer": "Dữ liệu thị trường được cung cấp bởi các nguồn bên thứ ba như CoinGecko. Dữ liệu chỉ mang tính chất tham khảo. MetaMask không chịu trách nhiệm về tính chính xác của dữ liệu." }, + "security_trust": { + "title": "Bảo mật và độ tin cậy", + "malicious": "Độc hại", + "risky": "Rủi ro", + "malicious_token_title": "Token độc hại", + "malicious_token_description": "{{symbol}} là một token độc hại. Tránh tương tác hoặc giao dịch với token này.", + "verified_token_title": "Token đã xác minh", + "verified_token_description": "{{symbol}} đang được giao dịch tích cực và được công nhận rộng rãi. Việc xác minh không phải là sự chứng thực của MetaMask.", + "risky_token_title": "Token rủi ro", + "risky_token_description": "Phát hiện tín hiệu cảnh báo đối với {{symbol}}. Hãy nghiên cứu kỹ trước khi giao dịch token này.", + "malicious_token_sheet_description": "Phát hiện tín hiệu rủi ro nghiêm trọng đối với {{symbol}}. Chúng tôi khuyến nghị không giao dịch token này.", + "got_it": "Tôi đã hiểu", + "proceed": "Tiếp tục", + "cancel": "Hủy", + "data_unavailable": "Không có dữ liệu bảo mật", + "subtitle_known": "Không phát hiện tín hiệu rủi ro. Luôn nghiên cứu bất kỳ tài sản nào trước khi giao dịch.", + "subtitle_no_issues": "Không phát hiện tín hiệu rủi ro. Luôn nghiên cứu bất kỳ tài sản nào trước khi giao dịch.", + "subtitle_suspicious": "Phát hiện tín hiệu cảnh báo. Hãy xem xét kỹ các vấn đề đã được đánh dấu trước khi giao dịch tài sản này.", + "subtitle_malicious": "Phát hiện tín hiệu rủi ro nghiêm trọng. Chúng tôi khuyến nghị tránh giao dịch với tài sản này.", + "subtitle_unavailable": "Không thể tải phân tích bảo mật cho token này.", + "token_distribution": "Phân bổ token", + "total_supply": "Tổng nguồn cung", + "top_10_holders": "Top 10 người nắm giữ", + "other": "Khác", + "no_hidden_fees_detected": "Không phát hiện phí ẩn", + "buy_sell_tax": "Thuế mua/bán", + "buy_tax": "Thuế mua", + "sell_tax": "Thuế bán", + "transfer": "Chuyển", + "token_info": "Thông tin token", + "created": "Đã tạo", + "token_age": "Tuổi token", + "network": "Mạng", + "type": "Nhập", + "official_links": "Liên kết chính thức", + "website": "Trang web", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "Không áp dụng", + "verified": "Đã xác minh", + "no_issues": "Không có vấn đề", + "suspicious": "Đáng ngờ", + "malicious_label": "Độc hại", + "more": "thêm", + "evaluation_disclaimer": "Đánh giá bảo mật này chỉ nhằm mục đích thẩm định và không cấu thành sự chứng thực hoặc khuyến nghị giao dịch." + }, "account_details": { "title": "Chi tiết tài khoản", "share_account": "Chia sẻ", @@ -5934,6 +5993,10 @@ "claimable_bonus": "Thưởng có thể nhận", "claim_bonus": "Nhận thưởng", "claim_bonus_subtitle": "Tiền thưởng sẽ được trả trên {{networkName}}.", + "percentage_bonus_on_linea": "Thưởng {{percentage}}% trên Linea", + "claim": "Nhận", + "sounds_good": "Có vẻ tốt", + "claimable_bonus_tooltip_with_percentage": "{{percentage}}% phần thưởng quy đổi theo năm bạn đã nhận được khi nắm giữ mUSD. Bạn có thể nhận thưởng hằng ngày trên Linea.", "empty_state_cta": { "heading": "Cho vay {{tokenSymbol}} và nhận lãi", "body": "Cho vay {{tokenSymbol}} với {{protocol}} và kiếm lợi nhuận", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "Đồng ổn định của bạn" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "Kiếm lợi nhuận", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "Bạn không có đủ số dư tài nguyên để thực hiện hành động này." }, - "trx_unstaking_in_progress": "Đang tiến hành hủy ký gửi {{amount}} TRX. Quá trình hủy ký gửi sẽ mất 14 ngày.", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "Đang tiến hành hủy ký gửi {{amount}} TRX", + "description": "Quá trình hủy ký gửi sẽ mất 14 ngày" + }, + "unstaked_banner": { + "title": "Đã hoàn tất hủy ký gửi {{amount}} TRX", + "description": "Hiện có thể rút TRX đã hủy ký gửi của bạn", + "button": "Rút tiền", + "error": "Rút tiền thất bại" + } }, "stake_eth": "Ký gửi ETH", "unstake_eth": "Hủy ký gửi ETH", @@ -6376,7 +6498,8 @@ "approve": "Phê duyệt yêu cầu", "perps_deposit": "Nạp tiền", "predict_deposit": "Nạp tiền Dự đoán", - "predict_withdraw": "Rút tiền" + "predict_withdraw": "Rút tiền", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "Trang web này muốn được cấp quyền chi tiêu token của bạn.", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "Giao dịch {{index}}", "transaction": "Bảo vệ", "available_balance": "Số dư khả dụng: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "Tiếp tục", "deposit_edit_amount_done": "Nạp tiền", "deposit_edit_amount_predict_withdraw": "Rút tiền", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "Ví cứng hiện chưa được hỗ trợ. Hãy sử dụng ví nóng để tiếp tục.", "hardware_wallet_not_supported_solana": "Ví cứng hiện chưa được hỗ trợ cho Solana. Hãy sử dụng ví nóng để tiếp tục.", "price_impact_info_title": "Mức tác động giá", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "Đây là cách giao dịch của bạn làm thay đổi giá thị trường của một token. Điều này phụ thuộc vào quy mô giao dịch, thanh khoản hiện có và phí nhà cung cấp. MetaMask không kiểm soát tác động giá.", "price_impact_info_gasless_description": "Tác động giá phản ánh cách lệnh hoán đổi của bạn ảnh hưởng đến giá thị trường của tài sản. Nếu bạn không có đủ tiền để trả phí gas, một phần token gốc của bạn sẽ tự động được phân bổ để trả phí, làm tăng tác động giá. MetaMask không can thiệp hoặc kiểm soát tác động giá.", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "Do quy mô giao dịch của bạn và thanh khoản hiện có, bạn sẽ nhận được ít hơn khoảng {{priceImpact}} so với giá thị trường. Điều này đã được tính vào báo giá của bạn.", "price_impact_high": "Tác động giá cao", "price_impact_execution_description": "Bạn sẽ mất khoảng {{priceImpact}} giá trị token trong lần hoán đổi này. Hãy thử giảm số lượng hoặc chọn tuyến thanh khoản tốt hơn.", "proceed": "Tiếp tục", @@ -6627,8 +6751,8 @@ "total_cost": "Tổng chi phí", "got_it": "Tôi đã hiểu", "price_impact_warning_title": "Tác động giá cao", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "Tác động giá rất cao", + "price_impact_error_description": "Bạn sẽ mất khoảng {{priceImpact}} giá thị trường của token trong giao dịch hoán đổi này. Hãy thử giao dịch với số lượng nhỏ hơn hoặc một lộ trình có thanh khoản cao hơn để cải thiện tỷ giá." }, "quote_expired_modal": { "title": "Đã có báo giá mới", @@ -6940,7 +7064,7 @@ "upgrade_title": "Nâng cấp lên Metal", "continue_button": "Tiếp tục", "virtual_card": { - "name": "Virtual Card", + "name": "Thẻ ảo", "price": "Miễn phí", "feature_1": "Thẻ ảo dùng cho Apple Pay và Google Pay", "feature_2": "Thanh toán bằng tiền mã hoá (USDC, USDT, WETH, v.v.)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "Thẻ Metal", "price": "$199/năm", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "Mọi thứ có trong thẻ ảo, cộng thêm:", + "feature_1": "Thẻ kim loại khắc tên cao cấp", + "feature_2": "Hoàn tiền 3% cho $10.000 đầu tiên mỗi năm", "feature_3": "Không tính phí giao dịch quốc tế" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "Nhận tối đa $300 hoàn tiền mỗi năm", + "upgrade_to_metal_label": "Hoặc nâng cấp lên Metal để nhận phần thưởng gấp 3 lần" }, "review_order": { "title": "Xem lại đơn hàng của bạn", @@ -7104,7 +7228,7 @@ "ssn_description": "Theo yêu cầu của đơn vị phát hành thẻ. Sẽ không thực hiện kiểm tra tín dụng.", "invalid_ssn": "Số An Sinh Xã Hội không hợp lệ", "invalid_date_of_birth": "Ngày sinh không hợp lệ. Bạn phải ít nhất 18 tuổi", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "Tên và họ phải trùng khớp với danh tính đã được xác minh của bạn" }, "physical_address": { "title": "Thêm địa chỉ của bạn", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "Bạn sắp đạt đến hạn mức chi tiêu", "description": "Cập nhật để tránh bị từ chối", - "confirm_button_label": "Đặt hạn mức mới" + "confirm_button_label": "Đặt hạn mức mới", + "dismiss_button_label": "Đóng" }, "need_delegation": { "title": "Bạn cần kích hoạt thẻ", @@ -7301,7 +7426,6 @@ "dismiss": "Đóng", "update_success": "Đã cập nhật hạn mức chi tiêu thành công", "update_error": "Cập nhật hạn mức chi tiêu thất bại", - "solana_not_supported": "Kích hoạt token Solana trên card.metamask.io", "select_token": "Chọn token", "loading": "Đang tải các token khả dụng...", "load_error": "Không thể tải token. Vui lòng thử lại.", @@ -7343,9 +7467,7 @@ "limited": "Bị giới hạn", "not_enabled": "Chưa kích hoạt", "update_success": "Đã cập nhật mức ưu tiên chi tiêu thành công", - "update_error": "Cập nhật mức ưu tiên chi tiêu thất bại", - "solana_not_supported_button_title": "Các token khác trên Solana", - "solana_not_supported_button_description": "Kích hoạt trên card.metamask.io" + "update_error": "Cập nhật mức ưu tiên chi tiêu thất bại" }, "card_authentication": { "title": "Đăng nhập vào tài khoản thẻ của bạn", @@ -7443,6 +7565,11 @@ "title": "Đăng ký tham gia thất bại", "description": "Kiểm tra kết nối của bạn và thử lại." }, + "version_guard": { + "title": "Yêu cầu cập nhật", + "description": "Cần phiên bản MetaMask mới hơn để sử dụng Phần thưởng. Vui lòng cập nhật để tiếp tục.", + "update_button": "Cập nhật MetaMask" + }, "season_error": { "error_fetching_title": "Không thể tải mùa giải", "error_fetching_description": "Kiểm tra kết nối của bạn và thử lại.", @@ -7525,7 +7652,6 @@ "main_title": "Phần thưởng", "referral_title": "Giới thiệu", "tab_overview_title": "Tổng quan", - "tab_snapshots_title": "Ảnh chụp nhanh", "tab_activity_title": "Hoạt động", "referral_stats_earned_from_referrals": "Nhận được từ giới thiệu", "referral_stats_referrals": "Giới thiệu", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "Bạn chưa nhận được phần thưởng trong mùa này, nhưng vẫn còn cơ hội lần sau.", "verifying_rewards": "Chúng tôi đang kiểm tra mọi thứ trước khi bạn nhận phần thưởng." }, + "previous_season_view": { + "title": "Mùa trước" + }, "season_status": { "points_earned": "Điểm đã tích lũy" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "Tính năng tăng cường đang hoạt động", "season_1": "Mùa 1", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "Máy tính thưởng mUSD", + "description": "Xem bạn có thể nhận được bao nhiêu khi chuyển đổi đồng ổn định của mình sang mUSD.", + "amount_label": "Số lượng đã chuyển đổi", + "estimated_bonus": "Thưởng ước tính theo năm: tối đa 3%", + "initial_amount": "Số lượng ban đầu", + "daily_bonus": "Thưởng có thể nhận hằng ngày", + "annualized_bonus": "Thưởng theo năm", + "disclaimer": "Đây chỉ là ước tính. Phần thưởng có thể thay đổi.", "buy_button": "Mua mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "Hoán đổi sang mUSD" }, "upcoming_rewards": { "title": "Phần thưởng bị khóa", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "Không thể tải" }, - "snapshot": { + "campaign": { "starts_date": "Bắt đầu {{date}}", "ends_date": "Kết thúc {{date}}", - "results_coming_soon": "Kết quả sẽ sớm được công bố", - "tokens_on_the_way": "Token đang được gửi đến", + "ended_date": "Ended {{date}}", "pill_up_next": "Sắp tới", - "pill_live_now": "Đang diễn ra", - "pill_calculating": "Đang tính toán", - "pill_results_ready": "Kết quả đã sẵn sàng", - "pill_complete": "Hoàn tất" - }, - "snapshots_section": { - "title": "Ảnh chụp nhanh", - "error_title": "Không thể tải ảnh chụp nhanh", - "error_description": "Chúng tôi không thể tải ảnh chụp nhanh. Vui lòng thử lại.", - "retry_button": "Thử lại" - }, - "snapshots_tab": { + "pill_active": "Đang diễn ra", + "pill_complete": "Hoàn tất", + "enter_now": "Tham gia ngay", + "entered": "Đã tham gia", + "participant_count": "#{{count}}", + "opt_in_cta": "Đồng ý tham gia", + "opt_in_sheet_title": "Tham gia chiến dịch", + "opt_in_sheet_description_pre_link": "Bằng cách nhấn vào \"Đồng ý tham gia\", bạn đồng ý với MetaMask Phần thưởng", + "opt_in_sheet_link_text": "Điều khoản sử dụng bổ sung và Thông báo quyền riêng tư", + "opt_in_sheet_description_post_link": "Chúng tôi sẽ theo dõi hoạt động trên chuỗi để tự động tặng thưởng cho bạn.", + "geo_restriction_banner_title": "Không khả dụng tại khu vực của bạn", + "geo_restriction_banner_description": "Chiến dịch này không khả dụng tại khu vực của bạn do quy định địa phương." + }, + "campaign_mechanics": { + "title": "Cơ chế" + }, + "campaign_details": { + "start_date": "Bắt đầu: {{date}}", + "end_date": "Kết thúc: {{date}}", + "opt_in": "Đồng ý tham gia", + "opting_in": "Đang xác nhận tham gia...", + "opted_in": "Bạn đã tham gia chiến dịch này", + "opt_in_error": "Không thể tham gia. Vui lòng thử lại.", + "join_campaign": "Tham gia chiến dịch", + "checking_opt_in_status": "Đang kiểm tra trạng thái tham gia", + "swap": "Hoán đổi", + "how_it_works": "Cách hoạt động" + }, + "campaigns_preview": { + "title": "Chiến dịch", + "coming_soon": "Sắp ra mắt", + "notify_me": "Thông báo cho tôi" + }, + "earn_rewards": { + "title": "Nhận phần thưởng", + "musd_title": "Thưởng lên đến 3% cho đồng ổn định", + "musd_subtitle": "Tính toán thưởng mUSD của bạn", + "card_title": "Hoàn tiền lên đến 3%", + "card_subtitle": "Nhận MetaMask Card của bạn ngay", + "card_subtitle_cardholder": "Truy cập các quyền lợi MetaMask Card của bạn" + }, + "campaigns_view": { + "title": "Chiến dịch", "active_title": "Đang hoạt động", "upcoming_title": "Sắp tới", "previous_title": "Trước đó", - "empty_state": "Không có ảnh chụp nhanh nào", - "error_title": "Không thể tải ảnh chụp nhanh", - "error_description": "Chúng tôi không thể tải ảnh chụp nhanh. Vui lòng thử lại.", + "empty_state": "Không có chiến dịch nào", + "error_title": "Không thể tải các chiến dịch", + "error_description": "Chúng tôi không thể tải các chiến dịch. Vui lòng thử lại.", "retry_button": "Thử lại", "refreshing": "Đang làm mới..." } @@ -7953,13 +8112,12 @@ "continue": "Tiếp tục" }, "connecting": { - "title": "Kết nối {{device}} của bạn", + "title": "Đang kết nối {{device}} của bạn...", "searching": "Đang tìm {{device}}...", - "tips_header": "Để tiếp tục, hãy đảm bảo:", + "tips_header": "Đảm bảo:", "tip_unlock": "{{device}} của bạn đã được mở khóa", "tip_open_app": "Ứng dụng Ethereum đang mở", "tip_enable_bluetooth": "Bluetooth đã được bật", - "tip_dnd_off": "Chế độ Không làm phiền đã được tắt", "tip_bluetooth_permission": "Quyền truy cập Vị trí và Bluetooth đã được cấp", "tip_bluetooth_permission_v12": "Quyền truy cập Thiết bị lân cận đã được cấp", "tip_stay_close": "Thiết bị của bạn ở gần điện thoại" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "Cần quyền truy cập Thiết bị lân cận", "bluetooth_off": "Vui lòng bật Bluetooth để kết nối với thiết bị của bạn", "bluetooth_scan_failed": "Không thể quét thiết bị. Vui lòng thử lại", - "bluetooth_connection_failed": "Bật Bluetooth trên thiết bị của bạn để tiếp tục", + "bluetooth_connection_failed": "Kết nối với thiết bị của bạn không thành công. Vui lòng thử lại", "not_supported": "Thao tác này không được hỗ trợ", "unknown_error": "Đảm bảo {{device}} của bạn đã được thiết lập với Cụm từ khôi phục bí mật hoặc cụm mật khẩu cho tài khoản này" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "Tiền mặt", + "cash_empty_description": "Bạn chưa có mUSD nào. Hãy chuyển đổi đồng ổn định sang mUSD từ mục Tiền mặt trên trang chủ.", + "cash_empty_description_network_filter": "Không có mUSD trên mạng này. Hãy chuyển mạng để xem mUSD của bạn.", "tokens": "Token", "perpetuals": "Hợp đồng vĩnh cửu", "predictions": "Dự đoán", + "whats_happening": "Tình hình hiện nay", + "whats_happening_categories": { + "geopolitical": "Địa chính trị", + "macro": "Vĩ mô", + "regulatory": "Quy định", + "technical": "Kỹ thuật", + "social": "Xã hội", + "other": "Khác" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "Nhập NFT", diff --git a/locales/languages/zh.json b/locales/languages/zh.json index 883af8b0d34..dbadf6900b1 100644 --- a/locales/languages/zh.json +++ b/locales/languages/zh.json @@ -20,6 +20,12 @@ "update": "更新" } }, + "access_restricted": { + "title": "Access restricted", + "description_line1": "This wallet address has been flagged during compliance screening. As a result, some MetaMask services are unavailable.", + "description_line2": "If you believe this is an error, contact support to request a review.", + "contact_support": "Contact support" + }, "alert_system": { "alert_modal": { "title": "提醒", @@ -120,8 +126,8 @@ "title": "正在向销毁地址发送资产" }, "token_contract_warning": { - "title": "Token contract warning", - "message": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer." + "title": "代币合约警告", + "message": "该代币接收地址可能不支持直接转账,这可能导致资金损失。建议仅在确认该合约能够接收转账的情况下继续操作。" }, "gas_sponsorship_reserve_balance": { "message": "本次交易无法使用燃料赞助。您的账户中需要至少保留 %{minBalance} %{nativeTokenSymbol}。", @@ -694,8 +700,8 @@ "could_not_resolve_name": "无法解析名称", "invalid_address": "地址无效", "contractAddressError": "您正在向代币的合约地址发送代币。这可能导致这些代币丢失。", - "smart_contract_address": "Smart contract address", - "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", + "smart_contract_address": "智能合约地址", + "smart_contract_address_warning": "该代币接收地址可能不支持直接转账,这可能导致资金损失。建议仅在确认该合约能够接收转账的情况下继续操作。", "i_understand": "我理解", "cancel": "取消" }, @@ -1471,6 +1477,8 @@ "stop_loss_invalid_price": "须以 {{direction}}{{priceType}} 价格止损", "stop_loss_beyond_liquidation_error": "须以 {{direction}} 清算价格止损", "stop_loss_order_view_warning": "以 {{direction}} 清算价格止损", + "take_profit_wrong_side_warning": "Take profit must be {{direction}} {{priceType}} price. Update or clear it to place the order.", + "stop_loss_wrong_side_warning": "Stop loss must be {{direction}} {{priceType}} price. Update or clear it to place the order.", "above": "高于", "below": "低于", "done": "已完成", @@ -2086,14 +2094,15 @@ "a_closer_look": "深度观察", "whats_being_said": "市场声音", "footer_disclaimer": "AI 摘要仅供参考", - "trade_button": "交易", + "swap_button": "交换", + "buy_button": "买入", "sources_count": "+{{count}} 个来源", "sources_title": "新闻来源", "feedback_submitted": "反馈已提交", "helpful_prompt": "这对您有帮助吗?", "feedback": { "title": "反馈", - "description": "帮助我们改进人工智能生成的市场洞察。", + "description": "您的回答有助于优化我们的 AI 摘要。", "not_relevant": "不相关", "not_accurate": "不准确", "hard_to_understand": "难以理解", @@ -2206,7 +2215,7 @@ "available_balance": "可用余额", "claim_amount_text": "领取 ${{amount}}", "claim_winnings_text": "领取收益", - "claiming_text": "Claiming...", + "claiming_text": "正在领取……", "unrealized_pnl_label": "未实现盈亏", "unrealized_pnl_value": "{{amount}}({{percent}})", "unrealized_pnl_error": "无法加载", @@ -2287,7 +2296,7 @@ "try_again": "请重试" }, "in_progress": { - "title": "Claim already in progress" + "title": "领取正在进行中" } } }, @@ -2347,9 +2356,9 @@ "exchange_fee_description": "支付给交易所或市场的费用", "total_incl_fees": "包含费用", "close": "关闭", - "fak_partial_fill_note": "Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.", - "deposit_fee": "Deposit fee", - "deposit_fee_description": "Fee charged to deposit funds into your prediction balance" + "fak_partial_fill_note": "显示的价格基于订单完全成交的假设。若仅部分成交,实际金额可能会有所不同。", + "deposit_fee": "存款手续费", + "deposit_fee_description": "将资金充值到您的预测余额所需支付的手续费" }, "error": { "title": "无法连接预测市场", @@ -3059,6 +3068,7 @@ "networks_no_results": "未找到网络", "network_name_label": "网络名称", "network_name_placeholder": "网络名称(可选)", + "required": "必需", "network_rpc_url_label": "RPC(远程过程调用)URL", "network_rpc_name_label": "RPC(远程过程调用)名称", "network_rpc_placeholder": "新 RPC 网络", @@ -3298,6 +3308,8 @@ "blockaid_desc": "此功能通过主动审查交易和签名请求向您发出恶意活动提醒。", "security_alerts": "安全警报", "security_alerts_desc": "此功能通过本地审查您的交易和签名请求来提醒您注意恶意活动。在批准任何请求之前,请务必自行进行审慎调查。无法保证此功能能够检测到所有恶意活动。启用此功能即表示您同意提供商的使用条款。", + "smart_account_dapp_requests_heading": "来自 dapp 的智能账户请求", + "smart_account_dapp_requests_desc": "让 dapp 为标准账户请求智能账户功能。这不会影响已经是智能账户的账户。", "smart_transactions_opt_in_heading": "智能交易", "smart_transactions_opt_in_desc_supported_networks": "开启智能交易,以便在支持网络上实现更加安全可靠的交易。", "smart_transactions_learn_more": "了解详情", @@ -3566,6 +3578,53 @@ "activity": "{{symbol}} 活动", "disclaimer": "市场数据由 CoinGecko 等第三方来源提供。数据仅供参考。MetaMask 不对其准确性负责。" }, + "security_trust": { + "title": "安全与信任", + "malicious": "恶意", + "risky": "高风险", + "malicious_token_title": "恶意代币", + "malicious_token_description": "{{symbol}} 是恶意代币。请避免与之交互或进行交易。", + "verified_token_title": "已验证代币", + "verified_token_description": "{{symbol}} 交易活跃且被广泛认可。验证并不代表 MetaMask 的背书。", + "risky_token_title": "高风险代币", + "risky_token_description": "检测到关于 {{symbol}} 的警示信号。交易此代币前,请仔细研究。", + "malicious_token_sheet_description": "检测到关于 {{symbol}} 的严重风险信号。建议不要交易此代币。", + "got_it": "知道了", + "proceed": "继续", + "cancel": "取消", + "data_unavailable": "无法获取安全数据", + "subtitle_known": "未检测到风险信号。交易任何资产前请务必自行研究。", + "subtitle_no_issues": "未检测到风险信号。交易任何资产前请务必自行研究。", + "subtitle_suspicious": "检测到警示信号。交易此资产前请仔细查看标记的问题。", + "subtitle_malicious": "检测到严重风险信号。建议规避此资产。", + "subtitle_unavailable": "无法加载此代币的安全分析。", + "token_distribution": "代币分配", + "total_supply": "总供给量", + "top_10_holders": "前十大持有者", + "other": "其他", + "no_hidden_fees_detected": "未检测到隐藏费用", + "buy_sell_tax": "买卖税", + "buy_tax": "买入税", + "sell_tax": "卖出税", + "transfer": "转账", + "token_info": "代币信息", + "created": "已创建", + "token_age": "代币年龄", + "network": "网络", + "type": "类型", + "official_links": "官方链接", + "website": "网站", + "twitter_x": "Twitter", + "telegram": "Telegram", + "etherscan": "Etherscan", + "na": "不适用", + "verified": "已验证", + "no_issues": "未发现问题", + "suspicious": "可疑", + "malicious_label": "恶意", + "more": "展开", + "evaluation_disclaimer": "本安全审查仅供评估之用,不构成对交易的背书或推荐。" + }, "account_details": { "title": "账户详情", "share_account": "共享", @@ -5934,6 +5993,10 @@ "claimable_bonus": "可领取奖励", "claim_bonus": "领取奖励", "claim_bonus_subtitle": "奖励将在 {{networkName}} 上发放。", + "percentage_bonus_on_linea": "Linea 上 {{percentage}}% 的奖励", + "claim": "领取", + "sounds_good": "听起来不错", + "claimable_bonus_tooltip_with_percentage": "您持有 mUSD 所获得的 {{percentage}}% 年化奖励。该奖励可在 Linea 上每日领取。", "empty_state_cta": { "heading": "借出 {{tokenSymbol}} 并赚取", "body": "通过 {{protocol}} 借出您的 {{tokenSymbol}},", @@ -6087,6 +6150,57 @@ }, "your_stablecoins": "您的稳定币" }, + "money": { + "title": "Money", + "apy_label": "{{percentage}}% APY", + "action": { + "add": "Add", + "transfer": "Transfer", + "card": "Card" + }, + "your_position": { + "title": "Your position", + "current_rate": "Current rate", + "lifetime_earnings": "Lifetime earnings", + "available_balance": "Avail. balance" + }, + "how_it_works": { + "title": "How it works", + "description": "Hold mUSD in a Money Account and earn automatically. It's dollar-backed, always liquid, and ready to spend, trade, or send anytime.", + "musd_name": "MetaMask USD", + "musd_symbol": "mUSD", + "add": "Add" + }, + "potential_earnings": { + "title": "Potential earnings", + "amount": "+$26,800", + "description": "See how your money can grow over time by converting your crypto to mUSD.", + "convert": "Convert", + "no_fee": "No fee", + "see_earnings": "See potential earnings" + }, + "metamask_card": { + "title": "MetaMask Card", + "subtitle": "Spend your money anywhere.", + "virtual_card": "Virtual card", + "metal_card": "Metal card", + "cashback": "{{percentage}}% cashback", + "get_now": "Get now" + }, + "why_metamask_money": { + "title": "Why MetaMask Money?", + "benefit_auto_earn": "Auto-earn ", + "benefit_dollar_backed": "Your money is held in mUSD, a 1:1 dollar-backed stablecoin", + "benefit_liquidity": "Full liquidity with no lockups, so you can trade or withdraw anytime", + "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", + "benefit_spend_cashback": "1-3% cashback", + "benefit_global": "Send and receive money globally with no middle man", + "learn_more": "Learn more" + }, + "footer": { + "add_money": "Add money" + } + }, "stake": { "stake": "Stake", "earn": "赚取", @@ -6108,8 +6222,16 @@ "errors": { "insufficient_balance": "您的资源余额不足以执行此操作。" }, - "trx_unstaking_in_progress": "{{amount}} TRX 解除质押正在进行中。解除质押需 14 天。", - "has_claimable_trx": "You can claim {{amount}} TRX. Once claimed you'll get TRX back in your wallet." + "unstaking_banner": { + "title": "正在解除质押 {{amount}} TRX", + "description": "解除质押需要 14 天" + }, + "unstaked_banner": { + "title": "解除质押 {{amount}} TRX 已完成", + "description": "您已解除质押的 TRX 现在可以提取了", + "button": "提取", + "error": "提款失败" + } }, "stake_eth": "质押 ETH", "unstake_eth": "解除质押 ETH", @@ -6376,7 +6498,8 @@ "approve": "批准请求", "perps_deposit": "充值", "predict_deposit": "存入预测资金", - "predict_withdraw": "提取" + "predict_withdraw": "提取", + "perps_withdraw": "Withdraw" }, "sub_title": { "permit": "该网站想获得花费您的代币的许可。", @@ -6502,6 +6625,7 @@ "nested_transaction_heading": "交易 {{index}}", "transaction": "交易", "available_balance": "可用余额: ", + "available_perps_balance": "Available Perps balance: ", "edit_amount_done": "继续", "deposit_edit_amount_done": "充值", "deposit_edit_amount_predict_withdraw": "提取", @@ -6588,9 +6712,9 @@ "hardware_wallet_not_supported": "目前暂不支持硬件钱包。请改用热钱包继续操作。", "hardware_wallet_not_supported_solana": "Solana 目前暂不支持硬件钱包。请改用热钱包继续操作。", "price_impact_info_title": "价格影响", - "price_impact_info_description": "This is how your trade changes the market price of a token. It depends on the trade size, available liquidity, and provider fees. MetaMask doesn't control price impact.", + "price_impact_info_description": "这是您的交易对代币市场价格产生的影响。影响程度取决于交易规模、可用流动性和服务商费用。MetaMask 无法控制价格影响。", "price_impact_info_gasless_description": "价格影响反映您的兑换订单对资产市场价格的影响程度。如果您没有足够的资金支付燃料费,系统将自动分配部分源代币用于支付费用,这会扩大价格影响。MetaMask 不参与亦不控制价格影响。", - "price_impact_warning_description": "Because of your trade size and available liquidity, you'll get about {{priceImpact}} less than the market price. This is already factored into your quote.", + "price_impact_warning_description": "由于您的交易规模及当前可用流动性,您的成交价格将比市场价格低约 {{priceImpact}}。此差价已包含在当前报价中。", "price_impact_high": "高价格影响", "price_impact_execution_description": "在此兑换中,您将损失约 {{priceImpact}} 的代币价值。请尝试降低金额或选择流动性更高的路径。", "proceed": "继续", @@ -6627,8 +6751,8 @@ "total_cost": "总成本", "got_it": "知道了", "price_impact_warning_title": "高价格影响", - "price_impact_error_title": "Very high price impact", - "price_impact_error_description": "You'll lose approximately {{priceImpact}} of your token's market price on this swap. Try a smaller trade or a more liquid route to improve your rate." + "price_impact_error_title": "价格影响极高", + "price_impact_error_description": "本次兑换将导致您损失约 {{priceImpact}} 的代币市值。请尝试减少交易量或选择流动性更高的路径,以获取更优汇率。" }, "quote_expired_modal": { "title": "有新的报价", @@ -6940,7 +7064,7 @@ "upgrade_title": "升级至金属卡", "continue_button": "继续", "virtual_card": { - "name": "Virtual Card", + "name": "虚拟卡", "price": "免费", "feature_1": "用于 Apple Pay 和 Google Pay 的虚拟卡", "feature_2": "使用加密货币支付(支持 USDC、USDT、WETH 等代币)", @@ -6949,13 +7073,13 @@ "metal_card": { "name": "金属卡", "price": "199美元/年", - "everything_in_virtual": "Everything in virtual, plus:", - "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "everything_in_virtual": "所有虚拟资产,外加:", + "feature_1": "高级镌刻金属卡", + "feature_2": "每年首 10000 美元消费可获 3% 返现", "feature_3": "无外币交易费用" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", - "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" + "earn_up_to_badge": "每年最高可获得 300 美元返现", + "upgrade_to_metal_label": "或升级至金属卡可享三倍奖励" }, "review_order": { "title": "查看您的订单", @@ -7104,7 +7228,7 @@ "ssn_description": "此为发卡机构的要求。不会进行信用检查。", "invalid_ssn": "社会安全号码无效", "invalid_date_of_birth": "出生日期无效。您必须年满 18 周岁", - "name_mismatch_error": "First and last name must match your verified identity" + "name_mismatch_error": "姓名必须与已验证身份一致" }, "physical_address": { "title": "填写您的地址", @@ -7201,7 +7325,8 @@ "close_spending_limit": { "title": "您即将达到消费限额", "description": "更新以避免被拒", - "confirm_button_label": "设置新限额" + "confirm_button_label": "设置新限额", + "dismiss_button_label": "忽略" }, "need_delegation": { "title": "您需要启用您的卡", @@ -7301,7 +7426,6 @@ "dismiss": "忽略", "update_success": "消费限额更新成功", "update_error": "消费限额更新失败", - "solana_not_supported": "请在 card.metamask.io 启用 Solana 代币", "select_token": "选择代币", "loading": "正在加载可用代币……", "load_error": "无法加载代币。请重试。", @@ -7343,9 +7467,7 @@ "limited": "受限", "not_enabled": "未启用", "update_success": "消费优先级更新成功", - "update_error": "消费优先级更新失败", - "solana_not_supported_button_title": "Solana 上的其他代币", - "solana_not_supported_button_description": "请在 card.metamask.io 启用" + "update_error": "消费优先级更新失败" }, "card_authentication": { "title": "登录您的卡账户", @@ -7443,6 +7565,11 @@ "title": "参与失败", "description": "请检查连接后重试。" }, + "version_guard": { + "title": "需要更新", + "description": "需要更高版本的 MetaMask 才能使用奖励功能。请更新后继续。", + "update_button": "更新 MetaMask" + }, "season_error": { "error_fetching_title": "赛季无法加载", "error_fetching_description": "请检查连接后重试。", @@ -7525,7 +7652,6 @@ "main_title": "奖励", "referral_title": "推荐", "tab_overview_title": "概览", - "tab_snapshots_title": "快照", "tab_activity_title": "活动", "referral_stats_earned_from_referrals": "通过推荐所获奖励", "referral_stats_referrals": "推荐", @@ -7580,6 +7706,9 @@ "no_end_of_season_rewards": "您本季未获得奖励,但未来仍有机会。", "verifying_rewards": "在您领取奖励前,我们正在核对所有信息以确保准确无误。" }, + "previous_season_view": { + "title": "上一季" + }, "season_status": { "points_earned": "已获得积分" }, @@ -7743,16 +7872,16 @@ "active_boosts_title": "活跃加成", "season_1": "第 1 季", "musd": { - "title": "mUSD bonus calculator", - "description": "See how much you could earn by converting your stablecoins to mUSD.", - "amount_label": "Amount converted", - "estimated_bonus": "Estimated annualized bonus: up to 3%", - "initial_amount": "Initial amount", - "daily_bonus": "Daily claimable bonus", - "annualized_bonus": "Annualized bonus", - "disclaimer": "This is only an estimate. The bonus is subject to change.", + "title": "mUSD 奖励计算器", + "description": "看看您将稳定币兑换为 mUSD 能赚取多少收益。", + "amount_label": "已兑换金额", + "estimated_bonus": "预估年化奖励:最高可达 3%", + "initial_amount": "初始金额", + "daily_bonus": "每日可领取奖励", + "annualized_bonus": "年化奖励", + "disclaimer": "仅为预估。奖励可能会有变动。", "buy_button": "购买 mUSD", - "swap_button": "Swap to mUSD" + "swap_button": "兑换为 mUSD" }, "upcoming_rewards": { "title": "锁定的奖励", @@ -7786,30 +7915,60 @@ "animation": { "could_not_load": "无法加载" }, - "snapshot": { + "campaign": { "starts_date": "开始于 {{date}}", "ends_date": "结束于 {{date}}", - "results_coming_soon": "结果即将公布", - "tokens_on_the_way": "代币即将到账", + "ended_date": "Ended {{date}}", "pill_up_next": "即将到来", - "pill_live_now": "现已上线", - "pill_calculating": "正在计算", - "pill_results_ready": "结果已就绪", - "pill_complete": "完成" - }, - "snapshots_section": { - "title": "快照", - "error_title": "无法加载快照", - "error_description": "无法加载快照。请重试。", - "retry_button": "重试" - }, - "snapshots_tab": { + "pill_active": "进行中", + "pill_complete": "完成", + "enter_now": "立即参加", + "entered": "已参加", + "participant_count": "#{{count}}", + "opt_in_cta": "选择加入", + "opt_in_sheet_title": "加入活动", + "opt_in_sheet_description_pre_link": "点击“选择加入”,即表示您同意参加 MetaMask 奖励计划", + "opt_in_sheet_link_text": "补充使用条款及隐私声明", + "opt_in_sheet_description_post_link": "我们将通过追踪链上活动为您自动发放奖励。", + "geo_restriction_banner_title": "您所在区域不可用", + "geo_restriction_banner_description": "由于当地法规限制,此活动不适用于您所在地区。" + }, + "campaign_mechanics": { + "title": "机制" + }, + "campaign_details": { + "start_date": "开始时间:{{date}}", + "end_date": "结束时间:{{date}}", + "opt_in": "选择加入", + "opting_in": "正在选择加入……", + "opted_in": "您已选择加入此活动", + "opt_in_error": "加入失败。请重试。", + "join_campaign": "加入活动", + "checking_opt_in_status": "正在检查加入状态", + "swap": "交换", + "how_it_works": "如何运行" + }, + "campaigns_preview": { + "title": "活动", + "coming_soon": "即将推出", + "notify_me": "通知我" + }, + "earn_rewards": { + "title": "赚取奖励", + "musd_title": "稳定币最高可享 3% 奖励", + "musd_subtitle": "计算您的 mUSD 奖励", + "card_title": "最高可享 3% 返现", + "card_subtitle": "立即获取您的 MetaMask 卡", + "card_subtitle_cardholder": "查看您的 MetaMask 卡专属福利" + }, + "campaigns_view": { + "title": "活动", "active_title": "已激活", "upcoming_title": "即将到来", "previous_title": "先前", - "empty_state": "暂无快照", - "error_title": "无法加载快照", - "error_description": "无法加载快照。请重试。", + "empty_state": "暂无活动", + "error_title": "无法加载活动", + "error_description": "我们无法加载活动。请重试。", "retry_button": "重试", "refreshing": "正在刷新……" } @@ -7953,13 +8112,12 @@ "continue": "继续" }, "connecting": { - "title": "连接您的 {{device}}", + "title": "正在连接您的 {{device}}……", "searching": "正在查找 {{device}}……", - "tips_header": "若要继续,请确保:", + "tips_header": "确保:", "tip_unlock": "您的 {{device}} 已解锁", "tip_open_app": "以太坊应用已打开", "tip_enable_bluetooth": "蓝牙已开启", - "tip_dnd_off": "勿扰模式已关闭", "tip_bluetooth_permission": "位置和蓝牙许可已授予", "tip_bluetooth_permission_v12": "附近设备许可已授予", "tip_stay_close": "您的设备需保持靠近手机" @@ -7993,7 +8151,7 @@ "nearby_permission_denied": "需要附近设备许可", "bluetooth_off": "请开启蓝牙以连接到您的设备", "bluetooth_scan_failed": "扫描设备失败。请重试", - "bluetooth_connection_failed": "在您的设备上启用蓝牙以继续", + "bluetooth_connection_failed": "连接您的设备失败。请重试", "not_supported": "不支持此操作", "unknown_error": "确保您的 {{device}} 已使用此账户的私钥助记词或密语进行设置" }, @@ -8033,12 +8191,21 @@ }, "homepage": { "sections": { - "cash": "Cash", - "cash_empty_description": "You don't have any mUSD yet. Convert stablecoins to mUSD from the Cash section on the homepage.", - "cash_empty_description_network_filter": "No mUSD on this network. Switch network to see your mUSD.", + "cash": "现金", + "cash_empty_description": "您目前尚未持有任何 mUSD。请从首页的“现金”部分将稳定币兑换为 mUSD。", + "cash_empty_description_network_filter": "此网络中没有 mUSD。请切换网络以查看您的 mUSD。", "tokens": "代币", "perpetuals": "永续合约", "predictions": "预测", + "whats_happening": "发生了什么", + "whats_happening_categories": { + "geopolitical": "地缘政治", + "macro": "宏观", + "regulatory": "监管", + "technical": "技术", + "social": "社会", + "other": "其他" + }, "defi": "DeFi", "nfts": "NFT", "import_nfts": "导入 NFT", From 759fd72ceec299f7d4bf8b7c7e13762ad04ffed1 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Wed, 25 Mar 2026 20:19:47 +0000 Subject: [PATCH 41/54] [skip ci] Bump version number to 4186 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index fa91bcdba45..086cd47bd80 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4184 + versionCode 4186 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 81122210301..7da7531cf82 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4184 + VERSION_NUMBER: 4186 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4184 + FLASK_VERSION_NUMBER: 4186 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 93003ea86e0..d86b260ca39 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4184; + CURRENT_PROJECT_VERSION = 4186; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4184; + CURRENT_PROJECT_VERSION = 4186; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4184; + CURRENT_PROJECT_VERSION = 4186; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4184; + CURRENT_PROJECT_VERSION = 4186; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4184; + CURRENT_PROJECT_VERSION = 4186; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4184; + CURRENT_PROJECT_VERSION = 4186; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 16da3b130764cd6a89ea53e8999aade7b7e35e6b Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:45:35 +0100 Subject: [PATCH 42/54] chore(runway): cherry-pick feat(earn): gate Tron unstaked claim button behind remote flag cp-7.71.0 (#27959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(earn): gate Tron unstaked claim button behind remote flag (#27908) ## **Description** Adds the remote boolean flag **`tronClaimUnstakedTrxButtonEnabled`** so we can hide the **claim** action on the Tron unstaked banner if something goes wrong in production, without removing the banner copy. **Why:** We need a safe kill switch for the claim CTA only. **How:** - Register the flag in `FeatureFlagNames` with default `false` (missing/undefined → button hidden; opt-in). - **`selectTronClaimUnstakedTrxButtonEnabled`** in `app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/` reads merged remote flags (same pattern as other boolean flags). - `TronUnstakedBanner` uses `useSelector(selectTronClaimUnstakedTrxButtonEnabled)` and renders the primary claim button only when the flag is `true`; title and description stay visible when the button is hidden. - Register the flag in **`tests/feature-flags/feature-flag-registry.ts`** (`inProd: true`, `productionDefault: false`) so CI/E2E mocks match production client-config. **Ops:** Ensure **`tronClaimUnstakedTrxButtonEnabled`** exists in LaunchDarkly / client-config; set to **`true`** where the claim button should appear. ## **Changelog** CHANGELOG entry: Added a remote feature flag to control visibility of the Tron unstaked TRX claim button on the token details banner. ## **Related issues** Fixes: NEB-838 ## **Manual testing steps** ```gherkin Feature: Tron unstaked banner claim button behind remote flag Scenario: user views TRX token details with claimable unstaked balance and flag enabled Given a Tron account with TRX ready for withdrawal and remote flag `tronClaimUnstakedTrxButtonEnabled` is true (or overridden in dev tools) When user opens native TRX token details and the unstaked banner is shown Then the banner shows title, description, and the claim button, and tapping claim still triggers the existing flow Scenario: user views TRX token details when flag is off or unset Given the same balance state but `tronClaimUnstakedTrxButtonEnabled` is false, missing, or undefined in remote flags When user opens native TRX token details and the unstaked banner is shown Then the banner shows title and description but does not show the claim button ``` ## **Screenshots/Recordings** ### **Before** See prior screenshots on this PR (token details with banner). ### **After** Feature flag disabled / enabled — screenshots attached in thread (banner with and without claim CTA). ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [ef5e684](https://github.com/MetaMask/metamask-mobile/commit/ef5e684c05e88164d2952f9fcdca6cdf1af68cd8) Co-authored-by: Ulisses Ferreira --- .../TronUnstakedBanner.test.tsx | 78 ++++++++++++++----- .../TronUnstakedBanner/TronUnstakedBanner.tsx | 25 +++--- app/components/UI/Perps/utils/wait.test.ts | 4 +- app/constants/featureFlags.ts | 2 + .../index.test.ts | 49 ++++++++++++ .../index.ts | 17 ++++ .../feature-flag-registry.test.ts | 1 + tests/feature-flags/feature-flag-registry.ts | 10 ++- 8 files changed, 153 insertions(+), 33 deletions(-) create mode 100644 app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.test.ts create mode 100644 app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.ts diff --git a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx index c50b458e01a..9ba6d9e7854 100644 --- a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx +++ b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.test.tsx @@ -1,11 +1,21 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; +import { fireEvent } from '@testing-library/react-native'; +import type { CaipChainId } from '@metamask/utils'; import TronUnstakedBanner from './TronUnstakedBanner'; import { strings } from '../../../../../../../locales/i18n'; import useTronClaimUnstakedTrx from '../../../hooks/useTronClaimUnstakedTrx'; import useEarnToasts from '../../../hooks/useEarnToasts'; +import renderWithProvider from '../../../../../../util/test/renderWithProvider'; +import { selectTronClaimUnstakedTrxButtonEnabled } from '../../../../../../selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled'; import { TronUnstakedBannerTestIds } from './TronUnstakedBanner.testIds'; +jest.mock( + '../../../../../../selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled', + () => ({ + selectTronClaimUnstakedTrxButtonEnabled: jest.fn(), + }), +); + jest.mock('../../../hooks/useTronClaimUnstakedTrx'); const mockUseTronClaimUnstakedTrx = useTronClaimUnstakedTrx as jest.MockedFunction< @@ -23,11 +33,18 @@ jest.mock('../../../hooks/useEarnToasts'); }, }); +const mockSelectTronClaimUnstakedTrxButtonEnabled = + selectTronClaimUnstakedTrxButtonEnabled as unknown as jest.Mock; + +const renderBanner = (props: { amount: string; chainId: CaipChainId }) => + renderWithProvider(, undefined, false); + describe('TronUnstakedBanner', () => { const mockHandleClaimUnstakedTrx = jest.fn(); beforeEach(() => { jest.clearAllMocks(); + mockSelectTronClaimUnstakedTrxButtonEnabled.mockReturnValue(true); mockUseTronClaimUnstakedTrx.mockReturnValue({ handleClaimUnstakedTrx: mockHandleClaimUnstakedTrx, isSubmitting: false, @@ -42,9 +59,10 @@ describe('TronUnstakedBanner', () => { }); it('renders the title with the given amount', () => { - const { getByText } = render( - , - ); + const { getByText } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); const expectedTitle = strings('stake.tron.unstaked_banner.title', { amount: '100', @@ -53,9 +71,10 @@ describe('TronUnstakedBanner', () => { }); it('renders the description', () => { - const { getByText } = render( - , - ); + const { getByText } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); const expectedDescription = strings( 'stake.tron.unstaked_banner.description', @@ -63,20 +82,38 @@ describe('TronUnstakedBanner', () => { expect(getByText(expectedDescription)).toBeOnTheScreen(); }); - it('renders the Withdraw button', () => { - const { getByTestId } = render( - , - ); + it('renders the claim button when tronClaimUnstakedTrxButtonEnabled is true', () => { + const { getByTestId } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); expect( getByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON), ).toBeOnTheScreen(); }); + it('does not render the claim button when tronClaimUnstakedTrxButtonEnabled is false', () => { + mockSelectTronClaimUnstakedTrxButtonEnabled.mockReturnValue(false); + + const { getByText, queryByTestId } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); + + expect( + queryByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON), + ).not.toBeOnTheScreen(); + expect( + getByText(strings('stake.tron.unstaked_banner.description')), + ).toBeOnTheScreen(); + }); + it('calls handleClaimUnstakedTrx when button is pressed', () => { - const { getByTestId } = render( - , - ); + const { getByTestId } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); fireEvent.press(getByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON)); expect(mockHandleClaimUnstakedTrx).toHaveBeenCalledTimes(1); @@ -89,9 +126,10 @@ describe('TronUnstakedBanner', () => { errors: undefined, }); - const { getByTestId } = render( - , - ); + const { getByTestId } = renderBanner({ + amount: '100', + chainId: 'tron:728126428', + }); const button = getByTestId(TronUnstakedBannerTestIds.CLAIM_BUTTON); expect(button.props.accessibilityState?.disabled).toBe(true); @@ -104,7 +142,7 @@ describe('TronUnstakedBanner', () => { errors: ['InsufficientBalance'], }); - render(); + renderBanner({ amount: '100', chainId: 'tron:728126428' }); expect(mockFailedToastFn).toHaveBeenCalledWith(['InsufficientBalance']); expect(mockShowToast).toHaveBeenCalledWith(mockFailedToastResult); @@ -117,14 +155,14 @@ describe('TronUnstakedBanner', () => { errors: [], }); - render(); + renderBanner({ amount: '100', chainId: 'tron:728126428' }); expect(mockFailedToastFn).toHaveBeenCalledWith([]); expect(mockShowToast).toHaveBeenCalledWith(mockFailedToastResult); }); it('does not show error toast when there are no errors', () => { - render(); + renderBanner({ amount: '100', chainId: 'tron:728126428' }); expect(mockShowToast).not.toHaveBeenCalled(); }); diff --git a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx index 5e245729048..65c6ba3d3d1 100644 --- a/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx +++ b/app/components/UI/Earn/components/Tron/TronUnstakedBanner/TronUnstakedBanner.tsx @@ -1,4 +1,5 @@ import React, { useEffect } from 'react'; +import { useSelector } from 'react-redux'; import type { CaipChainId } from '@metamask/utils'; import { strings } from '../../../../../../../locales/i18n'; import Banner, { @@ -13,6 +14,7 @@ import { } from '@metamask/design-system-react-native'; import useTronClaimUnstakedTrx from '../../../hooks/useTronClaimUnstakedTrx'; import useEarnToasts from '../../../hooks/useEarnToasts'; +import { selectTronClaimUnstakedTrxButtonEnabled } from '../../../../../../selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled'; import { TronUnstakedBannerTestIds } from './TronUnstakedBanner.testIds'; interface TronUnstakedBannerProps { @@ -21,6 +23,7 @@ interface TronUnstakedBannerProps { } const TronUnstakedBanner = ({ amount, chainId }: TronUnstakedBannerProps) => { + const showClaimButton = useSelector(selectTronClaimUnstakedTrxButtonEnabled); const { handleClaimUnstakedTrx, isSubmitting, errors } = useTronClaimUnstakedTrx({ chainId }); const { showToast, EarnToastOptions } = useEarnToasts(); @@ -41,19 +44,21 @@ const TronUnstakedBanner = ({ amount, chainId }: TronUnstakedBannerProps) => { <> {strings('stake.tron.unstaked_banner.description')} - + {showClaimButton ? ( + + ) : null} } /> diff --git a/app/components/UI/Perps/utils/wait.test.ts b/app/components/UI/Perps/utils/wait.test.ts index aea87705d79..ac38e76a03a 100644 --- a/app/components/UI/Perps/utils/wait.test.ts +++ b/app/components/UI/Perps/utils/wait.test.ts @@ -13,14 +13,14 @@ describe('wait', () => { const promise = wait(100); jest.advanceTimersByTime(100); await promise; - expect(promise).resolves.toBeUndefined(); + await expect(promise).resolves.toBeUndefined(); }); it('should handle zero duration', async () => { const promise = wait(0); jest.advanceTimersByTime(0); await promise; - expect(promise).resolves.toBeUndefined(); + await expect(promise).resolves.toBeUndefined(); }); it('should return a Promise that resolves to undefined', async () => { diff --git a/app/constants/featureFlags.ts b/app/constants/featureFlags.ts index a439f406657..67da83992fe 100644 --- a/app/constants/featureFlags.ts +++ b/app/constants/featureFlags.ts @@ -16,6 +16,7 @@ export enum FeatureFlagNames { tokenDetailsV2ButtonLayout = 'tokenDetailsV2ButtonLayout', complianceEnabled = 'complianceEnabled', legacyIosGoogleConfigEnabled = 'legacyIosGoogleConfigEnabled', + tronClaimUnstakedTrxButtonEnabled = 'tronClaimUnstakedTrxButtonEnabled', } export const DEFAULT_FEATURE_FLAG_VALUES: Partial< @@ -24,4 +25,5 @@ export const DEFAULT_FEATURE_FLAG_VALUES: Partial< [FeatureFlagNames.assetsDefiPositionsEnabled]: true, [FeatureFlagNames.tokenDetailsV2Buttons]: false, [FeatureFlagNames.tokenDetailsV2ButtonLayout]: false, + [FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled]: false, }; diff --git a/app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.test.ts b/app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.test.ts new file mode 100644 index 00000000000..55cb6066b58 --- /dev/null +++ b/app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.test.ts @@ -0,0 +1,49 @@ +import { Json } from '@metamask/utils'; +import { selectTronClaimUnstakedTrxButtonEnabled } from '.'; +import { + DEFAULT_FEATURE_FLAG_VALUES, + FeatureFlagNames, +} from '../../../constants/featureFlags'; + +describe('Tron claim unstaked TRX button enabled feature flag selector', () => { + describe('selectTronClaimUnstakedTrxButtonEnabled', () => { + it('returns true when remote flag is explicitly true', () => { + const result = selectTronClaimUnstakedTrxButtonEnabled.resultFunc({ + [FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled]: true, + }); + + expect(result).toBe(true); + }); + + it('returns false when remote flag is explicitly false', () => { + const result = selectTronClaimUnstakedTrxButtonEnabled.resultFunc({ + [FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled]: false, + }); + + expect(result).toBe(false); + }); + + it('returns default value when remote flag is not set', () => { + const result = selectTronClaimUnstakedTrxButtonEnabled.resultFunc({}); + + expect(result).toBe( + DEFAULT_FEATURE_FLAG_VALUES[ + FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled + ], + ); + }); + + it('returns default value when remote flag is undefined', () => { + const result = selectTronClaimUnstakedTrxButtonEnabled.resultFunc({ + [FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled]: + undefined as unknown as Json, + }); + + expect(result).toBe( + DEFAULT_FEATURE_FLAG_VALUES[ + FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled + ], + ); + }); + }); +}); diff --git a/app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.ts b/app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.ts new file mode 100644 index 00000000000..390dbe3a5a1 --- /dev/null +++ b/app/selectors/featureFlagController/tronClaimUnstakedTrxButtonEnabled/index.ts @@ -0,0 +1,17 @@ +import { createSelector } from 'reselect'; +import { selectRemoteFeatureFlags } from '..'; +import { + DEFAULT_FEATURE_FLAG_VALUES, + FeatureFlagNames, +} from '../../../constants/featureFlags'; + +export const selectTronClaimUnstakedTrxButtonEnabled = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => + Boolean( + remoteFeatureFlags[FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled] ?? + DEFAULT_FEATURE_FLAG_VALUES[ + FeatureFlagNames.tronClaimUnstakedTrxButtonEnabled + ], + ), +); diff --git a/tests/feature-flags/feature-flag-registry.test.ts b/tests/feature-flags/feature-flag-registry.test.ts index ed238d077e2..a86028cd6b1 100644 --- a/tests/feature-flags/feature-flag-registry.test.ts +++ b/tests/feature-flags/feature-flag-registry.test.ts @@ -85,6 +85,7 @@ describe('Feature Flag Registry', () => { expect(flagNames).toContain('bridgeConfigV2'); expect(flagNames).toContain('bitcoinAccounts'); expect(flagNames).toContain('tronAccounts'); + expect(flagNames).toContain('tronClaimUnstakedTrxButtonEnabled'); }); }); diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index 2c696a8020c..c16c13a2fb3 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -62,7 +62,7 @@ export interface FeatureFlagRegistryEntry { * Remote flag values are stored in the exact format returned by the production * client-config API, so they can be served directly by the E2E mock server. * - * Production defaults last synced: 2026-03-02 + * Production defaults last synced: 2026-03-25 * Source: https://client-config.api.cx.metamask.io/v1/flags?client=mobile&distribution=main&environment=prod */ export const FEATURE_FLAG_REGISTRY: Record = { @@ -3618,6 +3618,14 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + tronClaimUnstakedTrxButtonEnabled: { + name: 'tronClaimUnstakedTrxButtonEnabled', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: false, + status: FeatureFlagStatus.Active, + }, + tronStaking: { name: 'tronStaking', type: FeatureFlagType.Remote, From 9c40658056758dd2a64653f2be0caeb23a5e46da Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Thu, 26 Mar 2026 09:47:13 +0000 Subject: [PATCH 43/54] [skip ci] Bump version number to 4191 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 086cd47bd80..452b110ef63 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4186 + versionCode 4191 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 7da7531cf82..66bc38c842f 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4186 + VERSION_NUMBER: 4191 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4186 + FLASK_VERSION_NUMBER: 4191 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index d86b260ca39..7206c71e8d9 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4186; + CURRENT_PROJECT_VERSION = 4191; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4186; + CURRENT_PROJECT_VERSION = 4191; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4186; + CURRENT_PROJECT_VERSION = 4191; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4186; + CURRENT_PROJECT_VERSION = 4191; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4186; + CURRENT_PROJECT_VERSION = 4191; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4186; + CURRENT_PROJECT_VERSION = 4191; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 6ae902309485c17ea34428f918d8b7321cb8c226 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:53:54 +0100 Subject: [PATCH 44/54] chore(runway): cherry-pick feat: show legacy ios login warning prompt cp-7.71.0 (#27941) - feat: show legacy ios login warning prompt cp-7.71.0 (#27875) ## **Description** Add warning prompt for ios <17.4 for google login Supports the fix for: https://github.com/MetaMask/MetaMask-planning/issues/7148 Part 1/ 4 - #27741 Part 2/ 4 - #27848 Part 3/ 4 - #27850 (deferred to 7.72.0) Part 4/ 4 - #27875 ## **Changelog** CHANGELOG entry: Add warning prompt for ios <17.4 for google login ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** For < iOS 17.4 https://github.com/user-attachments/assets/f6f3a031-82cc-486d-af5f-e6e1bbc7ed10 For >= iOS 17.4 https://github.com/user-attachments/assets/2cdc0bf3-d59b-4858-be81-baae5e0a4dd2 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Modifies the onboarding social login path by inserting a conditional pre-login warning and new navigation helper, which could affect Google login flow timing/navigation on iOS devices. Changes are localized but touch user authentication entrypoints and analytics tracking. > > **Overview** > Adds an **iOS < 17.4 warning gate** before starting Google OAuth during onboarding (both create and import flows), showing a non-interactable `SuccessErrorSheet` that must be acknowledged before proceeding. > > Introduces `Device.comparePlatformVersionTo()` (using `compare-versions`) and a reusable `navigateToSuccessErrorSheetPromise()` helper to await sheet dismissal, plus a new MetaMetrics event (`WALLET_GOOGLE_IOS_WARNING_VIEWED`) and localized warning copy. > > Updates onboarding tests to mock the new device helper/navigation and to assert the warning sheet + tracking fire before continuing with Google login. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3b43b83cf7c608c88da4b01bfb67603d840d1582. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [002d91a](https://github.com/MetaMask/metamask-mobile/commit/002d91ac2a09420c712b1fdadca0a6aa6d3326cb) Co-authored-by: ieow <4881057+ieow@users.noreply.github.com> --- .../Views/Onboarding/index.test.tsx | 211 +++++++++++++++++- app/components/Views/Onboarding/index.tsx | 41 ++++ .../Views/SuccessErrorSheet/utils.test.ts | 113 ++++++++++ .../Views/SuccessErrorSheet/utils.ts | 37 +++ app/core/Analytics/MetaMetrics.events.ts | 4 + app/util/device/index.js | 16 ++ locales/languages/en.json | 8 +- 7 files changed, 420 insertions(+), 10 deletions(-) create mode 100644 app/components/Views/SuccessErrorSheet/utils.test.ts create mode 100644 app/components/Views/SuccessErrorSheet/utils.ts diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx index 771c308e15b..8f59eef7433 100644 --- a/app/components/Views/Onboarding/index.test.tsx +++ b/app/components/Views/Onboarding/index.test.tsx @@ -71,6 +71,7 @@ import Routes from '../../../constants/navigation/Routes'; import { ONBOARDING, PREVIOUS_SCREEN } from '../../../constants/navigation'; import { strings } from '../../../../locales/i18n'; import { OAuthError, OAuthErrorType } from '../../../core/OAuthService/error'; +import { IconName } from '../../../component-library/components/Icons/Icon'; import { captureException } from '@sentry/react-native'; import Logger from '../../../util/Logger'; import { MIGRATION_ERROR_HAPPENED } from '../../../constants/storage'; @@ -92,9 +93,22 @@ jest.mock('../../../util/test/utils', () => ({ import { fetch as netInfoFetch } from '@react-native-community/netinfo'; const mockNetInfoFetch = netInfoFetch as jest.Mock; +const mockNavigate = jest.fn(); +const mockReplace = jest.fn(); +const mockGoBack = jest.fn(); // Helper to flush all pending promises const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); +const IOS_GOOGLE_WARNING_TITLE = strings('error_sheet.ios_need_update_title'); +const IOS_GOOGLE_WARNING_BUTTON = strings('error_sheet.ios_need_update_button'); + +const getIosGoogleWarningSheetCall = () => + mockNavigate.mock.calls.find( + ([route, params]) => + route === Routes.MODAL.ROOT_MODAL_FLOW && + params?.screen === Routes.SHEET.SUCCESS_ERROR_SHEET && + params?.params?.title === IOS_GOOGLE_WARNING_TITLE, + ); const mockInitialState = { engine: { @@ -127,13 +141,22 @@ const mockInitialStateWithExistingUserAndPassword = { }, }; -jest.mock('../../../util/device', () => ({ - isLargeDevice: jest.fn(), - isIphoneX: jest.fn(), - isAndroid: jest.fn(), - isIos: jest.fn(), - isMediumDevice: jest.fn(), -})); +jest.mock('../../../util/device', () => { + const mockDevice = { + isLargeDevice: jest.fn(), + isIphoneX: jest.fn(), + isAndroid: jest.fn(), + isIos: jest.fn(), + isMediumDevice: jest.fn(), + comparePlatformVersionTo: jest.fn().mockReturnValue(1), + }; + + return { + __esModule: true, + default: mockDevice, + ...mockDevice, + }; +}); // expo library are not supported in jest ( unless using jest-expo as preset ), so we need to mock them jest.mock('../../../core/OAuthService/OAuthLoginHandlers', () => ({ @@ -276,13 +299,12 @@ jest.mock('../../../core/OAuthService/OAuthLoginHandlers/constants', () => ({ }, })); -const mockNavigate = jest.fn(); -const mockReplace = jest.fn(); const mockNav = { navigate: mockNavigate, replace: mockReplace, reset: jest.fn(), setOptions: jest.fn(), + goBack: mockGoBack, dispatch: jest.fn((action) => { if (action.type === 'REPLACE') { mockReplace(action.payload.name, action.payload.params); @@ -956,10 +978,13 @@ describe('Onboarding', () => { beforeEach(() => { mockSeedlessOnboardingEnabled.mockReturnValue(true); (StorageWrapper.getItem as jest.Mock).mockResolvedValue(null); + (Device.isIos as jest.Mock).mockReturnValue(false); + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(1); }); afterEach(() => { jest.clearAllMocks(); + mockNavigate.mockReset(); mockSeedlessOnboardingEnabled.mockReset(); }); @@ -1263,6 +1288,174 @@ describe('Onboarding', () => { ); }); + it('shows iOS version warning sheet before Google login on iOS < 17.4', async () => { + Platform.OS = 'ios'; + (Device.isIos as jest.Mock).mockReturnValue(true); + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(-1); + (mockAnalytics.isEnabled as jest.Mock).mockReturnValue(true); + mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); + mockOAuthService.handleOAuthLogin.mockResolvedValue({ + type: 'success', + existingUser: false, + accountName: 'test@example.com', + }); + + const { getByTestId } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + const createWalletButton = getByTestId( + OnboardingSelectorIDs.NEW_WALLET_BUTTON, + ); + await act(async () => { + fireEvent.press(createWalletButton); + }); + + const navCall = mockNavigate.mock.calls.find( + (call) => + call[0] === Routes.MODAL.ROOT_MODAL_FLOW && + call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET, + ); + + const googleOAuthFunction = navCall[1].params.onPressContinueWithGoogle; + + await act(async () => { + await googleOAuthFunction(true); + await flushPromises(); + await flushPromises(); + }); + + // Verify the warning sheet was shown with the iOS not-supported message. + const warningSheetCall = getIosGoogleWarningSheetCall(); + + expect(warningSheetCall).toEqual([ + Routes.MODAL.ROOT_MODAL_FLOW, + expect.objectContaining({ + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: expect.objectContaining({ + type: 'error', + icon: IconName.Warning, + isInteractable: false, + title: IOS_GOOGLE_WARNING_TITLE, + description: expect.anything(), + primaryButtonLabel: IOS_GOOGLE_WARNING_BUTTON, + onPrimaryButtonPress: expect.any(Function), + closeOnPrimaryButtonPress: true, + }), + }), + ]); + expect(warningSheetCall?.[1].params.onPrimaryButtonPress).toEqual( + expect.any(Function), + ); + expect(Device.comparePlatformVersionTo).toHaveBeenCalledWith('17.4'); + + await act(async () => { + await warningSheetCall?.[1].params.onPrimaryButtonPress?.(); + await flushPromises(); + await flushPromises(); + }); + + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Wallet Google Ios Warning Viewed', + properties: expect.objectContaining({ + account_type: AccountType.MetamaskGoogle, + }), + }), + ); + expect(mockCreateLoginHandler).toHaveBeenCalledWith('ios', 'google'); + expect(mockOAuthService.handleOAuthLogin).toHaveBeenCalledWith( + 'mockGoogleHandler', + false, + ); + }); + + it('shows iOS version warning for Google login on iOS < 17.4 during import wallet flow', async () => { + Platform.OS = 'ios'; + (Device.isIos as jest.Mock).mockReturnValue(true); + (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(-1); + (mockAnalytics.isEnabled as jest.Mock).mockReturnValue(true); + mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); + mockOAuthService.handleOAuthLogin.mockResolvedValue({ + type: 'success', + existingUser: false, + accountName: 'test@example.com', + }); + + const { getByTestId } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + const importWalletButton = getByTestId( + OnboardingSelectorIDs.EXISTING_WALLET_BUTTON, + ); + await act(async () => { + fireEvent.press(importWalletButton); + }); + + const navCall = mockNavigate.mock.calls.find( + (call) => + call[0] === Routes.MODAL.ROOT_MODAL_FLOW && + call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET, + ); + + const googleOAuthFunction = navCall[1].params.onPressContinueWithGoogle; + + await act(async () => { + await googleOAuthFunction(false); + await flushPromises(); + await flushPromises(); + }); + + const warningSheetCall = getIosGoogleWarningSheetCall(); + + expect(warningSheetCall).toBeDefined(); + expect(warningSheetCall?.[1].params).toEqual( + expect.objectContaining({ + type: 'error', + icon: IconName.Warning, + title: IOS_GOOGLE_WARNING_TITLE, + description: expect.anything(), + primaryButtonLabel: IOS_GOOGLE_WARNING_BUTTON, + onPrimaryButtonPress: expect.any(Function), + closeOnPrimaryButtonPress: true, + isInteractable: false, + }), + ); + expect(warningSheetCall?.[1].params.onPrimaryButtonPress).toEqual( + expect.any(Function), + ); + expect(Device.comparePlatformVersionTo).toHaveBeenCalledWith('17.4'); + + await act(async () => { + await warningSheetCall?.[1].params.onPrimaryButtonPress?.(); + await flushPromises(); + await flushPromises(); + }); + + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Wallet Google Ios Warning Viewed', + properties: expect.objectContaining({ + account_type: AccountType.ImportedGoogle, + }), + }), + ); + expect(mockCreateLoginHandler).toHaveBeenCalledWith('ios', 'google'); + expect(mockOAuthService.handleOAuthLogin).toHaveBeenCalledWith( + 'mockGoogleHandler', + true, + ); + }); + it('navigates to AccountAlreadyExists for existing user in create wallet flow', async () => { mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); mockOAuthService.handleOAuthLogin.mockResolvedValue({ diff --git a/app/components/Views/Onboarding/index.tsx b/app/components/Views/Onboarding/index.tsx index f2d715f0a34..3c105f2a087 100644 --- a/app/components/Views/Onboarding/index.tsx +++ b/app/components/Views/Onboarding/index.tsx @@ -110,6 +110,11 @@ import { } from '@metamask/design-system-twrnc-preset'; import { getBuildNumber, getVersion } from 'react-native-device-info'; +import { navigateToSuccessErrorSheetPromise } from '../SuccessErrorSheet/utils'; +import { + IconColor, + IconName, +} from '../../../component-library/components/Icons/Icon'; interface OnboardingState { warningModalVisible: boolean; loading: boolean; @@ -770,6 +775,41 @@ const Onboarding = () => { }); const action = async () => { + // prompt for ios google login not supported below iOS 17.4 + if ( + provider === AuthConnection.Google && + Device.isIos() && + Device.comparePlatformVersionTo('17.4') < 0 + ) { + const description = () => ( + <> + + {strings(`error_sheet.ios_need_update_description`)} + + {strings(`error_sheet.ios_need_update_description_version`)} + + {strings(`error_sheet.ios_need_update_description_end`)} + + + {strings(`error_sheet.ios_need_update_description2`)} + + + ); + + await navigateToSuccessErrorSheetPromise(navigation, { + type: 'error', + icon: IconName.Warning, + iconColor: IconColor.Warning, + title: strings(`error_sheet.ios_need_update_title`), + description: description(), + primaryButtonLabel: strings(`error_sheet.ios_need_update_button`), + closeOnPrimaryButtonPress: true, + isInteractable: false, + }); + track(MetaMetricsEvents.WALLET_GOOGLE_IOS_WARNING_VIEWED, { + account_type: accountType, + }); + } setLoading(); const loginHandler = createLoginHandler(Platform.OS, provider); try { @@ -799,6 +839,7 @@ const Onboarding = () => { handleExistingUser(action); }, [ + tw, navigation, metrics, track, diff --git a/app/components/Views/SuccessErrorSheet/utils.test.ts b/app/components/Views/SuccessErrorSheet/utils.test.ts new file mode 100644 index 00000000000..a0b29825c74 --- /dev/null +++ b/app/components/Views/SuccessErrorSheet/utils.test.ts @@ -0,0 +1,113 @@ +import { NavigationProp, ParamListBase } from '@react-navigation/native'; +import Routes from '../../../constants/navigation/Routes'; +import { + navigateToSuccessErrorSheet, + navigateToSuccessErrorSheetPromise, +} from './utils'; + +const mockNavigate = jest.fn(); +const mockNavigation = { + navigate: mockNavigate, +} as unknown as NavigationProp; + +const baseParams = { + type: 'error' as const, + title: 'Error Title', + description: 'Error description', +}; + +describe('navigateToSuccessErrorSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('forwards params to the success error sheet route', () => { + const onClose = jest.fn(); + const onPrimaryButtonPress = jest.fn(); + const params = { + ...baseParams, + type: 'success' as const, + onClose, + onPrimaryButtonPress, + descriptionAlign: 'center' as const, + primaryButtonLabel: 'OK', + }; + + navigateToSuccessErrorSheet(mockNavigation, params); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: expect.objectContaining({ + type: 'success', + onClose, + onPrimaryButtonPress, + descriptionAlign: 'center', + primaryButtonLabel: 'OK', + }), + }); + }); +}); + +describe('navigateToSuccessErrorSheetPromise', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('resolves and invokes the original onPrimaryButtonPress callback', async () => { + const onPrimaryButtonPress = jest.fn(); + + mockNavigate.mockImplementation((_route, params) => { + params.params.onPrimaryButtonPress(); + }); + + await expect( + navigateToSuccessErrorSheetPromise(mockNavigation, { + ...baseParams, + onPrimaryButtonPress, + }), + ).resolves.toBeUndefined(); + + expect(onPrimaryButtonPress).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MODAL.ROOT_MODAL_FLOW, + expect.objectContaining({ + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + }), + ); + }); + + it('resolves and invokes the original onSecondaryButtonPress callback', async () => { + const onSecondaryButtonPress = jest.fn(); + + mockNavigate.mockImplementation((_route, params) => { + params.params.onSecondaryButtonPress(); + }); + + await expect( + navigateToSuccessErrorSheetPromise(mockNavigation, { + ...baseParams, + onSecondaryButtonPress, + }), + ).resolves.toBeUndefined(); + + expect(onSecondaryButtonPress).toHaveBeenCalledTimes(1); + }); + + it('resolves and invokes the original onClose callback', async () => { + const onClose = jest.fn(); + + mockNavigate.mockImplementation((_route, params) => { + params.params.onClose(); + }); + + await expect( + navigateToSuccessErrorSheetPromise(mockNavigation, { + ...baseParams, + onClose, + }), + ).resolves.toBeUndefined(); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/Views/SuccessErrorSheet/utils.ts b/app/components/Views/SuccessErrorSheet/utils.ts new file mode 100644 index 00000000000..e9c655fa924 --- /dev/null +++ b/app/components/Views/SuccessErrorSheet/utils.ts @@ -0,0 +1,37 @@ +import { NavigationProp, ParamListBase } from '@react-navigation/native'; +import { SuccessErrorSheetParams } from './interface'; +import Routes from '../../../constants/navigation/Routes'; + +export const navigateToSuccessErrorSheet = ( + navigation: NavigationProp, + params: SuccessErrorSheetParams, +) => { + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: { + ...params, + }, + }); +}; + +export const navigateToSuccessErrorSheetPromise = async ( + navigation: NavigationProp, + params: SuccessErrorSheetParams, +) => + new Promise((resolve) => { + navigateToSuccessErrorSheet(navigation, { + ...params, + onPrimaryButtonPress: () => { + params.onPrimaryButtonPress?.(); + resolve(); + }, + onSecondaryButtonPress: () => { + params.onSecondaryButtonPress?.(); + resolve(); + }, + onClose: () => { + params.onClose?.(); + resolve(); + }, + }); + }); diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 3e0d1d23446..745311015fd 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -147,6 +147,7 @@ enum EVENT_NAME { WALLET_CREATION_ATTEMPTED = 'Wallet Creation Attempted', WALLET_CREATED = 'Wallet Created', WALLET_SETUP_FAILURE = 'Wallet Setup Failure', + WALLET_GOOGLE_IOS_WARNING_VIEWED = 'Wallet Google Ios Warning Viewed', WALLET_CREATION_ERROR_SCREEN_VIEWED = 'Wallet Creation Error Screen Viewed', WALLET_CREATION_ERROR_RETRY_CLICKED = 'Wallet Creation Error Retry Clicked', WALLET_CREATION_ERROR_REPORT_SENT = 'Wallet Creation Error Report Sent', @@ -859,6 +860,9 @@ const events = { WALLET_CREATION_ATTEMPTED: generateOpt(EVENT_NAME.WALLET_CREATION_ATTEMPTED), WALLET_CREATED: generateOpt(EVENT_NAME.WALLET_CREATED), WALLET_SETUP_FAILURE: generateOpt(EVENT_NAME.WALLET_SETUP_FAILURE), + WALLET_GOOGLE_IOS_WARNING_VIEWED: generateOpt( + EVENT_NAME.WALLET_GOOGLE_IOS_WARNING_VIEWED, + ), WALLET_CREATION_ERROR_SCREEN_VIEWED: generateOpt( EVENT_NAME.WALLET_CREATION_ERROR_SCREEN_VIEWED, ), diff --git a/app/util/device/index.js b/app/util/device/index.js index df04b68a24d..c3329ec2161 100644 --- a/app/util/device/index.js +++ b/app/util/device/index.js @@ -2,6 +2,7 @@ import { Dimensions, Platform } from 'react-native'; import { hasNotch, getApiLevel } from 'react-native-device-info'; +import compareVersions from 'compare-versions'; export default class Device { static getDeviceWidth() { @@ -12,6 +13,21 @@ export default class Device { return Dimensions.get('window').height; } + /** + * Compares this device's React Native {@link Platform.Version} to `referenceVersion` + * using the shared `compare-versions` package after normalizing both values to strings. + * + * @param {string|number} referenceVersion - Version to compare against (e.g. `"17.4"`). + * @returns {number} `1` if current > reference, `-1` if current < reference, `0` if equal. + * @remarks On iOS, `Platform.Version` is usually a string (`"17.3.1"`). On Android it is + * typically the API level as a number (for example `34`). Both values are coerced to strings + * before comparison so the helper remains safe across platforms while preserving component-wise + * numeric comparison semantics. + */ + static comparePlatformVersionTo(referenceVersion) { + return compareVersions(String(Platform.Version), String(referenceVersion)); + } + static isIos() { return Platform.OS === 'ios'; } diff --git a/locales/languages/en.json b/locales/languages/en.json index a30519785e2..c4c7879604d 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6751,7 +6751,13 @@ "oauth_error_button": "Try again", "no_internet_connection_title": "Unable to connect", "no_internet_connection_description": "Your internet connection is unstable. Check your connection and try again.", - "no_internet_connection_button": "Try again" + "no_internet_connection_button": "Try again", + "ios_need_update_title": "iOS update required", + "ios_need_update_description": "MetaMask Google Sign-In will soon require ", + "ios_need_update_description_version": "iOS 17.4 or later", + "ios_need_update_description_end": ". You can continue using Google Sign-In on this device for now, but it will no longer be supported in an upcoming update.", + "ios_need_update_description2": "You can still access your wallet using the same Google account on a supported device or the MetaMask extension. We strongly recommend backing up your Secret Recovery Phrase to ensure uninterrupted access.", + "ios_need_update_button": "Continue" }, "password_hint": { "title": "Password hint", From 7ebe54c2d11cc001884da58a388364a0751c9b67 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Thu, 26 Mar 2026 10:55:39 +0000 Subject: [PATCH 45/54] [skip ci] Bump version number to 4192 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 452b110ef63..ccfe0dc4687 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4191 + versionCode 4192 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index 66bc38c842f..b9a0dcef7fe 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4191 + VERSION_NUMBER: 4192 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4191 + FLASK_VERSION_NUMBER: 4192 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 7206c71e8d9..936c566402b 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4191; + CURRENT_PROJECT_VERSION = 4192; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4191; + CURRENT_PROJECT_VERSION = 4192; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4191; + CURRENT_PROJECT_VERSION = 4192; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4191; + CURRENT_PROJECT_VERSION = 4192; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4191; + CURRENT_PROJECT_VERSION = 4192; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4191; + CURRENT_PROJECT_VERSION = 4192; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 32d70a8d131bc1e089384101343aa2129a124b79 Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:16:50 +0100 Subject: [PATCH 46/54] chore(runway): cherry-pick fix: hides perps buttons in ai insights when user has a position cp-7.71.0 (#27924) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: hides perps buttons in ai insights when user has a position cp-7.71.0 (#27919) ## **Description** This PR is a bug fix for https://github.com/MetaMask/metamask-mobile/issues/27916 where: - In the AI Market Insights in Perps, the action buttons are wrong when user has an open position: the action buttons should be the same in AI market insight page and market page, ie. “modify” and “Close” when user has an open position Fix: - When the user has an existing perps position, the MarketInsights footer action buttons (Long/Short) are hidden since the relevant actions (modify/close) live on the Perps market details page - The "AI summary for information only" disclaimer is moved inline below the feedback section when the footer is hidden, so it remains visible - Position state is passed via route params (hasPerpsPosition) from the caller rather than fetched async inside MarketInsightsView, preventing a flash where buttons briefly appear then disappear while the position loads ## **Changelog** CHANGELOG entry: Fixed a bug that was causing incorrect Perps action buttons to be displayed ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/27916 ## **Manual testing steps** ```gherkin - Open MarketInsights from token details → Swap/Buy buttons visible with disclaimer in footer - Open MarketInsights from perps with no position → Long/Short buttons visible with disclaimer in footer - Open MarketInsights from perps with an existing position → no footer buttons, disclaimer shown below "Was this helpful?" - Verify no flash/layout shift on the perps + position flow ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/0373c1bb-d433-4c9a-8768-117a40d98f9e ### **After** SCR-20260325-nzfa ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk UI/navigation tweak that changes which CTAs are shown based on a new route param; main risk is incorrect param wiring causing missing actions or disclaimer placement in the Perps insights flow. > > **Overview** > Fixes Perps AI Market Insights showing inappropriate `Long`/`Short` CTAs when the user already has an open position. > > Adds a `hasPerpsPosition` route param (set by `PerpsMarketDetailsView`) and uses it in `MarketInsightsView` to **hide the footer action buttons** for Perps-with-position while **keeping the informational disclaimer visible** by moving it inline under the feedback section for that case. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 833593b6e060e6e8c51a711b921f69d7b53ed4aa. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [8a7bced](https://github.com/MetaMask/metamask-mobile/commit/8a7bcedd42c36ea7ec7d54782e509d154b549d41) Co-authored-by: João Santos --- .../MarketInsightsView/MarketInsightsView.tsx | 103 ++++++++++-------- .../PerpsMarketDetailsView.tsx | 3 +- 2 files changed, 62 insertions(+), 44 deletions(-) diff --git a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx index facf0801787..dc761d86a29 100644 --- a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx +++ b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx @@ -153,6 +153,8 @@ interface MarketInsightsRouteParams { tokenChainId?: string; /** When true, indicates the view was opened from the Perps market details view */ isPerps?: boolean; + /** When true, the user has an existing perps position for this asset */ + hasPerpsPosition?: boolean; } /** @@ -182,6 +184,7 @@ const MarketInsightsView: React.FC = () => { tokenName, tokenChainId, isPerps = false, + hasPerpsPosition = false, } = route.params; const isMarketInsightsEnabled = isPerps @@ -660,65 +663,79 @@ const MarketInsightsView: React.FC = () => { > {strings('market_insights.helpful_prompt')} + {isPerps && hasPerpsPosition && ( + + {strings('market_insights.footer_disclaimer')} + + )} - - {isPerps ? ( - - - - - ) : ( - - + {!(isPerps && hasPerpsPosition) && ( + + {isPerps ? ( + - - + ) : ( + + + + + + + + + )} + + + {strings('market_insights.footer_disclaimer')} + - )} - - - {strings('market_insights.footer_disclaimer')} - - + )} {selectedTrend ? ( = () => { assetSymbol: market.symbol, assetIdentifier: market.symbol, isPerps: true, + hasPerpsPosition: !!existingPosition, }); - }, [market?.symbol, navigation, track]); + }, [market?.symbol, navigation, track, existingPosition]); // Handler for order selection - navigates to order details const handleOrderSelect = useCallback( From 00749704bd33d02a056be75f8bd2b92bdcc82591 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Thu, 26 Mar 2026 11:18:38 +0000 Subject: [PATCH 47/54] [skip ci] Bump version number to 4193 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index ccfe0dc4687..3a707d90ed8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4192 + versionCode 4193 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index b9a0dcef7fe..a03422f5205 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4192 + VERSION_NUMBER: 4193 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4192 + FLASK_VERSION_NUMBER: 4193 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 936c566402b..e8c8cd7d5ef 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4192; + CURRENT_PROJECT_VERSION = 4193; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4192; + CURRENT_PROJECT_VERSION = 4193; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4192; + CURRENT_PROJECT_VERSION = 4193; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4192; + CURRENT_PROJECT_VERSION = 4193; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4192; + CURRENT_PROJECT_VERSION = 4193; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4192; + CURRENT_PROJECT_VERSION = 4193; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From 42968c3a119bd6d3f88f7ee528639797fd92cbb1 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Thu, 26 Mar 2026 20:32:58 +0000 Subject: [PATCH 48/54] [skip ci] Bump version number to 4199 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 3a707d90ed8..757984fcb69 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4193 + versionCode 4199 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index a03422f5205..ee3599c5940 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4193 + VERSION_NUMBER: 4199 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4193 + FLASK_VERSION_NUMBER: 4199 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index e8c8cd7d5ef..e6bc54ea18b 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4193; + CURRENT_PROJECT_VERSION = 4199; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4193; + CURRENT_PROJECT_VERSION = 4199; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4193; + CURRENT_PROJECT_VERSION = 4199; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4193; + CURRENT_PROJECT_VERSION = 4199; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4193; + CURRENT_PROJECT_VERSION = 4199; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4193; + CURRENT_PROJECT_VERSION = 4199; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From ad9aa0f648f9bae17de0e889fa6b93a4a8bfc854 Mon Sep 17 00:00:00 2001 From: MetaMask Bot <37885440+metamaskbot@users.noreply.github.com> Date: Fri, 27 Mar 2026 05:58:58 -0230 Subject: [PATCH 49/54] release: release-changelog/7.71.0 (#27710) This PR updates the change log for 7.71.0. (Hotfix - no test plan generated.) --------- Co-authored-by: metamaskbot Co-authored-by: chloeYue --- CHANGELOG.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a67c3c648..54895ba1644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.71.0] + +### Added + +- Added backend-provided intent typedData for signing intent swap txs (#25913) +- Added Security & Trust section to Token Details page showing risk level, contract security features, buy/sell tax, token distribution, and official links powered by Blockaid (#27073) +- Added a "Withdraw" button to the unstaked TRX banner so users can claim TRX that has completed the lock period (#27076) +- Added handling for aggregated balance on the new home page (#27172) +- Added LD flags to consume price impact threshold (#27196) +- Added Segment event tracking for mUSD Quick Convert flow and enriched generic Transaction\* events for mUSD conversion transactions (#27305) +- Improved bridge/swap quote expiry experience; expired quotes now remain visible inline with a prompt to refresh, replacing a separate modal flow (#27340) +- Added support for ramps providers such as PayPal, Robinhood & Coinbase that use a different checkout browser (#27364) +- Added authentication for transaction submission to sentinel and transaction API (#27410) +- Added skeleton loading indicator to NFT grid items while images are loading (#27413) +- Embedded the metal card checkout flow into the Card onboarding/sign-up flow (#27420) +- Added attention badge on Card button (#27425) +- Added a new tab for users to see their NFTs and fixed NFT flicker on that view (#27437) +- Added press opacity feedback to NFT grid items (#27488) +- Applied a minimum $0.01 threshold for showing the "Claim bonus" CTA for Merkl rewards so that amounts below the threshold show the 3% bonus label instead (#27522) +- Updated Predict withdraw to default to the user’s last used destination token before falling back to the remote preferred token (#27532) +- Enabled campaigns view under feature flag (#27556) +- Redirected buy deeplinks to the new Ramps Buy flow when Ramps Unified V2 is enabled; deprecated cash deposit deeplinks (#27557) +- Restored mUSD claimable bonus claim section on asset overview screen (#27567) +- Added campaign opt-in flow with details and mechanics screens in the Rewards section (#27619) +- Updated Ramp buy flow modal headers and typography to use shared compact header and design system components (#27627) +- Migrated Card authentication to CardController with new `useCardAuth` hook for controller-based auth flow (#27656) +- Extracted Card supported-country check into `selectIsUserInSupportedCardCountry` selector (#27695) +- Updated mUSD aggregated balance row to redirect to the Cash tokens list when the user holds mUSD on any network (#27703) + +### Changed + +- Removed deprecated payment request (#27519) +- Updated earn balance row layout (logo size, badge size, balance/percentage placement) and added privacy mode support for StakingBalance and EarnLendingBalance (#27457) +- Refactored Card onboarding to use the `useRegions` hook instead of Redux `selectedCountry` for region/country data (#27539) +- Adjusted spacing in homepage (#27637) + +### Fixed + +- Fixed a bug where closing the "Token not available" modal left the user in a stuck state instead of navigating back to the token selection screen (#27277) +- Fixed false "Token Not Available" errors during Buy flow when payment methods are still loading after provider change; fixed missing "Token Not Available" modal in home buy flow; fixed crash when navigating back from "Token Not Available" modal in token info buy flow (#27448) +- Fixed token row display on homepage to show price and variation separated by a dot for consistency with token list items (#27449) +- Fixed stop loss banner rendering issue (#27458) +- Fixed Order Details screen displaying excessive decimal places for crypto amounts after ramp purchases (#27469) +- Fixed remove network confirmation header casing to sentence case (#27480) +- Fixed the custom network header trash icon color to match other trash icons in the app (#27481) +- Fixed a bug where the RPC URL field in network details could appear focused after blur and had inconsistent typography between states (#27482) +- Fixed RAMP_INTERNAL_BUILD default for OTA push (#27507) +- Fixed a bug where Perps activity could appear blank after reopening the Activity screen from Perps home (#27509) +- Fixed universal link handling for redirect-oauth (#27511) +- Fixed Network Details so network name is required and no longer labeled optional (#27541) +- Fixed onboarding import button text being invisible in dark mode; ensured both CTAs have proper contrast in dark mode (#27550) +- Removed a stale feature-flag gate so the Networks menu item is always available (#27591) +- Fixed MegaETH explorer button to display "View on Megaeth Explorer" instead of "View on Megaeth" (#27592) +- Fixed padding in security screen header (#27621) +- Fixed TokenList crash when switching networks (#27655) +- Fixed miscategorization of BRENTOIL and other non-crypto instruments appearing in the "Explore Crypto" section on Perps Home (#27699) + ## [7.70.1] ### Fixed @@ -11015,7 +11072,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#957](https://github.com/MetaMask/metamask-mobile/pull/957): fix timeouts (#957) - [#954](https://github.com/MetaMask/metamask-mobile/pull/954): Bugfix: onboarding navigation (#954) -[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.1...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.71.0...HEAD +[7.71.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.1...v7.71.0 [7.70.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.70.0...v7.70.1 [7.70.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.1...v7.70.0 [7.69.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.0...v7.69.1 From 0ceeef02c13fdf61768f4e8498892276f9b301f3 Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Fri, 27 Mar 2026 08:30:36 +0000 Subject: [PATCH 50/54] [skip ci] Bump version number to 4208 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 757984fcb69..52a78ec36ff 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,7 +188,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionName "7.71.0" - versionCode 4199 + versionCode 4208 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/bitrise.yml b/bitrise.yml index ee3599c5940..470123948e7 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.71.0 - opts: is_expand: false - VERSION_NUMBER: 4199 + VERSION_NUMBER: 4208 - opts: is_expand: false FLASK_VERSION_NAME: 7.71.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 4199 + FLASK_VERSION_NUMBER: 4208 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index e6bc54ea18b..ccc4e0c1aff 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4199; + CURRENT_PROJECT_VERSION = 4208; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4199; + CURRENT_PROJECT_VERSION = 4208; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4199; + CURRENT_PROJECT_VERSION = 4208; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4199; + CURRENT_PROJECT_VERSION = 4208; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4199; + CURRENT_PROJECT_VERSION = 4208; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 4199; + CURRENT_PROJECT_VERSION = 4208; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From d50db18da613082fc8c57692c9387bb1ed023820 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 27 Mar 2026 09:40:02 +0100 Subject: [PATCH 51/54] feat: add new hardware wallet flows analytics (#27675) ## **Description** ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MUL-1546 ## **Manual testing steps** no manual testing steps ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [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] > **Medium Risk** > Touches the hardware wallet connection flow and bottom-sheet callbacks to emit new analytics, so incorrect state resets or callback wiring could affect connection UX/cleanup. No auth or funds-handling logic changes, and coverage is added with extensive unit tests. > > **Overview** > Adds a new hardware-wallet analytics module that classifies **flow context** (Connection/Send/Swaps/Transaction/Message) and normalizes **error types/details**, then emits three new MetaMetrics events for recovery: `HARDWARE_WALLET_RECOVERY_MODAL_VIEWED`, `..._CTA_CLICKED`, and `..._SUCCESS_MODAL_VIEWED`. > > Wires this into the hardware wallet UI/flow by deriving the analytics flow from the first pending approval at the start of each `ensureDeviceReady` run, tracking CTA taps from error screens via a new `onCTAClicked` prop on `HardwareWalletBottomSheet`, and resetting analytics state when the sheet is closed. Includes comprehensive unit tests for helper mappings, Redux-derived flow detection, and event firing/counting behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9bec1b0e5c5872df9caad1dcf4708151c53a140f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/core/Analytics/MetaMetrics.events.ts | 12 + .../HardwareWalletProvider.test.tsx | 17 +- .../HardwareWallet/HardwareWalletProvider.tsx | 49 +- .../HardwareWallet/analytics/helpers.test.ts | 365 +++++++++++ app/core/HardwareWallet/analytics/helpers.ts | 188 ++++++ app/core/HardwareWallet/analytics/index.ts | 11 + .../useAnalyticsFlowFromApproval.test.ts | 155 +++++ .../analytics/useAnalyticsFlowFromApproval.ts | 32 + .../useHardwareWalletAnalytics.test.ts | 579 ++++++++++++++++++ .../analytics/useHardwareWalletAnalytics.ts | 177 ++++++ .../HardwareWalletBottomSheet.tsx | 9 +- .../hooks/useDeviceConnectionFlow.ts | 6 + .../hooks/useHardwareWalletStateManager.ts | 9 +- 13 files changed, 1592 insertions(+), 17 deletions(-) create mode 100644 app/core/HardwareWallet/analytics/helpers.test.ts create mode 100644 app/core/HardwareWallet/analytics/helpers.ts create mode 100644 app/core/HardwareWallet/analytics/index.ts create mode 100644 app/core/HardwareWallet/analytics/useAnalyticsFlowFromApproval.test.ts create mode 100644 app/core/HardwareWallet/analytics/useAnalyticsFlowFromApproval.ts create mode 100644 app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.test.ts create mode 100644 app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.ts diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index b9453dbcf22..b0685f25056 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -233,6 +233,9 @@ enum EVENT_NAME { HARDWARE_WALLET_ADD_ACCOUNT = 'Hardware Wallet Account Connected', HARDWARE_WALLET_FORGOTTEN = 'Hardware Wallet Forgotten', HARDWARE_WALLET_ERROR = 'Hardware Wallet Connection Failed', + HARDWARE_WALLET_RECOVERY_MODAL_VIEWED = 'Hardware Wallet Recovery Modal Viewed', + HARDWARE_WALLET_RECOVERY_CTA_CLICKED = 'Hardware Wallet Recovery CTA Clicked', + HARDWARE_WALLET_RECOVERY_SUCCESS_MODAL_VIEWED = 'Hardware Wallet Recovery Success Modal Viewed', // Tokens TOKEN_DETECTED = 'Token Detected', @@ -984,6 +987,15 @@ const events = { ), HARDWARE_WALLET_FORGOTTEN: generateOpt(EVENT_NAME.HARDWARE_WALLET_FORGOTTEN), HARDWARE_WALLET_ERROR: generateOpt(EVENT_NAME.HARDWARE_WALLET_ERROR), + HARDWARE_WALLET_RECOVERY_MODAL_VIEWED: generateOpt( + EVENT_NAME.HARDWARE_WALLET_RECOVERY_MODAL_VIEWED, + ), + HARDWARE_WALLET_RECOVERY_CTA_CLICKED: generateOpt( + EVENT_NAME.HARDWARE_WALLET_RECOVERY_CTA_CLICKED, + ), + HARDWARE_WALLET_RECOVERY_SUCCESS_MODAL_VIEWED: generateOpt( + EVENT_NAME.HARDWARE_WALLET_RECOVERY_SUCCESS_MODAL_VIEWED, + ), TOKEN_DETECTED: generateOpt(EVENT_NAME.TOKEN_DETECTED), TOKEN_IMPORT_CLICKED: generateOpt(EVENT_NAME.TOKEN_IMPORT_CLICKED), diff --git a/app/core/HardwareWallet/HardwareWalletProvider.test.tsx b/app/core/HardwareWallet/HardwareWalletProvider.test.tsx index bd595ad0092..52f0d15aee0 100644 --- a/app/core/HardwareWallet/HardwareWalletProvider.test.tsx +++ b/app/core/HardwareWallet/HardwareWalletProvider.test.tsx @@ -51,6 +51,17 @@ jest.mock('./adapters', () => ({ createAdapter: jest.fn(() => mockAdapterInstance), })); +jest.mock('../../components/hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: jest.fn(), + createEventBuilder: jest.fn().mockReturnValue({ + addProperties: jest.fn().mockReturnValue({ + build: jest.fn().mockReturnValue({ name: 'built-event' }), + }), + }), + }), +})); + jest.mock('@ledgerhq/react-native-hw-transport-ble', () => ({ __esModule: true, default: { @@ -150,8 +161,7 @@ describe('HardwareWalletProvider', () => { describe('wallet type detection', () => { it('detects hardware wallet from selected account', async () => { - const mockAccount = { address: '0x1234' }; - mockUseSelector.mockReturnValue(mockAccount); + mockUseSelector.mockReturnValue({ address: '0x1234' }); mockGetHardwareWalletType.mockReturnValue(HardwareWalletType.Ledger); const { getByTestId } = renderProvider(); @@ -197,8 +207,6 @@ describe('HardwareWalletProvider', () => { renderProvider(); - // The provider always creates an adapter - for non-hardware accounts it creates - // a NonHardwareAdapter (passthrough) by calling createAdapter(null, ...) expect(mockCreateAdapter).toHaveBeenCalledWith( null, expect.objectContaining({ @@ -629,7 +637,6 @@ describe('HardwareWalletProvider', () => { it('updates wallet type when set', async () => { mockUseSelector.mockReturnValue(null); - mockGetHardwareWalletType.mockReturnValue(undefined); const { result } = renderHook(() => useTestActions(), { wrapper: ({ children }: { children: React.ReactNode }) => ( diff --git a/app/core/HardwareWallet/HardwareWalletProvider.tsx b/app/core/HardwareWallet/HardwareWalletProvider.tsx index 5268ada9023..e682937b481 100644 --- a/app/core/HardwareWallet/HardwareWalletProvider.tsx +++ b/app/core/HardwareWallet/HardwareWalletProvider.tsx @@ -1,4 +1,10 @@ -import React, { ReactNode, useCallback, useMemo, useRef } from 'react'; +import React, { + ReactNode, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; import HardwareWalletContext from './contexts/HardwareWalletContext'; import { HardwareWalletBottomSheet } from './components'; @@ -12,6 +18,11 @@ import { } from './hooks'; import { ConnectionStatus } from '@metamask/hw-wallet-sdk'; import DevLogger from '../SDKConnect/utils/DevLogger'; +import { + HardwareWalletAnalyticsFlow, + useHardwareWalletAnalytics, +} from './analytics'; +import { useAnalyticsFlowFromApproval } from './analytics/useAnalyticsFlowFromApproval'; interface HardwareWalletProviderProps { children: ReactNode; @@ -69,6 +80,29 @@ export const HardwareWalletProvider: React.FC = ({ updateConnectionState, }); + const awaitingConfirmationRejectRef = useRef<(() => void) | null>(null); + const operationTypeRef = useRef<'transaction' | 'message' | null>(null); + + const [analyticsFlow, setAnalyticsFlow] = useState( + HardwareWalletAnalyticsFlow.Connection, + ); + + const derivedAnalyticsFlow = useAnalyticsFlowFromApproval(); + const derivedAnalyticsFlowRef = useRef(derivedAnalyticsFlow); + derivedAnalyticsFlowRef.current = derivedAnalyticsFlow; + + const { trackCTAClicked, resetAnalyticsState } = useHardwareWalletAnalytics({ + connectionState, + walletType: effectiveWalletType, + flow: analyticsFlow, + deviceModel: deviceSelection.selectedDevice?.name ?? null, + }); + + const handleFlowStart = useCallback(() => { + resetAnalyticsState(); + setAnalyticsFlow(derivedAnalyticsFlowRef.current); + }, [resetAnalyticsState]); + const { ensureDeviceReady, connect, @@ -85,10 +119,9 @@ export const HardwareWalletProvider: React.FC = ({ createAdapterWithCallbacks, initializeAdapter, checkTransportEnabledOrShowError, + onFlowStart: handleFlowStart, }); - const awaitingConfirmationRejectRef = useRef<(() => void) | null>(null); - const showHardwareWalletError = useCallback( (error: unknown) => { DevLogger.log('[HardwareWallet] showHardwareWalletError:', error); @@ -104,6 +137,7 @@ export const HardwareWalletProvider: React.FC = ({ operationType, ); awaitingConfirmationRejectRef.current = onReject ?? null; + operationTypeRef.current = operationType; updateConnectionState({ status: ConnectionStatus.AwaitingConfirmation, @@ -117,9 +151,15 @@ export const HardwareWalletProvider: React.FC = ({ const hideAwaitingConfirmation = useCallback(() => { DevLogger.log('[HardwareWallet] hideAwaitingConfirmation'); awaitingConfirmationRejectRef.current = null; + operationTypeRef.current = null; updateConnectionState({ status: ConnectionStatus.Disconnected }); }, [updateConnectionState]); + const handleCloseFlow = useCallback(() => { + setAnalyticsFlow(HardwareWalletAnalyticsFlow.Connection); + closeFlow(); + }, [closeFlow]); + const handleAwaitingConfirmationCancel = useCallback(() => { DevLogger.log('[HardwareWallet] handleAwaitingConfirmationCancel'); // eslint-disable-next-line no-empty-function @@ -164,9 +204,10 @@ export const HardwareWalletProvider: React.FC = ({ selectDevice={selectDevice} rescan={rescan} connect={connect} - onClose={closeFlow} + onClose={handleCloseFlow} onAwaitingConfirmationCancel={handleAwaitingConfirmationCancel} onConnectionSuccess={handleConnectionSuccess} + onCTAClicked={trackCTAClicked} /> ); diff --git a/app/core/HardwareWallet/analytics/helpers.test.ts b/app/core/HardwareWallet/analytics/helpers.test.ts new file mode 100644 index 00000000000..6f17db5f33c --- /dev/null +++ b/app/core/HardwareWallet/analytics/helpers.test.ts @@ -0,0 +1,365 @@ +import { + ErrorCode, + ConnectionStatus, + HardwareWalletType, + HardwareWalletError, + Severity, + Category, + type HardwareWalletConnectionState, +} from '@metamask/hw-wallet-sdk'; +import { TransactionType } from '@metamask/transaction-controller'; +import { + HardwareWalletAnalyticsErrorType, + HardwareWalletAnalyticsFlow, + getAnalyticsErrorType, + getErrorTypeFromConnectionState, + getAnalyticsDeviceType, + getErrorDetails, + getAnalyticsFlowFromApproval, +} from './helpers'; + +describe('analytics helpers', () => { + describe('getAnalyticsErrorType', () => { + it('maps AuthenticationDeviceLocked to Device Locked', () => { + expect(getAnalyticsErrorType(ErrorCode.AuthenticationDeviceLocked)).toBe( + HardwareWalletAnalyticsErrorType.DeviceLocked, + ); + }); + + it('maps AuthenticationDeviceBlocked to Device Locked', () => { + expect(getAnalyticsErrorType(ErrorCode.AuthenticationDeviceBlocked)).toBe( + HardwareWalletAnalyticsErrorType.DeviceLocked, + ); + }); + + it('maps DeviceDisconnected to Device Disconnected', () => { + expect(getAnalyticsErrorType(ErrorCode.DeviceDisconnected)).toBe( + HardwareWalletAnalyticsErrorType.DeviceDisconnected, + ); + }); + + it('maps DeviceNotFound to Device Disconnected', () => { + expect(getAnalyticsErrorType(ErrorCode.DeviceNotFound)).toBe( + HardwareWalletAnalyticsErrorType.DeviceDisconnected, + ); + }); + + it('maps DeviceNotReady to Device Disconnected', () => { + expect(getAnalyticsErrorType(ErrorCode.DeviceNotReady)).toBe( + HardwareWalletAnalyticsErrorType.DeviceDisconnected, + ); + }); + + it('maps DeviceUnresponsive to Device Disconnected', () => { + expect(getAnalyticsErrorType(ErrorCode.DeviceUnresponsive)).toBe( + HardwareWalletAnalyticsErrorType.DeviceDisconnected, + ); + }); + + it('maps ConnectionClosed to Device Disconnected', () => { + expect(getAnalyticsErrorType(ErrorCode.ConnectionClosed)).toBe( + HardwareWalletAnalyticsErrorType.DeviceDisconnected, + ); + }); + + it('maps ConnectionTimeout to Device Disconnected', () => { + expect(getAnalyticsErrorType(ErrorCode.ConnectionTimeout)).toBe( + HardwareWalletAnalyticsErrorType.DeviceDisconnected, + ); + }); + + it('maps DeviceStateEthAppClosed to Ethereum App Not Opened', () => { + expect(getAnalyticsErrorType(ErrorCode.DeviceStateEthAppClosed)).toBe( + HardwareWalletAnalyticsErrorType.EthereumAppNotOpened, + ); + }); + + it('maps DeviceMissingCapability to Ethereum App Not Opened', () => { + expect(getAnalyticsErrorType(ErrorCode.DeviceMissingCapability)).toBe( + HardwareWalletAnalyticsErrorType.EthereumAppNotOpened, + ); + }); + + it('maps BluetoothDisabled to Bluetooth Disabled', () => { + expect(getAnalyticsErrorType(ErrorCode.BluetoothDisabled)).toBe( + HardwareWalletAnalyticsErrorType.BluetoothDisabled, + ); + }); + + it('maps PermissionBluetoothDenied to Bluetooth Disabled', () => { + expect(getAnalyticsErrorType(ErrorCode.PermissionBluetoothDenied)).toBe( + HardwareWalletAnalyticsErrorType.BluetoothDisabled, + ); + }); + + it('maps DeviceStateBlindSignNotSupported to Blind Signing Not Enabled', () => { + expect( + getAnalyticsErrorType(ErrorCode.DeviceStateBlindSignNotSupported), + ).toBe(HardwareWalletAnalyticsErrorType.BlindSigningNotEnabled); + }); + + it('maps Unknown to Generic Error', () => { + expect(getAnalyticsErrorType(ErrorCode.Unknown)).toBe( + HardwareWalletAnalyticsErrorType.GenericError, + ); + }); + + it('maps UserRejected to Generic Error', () => { + expect(getAnalyticsErrorType(ErrorCode.UserRejected)).toBe( + HardwareWalletAnalyticsErrorType.GenericError, + ); + }); + + it('maps unmapped codes to Generic Error', () => { + expect(getAnalyticsErrorType(999 as ErrorCode)).toBe( + HardwareWalletAnalyticsErrorType.GenericError, + ); + }); + }); + + describe('getErrorTypeFromConnectionState', () => { + it('returns error type for ErrorState status', () => { + const error = new HardwareWalletError('Disconnected', { + code: ErrorCode.DeviceDisconnected, + severity: Severity.Err, + category: Category.Connection, + userMessage: 'Disconnected', + }); + + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error, + }; + + expect(getErrorTypeFromConnectionState(state)).toBe( + HardwareWalletAnalyticsErrorType.DeviceDisconnected, + ); + }); + + it('returns Ethereum App Not Opened for AwaitingApp status', () => { + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.AwaitingApp, + deviceId: 'device-123', + appName: 'Ethereum', + }; + + expect(getErrorTypeFromConnectionState(state)).toBe( + HardwareWalletAnalyticsErrorType.EthereumAppNotOpened, + ); + }); + + it('returns null for Disconnected status', () => { + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.Disconnected, + }; + + expect(getErrorTypeFromConnectionState(state)).toBeNull(); + }); + + it('returns null for Ready status', () => { + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.Ready, + }; + + expect(getErrorTypeFromConnectionState(state)).toBeNull(); + }); + + it('returns null for Connecting status', () => { + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.Connecting, + }; + + expect(getErrorTypeFromConnectionState(state)).toBeNull(); + }); + }); + + describe('getAnalyticsDeviceType', () => { + it('returns Ledger for Ledger type', () => { + expect(getAnalyticsDeviceType(HardwareWalletType.Ledger)).toBe('Ledger'); + }); + + it('returns Trezor for Trezor type', () => { + expect(getAnalyticsDeviceType(HardwareWalletType.Trezor)).toBe('Trezor'); + }); + + it('returns QR Hardware for Qr type', () => { + expect(getAnalyticsDeviceType(HardwareWalletType.Qr)).toBe('QR Hardware'); + }); + + it('returns OneKey for OneKey type', () => { + expect(getAnalyticsDeviceType(HardwareWalletType.OneKey)).toBe('OneKey'); + }); + + it('returns Lattice for Lattice type', () => { + expect(getAnalyticsDeviceType(HardwareWalletType.Lattice)).toBe( + 'Lattice', + ); + }); + + it('returns Unknown for null', () => { + expect(getAnalyticsDeviceType(null)).toBe('Unknown'); + }); + + it('returns Unknown for Unknown type', () => { + expect(getAnalyticsDeviceType(HardwareWalletType.Unknown)).toBe( + 'Unknown', + ); + }); + }); + + describe('getErrorDetails', () => { + it('returns error_code and error_message from ErrorState', () => { + const error = new HardwareWalletError('Device disconnected', { + code: ErrorCode.DeviceDisconnected, + severity: Severity.Err, + category: Category.Connection, + userMessage: 'Your device was disconnected', + }); + + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error, + }; + + const result = getErrorDetails(state); + expect(result.error_code).toBe(String(ErrorCode.DeviceDisconnected)); + expect(result.error_message).toBe('Your device was disconnected'); + }); + + it('returns details from AwaitingApp state', () => { + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.AwaitingApp, + deviceId: 'device-123', + appName: 'Ethereum', + }; + + const result = getErrorDetails(state); + expect(result.error_code).toBe(String(ErrorCode.DeviceStateEthAppClosed)); + expect(result.error_message).toContain('Ethereum'); + }); + + it('defaults appName to Ethereum when not provided', () => { + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.AwaitingApp, + deviceId: 'device-123', + }; + + const result = getErrorDetails(state); + expect(result.error_message).toContain('Ethereum'); + }); + + it('returns empty strings for non-error states', () => { + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.Disconnected, + }; + + const result = getErrorDetails(state); + expect(result.error_code).toBe(''); + expect(result.error_message).toBe(''); + }); + }); + + describe('getAnalyticsFlowFromApproval', () => { + it('returns Message for personal_sign', () => { + expect( + getAnalyticsFlowFromApproval({ approvalType: 'personal_sign' }), + ).toBe(HardwareWalletAnalyticsFlow.Message); + }); + + it('returns Message for eth_signTypedData', () => { + expect( + getAnalyticsFlowFromApproval({ approvalType: 'eth_signTypedData' }), + ).toBe(HardwareWalletAnalyticsFlow.Message); + }); + + it('returns Send for simpleSend', () => { + expect( + getAnalyticsFlowFromApproval({ + approvalType: 'transaction', + transactionType: TransactionType.simpleSend, + }), + ).toBe(HardwareWalletAnalyticsFlow.Send); + }); + + it('returns Send for tokenMethodTransfer', () => { + expect( + getAnalyticsFlowFromApproval({ + approvalType: 'transaction', + transactionType: TransactionType.tokenMethodTransfer, + }), + ).toBe(HardwareWalletAnalyticsFlow.Send); + }); + + it('returns Send for tokenMethodTransferFrom', () => { + expect( + getAnalyticsFlowFromApproval({ + approvalType: 'transaction', + transactionType: TransactionType.tokenMethodTransferFrom, + }), + ).toBe(HardwareWalletAnalyticsFlow.Send); + }); + + it('returns Swaps for swap', () => { + expect( + getAnalyticsFlowFromApproval({ + approvalType: 'transaction', + transactionType: TransactionType.swap, + }), + ).toBe(HardwareWalletAnalyticsFlow.Swaps); + }); + + it('returns Swaps for swapApproval', () => { + expect( + getAnalyticsFlowFromApproval({ + approvalType: 'transaction', + transactionType: TransactionType.swapApproval, + }), + ).toBe(HardwareWalletAnalyticsFlow.Swaps); + }); + + it('returns Swaps for bridge', () => { + expect( + getAnalyticsFlowFromApproval({ + approvalType: 'transaction', + transactionType: TransactionType.bridge, + }), + ).toBe(HardwareWalletAnalyticsFlow.Swaps); + }); + + it('returns Swaps for bridgeApproval', () => { + expect( + getAnalyticsFlowFromApproval({ + approvalType: 'transaction', + transactionType: TransactionType.bridgeApproval, + }), + ).toBe(HardwareWalletAnalyticsFlow.Swaps); + }); + + it('returns Transaction for contractInteraction', () => { + expect( + getAnalyticsFlowFromApproval({ + approvalType: 'transaction', + transactionType: TransactionType.contractInteraction, + }), + ).toBe(HardwareWalletAnalyticsFlow.Transaction); + }); + + it('returns Connection for transaction without type', () => { + expect( + getAnalyticsFlowFromApproval({ approvalType: 'transaction' }), + ).toBe(HardwareWalletAnalyticsFlow.Connection); + }); + + it('returns Connection when no approvalType', () => { + expect(getAnalyticsFlowFromApproval({})).toBe( + HardwareWalletAnalyticsFlow.Connection, + ); + }); + + it('returns Connection for undefined approvalType', () => { + expect(getAnalyticsFlowFromApproval({ approvalType: undefined })).toBe( + HardwareWalletAnalyticsFlow.Connection, + ); + }); + }); +}); diff --git a/app/core/HardwareWallet/analytics/helpers.ts b/app/core/HardwareWallet/analytics/helpers.ts new file mode 100644 index 00000000000..45cec023278 --- /dev/null +++ b/app/core/HardwareWallet/analytics/helpers.ts @@ -0,0 +1,188 @@ +import { + ErrorCode, + HardwareWalletType, + HardwareWalletConnectionState, + ConnectionStatus, +} from '@metamask/hw-wallet-sdk'; +import { ApprovalType } from '@metamask/controller-utils'; +import { TransactionType } from '@metamask/transaction-controller'; + +/** + * Analytics flow locations for hardware wallet interactions. + */ +export enum HardwareWalletAnalyticsFlow { + Connection = 'Connection', + Send = 'Send', + Swaps = 'Swaps', + Transaction = 'Transaction', + Message = 'Message', +} + +const SIGNATURE_APPROVAL_TYPES = new Set([ + 'personal_sign', + 'eth_signTypedData', +]); + +const SEND_TRANSACTION_TYPES = new Set([ + TransactionType.simpleSend, + TransactionType.tokenMethodTransfer, + TransactionType.tokenMethodTransferFrom, + TransactionType.tokenMethodSafeTransferFrom, +]); + +const SWAP_TRANSACTION_TYPES = new Set([ + TransactionType.swap, + TransactionType.swapApproval, + TransactionType.swapAndSend, + TransactionType.bridge, + TransactionType.bridgeApproval, +]); + +/** + * Derives the analytics flow from the current pending approval. + * + * @param approvalType - The pending approval's `type` string (e.g. 'transaction', 'personal_sign'). + * @param transactionType - The transaction type from TransactionMeta, if available. + * @returns The analytics flow to report as `location`. + */ +export function getAnalyticsFlowFromApproval({ + approvalType, + transactionType, +}: { + approvalType?: string; + transactionType?: TransactionType; +}): HardwareWalletAnalyticsFlow { + if (!approvalType) { + return HardwareWalletAnalyticsFlow.Connection; + } + + if (SIGNATURE_APPROVAL_TYPES.has(approvalType)) { + return HardwareWalletAnalyticsFlow.Message; + } + + if (approvalType === ApprovalType.Transaction && transactionType) { + if (SEND_TRANSACTION_TYPES.has(transactionType)) { + return HardwareWalletAnalyticsFlow.Send; + } + if (SWAP_TRANSACTION_TYPES.has(transactionType)) { + return HardwareWalletAnalyticsFlow.Swaps; + } + return HardwareWalletAnalyticsFlow.Transaction; + } + + return HardwareWalletAnalyticsFlow.Connection; +} + +/** + * Normalized error type categories for analytics. + * Matches the segment schema enum for `error_type`. + */ +export enum HardwareWalletAnalyticsErrorType { + DeviceLocked = 'Device Locked', + DeviceDisconnected = 'Device Disconnected', + EthereumAppNotOpened = 'Ethereum App Not Opened', + BluetoothDisabled = 'Bluetooth Disabled', + BlindSigningNotEnabled = 'Blind Signing Not Enabled', + GenericError = 'Generic Error', +} + +/** + * Maps an SDK ErrorCode to the analytics error_type category. + */ +export function getAnalyticsErrorType( + errorCode: ErrorCode, +): HardwareWalletAnalyticsErrorType { + switch (errorCode) { + case ErrorCode.AuthenticationDeviceLocked: + case ErrorCode.AuthenticationDeviceBlocked: + return HardwareWalletAnalyticsErrorType.DeviceLocked; + + case ErrorCode.DeviceDisconnected: + case ErrorCode.DeviceNotFound: + case ErrorCode.DeviceNotReady: + case ErrorCode.DeviceUnresponsive: + case ErrorCode.ConnectionClosed: + case ErrorCode.ConnectionTimeout: + return HardwareWalletAnalyticsErrorType.DeviceDisconnected; + + case ErrorCode.DeviceStateEthAppClosed: + case ErrorCode.DeviceMissingCapability: + return HardwareWalletAnalyticsErrorType.EthereumAppNotOpened; + + case ErrorCode.BluetoothDisabled: + case ErrorCode.PermissionBluetoothDenied: + return HardwareWalletAnalyticsErrorType.BluetoothDisabled; + + case ErrorCode.DeviceStateBlindSignNotSupported: + return HardwareWalletAnalyticsErrorType.BlindSigningNotEnabled; + + default: + return HardwareWalletAnalyticsErrorType.GenericError; + } +} + +/** + * Derives the analytics error_type from the current connection state. + * Returns null for non-error states. + */ +export function getErrorTypeFromConnectionState( + connectionState: HardwareWalletConnectionState, +): HardwareWalletAnalyticsErrorType | null { + if (connectionState.status === ConnectionStatus.ErrorState) { + return getAnalyticsErrorType(connectionState.error.code); + } + if (connectionState.status === ConnectionStatus.AwaitingApp) { + return HardwareWalletAnalyticsErrorType.EthereumAppNotOpened; + } + return null; +} + +/** + * Maps HardwareWalletType to the capitalised manufacturer name + * expected by the segment schema `device_type` property. + */ +export function getAnalyticsDeviceType( + walletType: HardwareWalletType | null, +): string { + switch (walletType) { + case HardwareWalletType.Ledger: + return 'Ledger'; + case HardwareWalletType.Trezor: + return 'Trezor'; + case HardwareWalletType.OneKey: + return 'OneKey'; + case HardwareWalletType.Lattice: + return 'Lattice'; + case HardwareWalletType.Qr: + return 'QR Hardware'; + default: + return 'Unknown'; + } +} + +export interface ErrorDetails { + error_code: string; + error_message: string; +} + +/** + * Extracts `error_code` and `error_message` from the current connection state. + */ +export function getErrorDetails( + connectionState: HardwareWalletConnectionState, +): ErrorDetails { + if (connectionState.status === ConnectionStatus.ErrorState) { + const { error } = connectionState; + return { + error_code: String(error.code), + error_message: error.userMessage ?? 'No error message', + }; + } + if (connectionState.status === ConnectionStatus.AwaitingApp) { + return { + error_code: String(ErrorCode.DeviceStateEthAppClosed), + error_message: `Open ${connectionState.appName ?? 'Ethereum'} app on device`, + }; + } + return { error_code: '', error_message: '' }; +} diff --git a/app/core/HardwareWallet/analytics/index.ts b/app/core/HardwareWallet/analytics/index.ts new file mode 100644 index 00000000000..3c51279adcc --- /dev/null +++ b/app/core/HardwareWallet/analytics/index.ts @@ -0,0 +1,11 @@ +export { + HardwareWalletAnalyticsFlow, + HardwareWalletAnalyticsErrorType, + getAnalyticsFlowFromApproval, + getAnalyticsErrorType, + getErrorTypeFromConnectionState, + getAnalyticsDeviceType, + getErrorDetails, +} from './helpers'; + +export { useHardwareWalletAnalytics } from './useHardwareWalletAnalytics'; diff --git a/app/core/HardwareWallet/analytics/useAnalyticsFlowFromApproval.test.ts b/app/core/HardwareWallet/analytics/useAnalyticsFlowFromApproval.test.ts new file mode 100644 index 00000000000..b6a838c3a6c --- /dev/null +++ b/app/core/HardwareWallet/analytics/useAnalyticsFlowFromApproval.test.ts @@ -0,0 +1,155 @@ +import { ApprovalType } from '@metamask/controller-utils'; +import { TransactionType } from '@metamask/transaction-controller'; +import { + renderHookWithProvider, + DeepPartial, +} from '../../../util/test/renderWithProvider'; +import { RootState } from '../../../reducers'; +import { useAnalyticsFlowFromApproval } from './useAnalyticsFlowFromApproval'; +import { HardwareWalletAnalyticsFlow } from './helpers'; + +const MOCK_TX_ID = 'tx-id-123'; + +const createStateWithApproval = ( + approvalType: string, + transactionType?: TransactionType, +): DeepPartial => ({ + engine: { + backgroundState: { + ApprovalController: { + pendingApprovals: { + [MOCK_TX_ID]: { + id: MOCK_TX_ID, + type: approvalType, + }, + }, + }, + TransactionController: { + transactions: transactionType + ? [{ id: MOCK_TX_ID, type: transactionType }] + : [], + }, + }, + }, +}); + +const EMPTY_APPROVAL_STATE: DeepPartial = { + engine: { + backgroundState: { + ApprovalController: { + pendingApprovals: {}, + }, + TransactionController: { + transactions: [], + }, + }, + }, +}; + +describe('useAnalyticsFlowFromApproval', () => { + it('returns Connection when no pending approvals exist', () => { + const { result } = renderHookWithProvider( + () => useAnalyticsFlowFromApproval(), + { state: EMPTY_APPROVAL_STATE }, + ); + + expect(result.current).toBe(HardwareWalletAnalyticsFlow.Connection); + }); + + it('returns Message for personal_sign approval', () => { + const { result } = renderHookWithProvider( + () => useAnalyticsFlowFromApproval(), + { state: createStateWithApproval('personal_sign') }, + ); + + expect(result.current).toBe(HardwareWalletAnalyticsFlow.Message); + }); + + it('returns Message for eth_signTypedData approval', () => { + const { result } = renderHookWithProvider( + () => useAnalyticsFlowFromApproval(), + { state: createStateWithApproval('eth_signTypedData') }, + ); + + expect(result.current).toBe(HardwareWalletAnalyticsFlow.Message); + }); + + it('returns Send for simpleSend transaction', () => { + const { result } = renderHookWithProvider( + () => useAnalyticsFlowFromApproval(), + { + state: createStateWithApproval( + ApprovalType.Transaction, + TransactionType.simpleSend, + ), + }, + ); + + expect(result.current).toBe(HardwareWalletAnalyticsFlow.Send); + }); + + it('returns Send for tokenMethodTransfer transaction', () => { + const { result } = renderHookWithProvider( + () => useAnalyticsFlowFromApproval(), + { + state: createStateWithApproval( + ApprovalType.Transaction, + TransactionType.tokenMethodTransfer, + ), + }, + ); + + expect(result.current).toBe(HardwareWalletAnalyticsFlow.Send); + }); + + it('returns Swaps for swap transaction', () => { + const { result } = renderHookWithProvider( + () => useAnalyticsFlowFromApproval(), + { + state: createStateWithApproval( + ApprovalType.Transaction, + TransactionType.swap, + ), + }, + ); + + expect(result.current).toBe(HardwareWalletAnalyticsFlow.Swaps); + }); + + it('returns Swaps for bridge transaction', () => { + const { result } = renderHookWithProvider( + () => useAnalyticsFlowFromApproval(), + { + state: createStateWithApproval( + ApprovalType.Transaction, + TransactionType.bridge, + ), + }, + ); + + expect(result.current).toBe(HardwareWalletAnalyticsFlow.Swaps); + }); + + it('returns Transaction for contractInteraction', () => { + const { result } = renderHookWithProvider( + () => useAnalyticsFlowFromApproval(), + { + state: createStateWithApproval( + ApprovalType.Transaction, + TransactionType.contractInteraction, + ), + }, + ); + + expect(result.current).toBe(HardwareWalletAnalyticsFlow.Transaction); + }); + + it('returns Connection for unrecognized approval type', () => { + const { result } = renderHookWithProvider( + () => useAnalyticsFlowFromApproval(), + { state: createStateWithApproval('unknown_type') }, + ); + + expect(result.current).toBe(HardwareWalletAnalyticsFlow.Connection); + }); +}); diff --git a/app/core/HardwareWallet/analytics/useAnalyticsFlowFromApproval.ts b/app/core/HardwareWallet/analytics/useAnalyticsFlowFromApproval.ts new file mode 100644 index 00000000000..a3d6b6d8bc7 --- /dev/null +++ b/app/core/HardwareWallet/analytics/useAnalyticsFlowFromApproval.ts @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { selectPendingApprovals } from '../../../selectors/approvalController'; +import { selectTransactionMetadataById } from '../../../selectors/transactionController'; +import type { RootState } from '../../../reducers'; +import { + HardwareWalletAnalyticsFlow, + getAnalyticsFlowFromApproval, +} from './helpers'; + +/** + * Derives the current analytics flow from the first pending approval in the + * Redux store. Returns `Connection` when no approval is pending (e.g. during + * account creation). Used by `HardwareWalletProvider` to automatically set + * the analytics flow context when `ensureDeviceReady` is called. + */ +export function useAnalyticsFlowFromApproval(): HardwareWalletAnalyticsFlow { + const pendingApprovals = useSelector(selectPendingApprovals); + const firstApproval = Object.values(pendingApprovals ?? {})[0]; + const transactionMetadata = useSelector((state: RootState) => + selectTransactionMetadataById(state, firstApproval?.id ?? ''), + ); + + return useMemo( + () => + getAnalyticsFlowFromApproval({ + approvalType: firstApproval?.type, + transactionType: transactionMetadata?.type, + }), + [firstApproval?.type, transactionMetadata?.type], + ); +} diff --git a/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.test.ts b/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.test.ts new file mode 100644 index 00000000000..4e4bd3ff5c8 --- /dev/null +++ b/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.test.ts @@ -0,0 +1,579 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { + HardwareWalletError, + ErrorCode, + Severity, + Category, + HardwareWalletType, + ConnectionStatus, + type HardwareWalletConnectionState, +} from '@metamask/hw-wallet-sdk'; +import { useHardwareWalletAnalytics } from './useHardwareWalletAnalytics'; +import { + HardwareWalletAnalyticsErrorType, + HardwareWalletAnalyticsFlow, +} from './helpers'; +import { MetaMetricsEvents } from '../../Analytics'; + +const mockTrackEvent = jest.fn(); +const mockBuild = jest.fn().mockReturnValue({ name: 'built-event' }); +const mockAddProperties = jest.fn().mockReturnValue({ build: mockBuild }); +const mockCreateEventBuilder = jest.fn().mockReturnValue({ + addProperties: mockAddProperties, +}); + +jest.mock('../../../components/hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +function createError( + code: ErrorCode, + message = 'Test error', +): HardwareWalletError { + return new HardwareWalletError(message, { + code, + severity: Severity.Err, + category: Category.Connection, + userMessage: message, + }); +} + +describe('useHardwareWalletAnalytics', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const defaultOptions = { + connectionState: { + status: ConnectionStatus.Disconnected, + } as HardwareWalletConnectionState, + walletType: HardwareWalletType.Ledger, + flow: HardwareWalletAnalyticsFlow.Connection, + deviceModel: 'Nano X', + }; + + describe('Recovery Modal Viewed', () => { + it('fires when transitioning to ErrorState', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + const errorState: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected, 'Disconnected'), + }; + + rerender({ ...defaultOptions, connectionState: errorState }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_MODAL_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + location: 'Connection', + device_type: 'Ledger', + device_model: 'Nano X', + error_type: HardwareWalletAnalyticsErrorType.DeviceDisconnected, + error_type_view_count: 1, + error_code: String(ErrorCode.DeviceDisconnected), + error_message: 'Disconnected', + }), + ); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('fires when transitioning to AwaitingApp', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + const awaitingApp: HardwareWalletConnectionState = { + status: ConnectionStatus.AwaitingApp, + deviceId: 'device-123', + appName: 'Ethereum', + }; + + rerender({ ...defaultOptions, connectionState: awaitingApp }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_MODAL_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + error_type: HardwareWalletAnalyticsErrorType.EthereumAppNotOpened, + error_type_view_count: 1, + }), + ); + }); + + it('does not fire when status does not change', () => { + const errorState: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }; + + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { + initialProps: { ...defaultOptions, connectionState: errorState }, + }, + ); + + mockTrackEvent.mockClear(); + mockCreateEventBuilder.mockClear(); + + rerender({ ...defaultOptions, connectionState: errorState }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('omits device_model when null', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { + initialProps: { ...defaultOptions, deviceModel: null }, + }, + ); + + const errorState: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }; + + rerender({ + ...defaultOptions, + deviceModel: null, + connectionState: errorState, + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.not.objectContaining({ + device_model: expect.anything(), + }), + ); + }); + }); + + describe('error_type_view_count', () => { + it('increments for the same error type', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + const errorState: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }; + + rerender({ ...defaultOptions, connectionState: errorState }); + expect(mockAddProperties).toHaveBeenLastCalledWith( + expect.objectContaining({ error_type_view_count: 1 }), + ); + + rerender({ + ...defaultOptions, + connectionState: { status: ConnectionStatus.Connecting }, + }); + + mockAddProperties.mockClear(); + + rerender({ ...defaultOptions, connectionState: errorState }); + expect(mockAddProperties).toHaveBeenLastCalledWith( + expect.objectContaining({ error_type_view_count: 2 }), + ); + }); + + it('starts at 1 for a different error type', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + rerender({ + ...defaultOptions, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }, + }); + expect(mockAddProperties).toHaveBeenLastCalledWith( + expect.objectContaining({ + error_type: HardwareWalletAnalyticsErrorType.DeviceDisconnected, + error_type_view_count: 1, + }), + ); + + rerender({ + ...defaultOptions, + connectionState: { status: ConnectionStatus.Connecting }, + }); + + mockAddProperties.mockClear(); + + rerender({ + ...defaultOptions, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.BluetoothDisabled), + }, + }); + expect(mockAddProperties).toHaveBeenLastCalledWith( + expect.objectContaining({ + error_type: HardwareWalletAnalyticsErrorType.BluetoothDisabled, + error_type_view_count: 1, + }), + ); + }); + + it('resets on success', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + rerender({ + ...defaultOptions, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }, + }); + + rerender({ + ...defaultOptions, + connectionState: { status: ConnectionStatus.Ready }, + }); + + rerender({ + ...defaultOptions, + connectionState: { status: ConnectionStatus.Disconnected }, + }); + + mockAddProperties.mockClear(); + + rerender({ + ...defaultOptions, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }, + }); + expect(mockAddProperties).toHaveBeenLastCalledWith( + expect.objectContaining({ error_type_view_count: 1 }), + ); + }); + }); + + describe('Recovery Success Modal Viewed', () => { + it('fires when transitioning to Ready after an error', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + rerender({ + ...defaultOptions, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected, 'Disconnected'), + }, + }); + + mockCreateEventBuilder.mockClear(); + mockAddProperties.mockClear(); + mockTrackEvent.mockClear(); + + rerender({ + ...defaultOptions, + connectionState: { status: ConnectionStatus.Ready }, + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_SUCCESS_MODAL_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + location: 'Connection', + device_type: 'Ledger', + device_model: 'Nano X', + error_type: HardwareWalletAnalyticsErrorType.DeviceDisconnected, + error_type_view_count: 1, + error_code: String(ErrorCode.DeviceDisconnected), + error_message: 'Disconnected', + }), + ); + }); + + it('fires without error properties when no preceding error occurred', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + rerender({ + ...defaultOptions, + connectionState: { status: ConnectionStatus.Ready }, + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_SUCCESS_MODAL_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.not.objectContaining({ + error_type: expect.anything(), + error_type_view_count: expect.anything(), + }), + ); + }); + }); + + describe('CTA Clicked', () => { + it('fires with correct properties', () => { + const errorState: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.BluetoothDisabled, 'BT off'), + }; + + const { result } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { + initialProps: { ...defaultOptions, connectionState: errorState }, + }, + ); + + mockCreateEventBuilder.mockClear(); + mockAddProperties.mockClear(); + mockTrackEvent.mockClear(); + + act(() => { + result.current.trackCTAClicked(); + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_CTA_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + location: 'Connection', + device_type: 'Ledger', + device_model: 'Nano X', + error_type: HardwareWalletAnalyticsErrorType.BluetoothDisabled, + error_type_view_count: 1, + error_code: String(ErrorCode.BluetoothDisabled), + error_message: 'BT off', + }), + ); + }); + + it('does not fire when not in an error state', () => { + const { result } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + mockTrackEvent.mockClear(); + + act(() => { + result.current.trackCTAClicked(); + }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('fires for AwaitingApp state', () => { + const awaitingApp: HardwareWalletConnectionState = { + status: ConnectionStatus.AwaitingApp, + deviceId: 'device-123', + appName: 'Ethereum', + }; + + const { result } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { + initialProps: { ...defaultOptions, connectionState: awaitingApp }, + }, + ); + + mockCreateEventBuilder.mockClear(); + mockAddProperties.mockClear(); + mockTrackEvent.mockClear(); + + act(() => { + result.current.trackCTAClicked(); + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + error_type: HardwareWalletAnalyticsErrorType.EthereumAppNotOpened, + }), + ); + }); + }); + + describe('flow variants', () => { + it('tracks Transaction flow', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { + initialProps: { + ...defaultOptions, + flow: HardwareWalletAnalyticsFlow.Transaction, + }, + }, + ); + + rerender({ + ...defaultOptions, + flow: HardwareWalletAnalyticsFlow.Transaction, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }, + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ location: 'Transaction' }), + ); + }); + + it('tracks Message flow', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { + initialProps: { + ...defaultOptions, + flow: HardwareWalletAnalyticsFlow.Message, + }, + }, + ); + + rerender({ + ...defaultOptions, + flow: HardwareWalletAnalyticsFlow.Message, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }, + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ location: 'Message' }), + ); + }); + + it('tracks Send flow', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { + initialProps: { + ...defaultOptions, + flow: HardwareWalletAnalyticsFlow.Send, + }, + }, + ); + + rerender({ + ...defaultOptions, + flow: HardwareWalletAnalyticsFlow.Send, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }, + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ location: 'Send' }), + ); + }); + + it('tracks Swaps flow', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { + initialProps: { + ...defaultOptions, + flow: HardwareWalletAnalyticsFlow.Swaps, + }, + }, + ); + + rerender({ + ...defaultOptions, + flow: HardwareWalletAnalyticsFlow.Swaps, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }, + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ location: 'Swaps' }), + ); + }); + + it('defaults to Connection flow', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + rerender({ + ...defaultOptions, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }, + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ location: 'Connection' }), + ); + }); + }); + + describe('spurious disconnect mid-flow', () => { + it('preserves error state across disconnect so success includes it', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + rerender({ + ...defaultOptions, + connectionState: { + status: ConnectionStatus.ErrorState, + error: createError(ErrorCode.DeviceDisconnected), + }, + }); + + rerender({ + ...defaultOptions, + connectionState: { status: ConnectionStatus.Disconnected }, + }); + + mockCreateEventBuilder.mockClear(); + mockAddProperties.mockClear(); + + rerender({ + ...defaultOptions, + connectionState: { status: ConnectionStatus.Ready }, + }); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_SUCCESS_MODAL_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + error_type: HardwareWalletAnalyticsErrorType.DeviceDisconnected, + error_type_view_count: 1, + }), + ); + }); + }); +}); diff --git a/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.ts b/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.ts new file mode 100644 index 00000000000..803658f0998 --- /dev/null +++ b/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.ts @@ -0,0 +1,177 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { + HardwareWalletConnectionState, + HardwareWalletType, + ConnectionStatus, +} from '@metamask/hw-wallet-sdk'; +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../Analytics'; +import { + HardwareWalletAnalyticsErrorType, + HardwareWalletAnalyticsFlow, + getErrorTypeFromConnectionState, + getAnalyticsDeviceType, + getErrorDetails, + type ErrorDetails, +} from './helpers'; + +interface UseHardwareWalletAnalyticsOptions { + connectionState: HardwareWalletConnectionState; + walletType: HardwareWalletType | null; + flow: HardwareWalletAnalyticsFlow; + deviceModel: string | null; +} + +interface UseHardwareWalletAnalyticsResult { + trackCTAClicked: () => void; + resetAnalyticsState: () => void; +} + +/** + * Tracks hardware wallet recovery analytics events by reacting to + * `connectionState` transitions. + * + * - **Recovery Modal Viewed** – fires each time the user enters an + * error or "awaiting app" state. + * - **Recovery CTA Clicked** – fires when the user taps + * Continue/Retry from the error or awaiting-app screen. + * - **Recovery Success Modal Viewed** – fires every time the device + * reaches the Ready state. Error-related properties are only + * included when the user recovered from a preceding error. + * + * `error_type_view_count` is tracked per error_type and resets on + * success or when the flow is dismissed. + */ +export function useHardwareWalletAnalytics({ + connectionState, + walletType, + flow, + deviceModel, +}: UseHardwareWalletAnalyticsOptions): UseHardwareWalletAnalyticsResult { + const { trackEvent, createEventBuilder } = useAnalytics(); + + const viewCountsRef = useRef>(new Map()); + const lastErrorTypeRef = useRef( + null, + ); + const lastErrorTypeViewCountRef = useRef(0); + const lastErrorDetailsRef = useRef({ + error_code: '', + error_message: '', + }); + const prevStatusRef = useRef(ConnectionStatus.Disconnected); + + const resetAnalyticsState = useCallback(() => { + viewCountsRef.current.clear(); + lastErrorTypeRef.current = null; + lastErrorTypeViewCountRef.current = 0; + lastErrorDetailsRef.current = { error_code: '', error_message: '' }; + }, []); + + useEffect(() => { + const currentStatus = connectionState.status; + const prevStatus = prevStatusRef.current; + prevStatusRef.current = currentStatus; + + if (currentStatus === prevStatus) return; + + const isRecoveryState = + currentStatus === ConnectionStatus.ErrorState || + currentStatus === ConnectionStatus.AwaitingApp; + + const isSuccessState = currentStatus === ConnectionStatus.Ready; + + if (isRecoveryState) { + const errorType = getErrorTypeFromConnectionState(connectionState); + if (!errorType) return; + + const currentCount = viewCountsRef.current.get(errorType) ?? 0; + const newCount = currentCount + 1; + viewCountsRef.current.set(errorType, newCount); + + const errorDetails = getErrorDetails(connectionState); + + lastErrorTypeRef.current = errorType; + lastErrorTypeViewCountRef.current = newCount; + lastErrorDetailsRef.current = errorDetails; + + trackEvent( + createEventBuilder( + MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_MODAL_VIEWED, + ) + .addProperties({ + location: flow, + device_type: getAnalyticsDeviceType(walletType), + ...(deviceModel && { device_model: deviceModel }), + error_type: errorType, + error_type_view_count: newCount, + error_code: errorDetails.error_code, + error_message: errorDetails.error_message, + }) + .build(), + ); + } + + if (isSuccessState) { + const lastErrorType = lastErrorTypeRef.current; + + trackEvent( + createEventBuilder( + MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_SUCCESS_MODAL_VIEWED, + ) + .addProperties({ + location: flow, + device_type: getAnalyticsDeviceType(walletType), + ...(deviceModel && { device_model: deviceModel }), + ...(lastErrorType && { + error_type: lastErrorType, + error_type_view_count: lastErrorTypeViewCountRef.current, + error_code: lastErrorDetailsRef.current.error_code, + error_message: lastErrorDetailsRef.current.error_message, + }), + }) + .build(), + ); + + resetAnalyticsState(); + } + }, [ + connectionState, + walletType, + flow, + deviceModel, + trackEvent, + createEventBuilder, + resetAnalyticsState, + ]); + + const trackCTAClicked = useCallback(() => { + const errorType = getErrorTypeFromConnectionState(connectionState); + if (!errorType) return; + + const errorDetails = getErrorDetails(connectionState); + + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_CTA_CLICKED) + .addProperties({ + location: flow, + device_type: getAnalyticsDeviceType(walletType), + ...(deviceModel && { device_model: deviceModel }), + error_type: errorType, + error_type_view_count: viewCountsRef.current.get(errorType) ?? 1, + error_code: errorDetails.error_code, + error_message: errorDetails.error_message, + }) + .build(), + ); + }, [ + connectionState, + walletType, + flow, + deviceModel, + trackEvent, + createEventBuilder, + ]); + + return { trackCTAClicked, resetAnalyticsState }; +} diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx index 67f2c47520e..7c3eac4651b 100644 --- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx +++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx @@ -52,6 +52,8 @@ export interface HardwareWalletBottomSheetProps { onConnectionSuccess?: () => void; /** Callback when user cancels during awaiting confirmation state */ onAwaitingConfirmationCancel?: () => void; + /** Callback fired when the user taps the CTA on an error/recovery screen. */ + onCTAClicked?: () => void; } /** @@ -79,6 +81,7 @@ export const HardwareWalletBottomSheet: React.FC< successAutoDismissMs = 1000, onConnectionSuccess, onAwaitingConfirmationCancel, + onCTAClicked, }) => { const { colors } = useTheme(); const styles = useMemo(() => createStyles(colors), [colors]); @@ -124,12 +127,14 @@ export const HardwareWalletBottomSheet: React.FC< }, [onAwaitingConfirmationCancel]); const handleErrorContinue = useCallback(async () => { + onCTAClicked?.(); await retryEnsureDeviceReady(); - }, [retryEnsureDeviceReady]); + }, [retryEnsureDeviceReady, onCTAClicked]); const handleErrorDismiss = useCallback(() => { + onCTAClicked?.(); onClose(); - }, [onClose]); + }, [onClose, onCTAClicked]); const handleSuccessDismiss = useCallback(() => { onConnectionSuccess?.(); diff --git a/app/core/HardwareWallet/hooks/useDeviceConnectionFlow.ts b/app/core/HardwareWallet/hooks/useDeviceConnectionFlow.ts index f0215fc79bb..be9d09f5f38 100644 --- a/app/core/HardwareWallet/hooks/useDeviceConnectionFlow.ts +++ b/app/core/HardwareWallet/hooks/useDeviceConnectionFlow.ts @@ -26,6 +26,8 @@ interface UseDeviceConnectionFlowOptions { checkTransportEnabledOrShowError: ( adapter: HardwareWalletAdapter, ) => Promise; + /** Called at the start of each new ensureDeviceReady flow. */ + onFlowStart?: () => void; } interface UseDeviceConnectionFlowResult { @@ -52,6 +54,7 @@ export const useDeviceConnectionFlow = ({ createAdapterWithCallbacks, initializeAdapter, checkTransportEnabledOrShowError, + onFlowStart, }: UseDeviceConnectionFlowOptions): UseDeviceConnectionFlowResult => { const pendingReadyResolveRef = useRef<((ready: boolean) => void) | null>( null, @@ -188,6 +191,8 @@ export const useDeviceConnectionFlow = ({ targetDeviceId, ); + onFlowStart?.(); + if (pendingReadyResolveRef.current) { DevLogger.log( '[HardwareWallet] Abandoning previous pending readiness check (not resolving)', @@ -253,6 +258,7 @@ export const useDeviceConnectionFlow = ({ tryEnsureReady, checkTransportEnabledOrShowError, createBlockingPromise, + onFlowStart, ], ); diff --git a/app/core/HardwareWallet/hooks/useHardwareWalletStateManager.ts b/app/core/HardwareWallet/hooks/useHardwareWalletStateManager.ts index 1094a8a45d0..5bc77111a0e 100644 --- a/app/core/HardwareWallet/hooks/useHardwareWalletStateManager.ts +++ b/app/core/HardwareWallet/hooks/useHardwareWalletStateManager.ts @@ -85,12 +85,9 @@ export const useHardwareWalletStateManager = const selectedAccount = useSelector(selectSelectedInternalAccount); - const walletType = useMemo((): HardwareWalletType | null => { - if (!selectedAccount?.address) { - return null; - } - return getHardwareWalletTypeForAddress(selectedAccount.address) ?? null; - }, [selectedAccount?.address]); + const walletType: HardwareWalletType | null = selectedAccount?.address + ? (getHardwareWalletTypeForAddress(selectedAccount.address) ?? null) + : null; const state = useMemo( () => ({ From 2324e7c6668354306e963ba051524c05f677c0fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:39:57 +0100 Subject: [PATCH 52/54] feat: update Perps and Predictions sections to display unrealized P&L (#27844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Users with open Perpetuals or Predict positions could not see aggregate unrealized P&L on the wallet homepage next to those sections, and the Perps tab “Your positions” subtitle duplicated similar layout logic. This PR: - **Wallet homepage — Perps:** Shows a sub-row under “Perpetuals” with aggregate unrealized P&L (and ROE %) when the user has **filled** open positions; hides it for privacy mode, while loading account data, or when there are no positions (including **orders-only** — no row). Colors follow profit (green) / loss (red) / flat (default text). - **Wallet homepage — Predict:** Shows the same style of row under “Predictions” using the existing unrealized P&L API (`useUnrealizedPnL`); pull-to-refresh also invalidates that query. Respects privacy mode. - **Perps home (tab):** Keeps the positions subtitle visible whenever there are open positions (including flat P&L), uses design-system text colors, and reuses the shared **`HomepageSectionUnrealizedPnlRow`** for the value + “Unrealized P&L” line so layout matches the homepage spec (4px under title, 8px between value and label). - **Shared UI:** New `HomepageSectionUnrealizedPnlRow` under homepage components (used by homepage sections + `PerpsHomeSection`). - **Predict:** `formatPredictUnrealizedPnLStringParts` in `format.ts` centralizes i18n interpolation for unrealized P&L strings; `PredictPositionsHeader` and `PredictionsSection` both use it. ## **Changelog** CHANGELOG entry: Added unrealized P&L summary on the wallet homepage for Perpetuals and Predictions, and aligned the Perps home “Your positions” subtitle with the same layout. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-584 ## **Manual testing steps** ```gherkin Feature: Unrealized P&L on homepage Perps and Predict Scenario: Perps section shows aggregate unrealized P&L with open positions Given the user has at least one open Perpetuals position and privacy mode is off When the user views the wallet homepage Then the Perpetuals section shows a line with signed dollar P&L, percentage, and an unrealized P&L label below the section title And positive P&L is green and negative P&L is red Scenario: Perps section hides P&L without open positions Given the user has no open Perpetuals positions (only limit orders or none) When the user views the wallet homepage Then no unrealized P&L sub-row appears under Perpetuals and spacing below the title is unchanged Scenario: Predict section shows unrealized P&L with open positions Given the user has open Predict positions and privacy mode is off When the user views the wallet homepage Then the Predictions section shows the unrealized P&L sub-row consistent with design Scenario: Privacy mode hides homepage P&L values Given privacy mode is enabled When the user views the wallet homepage with Perps and/or Predict positions Then unrealized P&L sub-rows for those sections are not shown Scenario: Perps tab positions subtitle matches homepage styling Given the user has open Perps positions When the user opens the Perps home screen Then “Your positions” shows the unrealized P&L value and label with the same visual treatment as the homepage row ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Medium risk: adds new homepage UI rows driven by live Perps account state and Predict react-query data (including query invalidation on refresh), which could affect loading/visibility states and performance but does not touch auth or funds movement. > > **Overview** > Adds a shared `HomepageSectionUnrealizedPnlRow` component and uses it to display **aggregate unrealized P&L** under the Wallet homepage **Perpetuals** and **Predictions** section titles when the user has open positions (and not in privacy mode), including loading placeholders and tone-based coloring. > > Refactors Perps tab `PerpsHomeSection`/`PerpsHomeView` to reuse the same value+label row, switch to design-system `TextColor` tokens, and keep the positions subtitle visible whenever positions exist (including flat P&L). Predict’s positions header and homepage section now share a centralized `formatPredictUnrealizedPnLStringParts` formatter, and Predictions pull-to-refresh also invalidates the unrealized P&L query; tests were updated/added accordingly. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 50ae867b59ea612ddc8af1b579888b475eb0f36f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/PerpsHomeView/PerpsHomeView.tsx | 14 +- .../PerpsHomeSection.test.tsx | 8 +- .../PerpsHomeSection/PerpsHomeSection.tsx | 65 +++--- .../PredictPositionsHeader.tsx | 28 ++- .../UI/Predict/utils/format.test.ts | 30 +++ app/components/UI/Predict/utils/format.ts | 16 ++ .../Views/Homepage/Homepage.test.tsx | 22 +++ .../Sections/Perpetuals/PerpsSection.test.tsx | 54 ++++- .../Sections/Perpetuals/PerpsSection.tsx | 45 ++++- .../Predictions/PredictionsSection.test.tsx | 23 +++ .../Predictions/PredictionsSection.tsx | 115 ++++++++++- .../HomepageSectionUnrealizedPnlRow.test.tsx | 186 ++++++++++++++++++ .../HomepageSectionUnrealizedPnlRow.tsx | 127 ++++++++++++ .../HomepageSectionUnrealizedPnlRow/index.ts | 5 + 14 files changed, 670 insertions(+), 68 deletions(-) create mode 100644 app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/HomepageSectionUnrealizedPnlRow.test.tsx create mode 100644 app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/HomepageSectionUnrealizedPnlRow.tsx create mode 100644 app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/index.ts diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx index 47b45f0e55b..f67f1253362 100644 --- a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx @@ -21,9 +21,9 @@ import { Button, ButtonVariant, ButtonSize, + TextColor, } from '@metamask/design-system-react-native'; import { useStyles } from '../../../../../component-library/hooks'; -import { TextColor } from '../../../../../component-library/components/Texts/Text'; import { strings } from '../../../../../../locales/i18n'; import { formatPnl, formatPercentage } from '../../utils/formatUtils'; import Routes from '../../../../../constants/navigation/Routes'; @@ -141,10 +141,9 @@ const PerpsHomeView = () => { const { positionsSubtitle, positionsSubtitleColor, positionsSubtitleSuffix } = useMemo(() => { const pnlNum = parseFloat(unrealizedPnl); - const isPnlZero = BigNumber(unrealizedPnl).isZero(); - // Only show subtitle when there are positions and P&L is non-zero - if (!hasPositions || isPnlZero) { + // Open (filled) positions only — hide when flat so spacing matches homepage sections + if (!hasPositions) { return { positionsSubtitle: undefined, positionsSubtitleColor: undefined, @@ -154,12 +153,11 @@ const PerpsHomeView = () => { const color = pnlNum > 0 - ? TextColor.Success + ? TextColor.SuccessDefault : pnlNum < 0 - ? TextColor.Error - : TextColor.Alternative; + ? TextColor.ErrorDefault + : TextColor.TextDefault; - // Format: "-$18.47 (2.1%)" colored + "Unrealized PnL" in default color const subtitle = `${formatPnl(pnlNum)} (${formatPercentage(roe, 1)})`; const suffix = strings('perps.unrealized_pnl'); diff --git a/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.test.tsx b/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.test.tsx index 45da601dc46..2562890db3f 100644 --- a/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.test.tsx +++ b/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.test.tsx @@ -4,7 +4,7 @@ import { View, Text } from 'react-native'; import PerpsHomeSection from './PerpsHomeSection'; import { PerpsHomeSectionTestIds } from './PerpsHomeSection.testIds'; -import { TextColor } from '../../../../../component-library/components/Texts/Text'; +import { TextColor } from '@metamask/design-system-react-native'; describe('PerpsHomeSection', () => { const mockSkeleton = () => ; @@ -445,7 +445,7 @@ describe('PerpsHomeSection', () => { { { = ({ title, subtitle, - subtitleColor = TextColor.Alternative, + subtitleColor = TextColor.TextDefault, subtitleSuffix, subtitleTestID, isLoading, @@ -146,29 +148,34 @@ const PerpsHomeSection: React.FC = ({ ) : undefined } - twClassName="px-0 mb-2" + twClassName="px-0 mb-0" /> - {/* Subtitle - NOT pressable */} - {subtitle && ( - - {subtitle} - {subtitleSuffix && ( - - {' '} - {subtitleSuffix} - - )} - - )} + {/* Value + muted label: same row as wallet homepage unrealized P&L (8px gap). */} + {subtitle && subtitleSuffix ? ( + + ) : subtitle ? ( + + + {subtitle} + + + ) : null} {/* Section Content */} diff --git a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx index 259adb6888a..6bca1d097b8 100644 --- a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx +++ b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx @@ -49,7 +49,11 @@ import { usePredictPositions } from '../../hooks/usePredictPositions'; import { selectPredictWonPositions } from '../../selectors/predictController'; import { PredictPosition } from '../../types'; import { PredictNavigationParamList } from '../../types/navigation'; -import { formatPercentage, formatPrice } from '../../utils/format'; +import { + formatPercentage, + formatPredictUnrealizedPnLStringParts, + formatPrice, +} from '../../utils/format'; import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import PredictClaimButton from '../PredictActionButtons/PredictClaimButton'; import { PredictEventValues } from '../../constants/eventNames'; @@ -163,16 +167,10 @@ const PredictPositionsHeader = forwardRef< const unrealizedAmount = unrealizedPnL?.cashUpnl ?? 0; const unrealizedPercent = unrealizedPnL?.percentUpnl ?? 0; - - const formatAmount = (amount: number) => { - const sign = amount >= 0 ? '+' : '-'; - return `${sign}$${Math.abs(amount).toFixed(2)}`; - }; - - const formatPercent = (percent: number) => { - const sign = percent >= 0 ? '+' : ''; - return `${sign}${formatPercentage(percent)}`; - }; + const unrealizedPnLDisplayParts = formatPredictUnrealizedPnLStringParts({ + cashUpnl: unrealizedAmount, + percentUpnl: unrealizedPercent, + }); const hasClaimableAmount = wonPositions.length > 0 && totalClaimableAmount !== undefined; @@ -315,10 +313,10 @@ const PredictPositionsHeader = forwardRef< isHidden={privacyMode} length={SensitiveTextLength.Long} > - {strings('predict.unrealized_pnl_value', { - amount: formatAmount(unrealizedAmount), - percent: formatPercent(unrealizedPercent), - })} + {strings( + 'predict.unrealized_pnl_value', + unrealizedPnLDisplayParts, + )} )} diff --git a/app/components/UI/Predict/utils/format.test.ts b/app/components/UI/Predict/utils/format.test.ts index af58869975c..16403cea49c 100644 --- a/app/components/UI/Predict/utils/format.test.ts +++ b/app/components/UI/Predict/utils/format.test.ts @@ -11,6 +11,7 @@ import { calculateNetAmount, formatPriceWithSubscriptNotation, formatGameStartTime, + formatPredictUnrealizedPnLStringParts, } from './format'; import { Recurrence, PredictSeries } from '../types'; @@ -2118,6 +2119,35 @@ describe('format utils', () => { }); }); + describe('formatPredictUnrealizedPnLStringParts', () => { + it('formats positive cash and percent with explicit + on percent', () => { + expect( + formatPredictUnrealizedPnLStringParts({ + cashUpnl: 95.39, + percentUpnl: 9.4, + }), + ).toEqual({ amount: '+$95.39', percent: '+9.4%' }); + }); + + it('formats negative cash and percent', () => { + expect( + formatPredictUnrealizedPnLStringParts({ + cashUpnl: -10.5, + percentUpnl: -3.25, + }), + ).toEqual({ amount: '-$10.50', percent: '-3.25%' }); + }); + + it('formats zero with + on cash and percent (matches positions header)', () => { + expect( + formatPredictUnrealizedPnLStringParts({ + cashUpnl: 0, + percentUpnl: 0, + }), + ).toEqual({ amount: '+$0.00', percent: '+0%' }); + }); + }); + describe('formatGameStartTime', () => { // Store original Intl.DateTimeFormat const OriginalDateTimeFormat = Intl.DateTimeFormat; diff --git a/app/components/UI/Predict/utils/format.ts b/app/components/UI/Predict/utils/format.ts index 13797d2890d..ff41d9524d1 100644 --- a/app/components/UI/Predict/utils/format.ts +++ b/app/components/UI/Predict/utils/format.ts @@ -64,6 +64,22 @@ export const formatPercentage = ( return `${formatted}%`; }; +/** + * Builds `amount` / `percent` for `strings('predict.unrealized_pnl_value', …)`. + * Same rules as PredictPositionsHeader: signed cash, signed % via `formatPercentage`. + */ +export function formatPredictUnrealizedPnLStringParts(data: { + cashUpnl: number; + percentUpnl: number; +}): { amount: string; percent: string } { + const { cashUpnl, percentUpnl } = data; + const amountSign = cashUpnl >= 0 ? '+' : '-'; + const amount = `${amountSign}$${Math.abs(cashUpnl).toFixed(2)}`; + const percentSign = percentUpnl >= 0 ? '+' : ''; + const percent = `${percentSign}${formatPercentage(percentUpnl)}`; + return { amount, percent }; +} + /** * Formats a price value as USD currency with rounding up to nearest cent * @param price - Raw numeric price value diff --git a/app/components/Views/Homepage/Homepage.test.tsx b/app/components/Views/Homepage/Homepage.test.tsx index 2320527f091..d027bae43d5 100644 --- a/app/components/Views/Homepage/Homepage.test.tsx +++ b/app/components/Views/Homepage/Homepage.test.tsx @@ -59,6 +59,10 @@ jest.mock('../../UI/Perps/hooks', () => ({ orders: [], isInitialLoading: false, })), + usePerpsLiveAccount: jest.fn(() => ({ + account: null, + isInitialLoading: false, + })), usePerpsMarkets: jest.fn(() => ({ markets: [], isLoading: false, @@ -108,6 +112,24 @@ jest.mock('../../UI/Predict/selectors/featureFlags', () => ({ selectPredictEnabledFlag: jest.fn(() => true), })); +jest.mock('@tanstack/react-query', () => { + const actual = jest.requireActual('@tanstack/react-query'); + return { + ...actual, + useQueryClient: jest.fn(() => ({ + invalidateQueries: jest.fn(() => Promise.resolve()), + })), + }; +}); + +jest.mock('../../UI/Predict/hooks/useUnrealizedPnL', () => ({ + useUnrealizedPnL: jest.fn(() => ({ + data: null, + isLoading: false, + error: null, + })), +})); + jest.mock('../../UI/Predict/hooks/usePredictPositions', () => ({ usePredictPositions: () => ({ data: [], diff --git a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx index 68db99dab93..16f1e7254d0 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx @@ -48,6 +48,13 @@ jest.mock('../../../../UI/Perps/hooks', () => ({ orders: [], isInitialLoading: false, })), + usePerpsLiveAccount: jest.fn(() => ({ + account: { + unrealizedPnl: '95.39', + returnOnEquity: '9.4', + }, + isInitialLoading: false, + })), usePerpsMarkets: jest.fn(() => ({ markets: [], isLoading: false, @@ -170,8 +177,12 @@ jest.mock('./components/PerpsMarketTileCard', () => { }; }); -const { usePerpsLivePositions, usePerpsLiveOrders, usePerpsMarkets } = - jest.requireMock('../../../../UI/Perps/hooks'); +const { + usePerpsLivePositions, + usePerpsLiveOrders, + usePerpsMarkets, + usePerpsLiveAccount, +} = jest.requireMock('../../../../UI/Perps/hooks'); const makePosition = (overrides: Record = {}) => ({ symbol: 'BTC', @@ -290,6 +301,45 @@ describe('PerpsSection', () => { expect(screen.getByText('ETH 40X position')).toBeOnTheScreen(); }); + it('shows aggregate unrealized P&L row when user has filled positions', () => { + usePerpsLivePositions.mockReturnValue({ + positions: [makePosition()], + isInitialLoading: false, + }); + usePerpsLiveAccount.mockReturnValue({ + account: { + unrealizedPnl: '95.39', + returnOnEquity: '9.4', + }, + isInitialLoading: false, + }); + + renderWithProvider( + , + ); + + expect( + screen.getByTestId('homepage-perps-unrealized-pnl'), + ).toBeOnTheScreen(); + }); + + it('does not show unrealized P&L row when user has only open orders', () => { + usePerpsLivePositions.mockReturnValue({ + positions: [], + isInitialLoading: false, + }); + usePerpsLiveOrders.mockReturnValue({ + orders: [makeOrder()], + isInitialLoading: false, + }); + + renderWithProvider( + , + ); + + expect(screen.queryByTestId('homepage-perps-unrealized-pnl')).toBeNull(); + }); + it('renders multiple position rows', () => { usePerpsLivePositions.mockReturnValue({ positions: [ diff --git a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx index c156d473d31..7bac10e0309 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx +++ b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx @@ -12,6 +12,7 @@ import { useNavigation, type NavigationProp } from '@react-navigation/native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box } from '@metamask/design-system-react-native'; import { useSelector } from 'react-redux'; +import { selectPrivacyMode } from '../../../../../selectors/preferencesController'; import { type PerpsMarketData, type Position, @@ -27,7 +28,12 @@ import { usePerpsLivePositions, usePerpsLiveOrders, usePerpsMarkets, + usePerpsLiveAccount, } from '../../../../UI/Perps/hooks'; +import { + formatPnl, + formatPercentage, +} from '../../../../UI/Perps/utils/formatUtils'; import { usePerpsConnection } from '../../../../UI/Perps/hooks/usePerpsConnection'; import { filterAndSortMarkets } from '../../../../UI/Perps/utils/filterAndSortMarkets'; import { @@ -48,6 +54,9 @@ import useHomeViewedEvent, { HomeSectionNames, } from '../../hooks/useHomeViewedEvent'; import type { PerpsSectionProps } from './PerpsSectionWithProvider'; +import HomepageSectionUnrealizedPnlRow, { + type HomepageUnrealizedPnlTone, +} from '../../components/HomepageSectionUnrealizedPnlRow'; const MAX_ITEMS = 5; const MAX_TRENDING_MARKETS = 5; @@ -71,12 +80,18 @@ const PerpsSection = forwardRef( const { error: connectionError, reconnectWithNewContext } = usePerpsConnection(); const { track } = usePerpsEventTracking(); + const privacyMode = useSelector(selectPrivacyMode); const { positions, isInitialLoading: positionsLoading } = usePerpsLivePositions({ throttleMs: HOMEPAGE_THROTTLE_MS, }); + const { account: perpsAccount, isInitialLoading: perpsAccountLoading } = + usePerpsLiveAccount({ + throttleMs: HOMEPAGE_THROTTLE_MS, + }); + const { orders, isInitialLoading: ordersLoading } = usePerpsLiveOrders({ hideTpSl: true, throttleMs: HOMEPAGE_THROTTLE_MS, @@ -112,11 +127,28 @@ const PerpsSection = forwardRef( ); const hasItems = displayPositions.length > 0 || displayOrders.length > 0; + const hasFilledPositions = positions.length > 0; // When user has no positions/orders, keep skeleton visible until markets load. const pendingTrending = !showSkeleton && !hasItems && marketsLoading; const showTrending = !showSkeleton && !hasItems && !marketsLoading; + const showHomepageUnrealizedPnl = + !showSkeleton && !pendingTrending && hasFilledPositions && !privacyMode; + + const homepageUnrealizedPnl = useMemo(() => { + if (!showHomepageUnrealizedPnl) { + return null; + } + const unrealizedPnl = perpsAccount?.unrealizedPnl ?? '0'; + const roe = parseFloat(perpsAccount?.returnOnEquity || '0'); + const pnlNum = parseFloat(unrealizedPnl); + const valueText = `${formatPnl(pnlNum)} (${formatPercentage(roe, 1)})`; + const tone: HomepageUnrealizedPnlTone = + pnlNum > 0 ? 'positive' : pnlNum < 0 ? 'negative' : 'neutral'; + return { valueText, tone }; + }, [perpsAccount, showHomepageUnrealizedPnl]); + const safeWatchlistSymbols = useMemo( () => watchlistSymbols ?? [], [watchlistSymbols], @@ -271,7 +303,18 @@ const PerpsSection = forwardRef( return ( - + + + {showHomepageUnrealizedPnl && ( + + )} + {showSkeleton || pendingTrending ? ( diff --git a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx index 003a201ca44..c472dbaff2d 100644 --- a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx @@ -25,6 +25,29 @@ jest.mock('../../../../UI/Predict/hooks/usePredictClaim', () => ({ usePredictClaim: () => ({ claim: mockClaim }), })); +jest.mock('../../../../UI/Predict/hooks/useUnrealizedPnL', () => ({ + useUnrealizedPnL: jest.fn(() => ({ + data: { cashUpnl: 10, percentUpnl: 5, user: '0x0' }, + isLoading: false, + error: null, + })), +})); + +jest.mock('../../../../../selectors/preferencesController', () => ({ + ...jest.requireActual('../../../../../selectors/preferencesController'), + selectPrivacyMode: () => false, +})); + +jest.mock('@tanstack/react-query', () => { + const actual = jest.requireActual('@tanstack/react-query'); + return { + ...actual, + useQueryClient: jest.fn(() => ({ + invalidateQueries: jest.fn(() => Promise.resolve()), + })), + }; +}); + // Mock the hooks jest.mock('./hooks', () => ({ usePredictMarketsForHomepage: jest.fn(() => ({ diff --git a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx index b73b8fc97d9..ddea412a829 100644 --- a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx @@ -2,8 +2,10 @@ import React, { forwardRef, useCallback, useImperativeHandle, + useMemo, useRef, } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { ScrollView, View } from 'react-native'; import { useNavigation, NavigationProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; @@ -14,6 +16,7 @@ import Routes from '../../../../../constants/navigation/Routes'; import { WalletViewSelectorsIDs } from '../../../../Views/Wallet/WalletView.testIds'; import { SectionRefreshHandle } from '../../types'; import { selectPredictEnabledFlag } from '../../../../UI/Predict/selectors/featureFlags'; +import { selectPrivacyMode } from '../../../../../selectors/preferencesController'; import { strings } from '../../../../../../locales/i18n'; import { usePredictMarketsForHomepage, @@ -26,14 +29,24 @@ import { PredictPositionRowSkeleton, } from './components'; import ViewMoreCard from '../../components/ViewMoreCard'; -import type { PredictPosition } from '../../../../UI/Predict/types'; +import type { + PredictPosition, + UnrealizedPnL, +} from '../../../../UI/Predict/types'; import type { PredictNavigationParamList } from '../../../../UI/Predict/types/navigation'; import { PredictEventValues } from '../../../../UI/Predict/constants/eventNames'; import { PredictClaimButton } from '../../../../UI/Predict/components/PredictActionButtons'; import { usePredictClaim } from '../../../../UI/Predict/hooks/usePredictClaim'; +import { useUnrealizedPnL } from '../../../../UI/Predict/hooks/useUnrealizedPnL'; +import { predictQueries } from '../../../../UI/Predict/queries'; +import { getEvmAccountFromSelectedAccountGroup } from '../../../../UI/Predict/utils/accounts'; +import { formatPredictUnrealizedPnLStringParts } from '../../../../UI/Predict/utils/format'; import useHomeViewedEvent, { HomeSectionNames, } from '../../hooks/useHomeViewedEvent'; +import HomepageSectionUnrealizedPnlRow, { + type HomepageUnrealizedPnlTone, +} from '../../components/HomepageSectionUnrealizedPnlRow'; const MAX_MARKETS_DISPLAYED = 5; @@ -48,6 +61,48 @@ interface PredictionsSectionProps { totalSectionsLoaded: number; } +interface PredictHomepageUnrealizedPnlRowState { + show: boolean; + isLoading: boolean; + valueText?: string; + tone: HomepageUnrealizedPnlTone; +} + +function getPredictHomepageUnrealizedPnlRowState(input: { + hasPositions: boolean; + privacyMode: boolean; + isPnlLoading: boolean; + pnl: UnrealizedPnL | null | undefined; +}): PredictHomepageUnrealizedPnlRowState { + const { hasPositions, privacyMode, isPnlLoading, pnl } = input; + + if (!hasPositions || privacyMode) { + return { show: false, isLoading: false, tone: 'neutral' }; + } + if (isPnlLoading) { + return { show: true, isLoading: true, tone: 'neutral' }; + } + if (!pnl) { + return { show: false, isLoading: false, tone: 'neutral' }; + } + + const cashUpnl = pnl.cashUpnl ?? 0; + const valueText = strings( + 'predict.unrealized_pnl_value', + formatPredictUnrealizedPnLStringParts({ + cashUpnl, + percentUpnl: pnl.percentUpnl ?? 0, + }), + ); + + return { + show: true, + isLoading: false, + valueText, + tone: cashUpnl > 0 ? 'positive' : cashUpnl < 0 ? 'negative' : 'neutral', + }; +} + /** * PredictionsSection - Displays prediction content on the homepage * @@ -66,6 +121,8 @@ const PredictionsSection = forwardRef< const navigation = useNavigation>(); const isPredictEnabled = useSelector(selectPredictEnabledFlag); + const privacyMode = useSelector(selectPrivacyMode); + const queryClient = useQueryClient(); const title = strings('homepage.sections.predictions'); const { claim } = usePredictClaim(); @@ -94,6 +151,29 @@ const PredictionsSection = forwardRef< // Determine if user has positions const hasPositions = positions.length > 0; + const { + data: predictUnrealizedPnL, + isLoading: isPredictUnrealizedPnLLoading, + } = useUnrealizedPnL({ + enabled: hasPositions, + }); + + const predictHomepageUnrealizedPnl = useMemo( + () => + getPredictHomepageUnrealizedPnlRowState({ + hasPositions, + privacyMode, + isPnlLoading: isPredictUnrealizedPnLLoading, + pnl: predictUnrealizedPnL, + }), + [ + hasPositions, + privacyMode, + isPredictUnrealizedPnLLoading, + predictUnrealizedPnL, + ], + ); + const isLoading = isLoadingPositions || isLoadingMarkets; const hasError = @@ -127,8 +207,14 @@ const PredictionsSection = forwardRef< }); const refresh = useCallback(async () => { - await Promise.all([refetchPositions(), refetchMarkets()]); - }, [refetchPositions, refetchMarkets]); + const addr = getEvmAccountFromSelectedAccountGroup()?.address; + const invalidatePnl = addr + ? queryClient.invalidateQueries({ + queryKey: predictQueries.unrealizedPnL.keys.byAddress(addr), + }) + : Promise.resolve(); + await Promise.all([refetchPositions(), refetchMarkets(), invalidatePnl]); + }, [queryClient, refetchPositions, refetchMarkets]); useImperativeHandle(ref, () => ({ refresh }), [refresh]); @@ -167,13 +253,24 @@ const PredictionsSection = forwardRef< return ( - + + {predictHomepageUnrealizedPnl.show && ( + )} - /> + {isLoadingPositions ? ( <> diff --git a/app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/HomepageSectionUnrealizedPnlRow.test.tsx b/app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/HomepageSectionUnrealizedPnlRow.test.tsx new file mode 100644 index 00000000000..b58758df027 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/HomepageSectionUnrealizedPnlRow.test.tsx @@ -0,0 +1,186 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import { TextColor } from '@metamask/design-system-react-native'; +import HomepageSectionUnrealizedPnlRow from './HomepageSectionUnrealizedPnlRow'; + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ + style: (...args: string[]) => ({ testStyle: args.join(' ') }), + }), +})); + +jest.mock('../../../../../component-library/components-temp/Skeleton', () => { + const { View } = jest.requireActual('react-native'); + return { + Skeleton: (props: { width: number; height: number }) => ( + + ), + }; +}); + +describe('HomepageSectionUnrealizedPnlRow', () => { + it('renders null when valueText is undefined', () => { + const { toJSON } = render( + , + ); + expect(toJSON()).toBeNull(); + }); + + it('renders null when valueText is empty string', () => { + const { toJSON } = render( + , + ); + expect(toJSON()).toBeNull(); + }); + + it('renders skeleton when isLoading is true', () => { + render( + , + ); + expect(screen.getByTestId('skeleton')).toBeOnTheScreen(); + expect(screen.getByTestId('pnl-row')).toBeOnTheScreen(); + expect(screen.queryByText('Unrealized P&L')).toBeNull(); + }); + + it('renders value and label when valueText is provided', () => { + render( + , + ); + expect(screen.getByText('+$95.39 (+9.4%)')).toBeOnTheScreen(); + expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); + }); + + it('derives value/label testIDs from testID prop', () => { + render( + , + ); + expect(screen.getByTestId('pnl')).toBeOnTheScreen(); + expect(screen.getByTestId('pnl-value')).toBeOnTheScreen(); + expect(screen.getByTestId('pnl-label')).toBeOnTheScreen(); + }); + + it('uses explicit valueTestID and labelTestID over derived ones', () => { + render( + , + ); + expect(screen.getByTestId('custom-value')).toBeOnTheScreen(); + expect(screen.getByTestId('custom-label')).toBeOnTheScreen(); + expect(screen.queryByTestId('pnl-value')).toBeNull(); + expect(screen.queryByTestId('pnl-label')).toBeNull(); + }); + + it('does not set testIDs on value/label when testID is not provided', () => { + render(); + expect(screen.getByText('+$10')).toBeOnTheScreen(); + expect(screen.getByText('P&L')).toBeOnTheScreen(); + }); + + describe('toneToColor mapping', () => { + it('applies success color for positive tone', () => { + render( + , + ); + expect(screen.getByTestId('val')).toBeOnTheScreen(); + }); + + it('applies error color for negative tone', () => { + render( + , + ); + expect(screen.getByTestId('val')).toBeOnTheScreen(); + }); + + it('applies default text color for neutral tone (default)', () => { + render( + , + ); + expect(screen.getByTestId('val')).toBeOnTheScreen(); + }); + }); + + it('uses valueColor prop over tone-derived color', () => { + render( + , + ); + expect(screen.getByTestId('val')).toBeOnTheScreen(); + }); + + it('passes paddingHorizontal=0 without error', () => { + render( + , + ); + expect(screen.getByTestId('row')).toBeOnTheScreen(); + }); + + it('passes marginTop=1 without error', () => { + render( + , + ); + expect(screen.getByTestId('row')).toBeOnTheScreen(); + }); + + it('renders skeleton with marginTop and paddingHorizontal when loading', () => { + render( + , + ); + expect(screen.getByTestId('skeleton')).toBeOnTheScreen(); + expect(screen.getByTestId('loading-row')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/HomepageSectionUnrealizedPnlRow.tsx b/app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/HomepageSectionUnrealizedPnlRow.tsx new file mode 100644 index 00000000000..2a24244ea4c --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/HomepageSectionUnrealizedPnlRow.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { + Box, + Text, + TextColor, + TextVariant, + BoxFlexDirection, + BoxAlignItems, + BoxFlexWrap, + FontWeight, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; + +export type HomepageUnrealizedPnlTone = 'positive' | 'negative' | 'neutral'; + +const toneToColor = (tone: HomepageUnrealizedPnlTone): TextColor => { + if (tone === 'positive') { + return TextColor.SuccessDefault; + } + if (tone === 'negative') { + return TextColor.ErrorDefault; + } + return TextColor.TextDefault; +}; + +export interface HomepageSectionUnrealizedPnlRowProps { + /** Muted label after the value (e.g. Unrealized P&L) */ + label: string; + /** When true, shows a placeholder instead of value + label */ + isLoading?: boolean; + /** Main numeric segment, e.g. +$95.39 (+9.4%) */ + valueText?: string; + tone?: HomepageUnrealizedPnlTone; + /** + * When set, used for the value text color instead of deriving from `tone` + * (Perps home “Your positions” line passes design-system colors from account state). + */ + valueColor?: TextColor; + /** Container test id (homepage sections). */ + testID?: string; + /** Value segment test id; default `${testID}-value` when `testID` is set. */ + valueTestID?: string; + /** Label segment test id; default `${testID}-label` when `testID` is set. */ + labelTestID?: string; + /** + * Horizontal padding (design-system spacing). `4` = 16px — wallet homepage; + * `0` when the parent section already applies horizontal inset (Perps “Your positions”). + */ + paddingHorizontal?: 0 | 4; + /** `1` = 4px below title when the parent does not use `gap` (Perps home section). */ + marginTop?: 1; +} + +/** + * Section sub-row: colored unrealized P&L value + muted label. + * Used on wallet homepage (Perps / Predict) and Perps tab “Your positions”. + * Spacing: 8px gap between value and label; optional `marginTop` 4px below title when needed. + */ +const HomepageSectionUnrealizedPnlRow: React.FC< + HomepageSectionUnrealizedPnlRowProps +> = ({ + label, + isLoading, + valueText, + tone = 'neutral', + valueColor: valueColorProp, + testID, + valueTestID: valueTestIDProp, + labelTestID: labelTestIDProp, + paddingHorizontal = 4, + marginTop, +}) => { + const tw = useTailwind(); + const resolvedValueColor = valueColorProp ?? toneToColor(tone); + const valueTestID = + valueTestIDProp ?? (testID ? `${testID}-value` : undefined); + const labelTestID = + labelTestIDProp ?? (testID ? `${testID}-label` : undefined); + + if (isLoading) { + return ( + + + + ); + } + + if (!valueText) { + return null; + } + + return ( + + + + {valueText} + + + {label} + + + + ); +}; + +export default HomepageSectionUnrealizedPnlRow; diff --git a/app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/index.ts b/app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/index.ts new file mode 100644 index 00000000000..bf0bf5f51e1 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageSectionUnrealizedPnlRow/index.ts @@ -0,0 +1,5 @@ +export { + default, + type HomepageSectionUnrealizedPnlRowProps, + type HomepageUnrealizedPnlTone, +} from './HomepageSectionUnrealizedPnlRow'; From 18af7ab1cc417ccd30c461143188fc8ccd28120c Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Fri, 27 Mar 2026 11:59:56 +0100 Subject: [PATCH 53/54] feat: Add support for data services extending `BaseDataService` (#27921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Replace the default `QueryClient` with a custom `QueryClient` from `createUIQueryClient`. This establishes the query client required for using the `BaseDataService` pattern from the core repo, which handles cache syncing. Existing UI-only queries should work as they did previously. ## **Changelog** CHANGELOG entry: null ## **Related issues** https://consensyssoftware.atlassian.net/browse/WPC-445 --- > [!NOTE] > **Medium Risk** > Moderate risk because it changes how the global React Query `QueryClient` is constructed and introduces messenger-backed call/subscribe plumbing that could affect caching and network behavior across the app. > > **Overview** > Switches `ReactQueryService` to build its `queryClient` via `@metamask/react-data-query`’s `createUIQueryClient`, passing an Engine messenger adapter to support data services and cache syncing while keeping the existing default query options. > > Adds a `DATA_SERVICES` registry (currently empty) for wiring in available data services, updates unit tests to validate the new client defaults and cache clearing behavior, and adds the new `@metamask/react-data-query` dependency (plus lockfile updates). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 088a264d8045541da6021f10059e042a4fb6c5f6. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/constants/data-services.ts | 2 + .../ReactQueryService.test.ts | 23 ++++++----- .../ReactQueryService/ReactQueryService.ts | 25 +++++++++++- package.json | 1 + yarn.lock | 39 ++++++++++++++++++- 5 files changed, 78 insertions(+), 12 deletions(-) create mode 100644 app/constants/data-services.ts diff --git a/app/constants/data-services.ts b/app/constants/data-services.ts new file mode 100644 index 00000000000..6649192899f --- /dev/null +++ b/app/constants/data-services.ts @@ -0,0 +1,2 @@ +// A list of all names of data services available in the client. +export const DATA_SERVICES: string[] = []; diff --git a/app/core/ReactQueryService/ReactQueryService.test.ts b/app/core/ReactQueryService/ReactQueryService.test.ts index 22660415165..7f2112b1d10 100644 --- a/app/core/ReactQueryService/ReactQueryService.test.ts +++ b/app/core/ReactQueryService/ReactQueryService.test.ts @@ -7,8 +7,8 @@ import { import { addEventListener as addNetInfoEventListener } from '@react-native-community/netinfo'; import { ReactQueryService } from './ReactQueryService'; -jest.mock('@tanstack/react-query', () => ({ - QueryClient: jest.fn().mockImplementation(() => ({ clear: jest.fn() })), +jest.mock('@tanstack/query-core', () => ({ + ...jest.requireActual('@tanstack/query-core'), focusManager: { setFocused: jest.fn() }, onlineManager: { setEventListener: jest.fn() }, })); @@ -43,14 +43,15 @@ describe('ReactQueryService', () => { describe('constructor', () => { it('creates a QueryClient with expected default options', () => { - expect(QueryClient).toHaveBeenCalledWith({ - defaultOptions: { - queries: { - staleTime: 1000 * 60 * 5, - retry: 2, - cacheTime: 1000 * 60 * 60 * 24, - }, + // @ts-expect-error Accessing private property. + expect(service.queryClient.defaultOptions).toStrictEqual({ + queries: { + staleTime: 1000 * 60 * 5, + retry: 2, + cacheTime: 1000 * 60 * 60 * 24, + queryFn: expect.any(Function), }, + mutations: undefined, }); }); @@ -169,9 +170,11 @@ describe('ReactQueryService', () => { }); it('clears the query client cache', () => { + const spy = jest.spyOn(service.queryClient, 'clear'); + service.destroy(); - expect(service.queryClient.clear).toHaveBeenCalled(); + expect(spy).toHaveBeenCalled(); }); }); }); diff --git a/app/core/ReactQueryService/ReactQueryService.ts b/app/core/ReactQueryService/ReactQueryService.ts index afb6a28aa08..7cafe136e5b 100644 --- a/app/core/ReactQueryService/ReactQueryService.ts +++ b/app/core/ReactQueryService/ReactQueryService.ts @@ -8,6 +8,29 @@ import { addEventListener as addNetInfoEventListener, type NetInfoState, } from '@react-native-community/netinfo'; +import { createUIQueryClient } from '@metamask/react-data-query'; +import { Json } from '@metamask/utils'; +import { MessengerActions, MessengerEvents } from '@metamask/messenger'; +import Engine from '../Engine/Engine'; +import { RootMessenger } from '../Engine/types'; +import { DATA_SERVICES } from '../../constants/data-services'; + +type ActionType = MessengerActions['type']; +type EventType = MessengerEvents['type']; + +type JsonSubscriptionCallback = (data: Json) => void; + +const adapter = { + call: async (method: string, ...params: Json[]) => + // @ts-expect-error Target requires 1 element(s) but source may have fewer. + Engine.controllerMessenger.call(method as ActionType, ...params) as Json, + subscribe: (event: string, callback: JsonSubscriptionCallback) => { + Engine.controllerMessenger.subscribe(event as EventType, callback); + }, + unsubscribe: (event: string, callback: JsonSubscriptionCallback) => { + Engine.controllerMessenger.unsubscribe(event as EventType, callback); + }, +}; export class ReactQueryService { queryClient: QueryClient; @@ -16,7 +39,7 @@ export class ReactQueryService { #netInfoUnsubscribe?: () => void; constructor() { - this.queryClient = new QueryClient({ + this.queryClient = createUIQueryClient(DATA_SERVICES, adapter, { defaultOptions: { queries: { // Mobile users often trigger re-renders or navigate back/forth frequently. diff --git a/package.json b/package.json index 30899efefc5..c154d6956ff 100644 --- a/package.json +++ b/package.json @@ -281,6 +281,7 @@ "@metamask/profile-metrics-controller": "^3.1.0", "@metamask/profile-sync-controller": "^28.0.0", "@metamask/ramps-controller": "^12.0.1", + "@metamask/react-data-query": "^0.2.0", "@metamask/react-native-acm": "1.2.0", "@metamask/react-native-actionsheet": "2.4.2", "@metamask/react-native-button": "^3.0.0", diff --git a/yarn.lock b/yarn.lock index 2509c541b63..95827944b76 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7883,6 +7883,19 @@ __metadata: languageName: node linkType: hard +"@metamask/base-data-service@npm:^0.1.0": + version: 0.1.1 + resolution: "@metamask/base-data-service@npm:0.1.1" + dependencies: + "@metamask/controller-utils": "npm:^11.19.0" + "@metamask/messenger": "npm:^1.0.0" + "@metamask/utils": "npm:^11.9.0" + "@tanstack/query-core": "npm:^4.43.0" + fast-deep-equal: "npm:^3.1.3" + checksum: 10/b746cfaad6625d61e0d5e3202488d1f3139ef1e4b8f04c7b07a3e0fbe474c1307c73bacb1cf5d9288e946719137c52f1bc1fd40e9e44800f65b8729bbed7311d + languageName: node + linkType: hard + "@metamask/bitcoin-wallet-snap@npm:^1.10.0": version: 1.10.0 resolution: "@metamask/bitcoin-wallet-snap@npm:1.10.0" @@ -8890,6 +8903,13 @@ __metadata: languageName: node linkType: hard +"@metamask/messenger@npm:^1.0.0": + version: 1.0.0 + resolution: "@metamask/messenger@npm:1.0.0" + checksum: 10/ab1219a922d5acc86f2b1b557d79c75ca0c5f42572f50da8a2337bc5c8feb1ae95c0aaa2d2ee55b677acd4401fb2cc9c2dbacca7513edcddf20d88fb73fa7bea + languageName: node + linkType: hard + "@metamask/metamask-eth-abis@npm:3.1.1, @metamask/metamask-eth-abis@npm:^3.1.1": version: 3.1.1 resolution: "@metamask/metamask-eth-abis@npm:3.1.1" @@ -9433,6 +9453,22 @@ __metadata: languageName: node linkType: hard +"@metamask/react-data-query@npm:^0.2.0": + version: 0.2.0 + resolution: "@metamask/react-data-query@npm:0.2.0" + dependencies: + "@metamask/base-data-service": "npm:^0.1.0" + "@metamask/utils": "npm:^11.9.0" + "@tanstack/query-core": "npm:^4.43.0" + "@tanstack/react-query": "npm:^4.43.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-native: "*" + checksum: 10/c54a9943bb68ad46ad9964864d4ee81604e61216ebed00026a1a4681a1e1091b6e413b9812d25e38d028493f3c75e91c8102b9d1c0232f409cd6f1dd4d22b211 + languageName: node + linkType: hard + "@metamask/react-native-acm@npm:1.2.0": version: 1.2.0 resolution: "@metamask/react-native-acm@npm:1.2.0" @@ -17427,7 +17463,7 @@ __metadata: languageName: node linkType: hard -"@tanstack/query-core@npm:4.43.0": +"@tanstack/query-core@npm:4.43.0, @tanstack/query-core@npm:^4.43.0": version: 4.43.0 resolution: "@tanstack/query-core@npm:4.43.0" checksum: 10/c2a5a151c7adaea8311e01a643255f31946ae3164a71567ba80048242821ae14043f13f5516b695baebe5ea7e4b2cf717fd60908a929d18a5c5125fee925ff67 @@ -35612,6 +35648,7 @@ __metadata: "@metamask/profile-sync-controller": "npm:^28.0.0" "@metamask/providers": "npm:^18.3.1" "@metamask/ramps-controller": "npm:^12.0.1" + "@metamask/react-data-query": "npm:^0.2.0" "@metamask/react-native-acm": "npm:1.2.0" "@metamask/react-native-actionsheet": "npm:2.4.2" "@metamask/react-native-button": "npm:^3.0.0" From 3e308f81c62335d6695fe512c85d609e02010f7f Mon Sep 17 00:00:00 2001 From: Curtis David Date: Fri, 27 Mar 2026 07:22:57 -0400 Subject: [PATCH 54/54] test: update predict and confirmations components with proper testIDS (#28011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** > Updates Predict Market Details tab testIDs to be **stable and semantic** by switching from index-based IDs to typed tab-key IDs (`'positions' | 'outcomes' | 'about'`), and updates `PredictMarketDetailsTabBar` plus its unit tests to use the new `getPredictMarketDetailsSelector.tabBarTab(tabKey)` API. > > Adds explicit testIDs to the confirmations `NetworkFilter` tabs by introducing `network-filter.testIds.ts` and passing a required `testID` prop into `NetworkFilterTab` for the “All networks” option and each per-chain tab. > ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk: changes are limited to test ID generation and wiring for UI elements, with no business logic or data flow modifications. > > **Overview** > Updates Predict Market Details tab testIDs to be **stable and semantic** by switching from index-based IDs to typed tab-key IDs (`'positions' | 'outcomes' | 'about'`), and updates `PredictMarketDetailsTabBar` plus its unit tests to use the new `getPredictMarketDetailsSelector.tabBarTab(tabKey)` API. > > Adds explicit testIDs to the confirmations `NetworkFilter` tabs by introducing `network-filter.testIds.ts` and passing a required `testID` prop into `NetworkFilterTab` for the “All networks” option and each per-chain tab. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit bea4356a15416da2e62fbfb46082d4516c121ab4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/UI/Predict/Predict.testIds.ts | 19 +++++++----- .../PredictMarketDetails.test.tsx | 30 +++++++++---------- .../PredictMarketDetailsTabBar.tsx | 12 ++++---- .../network-filter/network-filter.testIds.ts | 4 +++ .../network-filter/network-filter.tsx | 9 ++++++ 5 files changed, 46 insertions(+), 28 deletions(-) create mode 100644 app/components/Views/confirmations/components/network-filter/network-filter.testIds.ts diff --git a/app/components/UI/Predict/Predict.testIds.ts b/app/components/UI/Predict/Predict.testIds.ts index 82c1f704f36..83a90b9f0ca 100644 --- a/app/components/UI/Predict/Predict.testIds.ts +++ b/app/components/UI/Predict/Predict.testIds.ts @@ -80,6 +80,14 @@ export const getPredictFeedMockSelector = { // PREDICT MARKET DETAILS SELECTORS // ======================================== +export type PredictMarketDetailsTabKey = 'positions' | 'outcomes' | 'about'; + +export const getPredictMarketDetailsSelector = { + tabBarTab: (tabKey: PredictMarketDetailsTabKey) => + `predict-market-details-tab-bar-tab-${tabKey}`, + icon: (name: string) => `icon-${name}`, +} as const; + export const PredictMarketDetailsSelectorsIDs = { // Main screen SCREEN: 'predict-market-details-screen', @@ -96,9 +104,9 @@ export const PredictMarketDetailsSelectorsIDs = { OUTCOMES_TAB: 'predict-market-details-outcomes-tab', // Tab labels - POSITIONS_TAB_LABEL: 'predict-market-details-tab-bar-tab-0', - OUTCOMES_TAB_LABEL: 'predict-market-details-tab-bar-tab-1', - ABOUT_TAB_LABEL: 'predict-market-details-tab-bar-tab-2', + POSITIONS_TAB_LABEL: getPredictMarketDetailsSelector.tabBarTab('positions'), + OUTCOMES_TAB_LABEL: getPredictMarketDetailsSelector.tabBarTab('outcomes'), + ABOUT_TAB_LABEL: getPredictMarketDetailsSelector.tabBarTab('about'), // Tab content containers ABOUT_TAB_CONTENT: 'about-tab-content', @@ -119,11 +127,6 @@ export const PredictMarketDetailsSelectorsIDs = { 'predict-details-buttons-skeleton-button-1', } as const; -export const getPredictMarketDetailsSelector = { - tabBarTab: (index: number) => `predict-market-details-tab-bar-tab-${index}`, - icon: (name: string) => `icon-${name}`, -} as const; - export const PredictMarketDetailsSelectorsText = { // Tab content containers ABOUT_TAB_TEXT: 'About', diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index b6b9731536e..568630de21a 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -805,7 +805,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(); const aboutTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(1), + getPredictMarketDetailsSelector.tabBarTab('about'), ); fireEvent.press(aboutTab); @@ -818,7 +818,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(); const aboutTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(1), + getPredictMarketDetailsSelector.tabBarTab('about'), ); fireEvent.press(aboutTab); @@ -834,7 +834,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(); const aboutTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(1), + getPredictMarketDetailsSelector.tabBarTab('about'), ); fireEvent.press(aboutTab); @@ -848,7 +848,7 @@ describe('PredictMarketDetails', () => { const { mockNavigate } = setupPredictMarketDetailsTest(); const aboutTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(1), + getPredictMarketDetailsSelector.tabBarTab('about'), ); fireEvent.press(aboutTab); @@ -1177,7 +1177,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(); const aboutTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(1), + getPredictMarketDetailsSelector.tabBarTab('about'), ); fireEvent.press(aboutTab); @@ -1351,7 +1351,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(marketWithoutEndDate); const aboutTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(1), + getPredictMarketDetailsSelector.tabBarTab('about'), ); fireEvent.press(aboutTab); @@ -1423,7 +1423,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(0), + getPredictMarketDetailsSelector.tabBarTab('positions'), ); fireEvent.press(positionsTab); @@ -1606,7 +1606,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(0), + getPredictMarketDetailsSelector.tabBarTab('positions'), ); fireEvent.press(positionsTab); @@ -1641,7 +1641,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(0), + getPredictMarketDetailsSelector.tabBarTab('positions'), ); fireEvent.press(positionsTab); @@ -1788,7 +1788,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(0), + getPredictMarketDetailsSelector.tabBarTab('positions'), ); fireEvent.press(positionsTab); @@ -1821,7 +1821,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(0), + getPredictMarketDetailsSelector.tabBarTab('positions'), ); fireEvent.press(positionsTab); @@ -1854,7 +1854,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(0), + getPredictMarketDetailsSelector.tabBarTab('positions'), ); fireEvent.press(positionsTab); @@ -2221,7 +2221,7 @@ describe('PredictMarketDetails', () => { // Switch to Positions tab (index 0 when positions exist) const positionsTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(0), + getPredictMarketDetailsSelector.tabBarTab('positions'), ); fireEvent.press(positionsTab); @@ -2661,7 +2661,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest(closedMarket); const aboutTab = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(1), + getPredictMarketDetailsSelector.tabBarTab('about'), ); fireEvent.press(aboutTab); @@ -2697,7 +2697,7 @@ describe('PredictMarketDetails', () => { ); const aboutTabWithPositions = screen.getByTestId( - getPredictMarketDetailsSelector.tabBarTab(2), + getPredictMarketDetailsSelector.tabBarTab('about'), ); fireEvent.press(aboutTabWithPositions); diff --git a/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsTabBar/PredictMarketDetailsTabBar.tsx b/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsTabBar/PredictMarketDetailsTabBar.tsx index 12cdc948d56..3478bfb5637 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsTabBar/PredictMarketDetailsTabBar.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsTabBar/PredictMarketDetailsTabBar.tsx @@ -9,12 +9,14 @@ import { TextVariant, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { PredictMarketDetailsSelectorsIDs } from '../../../../Predict.testIds'; - -type TabKey = 'positions' | 'outcomes' | 'about'; +import { + getPredictMarketDetailsSelector, + PredictMarketDetailsSelectorsIDs, + type PredictMarketDetailsTabKey, +} from '../../../../Predict.testIds'; export interface PredictMarketDetailsTabBarProps { - tabs: { label: string; key: TabKey }[]; + tabs: { label: string; key: PredictMarketDetailsTabKey }[]; activeTab: number | null; onTabPress: (tabIndex: number) => void; } @@ -41,7 +43,7 @@ const PredictMarketDetailsTabBar = memo( 'w-1/3 py-3', activeTab === index ? 'border-b-2 border-default' : '', )} - testID={`${PredictMarketDetailsSelectorsIDs.TAB_BAR}-tab-${index}`} + testID={getPredictMarketDetailsSelector.tabBarTab(tab.key)} > + `transaction-pay-network-filter-${chainId}`; diff --git a/app/components/Views/confirmations/components/network-filter/network-filter.tsx b/app/components/Views/confirmations/components/network-filter/network-filter.tsx index 570e2436a9e..2573285b932 100644 --- a/app/components/Views/confirmations/components/network-filter/network-filter.tsx +++ b/app/components/Views/confirmations/components/network-filter/network-filter.tsx @@ -20,6 +20,10 @@ import { NETWORK_FILTER_ALL, } from '../../hooks/send/useNetworkFilter'; import { ScrollView } from 'react-native-gesture-handler'; +import { + getNetworkFilterTestId, + NETWORK_FILTER_ALL_TEST_ID, +} from './network-filter.testIds'; interface NetworkFilterTabProps { label: string; @@ -27,6 +31,7 @@ interface NetworkFilterTabProps { isSelected: boolean; onPress: () => void; showIcon?: boolean; + testID: string; } const NetworkFilterTab: React.FC = ({ @@ -35,6 +40,7 @@ const NetworkFilterTab: React.FC = ({ isSelected, onPress, showIcon = false, + testID, }) => { const tw = useTailwind(); @@ -49,6 +55,7 @@ const NetworkFilterTab: React.FC = ({ ) } hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }} + testID={testID} > {showIcon && imageSource && ( @@ -135,6 +142,7 @@ export const NetworkFilter: React.FC = ({ isSelected={selectedNetworkFilter === NETWORK_FILTER_ALL} onPress={() => setSelectedNetworkFilter(NETWORK_FILTER_ALL)} showIcon={false} + testID={NETWORK_FILTER_ALL_TEST_ID} /> {/* Individual Network Tabs */} @@ -146,6 +154,7 @@ export const NetworkFilter: React.FC = ({ isSelected={selectedNetworkFilter === network.chainId} onPress={() => setSelectedNetworkFilter(network.chainId)} showIcon + testID={getNetworkFilterTestId(network.chainId)} /> ))}