From b6e494cc6fe2d72ae470d4394839771448778883 Mon Sep 17 00:00:00 2001 From: sethkfman Date: Wed, 5 Nov 2025 16:38:07 -0700 Subject: [PATCH 01/35] bump semver 7.58.1 --- android/app/build.gradle | 2 +- bitrise.yml | 4 ++-- ios/MetaMask.xcodeproj/project.pbxproj | 12 ++++++------ package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 5a430ef4cfe..6ac9de9ef6b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -187,7 +187,7 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionName "7.58.0" + versionName "7.58.1" versionCode 2948 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/bitrise.yml b/bitrise.yml index 519b8d9b6b3..6150993f6d3 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3737,13 +3737,13 @@ app: PROJECT_LOCATION_IOS: ios - opts: is_expand: false - VERSION_NAME: 7.58.0 + VERSION_NAME: 7.58.1 - opts: is_expand: false VERSION_NUMBER: 2948 - opts: is_expand: false - FLASK_VERSION_NAME: 7.58.0 + FLASK_VERSION_NAME: 7.58.1 - opts: is_expand: false FLASK_VERSION_NUMBER: 2948 diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index f934b2e995f..69b6ec68bc2 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1311,7 +1311,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.58.0; + MARKETING_VERSION = 7.58.1; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1377,7 +1377,7 @@ "${inherited}", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.58.0; + MARKETING_VERSION = 7.58.1; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1446,7 +1446,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.58.0; + MARKETING_VERSION = 7.58.1; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1510,7 +1510,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.58.0; + MARKETING_VERSION = 7.58.1; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = ( @@ -1676,7 +1676,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.58.0; + MARKETING_VERSION = 7.58.1; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "$(inherited)", @@ -1743,7 +1743,7 @@ "\"$(SRCROOT)/MetaMask/System/Library/Frameworks\"", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.58.0; + MARKETING_VERSION = 7.58.1; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "$(inherited)", diff --git a/package.json b/package.json index 8b2b3b3a219..504fc3b1c16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask", - "version": "7.58.0", + "version": "7.58.1", "private": true, "scripts": { "install:foundryup": "yarn mm-foundryup", From 39c63c19be42a5be45e10206549805ad144dbf7b Mon Sep 17 00:00:00 2001 From: metamaskbot Date: Wed, 5 Nov 2025 23:39:56 +0000 Subject: [PATCH 02/35] [skip ci] Bump version number to 2949 --- 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 6ac9de9ef6b..040b47c35a8 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.58.1" - versionCode 2948 + versionCode 2949 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 6150993f6d3..7498404399b 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3740,13 +3740,13 @@ app: VERSION_NAME: 7.58.1 - opts: is_expand: false - VERSION_NUMBER: 2948 + VERSION_NUMBER: 2949 - opts: is_expand: false FLASK_VERSION_NAME: 7.58.1 - opts: is_expand: false - FLASK_VERSION_NUMBER: 2948 + FLASK_VERSION_NUMBER: 2949 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 69b6ec68bc2..8c97b5e1a1d 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1273,7 +1273,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2948; + CURRENT_PROJECT_VERSION = 2949; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1342,7 +1342,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2948; + CURRENT_PROJECT_VERSION = 2949; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1408,7 +1408,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2948; + CURRENT_PROJECT_VERSION = 2949; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1475,7 +1475,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2948; + CURRENT_PROJECT_VERSION = 2949; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1638,7 +1638,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2948; + CURRENT_PROJECT_VERSION = 2949; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1708,7 +1708,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2948; + CURRENT_PROJECT_VERSION = 2949; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; From ca57ba1e3af2ebb749d61379981c1dff3c45786b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Loureiro?= <175489935+joaoloureirop@users.noreply.github.com> Date: Thu, 6 Nov 2025 15:20:08 +0000 Subject: [PATCH 03/35] chore: increase bundle size check value to 50 (#22254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Raise iOS JS bundle size check threshold in CI from 45 to 50. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ef99dc240626065d8d8709dcd9a799e1de27de35. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9453cacf98..721fc51878d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -335,7 +335,7 @@ jobs: run: yarn gen-bundle:ios - name: Check bundle size - run: ./scripts/js-bundle-stats.sh ios/main.jsbundle 45 + run: ./scripts/js-bundle-stats.sh ios/main.jsbundle 50 - name: Upload iOS bundle uses: actions/upload-artifact@v4 From 22b9edaaf3f62afb168dcc586391dffab2ea526c Mon Sep 17 00:00:00 2001 From: "runway-github[bot]" <73448015+runway-github[bot]@users.noreply.github.com> Date: Thu, 6 Nov 2025 16:29:34 +0000 Subject: [PATCH 04/35] chore(runway): cherry-pick fix(card): delegation issues (#22249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(card): delegation issues (#22058) ## **Description** This PR addresses several issues affecting the Card experience, ensuring proper asset display, navigation flow, and data consistency across components. Fixes - Fixed an issue where assets were not loading correctly after opening the Card Home screen. - Corrected balance handling on the Change Asset Bottom Sheet — it now displays `availableBalance` for enabled tokens and the user’s total balance for disabled ones. - Restored asset icons on all asset-related bottom sheets. - Fixed incorrect titles on the Spending Limit screen: - Selecting a token that isn’t enabled now correctly shows “Change token and network.” - Pressing “Manage spending limit” on Card Home now correctly shows “Enable token.” - Resolved concurrency issues caused by promise caching in the `useLoadCardData` hook. ## **Changelog** CHANGELOG entry: Fixed issue where assets failed to load after opening the Card Home screen. CHANGELOG entry: Fixed balance display on the Change Asset Bottom Sheet to correctly show availableBalance for enabled tokens and user balance for disabled tokens. CHANGELOG entry: Restored missing asset icons on asset bottom sheets. CHANGELOG entry: Fixed incorrect Spending Limit title when selecting a token that’s not enabled (now shows “Change token and network”). CHANGELOG entry: Fixed incorrect Spending Limit title when pressing “Manage spending limit” on Card Home (now shows “Enable token”). CHANGELOG entry: Resolved concurrency and caching issues in useLoadCardData hook. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [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] > Improve Card balances and UX (pull-to-refresh, spending limit progress), refactor delegation flow with confirmation and caching, enhance asset selection and data fetching with cache/timeout, and clear caches on logout/auth errors. > > - **Card Home UI**: > - Replace `useAssetBalance` with batched `useAssetBalances` and update balance fallbacks. > - Add pull‑to‑refresh, spending limit progress bar, and close‑limit warning with actions. > - Improve navigation for managing spending limit; hide Solana-specific options; show error toast on enable failure. > - **Spending Limit**: > - Refactor delegation flow: SIWE signing, tx confirmation wait, cache clearing, success/error toasts, navigation blocking; add `UserCancelledError`. > - **Asset Selection Bottom Sheet**: > - Use `useAssetBalances` for fiat/token balances and icons; filter/sort tokens; priority update with cache invalidation; Solana footer link. > - **Data Fetching & Caching**: > - Add `useGetDelegationSettings` and `useGetCardExternalWalletDetails` with caching and timeouts. > - Fix `useLoadCardData` concurrency; add `fetchAllData`/`refetchAllData` and authenticated data wiring. > - Clear all card caches on auth errors/logout. > - **SDK/Utils**: > - Simplify ethers provider usage; remove unused network mapper; add `mapCaipChainIdToChainName` and safer chainId utils. > - **Copy/UX**: > - Update warning dismiss label; add `enable_card_error` locale. > - **Tests**: > - Add/expand comprehensive unit/integration tests and snapshots across new hooks, components, and flows. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 69808b6f95940834be90f6a2eb7dd3882cb26c8e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [0b3c740](https://github.com/MetaMask/metamask-mobile/commit/0b3c7401ccfc380bd23d0e2f7d38d91db4b53e74) Co-authored-by: Bruno Nascimento Co-authored-by: João Loureiro <175489935+joaoloureirop@users.noreply.github.com> --- .../UI/Card/Views/CardHome/CardHome.test.tsx | 456 ++++--- .../UI/Card/Views/CardHome/CardHome.tsx | 223 +++- .../__snapshots__/CardHome.test.tsx.snap | 126 ++ .../SpendingLimit/SpendingLimit.test.tsx | 554 ++++++--- .../Views/SpendingLimit/SpendingLimit.tsx | 333 +++--- .../AssetSelectionBottomSheet.test.tsx | 1059 ++++++++++++++++ .../AssetSelectionBottomSheet.tsx | 385 +++--- .../CardWarningBox/CardWarningBox.tsx | 2 +- .../SpendingLimitProgressBar.styles.ts | 10 +- .../SpendingLimitProgressBar.test.tsx | 239 +--- .../SpendingLimitProgressBar.tsx | 100 +- .../UI/Card/hooks/useAssetBalance.test.ts | 1030 ---------------- .../UI/Card/hooks/useAssetBalance.tsx | 258 ---- .../UI/Card/hooks/useAssetBalances.test.ts | 740 ++++++++++++ .../UI/Card/hooks/useAssetBalances.tsx | 425 +++++++ app/components/UI/Card/hooks/useAssetsList.ts | 207 ---- .../UI/Card/hooks/useCardDelegation.test.ts | 1064 +++++++++++++++++ .../UI/Card/hooks/useCardDelegation.ts | 233 ++-- .../useGetCardExternalWalletDetails.test.ts | 931 +++++++++++++++ .../hooks/useGetCardExternalWalletDetails.ts | 53 +- .../hooks/useGetDelegationSettings.test.ts | 565 +++++++++ .../hooks/useGetPriorityCardToken.test.ts | 506 +++----- .../UI/Card/hooks/useGetPriorityCardToken.tsx | 8 +- .../UI/Card/hooks/useLoadCardData.test.ts | 758 ++++++++++++ .../UI/Card/hooks/useLoadCardData.ts | 27 +- .../UI/Card/hooks/useSupportedTokens.tsx | 57 - app/components/UI/Card/sdk/CardSDK.test.ts | 17 - app/components/UI/Card/sdk/CardSDK.ts | 17 +- app/components/UI/Card/sdk/index.test.tsx | 7 +- app/components/UI/Card/sdk/index.tsx | 15 +- app/components/UI/Card/types.ts | 4 +- .../util/mapCaipChainIdToChainName.test.ts | 138 +++ .../UI/Card/util/mapCaipChainIdToChainName.ts | 10 + .../UI/Card/util/mapCountryToLocation.test.ts | 114 ++ .../Card/util/safeFormatChainIdToHex.test.ts | 209 ++++ locales/languages/en.json | 1 + 36 files changed, 7859 insertions(+), 3022 deletions(-) create mode 100644 app/components/UI/Card/components/AssetSelectionBottomSheet/AssetSelectionBottomSheet.test.tsx delete mode 100644 app/components/UI/Card/hooks/useAssetBalance.test.ts delete mode 100644 app/components/UI/Card/hooks/useAssetBalance.tsx create mode 100644 app/components/UI/Card/hooks/useAssetBalances.test.ts create mode 100644 app/components/UI/Card/hooks/useAssetBalances.tsx delete mode 100644 app/components/UI/Card/hooks/useAssetsList.ts create mode 100644 app/components/UI/Card/hooks/useCardDelegation.test.ts create mode 100644 app/components/UI/Card/hooks/useGetCardExternalWalletDetails.test.ts create mode 100644 app/components/UI/Card/hooks/useGetDelegationSettings.test.ts create mode 100644 app/components/UI/Card/hooks/useLoadCardData.test.ts delete mode 100644 app/components/UI/Card/hooks/useSupportedTokens.tsx create mode 100644 app/components/UI/Card/util/mapCaipChainIdToChainName.test.ts create mode 100644 app/components/UI/Card/util/mapCaipChainIdToChainName.ts create mode 100644 app/components/UI/Card/util/mapCountryToLocation.test.ts create mode 100644 app/components/UI/Card/util/safeFormatChainIdToHex.test.ts diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx index 094f6526871..e08ee0372a0 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx @@ -90,8 +90,11 @@ const mockPriorityToken = { decimals: 6, balance: '1000000000', allowance: '500000000', + totalAllowance: '1000', name: 'USD Coin', chainId: 1, + caipChainId: 'eip155:1', + walletAddress: '0x789', allowanceState: AllowanceState.Enabled, }; @@ -118,26 +121,37 @@ const mockEventBuilder = { build: jest.fn().mockReturnValue({ event: 'built' }), }; -interface MockAssetBalanceReturn { +interface MockAssetBalanceInfo { balanceFiat: string | undefined; asset: { symbol: string; image: string }; - mainBalance: string | undefined; - secondaryBalance: string | undefined; + balanceFormatted: string | undefined; rawTokenBalance?: number; rawFiatNumber?: number; } -const mockUseAssetBalance = jest.fn(() => ({ - balanceFiat: '$1,000.00', - asset: { - symbol: 'USDC', - image: 'usdc-image-url', - }, - mainBalance: '$1,000.00', - secondaryBalance: '1000 USDC', - rawTokenBalance: 1000, - rawFiatNumber: 1000, -})); +const createMockAssetBalancesMap = ( + balanceInfo: MockAssetBalanceInfo, + token = mockPriorityToken, +): Map => { + const map = new Map(); + // Use the same key format as the component: `${address}-${caipChainId}-${walletAddress}` + const key = `${token.address?.toLowerCase()}-${token.caipChainId}-${token.walletAddress?.toLowerCase()}`; + map.set(key, balanceInfo); + return map; +}; + +const mockUseAssetBalances = jest.fn(() => + createMockAssetBalancesMap({ + balanceFiat: '$1,000.00', + asset: { + symbol: 'USDC', + image: 'usdc-image-url', + }, + balanceFormatted: '1000.000000 USDC', + rawTokenBalance: 1000, + rawFiatNumber: 1000, + }), +); const mockUseNavigateToCardPage = jest.fn(() => ({ navigateToCardPage: mockNavigateToCardPage, @@ -152,8 +166,8 @@ jest.mock('../../hooks/useLoadCardData', () => ({ default: jest.fn(), })); -jest.mock('../../hooks/useAssetBalance', () => ({ - useAssetBalance: () => mockUseAssetBalance(), +jest.mock('../../hooks/useAssetBalances', () => ({ + useAssetBalances: () => mockUseAssetBalances(), })); jest.mock('../../hooks/useNavigateToCardPage', () => ({ @@ -493,17 +507,18 @@ describe('CardHome Component', () => { isLoadingPollCardStatusUntilProvisioned: false, }); - mockUseAssetBalance.mockReturnValue({ - balanceFiat: '$1,000.00', - asset: { - symbol: 'USDC', - image: 'usdc-image-url', - }, - mainBalance: '$1,000.00', - secondaryBalance: '1000 USDC', - rawTokenBalance: 1000, - rawFiatNumber: 1000, - }); + mockUseAssetBalances.mockReturnValue( + createMockAssetBalancesMap({ + balanceFiat: '$1,000.00', + asset: { + symbol: 'USDC', + image: 'usdc-image-url', + }, + balanceFormatted: '1000.000000 USDC', + rawTokenBalance: 1000, + rawFiatNumber: 1000, + }), + ); mockUseNavigateToCardPage.mockReturnValue({ navigateToCardPage: mockNavigateToCardPage, @@ -791,46 +806,48 @@ describe('CardHome Component', () => { }); }); - it('falls back to mainBalance when balanceFiat is TOKEN_RATE_UNDEFINED', () => { + it('falls back to balanceFormatted when balanceFiat is TOKEN_RATE_UNDEFINED', () => { // Given: fiat rate is undefined - mockUseAssetBalance.mockReturnValue({ - balanceFiat: TOKEN_RATE_UNDEFINED, - asset: { - symbol: 'USDC', - image: 'usdc-image-url', - }, - mainBalance: '1000 USDC', - secondaryBalance: 'Unable to find conversion rate', - rawTokenBalance: 1000, - rawFiatNumber: 0, - }); + mockUseAssetBalances.mockReturnValue( + createMockAssetBalancesMap({ + balanceFiat: TOKEN_RATE_UNDEFINED, + asset: { + symbol: 'USDC', + image: 'usdc-image-url', + }, + balanceFormatted: '1000.000000 USDC', + rawTokenBalance: 1000, + rawFiatNumber: 0, + }), + ); // When: component renders render(); - // Then: should display main balance instead of fiat - expect(screen.getByText('1000 USDC')).toBeTruthy(); + // Then: should display formatted balance instead of fiat + expect(screen.getByText('1000.000000 USDC')).toBeTruthy(); }); - it('falls back to mainBalance when balanceFiat is not available', () => { + it('falls back to balanceFormatted when balanceFiat is not available', () => { // Given: fiat balance is empty - mockUseAssetBalance.mockReturnValue({ - balanceFiat: '', - asset: { - symbol: 'USDC', - image: 'usdc-image-url', - }, - mainBalance: '1000 USDC', - secondaryBalance: 'Unable to find conversion rate', - rawTokenBalance: 1000, - rawFiatNumber: 0, - }); + mockUseAssetBalances.mockReturnValue( + createMockAssetBalancesMap({ + balanceFiat: '', + asset: { + symbol: 'USDC', + image: 'usdc-image-url', + }, + balanceFormatted: '1000.000000 USDC', + rawTokenBalance: 1000, + rawFiatNumber: 0, + }), + ); // When: component renders render(); - // Then: should display main balance as fallback - expect(screen.getByText('1000 USDC')).toBeTruthy(); + // Then: should display formatted balance as fallback + expect(screen.getByText('1000.000000 USDC')).toBeTruthy(); }); it('fires CARD_HOME_VIEWED once when balances are loaded', async () => { @@ -861,14 +878,15 @@ describe('CardHome Component', () => { it('includes zero raw balances in metrics', async () => { // Given: zero balances - mockUseAssetBalance.mockReturnValueOnce({ - balanceFiat: '$0.00', - asset: { symbol: 'USDC', image: 'usdc-image-url' }, - mainBalance: '0 USDC', - secondaryBalance: '$0.00', - rawTokenBalance: 0, - rawFiatNumber: 0, - }); + mockUseAssetBalances.mockReturnValueOnce( + createMockAssetBalancesMap({ + balanceFiat: '$0.00', + asset: { symbol: 'USDC', image: 'usdc-image-url' }, + balanceFormatted: '0.000000 USDC', + rawTokenBalance: 0, + rawFiatNumber: 0, + }), + ); // When: component renders render(); @@ -884,15 +902,16 @@ describe('CardHome Component', () => { }); it('includes only rawTokenBalance when fiat is undefined', async () => { - // Given: only main balance is valid (fiat undefined) - mockUseAssetBalance.mockReturnValueOnce({ - balanceFiat: undefined as unknown as string, - asset: { symbol: 'USDC', image: 'usdc-image-url' }, - mainBalance: '1000 USDC', - secondaryBalance: '1000 USDC', - rawTokenBalance: 1000, - // rawFiatNumber intentionally omitted (undefined) - }); + // Given: only formatted balance is valid (fiat undefined) + mockUseAssetBalances.mockReturnValueOnce( + createMockAssetBalancesMap({ + balanceFiat: undefined as unknown as string, + asset: { symbol: 'USDC', image: 'usdc-image-url' }, + balanceFormatted: '1000.000000 USDC', + rawTokenBalance: 1000, + // rawFiatNumber intentionally omitted (undefined) + }), + ); // When: component renders render(); @@ -908,16 +927,17 @@ describe('CardHome Component', () => { ); }); - it('includes only rawFiatNumber when main balance is undefined', async () => { - // Given: only fiat balance is valid (main undefined) - mockUseAssetBalance.mockReturnValueOnce({ - balanceFiat: '$1,000.00', - asset: { symbol: 'USDC', image: 'usdc-image-url' }, - mainBalance: undefined as unknown as string, - secondaryBalance: '$1,000.00', - // rawTokenBalance omitted - rawFiatNumber: 1000, - }); + it('includes only rawFiatNumber when formatted balance is undefined', async () => { + // Given: only fiat balance is valid (formatted balance undefined) + mockUseAssetBalances.mockReturnValueOnce( + createMockAssetBalancesMap({ + balanceFiat: '$1,000.00', + asset: { symbol: 'USDC', image: 'usdc-image-url' }, + balanceFormatted: undefined as unknown as string, + // rawTokenBalance omitted + rawFiatNumber: 1000, + }), + ); // When: component renders render(); @@ -933,16 +953,17 @@ describe('CardHome Component', () => { ); }); - it('fires CARD_HOME_VIEWED once when only mainBalance is valid', async () => { - // Given: only main balance is available - mockUseAssetBalance.mockReturnValue({ - balanceFiat: undefined as unknown as string, - asset: { symbol: 'USDC', image: 'usdc-image-url' }, - mainBalance: '1000 USDC', - secondaryBalance: '1000 USDC', - rawTokenBalance: 1000, - // rawFiatNumber omitted - }); + it('fires CARD_HOME_VIEWED once when only balanceFormatted is valid', async () => { + // Given: only formatted balance is available + mockUseAssetBalances.mockReturnValue( + createMockAssetBalancesMap({ + balanceFiat: undefined as unknown as string, + asset: { symbol: 'USDC', image: 'usdc-image-url' }, + balanceFormatted: '1000.000000 USDC', + rawTokenBalance: 1000, + // rawFiatNumber omitted + }), + ); // When: component renders render(); @@ -958,14 +979,15 @@ describe('CardHome Component', () => { it('fires CARD_HOME_VIEWED once when only fiat balance is valid', async () => { // Given: only fiat balance is available - mockUseAssetBalance.mockReturnValue({ - balanceFiat: '$1,000.00', - asset: { symbol: 'USDC', image: 'usdc-image-url' }, - mainBalance: undefined as unknown as string, - secondaryBalance: '$1,000.00', - // rawTokenBalance omitted - rawFiatNumber: 1000, - }); + mockUseAssetBalances.mockReturnValue( + createMockAssetBalancesMap({ + balanceFiat: '$1,000.00', + asset: { symbol: 'USDC', image: 'usdc-image-url' }, + balanceFormatted: undefined as unknown as string, + // rawTokenBalance omitted + rawFiatNumber: 1000, + }), + ); // When: component renders render(); @@ -981,13 +1003,14 @@ describe('CardHome Component', () => { it('does not fire metrics when balances are still loading', async () => { // Given: balances show loading sentinels - mockUseAssetBalance.mockReturnValue({ - balanceFiat: 'tokenBalanceLoading', - asset: { symbol: 'USDC', image: 'usdc-image-url' }, - mainBalance: 'TOKENBALANCELOADING', - secondaryBalance: 'loading', - // raw values omitted - }); + mockUseAssetBalances.mockReturnValue( + createMockAssetBalancesMap({ + balanceFiat: 'tokenBalanceLoading', + asset: { symbol: 'USDC', image: 'usdc-image-url' }, + balanceFormatted: 'TOKENBALANCELOADING', + // raw values omitted + }), + ); // When: component renders render(); @@ -998,14 +1021,15 @@ describe('CardHome Component', () => { }); it('does not fire metrics when balances are unavailable', async () => { - // Given: fiat is undefined and main is also undefined - mockUseAssetBalance.mockReturnValue({ - balanceFiat: 'tokenRateUndefined', - asset: { symbol: 'USDC', image: 'usdc-image-url' }, - mainBalance: undefined as unknown as string, - secondaryBalance: 'n/a', - // raw values omitted - }); + // Given: fiat is undefined and formatted balance is also undefined + mockUseAssetBalances.mockReturnValue( + createMockAssetBalancesMap({ + balanceFiat: 'tokenRateUndefined', + asset: { symbol: 'USDC', image: 'usdc-image-url' }, + balanceFormatted: undefined as unknown as string, + // raw values omitted + }), + ); // When: component renders render(); @@ -1017,14 +1041,15 @@ describe('CardHome Component', () => { it('converts NaN rawTokenBalance to 0 in metrics', async () => { // Given: rawTokenBalance is NaN - mockUseAssetBalance.mockReturnValueOnce({ - balanceFiat: '$1,000.00', - asset: { symbol: 'USDC', image: 'usdc-image-url' }, - mainBalance: '1000 USDC', - secondaryBalance: '1000 USDC', - rawTokenBalance: NaN, - rawFiatNumber: 1000, - }); + mockUseAssetBalances.mockReturnValueOnce( + createMockAssetBalancesMap({ + balanceFiat: '$1,000.00', + asset: { symbol: 'USDC', image: 'usdc-image-url' }, + balanceFormatted: '1000.000000 USDC', + rawTokenBalance: NaN, + rawFiatNumber: 1000, + }), + ); // When: component renders and fires metrics render(); @@ -1042,14 +1067,15 @@ describe('CardHome Component', () => { it('converts NaN rawFiatNumber to 0 in metrics', async () => { // Given: rawFiatNumber is NaN - mockUseAssetBalance.mockReturnValueOnce({ - balanceFiat: '$1,000.00', - asset: { symbol: 'USDC', image: 'usdc-image-url' }, - mainBalance: '1000 USDC', - secondaryBalance: '1000 USDC', - rawTokenBalance: 1000, - rawFiatNumber: NaN, - }); + mockUseAssetBalances.mockReturnValueOnce( + createMockAssetBalancesMap({ + balanceFiat: '$1,000.00', + asset: { symbol: 'USDC', image: 'usdc-image-url' }, + balanceFormatted: '1000.000000 USDC', + rawTokenBalance: 1000, + rawFiatNumber: NaN, + }), + ); // When: component renders and fires metrics render(); @@ -1067,14 +1093,15 @@ describe('CardHome Component', () => { it('converts both NaN raw values to 0 in metrics', async () => { // Given: both raw values are NaN - mockUseAssetBalance.mockReturnValueOnce({ - balanceFiat: '$1,000.00', - asset: { symbol: 'USDC', image: 'usdc-image-url' }, - mainBalance: '1000 USDC', - secondaryBalance: '1000 USDC', - rawTokenBalance: NaN, - rawFiatNumber: NaN, - }); + mockUseAssetBalances.mockReturnValueOnce( + createMockAssetBalancesMap({ + balanceFiat: '$1,000.00', + asset: { symbol: 'USDC', image: 'usdc-image-url' }, + balanceFormatted: '1000.000000 USDC', + rawTokenBalance: NaN, + rawFiatNumber: NaN, + }), + ); // When: component renders and fires metrics render(); @@ -1092,13 +1119,14 @@ describe('CardHome Component', () => { it('preserves undefined raw values in metrics', async () => { // Given: raw values are undefined (not provided) - mockUseAssetBalance.mockReturnValueOnce({ - balanceFiat: '$1,000.00', - asset: { symbol: 'USDC', image: 'usdc-image-url' }, - mainBalance: '1000 USDC', - secondaryBalance: '1000 USDC', - // rawTokenBalance and rawFiatNumber intentionally omitted (undefined) - }); + mockUseAssetBalances.mockReturnValueOnce( + createMockAssetBalancesMap({ + balanceFiat: '$1,000.00', + asset: { symbol: 'USDC', image: 'usdc-image-url' }, + balanceFormatted: '1000.000000 USDC', + // rawTokenBalance and rawFiatNumber intentionally omitted (undefined) + }), + ); // When: component renders and fires metrics render(); @@ -1555,4 +1583,140 @@ describe('CardHome Component', () => { ).not.toBeOnTheScreen(); }); }); + + describe('SpendingLimitProgressBar', () => { + it('renders when authenticated and allowance is limited', () => { + // Given: authenticated with limited allowance + setupMockSelectors({ isAuthenticated: true }); + const limitedAllowanceToken = { + ...mockPriorityToken, + allowanceState: AllowanceState.Limited, + totalAllowance: '1000', + allowance: '500', + }; + setupLoadCardDataMock({ + priorityToken: limitedAllowanceToken, + allTokens: [limitedAllowanceToken], + isAuthenticated: true, + }); + + // When: component renders + render(); + + // Then: should display spending limit progress bar + expect(screen.getByText('Spending Limit')).toBeOnTheScreen(); + expect(screen.getByText('500/1000 USDC')).toBeOnTheScreen(); + }); + + it('does not render when not authenticated', () => { + // Given: not authenticated with limited allowance + setupMockSelectors({ isAuthenticated: false }); + const limitedAllowanceToken = { + ...mockPriorityToken, + allowanceState: AllowanceState.Limited, + totalAllowance: '1000', + allowance: '500', + }; + setupLoadCardDataMock({ + priorityToken: limitedAllowanceToken, + allTokens: [limitedAllowanceToken], + isAuthenticated: false, + }); + + // When: component renders + render(); + + // Then: should not display spending limit progress bar + expect(screen.queryByText('Spending Limit')).not.toBeOnTheScreen(); + }); + + it('does not render when allowance is enabled', () => { + // Given: authenticated with enabled allowance + setupMockSelectors({ isAuthenticated: true }); + const enabledAllowanceToken = { + ...mockPriorityToken, + allowanceState: AllowanceState.Enabled, + totalAllowance: '1000', + allowance: '500', + }; + setupLoadCardDataMock({ + priorityToken: enabledAllowanceToken, + allTokens: [enabledAllowanceToken], + isAuthenticated: true, + }); + + // When: component renders + render(); + + // Then: should not display spending limit progress bar + expect(screen.queryByText('Spending Limit')).not.toBeOnTheScreen(); + }); + + it('displays correct consumed and total amounts', () => { + // Given: authenticated with specific allowance values + setupMockSelectors({ isAuthenticated: true }); + const limitedAllowanceToken = { + ...mockPriorityToken, + allowanceState: AllowanceState.Limited, + totalAllowance: '200', + allowance: '150', + symbol: 'USDC', + }; + setupLoadCardDataMock({ + priorityToken: limitedAllowanceToken, + allTokens: [limitedAllowanceToken], + isAuthenticated: true, + }); + + // When: component renders + render(); + + // Then: should display correct consumed amount (50/200) + expect(screen.getByText('50/200 USDC')).toBeOnTheScreen(); + }); + + it('handles zero remaining allowance', () => { + // Given: authenticated with zero remaining allowance + setupMockSelectors({ isAuthenticated: true }); + const limitedAllowanceToken = { + ...mockPriorityToken, + allowanceState: AllowanceState.Limited, + totalAllowance: '1000', + allowance: '0', + }; + setupLoadCardDataMock({ + priorityToken: limitedAllowanceToken, + allTokens: [limitedAllowanceToken], + isAuthenticated: true, + }); + + // When: component renders + render(); + + // Then: should display fully consumed allowance + expect(screen.getByText('1000/1000 USDC')).toBeOnTheScreen(); + }); + + it('handles undefined allowance values', () => { + // Given: authenticated with undefined allowance values + setupMockSelectors({ isAuthenticated: true }); + const limitedAllowanceToken = { + ...mockPriorityToken, + allowanceState: AllowanceState.Limited, + totalAllowance: undefined as unknown as string, + allowance: undefined as unknown as string, + }; + setupLoadCardDataMock({ + priorityToken: limitedAllowanceToken, + allTokens: [limitedAllowanceToken], + isAuthenticated: true, + }); + + // When: component renders + render(); + + // Then: should display zero values as fallback + expect(screen.getByText('0/0 USDC')).toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index f16a9ac42b6..b37f9732d90 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -1,11 +1,18 @@ import React, { useCallback, + useContext, useEffect, useMemo, useRef, useState, } from 'react'; -import { Alert, ScrollView, TouchableOpacity, View } from 'react-native'; +import { + Alert, + RefreshControl, + ScrollView, + TouchableOpacity, + View, +} from 'react-native'; import Icon, { IconName, @@ -30,7 +37,6 @@ import Button, { ButtonWidthTypes, } from '../../../../../component-library/components/Buttons/Button'; import { strings } from '../../../../../../locales/i18n'; -import { useAssetBalance } from '../../hooks/useAssetBalance'; import { useNavigateToCardPage } from '../../hooks/useNavigateToCardPage'; import { AllowanceState, CardStatus, CardType, CardWarning } from '../../types'; import CardAssetItem from '../../components/CardAssetItem'; @@ -55,6 +61,7 @@ import { setAuthenticatedPriorityToken, setAuthenticatedPriorityTokenLastFetched, setUserCardLocation, + clearAllCache, } from '../../../../../core/redux/slices/card'; import { useCardProvision } from '../../hooks/useCardProvision'; import CardWarningBox from '../../components/CardWarningBox/CardWarningBox'; @@ -65,6 +72,13 @@ import Logger from '../../../../../util/Logger'; import useLoadCardData from '../../hooks/useLoadCardData'; import AssetSelectionBottomSheet from '../../components/AssetSelectionBottomSheet/AssetSelectionBottomSheet'; import { CardActions } from '../../util/metrics'; +import { isSolanaChainId } from '@metamask/bridge-controller'; +import { useAssetBalances } from '../../hooks/useAssetBalances'; +import { + ToastContext, + ToastVariants, +} from '../../../../../component-library/components/Toast'; +import SpendingLimitProgressBar from '../../components/SpendingLimitProgressBar/SpendingLimitProgressBar'; /** * CardHome Component @@ -83,9 +97,15 @@ const CardHome = () => { const [openAssetSelectionBottomSheet, setOpenAssetSelectionBottomSheet] = useState(false); const [retries, setRetries] = useState(0); + const [isRefreshing, setIsRefreshing] = useState(false); const addFundsSheetRef = useRef(null); const assetSelectionSheetRef = useRef(null); + const { toastRef } = useContext(ToastContext); const { logoutFromProvider, isLoading: isSDKLoading } = useCardSDK(); + const [ + isCloseSpendingLimitWarningShown, + setIsCloseSpendingLimitWarningShown, + ] = useState(true); const { trackEvent, createEventBuilder } = useMetrics(); const navigation = useNavigation(); @@ -114,8 +134,19 @@ const CardHome = () => { externalWalletDetailsData, } = useLoadCardData(); - const { balanceFiat, mainBalance, rawFiatNumber, rawTokenBalance, asset } = - useAssetBalance(priorityToken); + const assetBalancesMap = useAssetBalances( + priorityToken ? [priorityToken] : [], + ); + const assetBalance = assetBalancesMap.get( + `${priorityToken?.address?.toLowerCase()}-${priorityToken?.caipChainId}-${priorityToken?.walletAddress?.toLowerCase()}`, + ); + const { + asset, + balanceFiat, + balanceFormatted, + rawFiatNumber, + rawTokenBalance, + } = assetBalance ?? {}; const { provisionCard, isLoading: isLoadingProvisionCard } = useCardProvision(); @@ -147,11 +178,11 @@ const CardHome = () => { const balanceAmount = useMemo(() => { if (!balanceFiat || balanceFiat === TOKEN_RATE_UNDEFINED) { - return mainBalance; + return balanceFormatted; } return balanceFiat; - }, [balanceFiat, mainBalance]); + }, [balanceFiat, balanceFormatted]); const renderAddFundsBottomSheet = useCallback( () => ( @@ -171,14 +202,22 @@ const CardHome = () => { sheetRef={assetSelectionSheetRef} setOpenAssetSelectionBottomSheet={setOpenAssetSelectionBottomSheet} tokensWithAllowances={allTokens} - priorityToken={priorityToken} delegationSettings={delegationSettings} cardExternalWalletDetails={externalWalletDetailsData} /> ), - [allTokens, priorityToken, delegationSettings, externalWalletDetailsData], + [allTokens, delegationSettings, externalWalletDetailsData], ); + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + try { + await fetchAllData(); + } finally { + setIsRefreshing(false); + } + }, [fetchAllData]); + // Track event only once after priorityToken and balances are loaded const hasTrackedCardHomeView = useRef(false); @@ -193,10 +232,10 @@ const CardHome = () => { return; } - const hasValidMainBalance = - mainBalance !== undefined && - mainBalance !== TOKEN_BALANCE_LOADING && - mainBalance !== TOKEN_BALANCE_LOADING_UPPERCASE; + const hasValidTokenBalance = + balanceFormatted !== undefined && + balanceFormatted !== TOKEN_BALANCE_LOADING && + balanceFormatted !== TOKEN_BALANCE_LOADING_UPPERCASE; const hasValidFiatBalance = balanceFiat !== undefined && @@ -205,7 +244,7 @@ const CardHome = () => { balanceFiat !== TOKEN_RATE_UNDEFINED; const isLoaded = - !!priorityToken && (hasValidMainBalance || hasValidFiatBalance); + !!priorityToken && (hasValidTokenBalance || hasValidFiatBalance); if (isLoaded) { // Set flag immediately to prevent race conditions @@ -229,7 +268,7 @@ const CardHome = () => { } }, [ priorityToken, - mainBalance, + balanceFormatted, balanceFiat, rawTokenBalance, rawFiatNumber, @@ -279,11 +318,26 @@ const CardHome = () => { ); if (isAuthenticated) { - navigation.navigate(Routes.CARD.SPENDING_LIMIT, { flow: 'manage' }); + navigation.navigate(Routes.CARD.SPENDING_LIMIT, { + flow: 'enable', + priorityToken, + allTokens, + delegationSettings, + externalWalletDetailsData, + }); } else { navigation.navigate(Routes.CARD.WELCOME); } - }, [isAuthenticated, navigation, trackEvent, createEventBuilder]); + }, [ + isAuthenticated, + navigation, + trackEvent, + createEventBuilder, + priorityToken, + allTokens, + delegationSettings, + externalWalletDetailsData, + ]); const logoutAction = useCallback(() => { Alert.alert( @@ -306,19 +360,41 @@ const CardHome = () => { ); }, [logoutFromProvider, navigation]); + const needToEnableCard = useMemo( + () => cardDetailsWarning === CardWarning.NoCard, + [cardDetailsWarning], + ); + const needToEnableAssets = useMemo( + () => priorityTokenWarning === CardWarning.NeedDelegation, + [priorityTokenWarning], + ); + const enableCardAction = useCallback(async () => { - await provisionCard(); - const isProvisioned = await pollCardStatusUntilProvisioned(); + try { + await provisionCard(); + const isProvisioned = await pollCardStatusUntilProvisioned(); - if (isProvisioned) { - fetchPriorityToken(); - changeAssetAction(); + if (isProvisioned) { + fetchPriorityToken(); + changeAssetAction(); + } + } catch (error) { + toastRef?.current?.showToast({ + variant: ToastVariants.Icon, + labelOptions: [{ label: strings('card.card_home.enable_card_error') }], + iconName: IconName.Danger, + iconColor: theme.colors.error.default, + backgroundColor: theme.colors.error.muted, + hasNoTimeout: false, + }); } }, [ provisionCard, pollCardStatusUntilProvisioned, fetchPriorityToken, changeAssetAction, + toastRef, + theme, ]); const ButtonsSection = useMemo(() => { @@ -334,7 +410,7 @@ const CardHome = () => { } if (isBaanxLoginEnabled) { - if (cardDetailsWarning === CardWarning.NoCard) { + if (needToEnableCard) { return (