From 02f51cdcdd37e1ac0b4b935e0578bae358e224f1 Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Tue, 11 Nov 2025 09:06:02 +0100 Subject: [PATCH 1/5] fix: cp-7.59.0 Update minimum BTC amount (#22401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR aims to update minimum BTC amount for send flow. As this is a new feature we will release - I will skip adding any changelog record. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/22337 ## **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] > Lowers the Bitcoin minimum send amount validation to 0.000006 BTC and updates related tests. > > - **Validation**: > - Update `MINIMUM_BITCOIN_TRANSACTION_AMOUNT` to `0.000006` in `useAmountValidation.ts`. > - `isValidBitcoinAmount` now validates against the new threshold. > - **Tests**: > - Adjust BTC threshold cases in `useAmountValidation.test.ts` for below (`0.000005`) and at (`0.000006`) minimum values. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b0da6a327aeae160a1407dbb48e8acf928c3ee3a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../confirmations/hooks/send/useAmountValidation.test.ts | 4 ++-- .../Views/confirmations/hooks/send/useAmountValidation.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/Views/confirmations/hooks/send/useAmountValidation.test.ts b/app/components/Views/confirmations/hooks/send/useAmountValidation.test.ts index 645ede32c3c..d4f7765d249 100644 --- a/app/components/Views/confirmations/hooks/send/useAmountValidation.test.ts +++ b/app/components/Views/confirmations/hooks/send/useAmountValidation.test.ts @@ -129,7 +129,7 @@ describe('useAmountValidation', () => { isBitcoinSendType: true, } as ReturnType); mockUseSendContext.mockReturnValue({ - value: '0.00005', + value: '0.000005', } as unknown as ReturnType); mockUseBalance.mockReturnValue({ balance: '1', @@ -150,7 +150,7 @@ describe('useAmountValidation', () => { isBitcoinSendType: true, } as ReturnType); mockUseSendContext.mockReturnValue({ - value: '0.0001', + value: '0.000006', } as unknown as ReturnType); mockUseBalance.mockReturnValue({ balance: '1', diff --git a/app/components/Views/confirmations/hooks/send/useAmountValidation.ts b/app/components/Views/confirmations/hooks/send/useAmountValidation.ts index 154f42f1b03..1130f19aa32 100644 --- a/app/components/Views/confirmations/hooks/send/useAmountValidation.ts +++ b/app/components/Views/confirmations/hooks/send/useAmountValidation.ts @@ -11,7 +11,7 @@ import { useSendContext } from '../../context/send-context'; import { useBalance } from './useBalance'; import { useSendType } from './useSendType'; -const MINIMUM_BITCOIN_TRANSACTION_AMOUNT = new BigNumber('0.0001'); +const MINIMUM_BITCOIN_TRANSACTION_AMOUNT = new BigNumber('0.000006'); const isValidBitcoinAmount = (value: string) => { const valueBN = new BigNumber(value); return valueBN.gte(MINIMUM_BITCOIN_TRANSACTION_AMOUNT); From c8f185bdc5f1fe23cd51dffe91e26bd01c645e23 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 11 Nov 2025 08:49:53 +0000 Subject: [PATCH 2/5] fix: safe area in full screen confirmations (#22365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Respect safe area in full screen confirmations to accommodate navigation controls. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [#6161](https://github.com/MetaMask/MetaMask-planning/issues/6161) ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ### **After** Confirmation ## **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] > Wrap full-screen confirmation and custom-amount loader in SafeAreaView and switch layout to flex to respect device safe areas. > > - **UI/Behavior**: > - Wrap full-screen confirmation (`ConfirmationUIType.FLAT`) and custom-amount loader with `SafeAreaView` using edges `['right','bottom','left']`. > - **Styles**: > - Replace absolute positioning in `styles.flatContainer` with `flex: 1` for proper layout with safe areas. > - **Code Cleanup**: > - Type improvement for `styles` prop (`ReturnType`); remove unnecessary `StyleSheet` import and TS expect-error comments. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 643c9a47752b21061ba2288f82955586bc937f5b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../confirm/confirm-component.styles.ts | 6 +--- .../components/confirm/confirm-component.tsx | 30 +++++++++---------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/app/components/Views/confirmations/components/confirm/confirm-component.styles.ts b/app/components/Views/confirmations/components/confirm/confirm-component.styles.ts index 7a6751ba956..20a19965fb6 100644 --- a/app/components/Views/confirmations/components/confirm/confirm-component.styles.ts +++ b/app/components/Views/confirmations/components/confirm/confirm-component.styles.ts @@ -16,11 +16,7 @@ const styleSheet = (params: { maxHeight: '100%', }, flatContainer: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, + flex: 1, zIndex: 9999, backgroundColor: theme.colors.background.alternative, justifyContent: 'space-between', diff --git a/app/components/Views/confirmations/components/confirm/confirm-component.tsx b/app/components/Views/confirmations/components/confirm/confirm-component.tsx index 2063a2f4c36..20bc63c1f10 100755 --- a/app/components/Views/confirmations/components/confirm/confirm-component.tsx +++ b/app/components/Views/confirmations/components/confirm/confirm-component.tsx @@ -1,10 +1,5 @@ import React, { useEffect } from 'react'; -import { - BackHandler, - StyleSheet, - TouchableWithoutFeedback, - View, -} from 'react-native'; +import { BackHandler, TouchableWithoutFeedback, View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { useNavigation } from '@react-navigation/native'; @@ -31,6 +26,7 @@ import { TransactionType } from '@metamask/transaction-controller'; import { useParams } from '../../../../../util/navigation/navUtils'; import AnimatedSpinner, { SpinnerSize } from '../../../../UI/AnimatedSpinner'; import { CustomAmountInfoSkeleton } from '../info/custom-amount-info'; +import { SafeAreaView } from 'react-native-safe-area-context'; export enum ConfirmationLoader { Default = 'default', @@ -46,7 +42,7 @@ const ConfirmWrapped = ({ styles, route, }: { - styles: StyleSheet.NamedStyles>; + styles: ReturnType; route?: UnstakeConfirmationViewProps['route']; }) => { const alerts = useConfirmationAlerts(); @@ -59,11 +55,7 @@ const ConfirmWrapped = ({ <ScrollView - // @ts-expect-error - React Native style type mismatch due to outdated @types/react-native - // See: https://github.com/MetaMask/metamask-mobile/pull/18956#discussion_r2316407382 style={styles.scrollView} - // @ts-expect-error - React Native style type mismatch due to outdated @types/react-native - // See: https://github.com/MetaMask/metamask-mobile/pull/18956#discussion_r2316407382 contentContainerStyle={styles.scrollViewContent} nestedScrollEnabled > @@ -133,9 +125,13 @@ export const Confirm = ({ route }: ConfirmProps) => { // Show confirmation in a flat container if the confirmation is full screen if (isFullScreenConfirmation) { return ( - <View style={styles.flatContainer} testID={ConfirmationUIType.FLAT}> + <SafeAreaView + edges={['right', 'bottom', 'left']} + style={styles.flatContainer} + testID={ConfirmationUIType.FLAT} + > <ConfirmWrapped styles={styles} route={route} /> - </View> + </SafeAreaView> ); } @@ -160,14 +156,18 @@ function Loader() { if (loader === ConfirmationLoader.CustomAmount) { return ( - <View style={styles.flatContainer} testID="confirm-loader-custom-amount"> + <SafeAreaView + edges={['right', 'bottom', 'left']} + style={styles.flatContainer} + testID="confirm-loader-custom-amount" + > <ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollViewContent} > <CustomAmountInfoSkeleton /> </ScrollView> - </View> + </SafeAreaView> ); } From 042b0db3841ddd41498bb7d5691a1798110817d9 Mon Sep 17 00:00:00 2001 From: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com> Date: Tue, 11 Nov 2025 09:47:47 +0000 Subject: [PATCH 3/5] feat: Support sponsored transactions through smart transactions (#21932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit <!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description**. This PR consolidates recent changes to improve the sponsored transaction flow, prioritizing Smart Transactions (STX) over EIP-7702, and properly handling insufficient-funds alerts for sponsored transactions. We now consistently favor sponsored flows via STX whenever available. If STX is disabled or unsupported, we fall back to 7702, and only if neither is available do we proceed with a standard transaction. In addition, the `isSponsored` flag logic was refined to accurately reflect when sponsorship is actually used, enabling correct tracking in the activity UI. We also ensure the UI uses the correct sponsorship-support logic (`useGaslessSupported`) when determining whether to display gas inputs. Lastly, the insufficient-funds alert logic has been updated to account for sponsored flows, which previously weren’t considered. Related PRs from extension: - https://github.com/MetaMask/metamask-extension/pull/37300 - https://github.com/MetaMask/metamask-extension/pull/37117 <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Fixed order of STX over 7702 for sponsored transactions and improved sponsorship detection in the UI ## **Related issues** Fixes: https://github.com/MetaMask/mobile-planning/issues/2363 ## **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** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Centralizes gasless support logic with new hooks, prioritizes Smart Transactions over EIP-7702, updates UI and insufficient-balance alert behavior, and streamlines publish fallback; adds focused tests. > > - **Gasless support logic**: > - Add `useGaslessSupportedSmartTransactions` to determine STX gasless eligibility (`isSmartTransaction`, `isSupported`, `pending`). > - Refactor `useIsGaslessSupported` to use the above and only check 7702 (atomic batch + relay + non-deploy) when STX gasless isn’t supported. > - **Transaction confirm flow** (`useTransactionConfirm`): > - Use new hooks; set `waitForResult` based on `isSmartTransaction`. > - For STX: append batch transfer and gas fields; for 7702: mark `isExternalSign`. > - In both paths, set `txMeta.isGasFeeSponsored` only if gasless is actually supported. > - **UI**: > - Gas fee row (`gas-fee-details-row.tsx`): show sponsorship (“Paid by MetaMask”) only when gasless is supported; otherwise show regular fees. > - Insufficient-balance alert: suppress when transaction is sponsored and gasless is supported. > - **Controller** (`transaction-controller-init.ts`): > - Publish: delegate to 7702 only when not STX or chain doesn’t support send bundle; remove dependency on `isGasFeeSponsored`. > - **Tests**: > - Add/modify tests for new hooks, alert behavior, confirm flow, and gasless support gating. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 6ec4ceab40e6a12b523ea2a6c72533c9f5a2b551. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --- .../gas-fee-details-row.tsx | 11 +- .../useInsufficientBalanceAlert.test.ts | 8 + .../alerts/useInsufficientBalanceAlert.ts | 7 +- ...eGaslessSupportedSmartTransactions.test.ts | 176 ++++++++++++++++++ .../useGaslessSupportedSmartTransactions.ts | 31 +++ .../hooks/gas/useIsGaslessSupported.test.ts | 32 ++-- .../hooks/gas/useIsGaslessSupported.ts | 31 ++- .../useTransactionConfirm.test.ts | 57 +++--- .../transactions/useTransactionConfirm.ts | 52 ++++-- .../transaction-controller-init.ts | 6 +- 10 files changed, 331 insertions(+), 80 deletions(-) create mode 100644 app/components/Views/confirmations/hooks/gas/useGaslessSupportedSmartTransactions.test.ts create mode 100644 app/components/Views/confirmations/hooks/gas/useGaslessSupportedSmartTransactions.ts diff --git a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx index 183d2bd7cb0..f692f49471f 100644 --- a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx @@ -23,6 +23,7 @@ import useBalanceChanges from '../../../../../../UI/SimulationDetails/useBalance import { useFeeCalculations } from '../../../../hooks/gas/useFeeCalculations'; import { useFeeCalculationsTransactionBatch } from '../../../../hooks/gas/useFeeCalculationsTransactionBatch'; import { useSelectedGasFeeToken } from '../../../../hooks/gas/useGasFeeToken'; +import { useIsGaslessSupported } from '../../../../hooks/gas/useIsGaslessSupported'; import { useConfirmationMetricEvents } from '../../../../hooks/metrics/useConfirmationMetricEvents'; import { useTransactionBatchesMetadata } from '../../../../hooks/transactions/useTransactionBatchesMetadata'; import { useTransactionMetadataRequest } from '../../../../hooks/transactions/useTransactionMetadataRequest'; @@ -228,14 +229,20 @@ const GasFeesDetailsRow = ({ const transactionBatchesMetadata = useTransactionBatchesMetadata(); const gasFeeToken = useSelectedGasFeeToken(); const metamaskFeeFiat = gasFeeToken?.metamaskFeeFiat; + const { + userFeeLevel: isUserFeeLevelExists, + isGasFeeSponsored: doesSentinelAllowSponsorship, + } = transactionMetadata ?? {}; const hideFiatForTestnet = useHideFiatForTestnet( transactionMetadata?.chainId, ); const { trackTooltipClickedEvent } = useConfirmationMetricEvents(); - const isUserFeeLevelExists = transactionMetadata?.userFeeLevel; - const isGasFeeSponsored = transactionMetadata?.isGasFeeSponsored; + // This prevents the gas fee row from showing as sponsored if stx is disabled + // by the user and 7702 is not supported in the chain. + const { isSupported: isGaslessSupported } = useIsGaslessSupported(); + const isGasFeeSponsored = isGaslessSupported && doesSentinelAllowSponsorship; const handleNetworkFeeTooltipClickedEvent = () => { trackTooltipClickedEvent({ diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts index 2cdc6df114d..1434bb368f3 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts @@ -12,6 +12,7 @@ import { useConfirmActions } from '../useConfirmActions'; import { useTransactionPayToken } from '../pay/useTransactionPayToken'; import { noop } from 'lodash'; import { useConfirmationContext } from '../../context/confirmation-context'; +import { useIsGaslessSupported } from '../gas/useIsGaslessSupported'; jest.mock('../../../../../util/navigation/navUtils', () => ({ useParams: jest.fn().mockReturnValue({ @@ -44,6 +45,7 @@ jest.mock('../../../../../reducers/transaction', () => ({ selectTransactionState: jest.fn(), })); jest.mock('../../context/confirmation-context'); +jest.mock('../gas/useIsGaslessSupported'); describe('useInsufficientBalanceAlert', () => { const mockUseTransactionMetadataRequest = jest.mocked( @@ -56,6 +58,8 @@ describe('useInsufficientBalanceAlert', () => { ); const mockUseTransactionPayToken = jest.mocked(useTransactionPayToken); const mockUseConfirmationContext = jest.mocked(useConfirmationContext); + const useIsGaslessSupportedMock = jest.mocked(useIsGaslessSupported); + const mockChainId = '0x1'; const mockFromAddress = '0x123'; const mockNativeCurrency = 'ETH'; @@ -72,6 +76,10 @@ describe('useInsufficientBalanceAlert', () => { beforeEach(() => { jest.clearAllMocks(); + useIsGaslessSupportedMock.mockReturnValue({ + isSmartTransaction: false, + isSupported: false, + }); mockUseAccountNativeBalance.mockReturnValue({ balanceWeiInHex: '0x8', // 8 wei } as unknown as ReturnType<typeof useAccountNativeBalance>); diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts index 722c9f16a61..b3eda37d12e 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts @@ -19,6 +19,7 @@ import { useAccountNativeBalance } from '../useAccountNativeBalance'; import { useConfirmActions } from '../useConfirmActions'; import { useTransactionPayToken } from '../pay/useTransactionPayToken'; import { useConfirmationContext } from '../../context/confirmation-context'; +import { useIsGaslessSupported } from '../gas/useIsGaslessSupported'; const HEX_ZERO = '0x0'; @@ -37,6 +38,7 @@ export const useInsufficientBalanceAlert = ({ const { isTransactionValueUpdating } = useConfirmationContext(); const { onReject } = useConfirmActions(); const { payToken } = useTransactionPayToken(); + const { isSupported: isGaslessSupported } = useIsGaslessSupported(); return useMemo(() => { if (!transactionMetadata || isTransactionValueUpdating) { @@ -65,11 +67,13 @@ export const useInsufficientBalanceAlert = ({ totalTransactionValueBN, ); + const isSponsoredTransaction = isGasFeeSponsored && isGaslessSupported; + const showAlert = hasInsufficientBalance && (ignoreGasFeeToken || !selectedGasFeeToken) && !payToken && - !isGasFeeSponsored; + !isSponsoredTransaction; if (!showAlert) { return []; @@ -100,6 +104,7 @@ export const useInsufficientBalanceAlert = ({ }, [ balanceWeiInHex, ignoreGasFeeToken, + isGaslessSupported, isTransactionValueUpdating, navigation, networkConfigurations, diff --git a/app/components/Views/confirmations/hooks/gas/useGaslessSupportedSmartTransactions.test.ts b/app/components/Views/confirmations/hooks/gas/useGaslessSupportedSmartTransactions.test.ts new file mode 100644 index 00000000000..c95584a96dc --- /dev/null +++ b/app/components/Views/confirmations/hooks/gas/useGaslessSupportedSmartTransactions.test.ts @@ -0,0 +1,176 @@ +import { waitFor } from '@testing-library/react-native'; +import { merge } from 'lodash'; +import { useGaslessSupportedSmartTransactions } from './useGaslessSupportedSmartTransactions'; +import { isSendBundleSupported } from '../../../../../util/transactions/sentinel-api'; +import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { transferConfirmationState } from '../../../../../util/test/confirm-data-helpers'; +import { transferTransactionStateMock } from '../../__mocks__/transfer-transaction-mock'; +import { TransactionMeta } from '@metamask/transaction-controller'; + +jest.mock('../../../../../util/transactions/sentinel-api'); +jest.mock('../../../../../selectors/smartTransactionsController'); +jest.mock('../transactions/useTransactionMetadataRequest'); + +const CHAIN_ID_MOCK = '0x1'; + +describe('useGaslessSupportedSmartTransactions (mobile)', () => { + const isSendBundleSupportedMock = jest.mocked(isSendBundleSupported); + const selectShouldUseSmartTransactionMock = jest.mocked( + selectShouldUseSmartTransaction, + ); + const useTransactionMetadataRequestMock = jest.mocked( + useTransactionMetadataRequest, + ); + + beforeEach(() => { + jest.resetAllMocks(); + useTransactionMetadataRequestMock.mockReturnValue({ + chainId: CHAIN_ID_MOCK, + } as unknown as TransactionMeta); + + isSendBundleSupportedMock.mockResolvedValue(false); + selectShouldUseSmartTransactionMock.mockReturnValue(false); + }); + + it('returns isSupported = true when both smart transaction and bundle supported', async () => { + isSendBundleSupportedMock.mockResolvedValue(true); + selectShouldUseSmartTransactionMock.mockReturnValue(true); + + const { result } = renderHookWithProvider( + () => useGaslessSupportedSmartTransactions(), + { + state: merge({}, transferConfirmationState), + }, + ); + await waitFor(() => + expect(result.current).toStrictEqual({ + isSmartTransaction: true, + isSupported: true, + pending: false, + }), + ); + }); + + it('returns isSupported = false when smart transaction enabled but bundle not supported', async () => { + isSendBundleSupportedMock.mockResolvedValue(false); + selectShouldUseSmartTransactionMock.mockReturnValue(true); + + const { result } = renderHookWithProvider( + () => useGaslessSupportedSmartTransactions(), + { + state: merge({}, transferConfirmationState), + }, + ); + await waitFor(() => + expect(result.current).toStrictEqual({ + isSmartTransaction: true, + isSupported: false, + pending: false, + }), + ); + }); + + it('returns isSupported = false when bundle supported but not a smart transaction', async () => { + isSendBundleSupportedMock.mockResolvedValue(true); + selectShouldUseSmartTransactionMock.mockReturnValue(false); + + const { result } = renderHookWithProvider( + () => useGaslessSupportedSmartTransactions(), + { + state: merge({}, transferConfirmationState), + }, + ); + await waitFor(() => + expect(result.current).toStrictEqual({ + isSmartTransaction: false, + isSupported: false, + pending: false, + }), + ); + }); + + it('returns isSupported = false when neither smart transaction nor bundle is supported', async () => { + isSendBundleSupportedMock.mockResolvedValue(false); + selectShouldUseSmartTransactionMock.mockReturnValue(false); + + const { result } = renderHookWithProvider( + () => useGaslessSupportedSmartTransactions(), + { + state: merge({}, transferConfirmationState), + }, + ); + await waitFor(() => + expect(result.current).toStrictEqual({ + isSmartTransaction: false, + isSupported: false, + pending: false, + }), + ); + }); + + it('returns pending = true while sendBundleSupported is still pending', async () => { + let resolvePromise: (value: boolean) => void = () => { + // no-op + }; + const pendingPromise = new Promise<boolean>((resolve) => { + resolvePromise = resolve; + }); + isSendBundleSupportedMock.mockReturnValue( + pendingPromise as Promise<boolean>, + ); + selectShouldUseSmartTransactionMock.mockReturnValue(true); + + const { result, rerender } = renderHookWithProvider( + () => useGaslessSupportedSmartTransactions(), + { state: merge({}, transferConfirmationState) }, + ); + expect(result.current.pending).toBe(true); + + // Resolve and trigger update + resolvePromise(true); + rerender(transferConfirmationState); + await waitFor(() => + expect(result.current).toStrictEqual({ + isSmartTransaction: true, + isSupported: true, + pending: false, + }), + ); + }); + + it('returns false if chainId is missing', async () => { + useTransactionMetadataRequestMock.mockReturnValue({ + chainId: undefined, + } as unknown as TransactionMeta); + + const { result } = renderHookWithProvider( + () => useGaslessSupportedSmartTransactions(), + { state: transferTransactionStateMock }, + ); + await waitFor(() => + expect(result.current).toStrictEqual({ + isSmartTransaction: false, + isSupported: false, + pending: false, + }), + ); + }); + + it('returns false if transactionMeta is null', async () => { + useTransactionMetadataRequestMock.mockReturnValue(undefined); + + const { result } = renderHookWithProvider( + () => useGaslessSupportedSmartTransactions(), + { state: transferTransactionStateMock }, + ); + await waitFor(() => + expect(result.current).toStrictEqual({ + isSmartTransaction: false, + isSupported: false, + pending: false, + }), + ); + }); +}); diff --git a/app/components/Views/confirmations/hooks/gas/useGaslessSupportedSmartTransactions.ts b/app/components/Views/confirmations/hooks/gas/useGaslessSupportedSmartTransactions.ts new file mode 100644 index 00000000000..869d7b055c9 --- /dev/null +++ b/app/components/Views/confirmations/hooks/gas/useGaslessSupportedSmartTransactions.ts @@ -0,0 +1,31 @@ +import { Hex } from '@metamask/utils'; +import { useSelector } from 'react-redux'; +import { isSendBundleSupported } from '../../../../../util/transactions/sentinel-api'; +import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; +import { useAsyncResult } from '../../../../hooks/useAsyncResult'; +import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { RootState } from '../../../../../reducers'; + +export function useGaslessSupportedSmartTransactions(): { + isSmartTransaction: boolean; + isSupported: boolean; + pending: boolean; +} { + const transactionMeta = useTransactionMetadataRequest(); + + const { chainId } = transactionMeta ?? {}; + const isSmartTransaction = useSelector((state: RootState) => + selectShouldUseSmartTransaction(state, chainId), + ); + + const { value: sendBundleSupported, pending } = useAsyncResult( + async () => (chainId ? isSendBundleSupported(chainId as Hex) : false), + [chainId], + ); + + return { + isSmartTransaction: Boolean(isSmartTransaction), + isSupported: Boolean(isSmartTransaction && sendBundleSupported), + pending, + }; +} diff --git a/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.test.ts b/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.test.ts index fd8400e8b6f..e2287cc11db 100644 --- a/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.test.ts +++ b/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.test.ts @@ -4,16 +4,17 @@ import { merge } from 'lodash'; import { transferConfirmationState } from '../../../../../util/test/confirm-data-helpers'; import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; import { isAtomicBatchSupported } from '../../../../../util/transaction-controller'; -import { isSendBundleSupported } from '../../../../../util/transactions/sentinel-api'; import { isRelaySupported } from '../../../../../util/transactions/transaction-relay'; import { transferTransactionStateMock } from '../../__mocks__/transfer-transaction-mock'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; import { useIsGaslessSupported } from './useIsGaslessSupported'; +import { useGaslessSupportedSmartTransactions } from './useGaslessSupportedSmartTransactions'; jest.mock('../../../../../util/transactions/sentinel-api'); jest.mock('../../../../../util/transaction-controller'); jest.mock('../../../../../util/transactions/transaction-relay'); jest.mock('../transactions/useTransactionMetadataRequest'); +jest.mock('./useGaslessSupportedSmartTransactions'); const SMART_TRANSACTIONS_ENABLED_STATE = { swaps: { @@ -50,9 +51,11 @@ describe('useIsGaslessSupported', () => { const mockUseTransactionMetadataRequest = jest.mocked( useTransactionMetadataRequest, ); - const isSendBundleSupportedMock = jest.mocked(isSendBundleSupported); const isAtomicBatchSupportedMock = jest.mocked(isAtomicBatchSupported); const isRelaySupportedMock = jest.mocked(isRelaySupported); + const useGaslessSupportedSmartTransactionsMock = jest.mocked( + useGaslessSupportedSmartTransactions, + ); beforeEach(() => { mockUseTransactionMetadataRequest.mockReturnValue({ @@ -61,7 +64,11 @@ describe('useIsGaslessSupported', () => { } as unknown as TransactionMeta); isRelaySupportedMock.mockResolvedValue(false); isAtomicBatchSupportedMock.mockResolvedValue([]); - isSendBundleSupportedMock.mockResolvedValue(false); + useGaslessSupportedSmartTransactionsMock.mockReturnValue({ + isSmartTransaction: false, + isSupported: false, + pending: false, + }); }); describe('Gasless Smart Transactions', () => { @@ -71,7 +78,11 @@ describe('useIsGaslessSupported', () => { transferConfirmationState, SMART_TRANSACTIONS_ENABLED_STATE, ); - isSendBundleSupportedMock.mockResolvedValue(true); + useGaslessSupportedSmartTransactionsMock.mockReturnValue({ + isSmartTransaction: true, + isSupported: true, + pending: false, + }); const { result } = renderHookWithProvider(() => useIsGaslessSupported(), { state: stateWithSmartTransactionEnabled, @@ -99,7 +110,11 @@ describe('useIsGaslessSupported', () => { }); it('returns false if smart transaction is enabled but sendBundle is not supported', async () => { - isSendBundleSupportedMock.mockResolvedValue(false); + useGaslessSupportedSmartTransactionsMock.mockReturnValue({ + isSmartTransaction: true, + isSupported: false, + pending: false, + }); const stateWithSmartTransactionEnabled = merge( {}, @@ -123,7 +138,6 @@ describe('useIsGaslessSupported', () => { describe('Gasless EIP-7702', () => { it('returns isSupported true and isSmartTransaction: false when EIP-7702 conditions met', async () => { isRelaySupportedMock.mockResolvedValue(true); - isSendBundleSupportedMock.mockResolvedValue(false); isAtomicBatchSupportedMock.mockResolvedValue([ { chainId: '0x1', @@ -147,7 +161,6 @@ describe('useIsGaslessSupported', () => { it('returns isSupported false and isSmartTransaction: false when atomicBatchSupported account not upgraded', async () => { isRelaySupportedMock.mockResolvedValue(true); - isSendBundleSupportedMock.mockResolvedValue(false); isAtomicBatchSupportedMock.mockResolvedValue([ { chainId: '0x1', @@ -171,7 +184,6 @@ describe('useIsGaslessSupported', () => { it('returns isSupported false and isSmartTransaction: false when relay not supported', async () => { isRelaySupportedMock.mockResolvedValue(false); - isSendBundleSupportedMock.mockResolvedValue(false); isAtomicBatchSupportedMock.mockResolvedValue([ { chainId: '0x1', @@ -199,7 +211,6 @@ describe('useIsGaslessSupported', () => { txParams: { from: '0x123' }, // no "to" } as unknown as TransactionMeta); isRelaySupportedMock.mockResolvedValue(true); - isSendBundleSupportedMock.mockResolvedValue(false); isAtomicBatchSupportedMock.mockResolvedValue([ { chainId: '0x1', @@ -223,7 +234,6 @@ describe('useIsGaslessSupported', () => { it('returns isSupported false and isSmartTransaction: false when no matching chain support in atomicBatch', async () => { isRelaySupportedMock.mockResolvedValue(true); - isSendBundleSupportedMock.mockResolvedValue(false); isAtomicBatchSupportedMock.mockResolvedValue([ { chainId: '0x3', @@ -247,7 +257,6 @@ describe('useIsGaslessSupported', () => { it('returns isSupported false and isSmartTransaction: false if isAtomicBatchSupported returns undefined', async () => { isRelaySupportedMock.mockResolvedValue(true); - isSendBundleSupportedMock.mockResolvedValue(false); isAtomicBatchSupportedMock.mockResolvedValue( undefined as unknown as ReturnType<typeof isAtomicBatchSupported>, ); @@ -269,7 +278,6 @@ describe('useIsGaslessSupported', () => { isRelaySupportedMock.mockResolvedValue( undefined as unknown as ReturnType<typeof isRelaySupported>, ); - isSendBundleSupportedMock.mockResolvedValue(false); isAtomicBatchSupportedMock.mockResolvedValue([ { chainId: '0x1', diff --git a/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.ts b/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.ts index e3ce76a46e1..9c966773c69 100644 --- a/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.ts +++ b/app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.ts @@ -1,12 +1,9 @@ -import { useSelector } from 'react-redux'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; -import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; -import { RootState } from '../../../../../reducers'; import { useAsyncResult } from '../../../../hooks/useAsyncResult'; -import { isSendBundleSupported } from '../../../../../util/transactions/sentinel-api'; import { isRelaySupported } from '../../../../../util/transactions/transaction-relay'; import { isAtomicBatchSupported } from '../../../../../util/transaction-controller'; import { Hex } from '@metamask/utils'; +import { useGaslessSupportedSmartTransactions } from './useGaslessSupportedSmartTransactions'; /** * Hook to determine if gasless transactions are supported for the current confirmation context. @@ -25,21 +22,17 @@ export function useIsGaslessSupported() { const { chainId, txParams } = transactionMeta ?? {}; const { from } = txParams ?? {}; - const isSmartTransaction = useSelector((state: RootState) => - selectShouldUseSmartTransaction(state, chainId), - ); - - const { value: sendBundleSupportsChain } = useAsyncResult( - async () => (chainId ? isSendBundleSupported(chainId) : false), - [chainId], - ); + const { + isSmartTransaction, + isSupported: isSmartTransactionAndBundleSupported, + pending, + } = useGaslessSupportedSmartTransactions(); - const isSmartTransactionAndBundleSupported = Boolean( - isSmartTransaction && sendBundleSupportsChain, - ); + const shouldCheck7702Eligibility = + !pending && !isSmartTransactionAndBundleSupported; const { value: atomicBatchSupportResult } = useAsyncResult(async () => { - if (isSmartTransactionAndBundleSupported) { + if (!shouldCheck7702Eligibility) { return undefined; } @@ -47,15 +40,15 @@ export function useIsGaslessSupported() { address: from as Hex, chainIds: [chainId as Hex], }); - }, [chainId, from, isSmartTransactionAndBundleSupported]); + }, [chainId, from, shouldCheck7702Eligibility]); const { value: relaySupportsChain } = useAsyncResult(async () => { - if (isSmartTransactionAndBundleSupported) { + if (!shouldCheck7702Eligibility) { return undefined; } return isRelaySupported(chainId as Hex); - }, [chainId, isSmartTransactionAndBundleSupported]); + }, [chainId, shouldCheck7702Eligibility]); const atomicBatchChainSupport = atomicBatchSupportResult?.find( (result) => result.chainId.toLowerCase() === chainId?.toLowerCase(), diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts index 476295cb80b..314bbda53a1 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts @@ -12,7 +12,6 @@ import { transactionIdMock, } from '../../__mocks__/controllers/transaction-controller-mock'; import { transactionApprovalControllerMock } from '../../__mocks__/controllers/approval-controller-mock'; -import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; import Routes from '../../../../../constants/navigation/Routes'; import { ORIGIN_METAMASK } from '@metamask/controller-utils'; import { useFullScreenConfirmation } from '../ui/useFullScreenConfirmation'; @@ -23,11 +22,12 @@ import { useNetworkEnablement } from '../../../../hooks/useNetworkEnablement/use import { flushPromises } from '../../../../../util/test/utils'; import { useSelectedGasFeeToken } from '../gas/useGasFeeToken'; import { isSendBundleSupported } from '../../../../../util/transactions/sentinel-api'; -import { useAsyncResult as useAsyncResultHook } from '../../../../hooks/useAsyncResult'; import { act } from '@testing-library/react-hooks'; import { useTransactionPayQuotes } from '../pay/useTransactionPayData'; import { TransactionPayQuote } from '@metamask/transaction-pay-controller'; import { Json } from '@metamask/utils'; +import { useIsGaslessSupported } from '../gas/useIsGaslessSupported'; +import { useGaslessSupportedSmartTransactions } from '../gas/useGaslessSupportedSmartTransactions'; const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); @@ -40,11 +40,10 @@ jest.mock('../../../../../actions/transaction'); jest.mock('../../../../../util/networks'); jest.mock('../../../../hooks/useNetworkEnablement/useNetworkEnablement'); jest.mock('../gas/useGasFeeToken'); -jest.mock('../../../../hooks/useAsyncResult', () => ({ - useAsyncResult: jest.fn(), -})); jest.mock('../../../../../util/transactions/sentinel-api'); jest.mock('../pay/useTransactionPayData'); +jest.mock('../gas/useIsGaslessSupported'); +jest.mock('../gas/useGaslessSupportedSmartTransactions'); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -75,24 +74,32 @@ describe('useTransactionConfirm', () => { const useNetworkEnablementMock = jest.mocked(useNetworkEnablement); const useSelectedGasFeeTokenMock = jest.mocked(useSelectedGasFeeToken); const isSendBundleSupportedMock = jest.mocked(isSendBundleSupported); - const useAsyncResultMock = jest.mocked(useAsyncResultHook); const useTransactionPayQuotesMock = jest.mocked(useTransactionPayQuotes); + const useIsGaslessSupportedMock = jest.mocked(useIsGaslessSupported); + const useGaslessSupportedSmartTransactionsMock = jest.mocked( + useGaslessSupportedSmartTransactions, + ); const isRemoveGlobalNetworkSelectorEnabledMock = jest.mocked( isRemoveGlobalNetworkSelectorEnabled, ); - const selectShouldUseSmartTransactionMock = jest.mocked( - selectShouldUseSmartTransaction, - ); - const useTransactionMetadataRequestMock = jest.mocked( useTransactionMetadataRequest, ); beforeEach(() => { jest.resetAllMocks(); - useAsyncResultMock.mockReturnValue({ pending: false, value: false }); + useIsGaslessSupportedMock.mockReturnValue({ + isSmartTransaction: true, + isSupported: true, + }); + + useGaslessSupportedSmartTransactionsMock.mockReturnValue({ + isSupported: false, + isSmartTransaction: false, + pending: false, + }); useApprovalRequestMock.mockReturnValue({ onConfirm: onApprovalConfirm, @@ -105,8 +112,6 @@ describe('useTransactionConfirm', () => { txParams: {}, } as unknown as TransactionMeta); - selectShouldUseSmartTransactionMock.mockReturnValue(false); - useFullScreenConfirmationMock.mockReturnValue({ isFullScreenConfirmation: true, }); @@ -133,6 +138,9 @@ describe('useTransactionConfirm', () => { }); it('sets waitForResult true when not smart tx, no quotes, no fee token', async () => { + useSelectedGasFeeTokenMock.mockReturnValue( + undefined as unknown as ReturnType<typeof useSelectedGasFeeToken>, + ); const { result } = renderHook(); await act(async () => { @@ -148,7 +156,11 @@ describe('useTransactionConfirm', () => { }); it('does not wait for result if smart transaction', async () => { - selectShouldUseSmartTransactionMock.mockReturnValue(true); + useGaslessSupportedSmartTransactionsMock.mockReturnValue({ + isSupported: true, + isSmartTransaction: true, + pending: false, + }); const { result } = renderHook(); @@ -263,9 +275,10 @@ describe('useTransactionConfirm', () => { isSendBundleSupportedMock.mockResolvedValue(true); - useAsyncResultMock.mockImplementation((fn) => { - fn(); - return { pending: false, value: false }; + useGaslessSupportedSmartTransactionsMock.mockReturnValue({ + isSupported: false, + isSmartTransaction: false, + pending: false, }); const { result } = renderHook(); @@ -344,8 +357,11 @@ describe('useTransactionConfirm', () => { describe('handleSmartTransaction', () => { beforeEach(() => { - selectShouldUseSmartTransactionMock.mockReturnValue(true); - useAsyncResultMock.mockReturnValue({ pending: false, value: true }); + useGaslessSupportedSmartTransactionsMock.mockReturnValue({ + isSupported: true, + isSmartTransaction: true, + pending: false, + }); isSendBundleSupportedMock.mockReturnValue(Promise.resolve(true)); useSelectedGasFeeTokenMock.mockReturnValue({ transferTransaction: { data: '0xabc', to: '0xdef', value: '0x0' }, @@ -396,7 +412,6 @@ describe('useTransactionConfirm', () => { describe('handleGasless7702', () => { it('sets isExternalSign when selectedGasFeeToken is present and not smart transaction', async () => { - selectShouldUseSmartTransactionMock.mockReturnValue(false); isSendBundleSupportedMock.mockReturnValue(Promise.resolve(false)); useSelectedGasFeeTokenMock.mockReturnValue({ @@ -417,7 +432,6 @@ describe('useTransactionConfirm', () => { }); it('sets isExternalSign when selectedGasFeeToken is present and smart transaction but the chain does not support send bundle', async () => { - selectShouldUseSmartTransactionMock.mockReturnValue(true); isSendBundleSupportedMock.mockReturnValue(Promise.resolve(false)); useSelectedGasFeeTokenMock.mockReturnValue({ @@ -438,7 +452,6 @@ describe('useTransactionConfirm', () => { }); it('does nothing if selectedGasFeeToken is missing', async () => { - selectShouldUseSmartTransactionMock.mockReturnValue(false); useSelectedGasFeeTokenMock.mockReturnValue( undefined as unknown as ReturnType<typeof useSelectedGasFeeToken>, ); diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts index b22407db25d..261909d5fc2 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts @@ -1,10 +1,7 @@ import { useCallback } from 'react'; import { useNavigation } from '@react-navigation/native'; -import { useDispatch, useSelector } from 'react-redux'; -import { isSendBundleSupported } from '../../../../../util/transactions/sentinel-api'; -import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; +import { useDispatch } from 'react-redux'; import Routes from '../../../../../constants/navigation/Routes'; -import { RootState } from '../../../../../reducers'; import { resetTransaction } from '../../../../../actions/transaction'; import useApprovalRequest from '../useApprovalRequest'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; @@ -18,7 +15,8 @@ import { useNetworkEnablement } from '../../../../hooks/useNetworkEnablement/use import { createProjectLogger } from '@metamask/utils'; import { useSelectedGasFeeToken } from '../gas/useGasFeeToken'; import { hasTransactionType } from '../../utils/transaction'; -import { useAsyncResult } from '../../../../hooks/useAsyncResult'; +import { useIsGaslessSupported } from '../gas/useIsGaslessSupported'; +import { useGaslessSupportedSmartTransactions } from '../gas/useGaslessSupportedSmartTransactions'; import { cloneDeep } from 'lodash'; import { useTransactionPayQuotes } from '../pay/useTransactionPayData'; @@ -42,12 +40,13 @@ export function useTransactionConfirm() { const { tryEnableEvmNetwork } = useNetworkEnablement(); - const shouldUseSmartTransaction = useSelector((state: RootState) => - selectShouldUseSmartTransaction(state, chainId), - ); + const { isSupported: isGaslessSupportedSTX, isSmartTransaction } = + useGaslessSupportedSmartTransactions(); + + const { isSupported: isGaslessSupported } = useIsGaslessSupported(); const waitForResult = - !shouldUseSmartTransaction && !quotes?.length && !selectedGasFeeToken; + !isSmartTransaction && !quotes?.length && !selectedGasFeeToken; const handleSmartTransaction = useCallback( (updatedMetadata: TransactionMeta) => { @@ -64,13 +63,23 @@ export function useTransactionConfirm() { updatedMetadata.txParams.maxFeePerGas = selectedGasFeeToken.maxFeePerGas; updatedMetadata.txParams.maxPriorityFeePerGas = selectedGasFeeToken.maxPriorityFeePerGas; - }, - [selectedGasFeeToken], - ); - const { value: chainSupportsSendBundle } = useAsyncResult( - async () => (chainId ? isSendBundleSupported(chainId) : false), - [chainId], + // If the gasless flow is not supported (e.g. stx is disabled by the user, + // or 7702 is not supported in the chain), we override the + // `isGasFeeSponsored` flag to `false` so the transaction meta object in + // state has the correct value for the transaction details on the activity + // list to not show as sponsored. One limitation on the activity list will + // be that pre-populated transactions on fresh installs will not show as + // sponsored even if they were because this is not easily observable onchain + // for all cases. + updatedMetadata.isGasFeeSponsored = + isGaslessSupported && transactionMetadata?.isGasFeeSponsored; + }, + [ + selectedGasFeeToken, + isGaslessSupported, + transactionMetadata?.isGasFeeSponsored, + ], ); const handleGasless7702 = useCallback( @@ -80,8 +89,14 @@ export function useTransactionConfirm() { } updatedMetadata.isExternalSign = true; + updatedMetadata.isGasFeeSponsored = + isGaslessSupported && transactionMetadata?.isGasFeeSponsored; }, - [selectedGasFeeToken], + [ + isGaslessSupported, + selectedGasFeeToken, + transactionMetadata?.isGasFeeSponsored, + ], ); const onConfirm = useCallback(async () => { @@ -91,7 +106,7 @@ export function useTransactionConfirm() { const updatedMetadata = cloneDeep(transactionMetadata); - if (shouldUseSmartTransaction && chainSupportsSendBundle) { + if (isGaslessSupportedSTX) { handleSmartTransaction(updatedMetadata); } else if (selectedGasFeeToken) { handleGasless7702(updatedMetadata); @@ -132,16 +147,15 @@ export function useTransactionConfirm() { tryEnableEvmNetwork(chainId); } }, [ - chainSupportsSendBundle, chainId, dispatch, handleGasless7702, handleSmartTransaction, isFullScreenConfirmation, + isGaslessSupportedSTX, navigation, onRequestConfirm, selectedGasFeeToken, - shouldUseSmartTransaction, transactionMetadata, tryEnableEvmNetwork, type, 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 f6116c646f7..dca7925baab 100644 --- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts +++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts @@ -198,11 +198,7 @@ async function publishHook({ return payResult; } - if ( - !shouldUseSmartTransaction || - !sendBundleSupport || - transactionMeta.isGasFeeSponsored - ) { + if (!shouldUseSmartTransaction || !sendBundleSupport) { const hook = new Delegation7702PublishHook({ isAtomicBatchSupported: transactionController.isAtomicBatchSupported.bind( transactionController, From 1348d2e881639238a22fbd3e7e8e6b2639e100a9 Mon Sep 17 00:00:00 2001 From: Maarten Zuidhoorn <maarten@zuidhoorn.com> Date: Tue, 11 Nov 2025 11:16:02 +0100 Subject: [PATCH 4/5] chore: Bump Snaps packages (#22317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit <!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** This bumps the Snaps packages, which mainly contain some bug fixes, and the addition of client and platform versions in `snap_getClientStatus`. ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Include client version and platform version in `snap_getClientStatus` response. ## **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** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Upgrades Snaps packages and adds version reporting for `snap_getClientStatus` via middleware `getVersion`, with new e2e assertions; removes unused `dynamicPermissions` from SnapController init. > > - **Snaps middleware**: > - Add `getVersion` using `react-native-device-info`, formatting by `METAMASK_BUILD_TYPE` in `app/core/Snaps/SnapsMethodMiddleware.ts`. > - **Snap controller init**: > - Remove `dynamicPermissions` argument and `Caip25EndowmentPermissionName` import in `app/core/Engine/controllers/snaps/snap-controller-init.ts` (tests updated accordingly). > - **E2E tests**: > - Add `checkClientStatus` helper to verify JSON and version prefix in `e2e/pages/Browser/TestSnaps.ts`. > - Update `e2e/specs/snaps/test-snap-client-status.spec.ts` to assert `clientVersion` (from `package.json`) and `platformVersion` (from `@metamask/snaps-sdk`). > - **Dependencies**: > - Bump `@metamask/snaps-*` and `@metamask/preinstalled-example-snap` versions in `package.json` (lockfile updated). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2b20fc60ccf9d794a8e4ae8ace594a9974b3a42c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --- .../snaps/snap-controller-init.test.ts | 1 - .../controllers/snaps/snap-controller-init.ts | 2 - app/core/Snaps/SnapsMethodMiddleware.ts | 11 +++ e2e/pages/Browser/TestSnaps.ts | 38 ++++++++ .../snaps/test-snap-client-status.spec.ts | 6 +- package.json | 10 +-- yarn.lock | 90 +++++++------------ 7 files changed, 91 insertions(+), 67 deletions(-) diff --git a/app/core/Engine/controllers/snaps/snap-controller-init.test.ts b/app/core/Engine/controllers/snaps/snap-controller-init.test.ts index eb72cfa3565..2b6e7b885a7 100644 --- a/app/core/Engine/controllers/snaps/snap-controller-init.test.ts +++ b/app/core/Engine/controllers/snaps/snap-controller-init.test.ts @@ -57,7 +57,6 @@ describe('SnapControllerInit', () => { const controllerMock = jest.mocked(SnapController); expect(controllerMock).toHaveBeenCalledWith({ - dynamicPermissions: expect.any(Array), messenger: expect.any(Object), state: undefined, clientCryptography: { diff --git a/app/core/Engine/controllers/snaps/snap-controller-init.ts b/app/core/Engine/controllers/snaps/snap-controller-init.ts index c404bd5a878..877122e36a9 100644 --- a/app/core/Engine/controllers/snaps/snap-controller-init.ts +++ b/app/core/Engine/controllers/snaps/snap-controller-init.ts @@ -20,7 +20,6 @@ import { KeyringTypes } from '@metamask/keyring-controller'; import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings'; import { store } from '../../../../store'; import PREINSTALLED_SNAPS from '../../../../lib/snaps/preinstalled-snaps'; -import { Caip25EndowmentPermissionName } from '@metamask/chain-agnostic-permission'; import { MetaMetrics } from '../../../Analytics'; import { MetricsEventBuilder } from '../../../Analytics/MetricsEventBuilder'; @@ -86,7 +85,6 @@ export const snapControllerInit: ControllerInitFunction< } const controller = new SnapController({ - dynamicPermissions: [Caip25EndowmentPermissionName], environmentEndowmentPermissions: Object.values(EndowmentPermissions), excludedPermissions: { ...ExcludedSnapPermissions, diff --git a/app/core/Snaps/SnapsMethodMiddleware.ts b/app/core/Snaps/SnapsMethodMiddleware.ts index ec8ee505c6c..f1af194fc7e 100644 --- a/app/core/Snaps/SnapsMethodMiddleware.ts +++ b/app/core/Snaps/SnapsMethodMiddleware.ts @@ -37,6 +37,7 @@ import { Json } from '@metamask/utils'; import { SchedulableBackgroundEvent } from '@metamask/snaps-controllers'; import { endTrace, trace } from '../../util/trace'; import { AppState } from 'react-native'; +import { getVersion } from 'react-native-device-info'; export function getSnapIdFromRequest( request: Record<string, unknown>, @@ -195,6 +196,16 @@ const snapMethodMiddlewareBuilder = ( AppState.currentState === 'active' && engineContext.KeyringController.isUnlocked(), getIsLocked: () => !engineContext.KeyringController.isUnlocked(), + getVersion: () => { + const baseVersion = getVersion(); + const buildType = process.env.METAMASK_BUILD_TYPE; + + if (buildType === 'main' || buildType === 'qa') { + return baseVersion; + } + + return `${baseVersion}-${buildType}.0`; + }, getEntropySources: () => { const state = controllerMessenger.call('KeyringController:getState'); diff --git a/e2e/pages/Browser/TestSnaps.ts b/e2e/pages/Browser/TestSnaps.ts index 6055f0cf66f..05f6e24ab64 100644 --- a/e2e/pages/Browser/TestSnaps.ts +++ b/e2e/pages/Browser/TestSnaps.ts @@ -161,6 +161,44 @@ class TestSnaps { }, options); } + async checkClientStatus( + { + clientVersion: expectedClientVersion, + ...expectedStatus + }: Record<string, Json>, + options: Partial<RetryOptions> = { + timeout: 5_000, + interval: 100, + }, + ) { + const webElement = await Matchers.getElementByWebID( + BrowserViewSelectorsIDs.BROWSER_WEBVIEW_ID, + TestSnapResultSelectorWebIDS.clientStatusResultSpan, + ); + + return await Utilities.executeWithRetry(async () => { + const actualText = await webElement.getText(); + let actualStatusWithVersion; + try { + actualStatusWithVersion = JSON.parse(actualText); + } catch (error) { + throw new Error( + `Failed to parse JSON from client status span: ${actualText}`, + ); + } + + const { clientVersion: actualClientVersion, ...actualStatus } = + actualStatusWithVersion; + + await Assertions.checkIfJsonEqual(actualStatus, expectedStatus); + if (!actualClientVersion.startsWith(expectedClientVersion)) { + throw new Error( + `Client version mismatch: Expected version to start with "${expectedClientVersion}", got "${actualClientVersion}".`, + ); + } + }, options); + } + async navigateToTestSnap(): Promise<void> { await Browser.tapUrlInputBox(); await Browser.navigateToURL(TEST_SNAPS_URL); diff --git a/e2e/specs/snaps/test-snap-client-status.spec.ts b/e2e/specs/snaps/test-snap-client-status.spec.ts index cc731709e0d..486efc6a625 100644 --- a/e2e/specs/snaps/test-snap-client-status.spec.ts +++ b/e2e/specs/snaps/test-snap-client-status.spec.ts @@ -4,6 +4,8 @@ import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; import { withFixtures } from '../../framework/fixtures/FixtureHelper'; import TabBarComponent from '../../pages/wallet/TabBarComponent'; import TestSnaps from '../../pages/Browser/TestSnaps'; +import sdkPackageJson from '@metamask/snaps-sdk/package.json'; +import packageJson from '../../../package.json'; jest.setTimeout(150_000); @@ -33,9 +35,11 @@ describe(FlaskBuildTests('Client Status Snap Tests'), () => { }, async () => { await TestSnaps.tapButton('sendClientStatusButton'); - await TestSnaps.checkResultJson('clientStatusResultSpan', { + await TestSnaps.checkClientStatus({ locked: false, active: true, + clientVersion: packageJson.version, + platformVersion: sdkPackageJson.version, }); }, ); diff --git a/package.json b/package.json index db548797088..ffbc70f877a 100644 --- a/package.json +++ b/package.json @@ -256,7 +256,7 @@ "@metamask/phishing-controller": "^15.0.0", "@metamask/post-message-stream": "^10.0.0", "@metamask/preferences-controller": "^21.0.0", - "@metamask/preinstalled-example-snap": "^0.7.1", + "@metamask/preinstalled-example-snap": "^0.7.2", "@metamask/profile-sync-controller": "^26.0.0", "@metamask/react-native-acm": "^1.0.1", "@metamask/react-native-actionsheet": "2.4.2", @@ -275,10 +275,10 @@ "@metamask/signature-controller": "^35.0.0", "@metamask/slip44": "^4.2.0", "@metamask/smart-transactions-controller": "^20.1.0", - "@metamask/snaps-controllers": "^16.0.0", - "@metamask/snaps-execution-environments": "^10.2.2", - "@metamask/snaps-rpc-methods": "^14.0.0", - "@metamask/snaps-sdk": "^10.0.0", + "@metamask/snaps-controllers": "^16.1.0", + "@metamask/snaps-execution-environments": "^10.2.3", + "@metamask/snaps-rpc-methods": "^14.1.0", + "@metamask/snaps-sdk": "^10.1.0", "@metamask/snaps-utils": "^11.6.1", "@metamask/solana-wallet-snap": "^2.4.6", "@metamask/solana-wallet-standard": "^0.6.0", diff --git a/yarn.lock b/yarn.lock index 710bc31e9b3..0d9893bc9a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7036,17 +7036,6 @@ __metadata: languageName: node linkType: hard -"@metamask/base-controller@npm:^8.0.1": - version: 8.4.2 - resolution: "@metamask/base-controller@npm:8.4.2" - dependencies: - "@metamask/messenger": "npm:^0.3.0" - "@metamask/utils": "npm:^11.8.1" - immer: "npm:^9.0.6" - checksum: 10/e5c4d97a35952072dc8e93c90a334ea60a8d9faa7c15584b5ce8f60b151cf56a7dd5304cb8549b183cdd61d53421b45e3c4688170fcd0a3e2c6a184aeb942067 - languageName: node - linkType: hard - "@metamask/base-controller@npm:^9.0.0": version: 9.0.0 resolution: "@metamask/base-controller@npm:9.0.0" @@ -8188,21 +8177,6 @@ __metadata: languageName: node linkType: hard -"@metamask/phishing-controller@npm:^13.1.0": - version: 13.1.0 - resolution: "@metamask/phishing-controller@npm:13.1.0" - dependencies: - "@metamask/base-controller": "npm:^8.0.1" - "@metamask/controller-utils": "npm:^11.11.0" - "@noble/hashes": "npm:^1.4.0" - "@types/punycode": "npm:^2.1.0" - ethereum-cryptography: "npm:^2.1.2" - fastest-levenshtein: "npm:^1.0.16" - punycode: "npm:^2.1.1" - checksum: 10/c62f71291736dfd635cc69b2d422687d8d610591a5e1cd9a6b4806cdc19221a72fe7699c0cabe0a2a108b49c3cc4dcb88a5b283fba374fe13e54d5813fb77902 - languageName: node - linkType: hard - "@metamask/phishing-controller@npm:^15.0.0": version: 15.0.0 resolution: "@metamask/phishing-controller@npm:15.0.0" @@ -8260,12 +8234,12 @@ __metadata: languageName: node linkType: hard -"@metamask/preinstalled-example-snap@npm:^0.7.1": - version: 0.7.1 - resolution: "@metamask/preinstalled-example-snap@npm:0.7.1" +"@metamask/preinstalled-example-snap@npm:^0.7.2": + version: 0.7.2 + resolution: "@metamask/preinstalled-example-snap@npm:0.7.2" dependencies: - "@metamask/snaps-sdk": "npm:^9.3.0" - checksum: 10/6b8a0e81de0cb6591d8379084711a5b110e967355b8151a8e23c03c5d62d735c1c19f0c6537b4e70f72ae57207b962630dfd47d2722c15b0f5e09bec807261ce + "@metamask/snaps-sdk": "npm:^10.1.0" + checksum: 10/9605e6f2d120e85ee0ec6b43ff7d407ff07b57a4b790312ff6b176e2eb306a4c997acca034e2612155f327e12bdccdf258291e16b406d98658b45092cb4b63a3 languageName: node linkType: hard @@ -8598,9 +8572,9 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-controllers@npm:^16.0.0": - version: 16.0.0 - resolution: "@metamask/snaps-controllers@npm:16.0.0" +"@metamask/snaps-controllers@npm:^16.1.0": + version: 16.1.0 + resolution: "@metamask/snaps-controllers@npm:16.1.0" dependencies: "@metamask/approval-controller": "npm:^8.0.0" "@metamask/base-controller": "npm:^9.0.0" @@ -8609,13 +8583,13 @@ __metadata: "@metamask/key-tree": "npm:^10.1.1" "@metamask/messenger": "npm:^0.3.0" "@metamask/object-multiplex": "npm:^2.1.0" - "@metamask/permission-controller": "npm:^12.0.0" - "@metamask/phishing-controller": "npm:^13.1.0" + "@metamask/permission-controller": "npm:^12.1.0" + "@metamask/phishing-controller": "npm:^15.0.0" "@metamask/post-message-stream": "npm:^10.0.0" "@metamask/rpc-errors": "npm:^7.0.3" "@metamask/snaps-registry": "npm:^3.2.3" - "@metamask/snaps-rpc-methods": "npm:^14.0.0" - "@metamask/snaps-sdk": "npm:^10.0.0" + "@metamask/snaps-rpc-methods": "npm:^14.1.0" + "@metamask/snaps-sdk": "npm:^10.1.0" "@metamask/snaps-utils": "npm:^11.6.1" "@metamask/utils": "npm:^11.8.1" "@xstate/fsm": "npm:^2.0.0" @@ -8632,29 +8606,29 @@ __metadata: semver: "npm:^7.5.4" tar-stream: "npm:^3.1.7" peerDependencies: - "@metamask/snaps-execution-environments": ^10.2.2 + "@metamask/snaps-execution-environments": ^10.2.3 peerDependenciesMeta: "@metamask/snaps-execution-environments": optional: true - checksum: 10/e5191fe2b41f437720b6263a394adf6e0ba6060279350b0eba902887d7137222e4bb54e8c6b0b052f587dd73254f5d61f6ea37bedf349ec44c60f76535f8afd8 + checksum: 10/9af230904002608d73de7d9b87e9e63dda304da2f68fc1cf813b0d43a222edb1d359ba82921f7f88b1e066b39c72a7a73fab90c824907e0b3bcfe84de6b1bc46 languageName: node linkType: hard -"@metamask/snaps-execution-environments@npm:^10.2.2": - version: 10.2.2 - resolution: "@metamask/snaps-execution-environments@npm:10.2.2" +"@metamask/snaps-execution-environments@npm:^10.2.3": + version: 10.2.3 + resolution: "@metamask/snaps-execution-environments@npm:10.2.3" dependencies: "@metamask/json-rpc-engine": "npm:^10.1.0" "@metamask/object-multiplex": "npm:^2.1.0" "@metamask/post-message-stream": "npm:^10.0.0" "@metamask/providers": "npm:^22.1.1" "@metamask/rpc-errors": "npm:^7.0.3" - "@metamask/snaps-sdk": "npm:^10.0.0" - "@metamask/snaps-utils": "npm:^11.6.0" + "@metamask/snaps-sdk": "npm:^10.1.0" + "@metamask/snaps-utils": "npm:^11.6.1" "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.8.1" readable-stream: "npm:^3.6.2" - checksum: 10/b3c4dd386e6771c8114150965a145c3c0aa2da49309d4d6ce05f7780ff718232514833b37a84a1c1a8b2d0bc68ed6d98be23a8585af593b196ecd4dedd029774 + checksum: 10/da92c33942e1422de8a24b1f5885a037d3d4cbd33f802a7493c0f05b1ad2cce3721576288e2caa7a2369a7348d6b031b24f47087a04f2a7c0b0ac6eb5b01cc27 languageName: node linkType: hard @@ -8670,19 +8644,19 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-rpc-methods@npm:^14.0.0": - version: 14.0.0 - resolution: "@metamask/snaps-rpc-methods@npm:14.0.0" +"@metamask/snaps-rpc-methods@npm:^14.1.0": + version: 14.1.0 + resolution: "@metamask/snaps-rpc-methods@npm:14.1.0" dependencies: "@metamask/key-tree": "npm:^10.1.1" - "@metamask/permission-controller": "npm:^12.0.0" + "@metamask/permission-controller": "npm:^12.1.0" "@metamask/rpc-errors": "npm:^7.0.3" - "@metamask/snaps-sdk": "npm:^10.0.0" + "@metamask/snaps-sdk": "npm:^10.1.0" "@metamask/snaps-utils": "npm:^11.6.1" "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.8.1" "@noble/hashes": "npm:^1.7.1" - checksum: 10/74fd793c3e477a1ccc769c39a5a9e38c4ffe7fa061d8511c6fdf62af0d60b7328ea1349a1342276cb47cacbf04168a96fca1496ee3436c712f7e1e05da2b8e5e + checksum: 10/6502f406f778baa0e1307b8e5b0bf3746e554a114e5bd9289d4814472a794fd84cfe32700bad162afef8484384f2f0012a81fc21360e5d408553804f253e7e69 languageName: node linkType: hard @@ -8699,7 +8673,7 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-utils@npm:^11.0.0, @metamask/snaps-utils@npm:^11.6.0, @metamask/snaps-utils@npm:^11.6.1": +"@metamask/snaps-utils@npm:^11.0.0, @metamask/snaps-utils@npm:^11.6.1": version: 11.6.1 resolution: "@metamask/snaps-utils@npm:11.6.1" dependencies: @@ -34324,7 +34298,7 @@ __metadata: "@metamask/phishing-controller": "npm:^15.0.0" "@metamask/post-message-stream": "npm:^10.0.0" "@metamask/preferences-controller": "npm:^21.0.0" - "@metamask/preinstalled-example-snap": "npm:^0.7.1" + "@metamask/preinstalled-example-snap": "npm:^0.7.2" "@metamask/profile-sync-controller": "npm:^26.0.0" "@metamask/providers": "npm:^18.3.1" "@metamask/react-native-acm": "npm:^1.0.1" @@ -34344,10 +34318,10 @@ __metadata: "@metamask/signature-controller": "npm:^35.0.0" "@metamask/slip44": "npm:^4.2.0" "@metamask/smart-transactions-controller": "npm:^20.1.0" - "@metamask/snaps-controllers": "npm:^16.0.0" - "@metamask/snaps-execution-environments": "npm:^10.2.2" - "@metamask/snaps-rpc-methods": "npm:^14.0.0" - "@metamask/snaps-sdk": "npm:^10.0.0" + "@metamask/snaps-controllers": "npm:^16.1.0" + "@metamask/snaps-execution-environments": "npm:^10.2.3" + "@metamask/snaps-rpc-methods": "npm:^14.1.0" + "@metamask/snaps-sdk": "npm:^10.1.0" "@metamask/snaps-utils": "npm:^11.6.1" "@metamask/solana-wallet-snap": "npm:^2.4.6" "@metamask/solana-wallet-standard": "npm:^0.6.0" From 358b4c7ac9f8c525b194aac52e373b4a603df645 Mon Sep 17 00:00:00 2001 From: Bruno Nascimento <brunonascimentodev@gmail.com> Date: Tue, 11 Nov 2025 08:19:39 -0300 Subject: [PATCH 5/5] fix(card): cp-7.58.2 delegation issues (#22435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit <!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> This PR fixes an issue where delegation was failing in certain cases due to the wallet address being lowercased. The lowercase format did not comply with the SIWE (Sign-In With Ethereum) standard required by the delegation flow. Additionally, this update includes a fix for assets that were not enabled, ensuring consistent behavior across all supported assets. ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Delegation failing due to lowercased wallet address not complying with SIWE standard CHANGELOG entry: Handling of not-enabled assets in the delegation flow ## **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** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Use checksummed EVM addresses for delegation, filter external wallets with zero/invalid allowances, and only update token priority when delegation amount > 0, with corresponding tests. > > - **Hooks**: > - `useCardDelegation`: Use `safeToChecksumAddress` for non-Solana networks; keep raw address for Solana. > - **SDK**: > - `CardSDK.getCardExternalWalletDetails`: Filter out wallets with unsupported networks or zero/invalid `allowance` using `isZeroValue`; maintain priority mapping and sorting. > - **UI**: > - `SpendingLimit`: Only call `updateTokenPriority` when external wallet details exist and `delegationAmount` is non-zero; otherwise clear `card-external-wallet-details` cache. > - **Tests**: > - Add/expand tests covering checksummed vs raw addresses, zero/hex-zero allowances filtering, and skipping `updateTokenPriority` when delegation amount is 0/`0x0`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b03ccdc1fd1a5908c8428f6ea112bb3a41227052. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --- .../SpendingLimit/SpendingLimit.test.tsx | 110 ++++++++++++++ .../Views/SpendingLimit/SpendingLimit.tsx | 7 +- .../UI/Card/hooks/useCardDelegation.test.ts | 82 +++++++++- .../UI/Card/hooks/useCardDelegation.ts | 6 +- app/components/UI/Card/sdk/CardSDK.test.ts | 142 ++++++++++++++++++ app/components/UI/Card/sdk/CardSDK.ts | 7 +- 6 files changed, 346 insertions(+), 8 deletions(-) diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.test.tsx b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.test.tsx index ce0301e83e5..6a64b3df337 100644 --- a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.test.tsx +++ b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.test.tsx @@ -1022,6 +1022,116 @@ describe('SpendingLimit Component', () => { ); }); }); + + it('does not call updateTokenPriority when delegation amount is zero', async () => { + const mockExternalWalletDetails = { + walletDetails: [ + { + id: 1, + walletAddress: '0xwallet123', + currency: 'USDC', + balance: '1000', + allowance: '1000000', + priority: 1, + tokenDetails: { + address: '0x123', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + caipChainId: 'eip155:59144' as `${string}:${string}`, + network: 'linea' as const, + }, + ] as unknown as CardExternalWalletDetailsResponse, + mappedWalletDetails: [mockPriorityToken], + priorityWalletDetail: mockPriorityToken, + }; + + const routeWithWalletDetails: MockRoute = { + params: { + ...mockRoute.params, + externalWalletDetailsData: mockExternalWalletDetails, + }, + }; + + render(routeWithWalletDetails); + + const setLimitButton = screen.getByText('Set a limit'); + fireEvent.press(setLimitButton); + + const restrictedOption = screen.getByText('Restricted'); + fireEvent.press(restrictedOption); + + const input = screen.getByPlaceholderText('0'); + fireEvent.changeText(input, '0'); + + const confirmButton = screen.getByText('Confirm'); + fireEvent.press(confirmButton); + + await waitFor(() => { + expect(mockSubmitDelegation).toHaveBeenCalled(); + }); + + expect(mockUpdateTokenPriority).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: expect.stringContaining('clearCacheData'), + payload: 'card-external-wallet-details', + }), + ); + }); + + it('does not call updateTokenPriority when delegation amount is 0x0', async () => { + const mockExternalWalletDetails = { + walletDetails: [ + { + id: 1, + walletAddress: '0xwallet123', + currency: 'USDC', + balance: '1000', + allowance: '1000000', + priority: 1, + tokenDetails: { + address: '0x123', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + caipChainId: 'eip155:59144' as `${string}:${string}`, + network: 'linea' as const, + }, + ] as unknown as CardExternalWalletDetailsResponse, + mappedWalletDetails: [mockPriorityToken], + priorityWalletDetail: mockPriorityToken, + }; + + const routeWithWalletDetails: MockRoute = { + params: { + ...mockRoute.params, + externalWalletDetailsData: mockExternalWalletDetails, + }, + }; + + render(routeWithWalletDetails); + + const setLimitButton = screen.getByText('Set a limit'); + fireEvent.press(setLimitButton); + + const restrictedOption = screen.getByText('Restricted'); + fireEvent.press(restrictedOption); + + const input = screen.getByPlaceholderText('0'); + fireEvent.changeText(input, '0x0'); + + const confirmButton = screen.getByText('Confirm'); + fireEvent.press(confirmButton); + + await waitFor(() => { + expect(mockSubmitDelegation).toHaveBeenCalled(); + }); + + expect(mockUpdateTokenPriority).not.toHaveBeenCalled(); + }); }); describe('Cancel Behavior', () => { diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx index 596a76ee280..5a53f15cbaf 100644 --- a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx +++ b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx @@ -54,6 +54,7 @@ import { useDispatch } from 'react-redux'; import Routes from '../../../../../constants/navigation/Routes'; import { SafeAreaView } from 'react-native-safe-area-context'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; +import { isZeroValue } from '../../../../../util/number'; const getNetworkFromCaipChainId = (caipChainId: string): CardNetwork => { if (caipChainId === SolScope.Mainnet || caipChainId.startsWith('solana:')) { @@ -271,10 +272,11 @@ const SpendingLimit = ({ network, }); - // Update token priority if external wallet details are available + // Update token priority if external wallet details are available and delegation is more than 0 if ( externalWalletDetailsData?.walletDetails && - externalWalletDetailsData.walletDetails.length > 0 + externalWalletDetailsData.walletDetails.length > 0 && + !isZeroValue(parseFloat(delegationAmount)) ) { const tokenWithWallet = tokenToUse || priorityToken; if (tokenWithWallet) { @@ -284,7 +286,6 @@ const SpendingLimit = ({ ); } } else { - // If no external wallet details, just invalidate cache dispatch(clearCacheData('card-external-wallet-details')); } diff --git a/app/components/UI/Card/hooks/useCardDelegation.test.ts b/app/components/UI/Card/hooks/useCardDelegation.test.ts index a057c109820..1af75a50b5f 100644 --- a/app/components/UI/Card/hooks/useCardDelegation.test.ts +++ b/app/components/UI/Card/hooks/useCardDelegation.test.ts @@ -12,6 +12,7 @@ import { useMetrics, } from '../../../hooks/useMetrics'; import { toTokenMinimalUnit } from '../../../../util/number'; +import { safeToChecksumAddress } from '../../../../util/address'; import { ARBITRARY_ALLOWANCE } from '../constants'; import { TransactionType, @@ -47,6 +48,10 @@ jest.mock('../../../../util/number', () => ({ toTokenMinimalUnit: jest.fn(), })); +jest.mock('../../../../util/address', () => ({ + safeToChecksumAddress: jest.fn(), +})); + jest.mock('../../../../core/Engine', () => ({ context: { KeyringController: { @@ -70,6 +75,9 @@ const mockUseMetrics = useMetrics as jest.MockedFunction<typeof useMetrics>; const mockToTokenMinimalUnit = toTokenMinimalUnit as jest.MockedFunction< typeof toTokenMinimalUnit >; +const mockSafeToChecksumAddress = safeToChecksumAddress as jest.MockedFunction< + typeof safeToChecksumAddress +>; // Helper functions const createMockToken = ( @@ -191,6 +199,9 @@ describe('useCardDelegation', () => { // Setup utility mocks mockToTokenMinimalUnit.mockReturnValue('100000000000000000000'); + mockSafeToChecksumAddress.mockImplementation( + (address?: string) => (address as `0x${string}`) || undefined, + ); // Setup SDK method mocks mockSDK.generateDelegationToken.mockResolvedValue({ @@ -1086,8 +1097,9 @@ describe('useCardDelegation', () => { }); }); - it('handles solana network selection', async () => { + it('uses raw address for solana network without checksum', async () => { const mockToken = createMockToken(); + const mockSolanaAddress = 'SolanaAddress123ABC'; const params = { ...createMockDelegationParams(), network: 'solana' as const, @@ -1095,17 +1107,81 @@ describe('useCardDelegation', () => { mockUseSelector.mockReturnValue( jest.fn().mockReturnValue({ - address: mockAddress, + address: mockSolanaAddress, + }), + ); + + const { result } = renderHook(() => useCardDelegation(mockToken)); + + await act(async () => { + await result.current.submitDelegation(params); + }); + + expect(mockSafeToChecksumAddress).not.toHaveBeenCalled(); + expect(mockSDK.generateDelegationToken).toHaveBeenCalledWith( + 'solana', + mockSolanaAddress, + ); + }); + + it('uses checksummed address for linea network', async () => { + const mockToken = createMockToken(); + const mockRawAddress = '0xABCDEF123456'; + const mockChecksummedAddress = '0xabcdef123456' as `0x${string}`; + const params = createMockDelegationParams(); + + mockUseSelector.mockReturnValue( + jest.fn().mockReturnValue({ + address: mockRawAddress, + }), + ); + + mockSafeToChecksumAddress.mockReturnValue(mockChecksummedAddress); + + const { result } = renderHook(() => useCardDelegation(mockToken)); + + await act(async () => { + await result.current.submitDelegation(params); + }); + + expect(mockSafeToChecksumAddress).toHaveBeenCalledWith(mockRawAddress); + expect(mockSDK.generateDelegationToken).toHaveBeenCalledWith( + 'linea', + mockChecksummedAddress, + ); + }); + + it('uses checksummed address for non-solana networks', async () => { + const mockToken = createMockToken(); + const mockRawAddress = '0x1234567890ABCDEF'; + const mockChecksummedAddress = '0x1234567890abcdef' as `0x${string}`; + const params = { + ...createMockDelegationParams(), + network: 'linea' as const, + }; + + mockUseSelector.mockReturnValue( + jest.fn().mockReturnValue({ + address: mockRawAddress, }), ); + mockSafeToChecksumAddress.mockReturnValue(mockChecksummedAddress); + const { result } = renderHook(() => useCardDelegation(mockToken)); await act(async () => { await result.current.submitDelegation(params); }); - expect(mockUseSelector).toHaveBeenCalled(); + expect(mockSafeToChecksumAddress).toHaveBeenCalledWith(mockRawAddress); + expect( + Engine.context.KeyringController.signPersonalMessage, + ).toHaveBeenCalledWith( + expect.objectContaining({ + from: mockChecksummedAddress, + }), + ); }); it('handles very large allowance amounts', async () => { diff --git a/app/components/UI/Card/hooks/useCardDelegation.ts b/app/components/UI/Card/hooks/useCardDelegation.ts index 67adf46271c..a13db2f0463 100644 --- a/app/components/UI/Card/hooks/useCardDelegation.ts +++ b/app/components/UI/Card/hooks/useCardDelegation.ts @@ -18,6 +18,7 @@ import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; import { ARBITRARY_ALLOWANCE } from '../constants'; import { toTokenMinimalUnit } from '../../../../util/number'; import AppConstants from '../../../../core/AppConstants'; +import { safeToChecksumAddress } from '../../../../util/address'; /** * Custom error class for user-initiated cancellations @@ -238,7 +239,10 @@ export const useCardDelegation = (token?: CardTokenAllowance | null) => { const userAccount = selectAccountByScope( params.network === 'solana' ? SolScope.Mainnet : 'eip155:0', ); - const address = userAccount?.address; + const address = + params.network === 'solana' + ? userAccount?.address + : safeToChecksumAddress(userAccount?.address); if (!address) { throw new Error('No account found'); diff --git a/app/components/UI/Card/sdk/CardSDK.test.ts b/app/components/UI/Card/sdk/CardSDK.test.ts index 20c109b6f2c..69c01c3c8b8 100644 --- a/app/components/UI/Card/sdk/CardSDK.test.ts +++ b/app/components/UI/Card/sdk/CardSDK.test.ts @@ -1753,6 +1753,48 @@ describe('CardSDK', () => { }); describe('getCardExternalWalletDetails', () => { + const createMockWalletData = ( + externalWallets: { + address: string; + currency: string; + allowance: string; + network?: string; + }[], + ) => { + const mockExternalWalletResponse = externalWallets.map((wallet) => ({ + address: wallet.address, + currency: wallet.currency, + balance: '1000.00', + allowance: wallet.allowance, + network: wallet.network || 'linea', + })); + + const mockPriorityWalletResponse = externalWallets.map( + (wallet, index) => ({ + id: index + 1, + address: wallet.address, + currency: wallet.currency, + network: wallet.network || 'linea', + priority: index + 1, + }), + ); + + let callCount = 0; + (global.fetch as jest.Mock).mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + ok: true, + json: jest.fn().mockResolvedValue(mockExternalWalletResponse), + }); + } + return Promise.resolve({ + ok: true, + json: jest.fn().mockResolvedValue(mockPriorityWalletResponse), + }); + }); + }; + it('gets external wallet details successfully', async () => { const mockExternalWalletResponse = [ { @@ -1990,6 +2032,106 @@ describe('CardSDK', () => { expect(result).toEqual([]); }); + + it.each([ + ['invalid', 'NaN allowance'], + ['0', 'string zero allowance'], + ['0x0', 'hex zero allowance'], + ])( + 'filters out wallets with %s', + async (invalidAllowance, _description) => { + createMockWalletData([ + { + address: '0x1234567890123456789012345678901234567890', + currency: 'USDC', + allowance: invalidAllowance, + }, + { + address: '0x0987654321098765432109876543210987654321', + currency: 'USDT', + allowance: '500', + }, + ]); + + const result = await cardSDK.getCardExternalWalletDetails([]); + + expect(result).toHaveLength(1); + expect(result[0].currency).toBe('USDT'); + expect(result[0].allowance).toBe('500'); + }, + ); + + it('includes wallets with valid non-zero allowance', async () => { + createMockWalletData([ + { + address: '0x1234567890123456789012345678901234567890', + currency: 'USDC', + allowance: '500', + }, + { + address: '0x0987654321098765432109876543210987654321', + currency: 'USDT', + allowance: '1500', + }, + ]); + + const result = await cardSDK.getCardExternalWalletDetails([]); + + expect(result).toHaveLength(2); + expect(result[0].currency).toBe('USDC'); + expect(result[1].currency).toBe('USDT'); + }); + + it('filters out multiple wallets with mixed invalid allowances', async () => { + createMockWalletData([ + { + address: '0x1111111111111111111111111111111111111111', + currency: 'USDC', + allowance: 'abc', + }, + { + address: '0x2222222222222222222222222222222222222222', + currency: 'USDT', + allowance: '0', + }, + { + address: '0x3333333333333333333333333333333333333333', + currency: 'DAI', + allowance: '0x0', + }, + { + address: '0x4444444444444444444444444444444444444444', + currency: 'WETH', + allowance: '250', + }, + ]); + + const result = await cardSDK.getCardExternalWalletDetails([]); + + expect(result).toHaveLength(1); + expect(result[0].currency).toBe('WETH'); + }); + + it('filters out wallets with unsupported network', async () => { + createMockWalletData([ + { + address: '0x1234567890123456789012345678901234567890', + currency: 'USDC', + allowance: '500', + network: 'ethereum', + }, + { + address: '0x0987654321098765432109876543210987654321', + currency: 'USDT', + allowance: '1000', + }, + ]); + + const result = await cardSDK.getCardExternalWalletDetails([]); + + expect(result).toHaveLength(1); + expect(result[0].currency).toBe('USDT'); + }); }); describe('emailVerificationSend', () => { diff --git a/app/components/UI/Card/sdk/CardSDK.ts b/app/components/UI/Card/sdk/CardSDK.ts index e7dc6affefd..8278e82ac00 100644 --- a/app/components/UI/Card/sdk/CardSDK.ts +++ b/app/components/UI/Card/sdk/CardSDK.ts @@ -53,6 +53,7 @@ import { SOLANA_MAINNET } from '../../Ramp/Deposit/constants/networks'; import { CaipChainId } from '@metamask/utils'; import { formatChainIdToCaip } from '@metamask/bridge-controller'; import { SolScope } from '@metamask/keyring-api'; +import { isZeroValue } from '../../../../util/number'; // Default timeout for all API requests (10 seconds) const DEFAULT_REQUEST_TIMEOUT_MS = 10000; @@ -890,7 +891,11 @@ export class CardSDK { const combinedDetails = externalWalletDetails .map((wallet: CardWalletExternalResponse) => { const networkLower = wallet.network?.toLowerCase(); - if (!SUPPORTED_ASSET_NETWORKS.includes(networkLower)) { + if ( + !SUPPORTED_ASSET_NETWORKS.includes(networkLower) || + isNaN(parseInt(wallet.allowance)) || + isZeroValue(parseInt(wallet.allowance)) + ) { return null; }