diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.test.tsx b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.test.tsx index ce0301e83e51..6a64b3df3371 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 596a76ee280b..5a53f15cbaff 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 a057c1098206..1af75a50b5f7 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; 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 67adf46271c6..a13db2f04637 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 20c109b6f2c0..69c01c3c8b82 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 e7dc6affefda..8278e82ac000 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; } 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 7a6751ba9568..20a19965fb6a 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 2063a2f4c360..20bc63c1f101 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> ); } 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 183d2bd7cb0d..f692f49471f3 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 2cdc6df114df..1434bb368f3e 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 722c9f16a61b..b3eda37d12e8 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 000000000000..c95584a96dc7 --- /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 000000000000..869d7b055c93 --- /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 fd8400e8b6fe..e2287cc11dbb 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 e3ce76a46e1b..9c966773c699 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/send/useAmountValidation.test.ts b/app/components/Views/confirmations/hooks/send/useAmountValidation.test.ts index 645ede32c3c6..d4f7765d2492 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<typeof useSendType>); mockUseSendContext.mockReturnValue({ - value: '0.00005', + value: '0.000005', } as unknown as ReturnType<typeof useSendContext>); mockUseBalance.mockReturnValue({ balance: '1', @@ -150,7 +150,7 @@ describe('useAmountValidation', () => { isBitcoinSendType: true, } as ReturnType<typeof useSendType>); mockUseSendContext.mockReturnValue({ - value: '0.0001', + value: '0.000006', } as unknown as ReturnType<typeof useSendContext>); 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 154f42f1b03d..1130f19aa326 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); diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts index 476295cb80ba..314bbda53a11 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 b22407db25d8..261909d5fc22 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/snaps/snap-controller-init.test.ts b/app/core/Engine/controllers/snaps/snap-controller-init.test.ts index eb72cfa3565c..2b6e7b885a76 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 c404bd5a8780..877122e36a94 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/Engine/controllers/transaction-controller/transaction-controller-init.ts b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts index f6116c646f79..dca7925baabb 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, diff --git a/app/core/Snaps/SnapsMethodMiddleware.ts b/app/core/Snaps/SnapsMethodMiddleware.ts index ec8ee505c6c2..f1af194fc7e7 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 6055f0cf66fc..05f6e24ab649 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 cc731709e0dc..486efc6a6253 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 db548797088b..ffbc70f877a1 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 710bc31e9b3a..0d9893bc9a77 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"