diff --git a/app/components/Views/confirmations/components/modals/advanced-eip1559-modal/advanced-eip1559-modal.test.tsx b/app/components/Views/confirmations/components/modals/advanced-eip1559-modal/advanced-eip1559-modal.test.tsx index 3efd29a2981d..b4560e78b403 100644 --- a/app/components/Views/confirmations/components/modals/advanced-eip1559-modal/advanced-eip1559-modal.test.tsx +++ b/app/components/Views/confirmations/components/modals/advanced-eip1559-modal/advanced-eip1559-modal.test.tsx @@ -7,7 +7,12 @@ import { simpleSendTransaction } from '../../../__mocks__/controllers/transactio import { GasModalType } from '../../../constants/gas'; import { AdvancedEIP1559Modal } from './advanced-eip1559-modal'; +const mockPersistGasFeePreference = jest.fn(); + jest.mock('../../../../../../util/transaction-controller'); +jest.mock('../../../hooks/gas/usePersistGasFeePreference', () => ({ + usePersistGasFeePreference: jest.fn(() => mockPersistGasFeePreference), +})); jest.mock('../../../hooks/transactions/useTransactionMetadataRequest', () => { const { simpleSendTransaction: actualSimpleSendTransaction } = jest.requireActual( @@ -73,6 +78,14 @@ describe('AdvancedEIP1559Modal', () => { userFeeLevel: 'custom', }), ); + expect(mockPersistGasFeePreference).toHaveBeenCalledWith( + simpleSendTransaction, + { + userFeeLevel: 'custom', + maxBaseFee: simpleSendTransaction.txParams.maxFeePerGas, + priorityFee: simpleSendTransaction.txParams.maxPriorityFeePerGas, + }, + ); expect(mockHandleCloseModals).toHaveBeenCalledTimes(1); }); @@ -108,6 +121,14 @@ describe('AdvancedEIP1559Modal', () => { userFeeLevel: 'custom', }), ); + expect(mockPersistGasFeePreference).toHaveBeenCalledWith( + simpleSendTransaction, + { + userFeeLevel: 'custom', + maxBaseFee: '0x174876e800', + priorityFee: '0x12a05f200', + }, + ); }); it('calls navigateToEstimatesModal when the back button is pressed', () => { diff --git a/app/components/Views/confirmations/components/modals/advanced-eip1559-modal/advanced-eip1559-modal.tsx b/app/components/Views/confirmations/components/modals/advanced-eip1559-modal/advanced-eip1559-modal.tsx index f0b8e2a14cbf..277387081c49 100644 --- a/app/components/Views/confirmations/components/modals/advanced-eip1559-modal/advanced-eip1559-modal.tsx +++ b/app/components/Views/confirmations/components/modals/advanced-eip1559-modal/advanced-eip1559-modal.tsx @@ -23,6 +23,7 @@ import { GasInput } from '../../../components/gas/gas-input'; import { MaxBaseFeeInput } from '../../../components/gas/max-base-fee-input'; import { PriorityFeeInput } from '../../../components/gas/priority-fee-input'; import styleSheet from './advanced-eip1559-modal.styles'; +import { usePersistGasFeePreference } from '../../../hooks/gas/usePersistGasFeePreference'; export const AdvancedEIP1559Modal = ({ setActiveModal, @@ -33,6 +34,7 @@ export const AdvancedEIP1559Modal = ({ }) => { const { styles } = useStyles(styleSheet, {}); const transactionMeta = useTransactionMetadataRequest() as TransactionMeta; + const persistGasFeePreference = usePersistGasFeePreference(); const { gas, maxFeePerGas, maxPriorityFeePerGas } = transactionMeta?.txParams || {}; @@ -61,8 +63,18 @@ export const AdvancedEIP1559Modal = ({ userFeeLevel: UserFeeLevel.CUSTOM, ...pickBy(gasParams, Boolean), }); + persistGasFeePreference(transactionMeta, { + userFeeLevel: UserFeeLevel.CUSTOM, + ...pickBy( + { + maxBaseFee: gasParams.maxFeePerGas, + priorityFee: gasParams.maxPriorityFeePerGas, + }, + Boolean, + ), + }); handleCloseModals(); - }, [transactionMeta.id, gasParams, handleCloseModals]); + }, [transactionMeta, gasParams, persistGasFeePreference, handleCloseModals]); const navigateToEstimatesModal = useCallback(() => { setActiveModal(GasModalType.ESTIMATES); diff --git a/app/components/Views/confirmations/components/modals/advanced-gas-price-modal/advanced-gas-price-modal.test.tsx b/app/components/Views/confirmations/components/modals/advanced-gas-price-modal/advanced-gas-price-modal.test.tsx index 58268f6463a6..26c4ccd562c9 100644 --- a/app/components/Views/confirmations/components/modals/advanced-gas-price-modal/advanced-gas-price-modal.test.tsx +++ b/app/components/Views/confirmations/components/modals/advanced-gas-price-modal/advanced-gas-price-modal.test.tsx @@ -6,7 +6,12 @@ import { simpleSendTransaction } from '../../../__mocks__/controllers/transactio import { GasModalType } from '../../../constants/gas'; import { AdvancedGasPriceModal } from './advanced-gas-price-modal'; +const mockPersistGasFeePreference = jest.fn(); + jest.mock('../../../../../../util/transaction-controller'); +jest.mock('../../../hooks/gas/usePersistGasFeePreference', () => ({ + usePersistGasFeePreference: jest.fn(() => mockPersistGasFeePreference), +})); jest.mock('../../../hooks/transactions/useTransactionMetadataRequest', () => { const { simpleSendTransaction: actualSimpleSendTransaction } = jest.requireActual( @@ -60,6 +65,12 @@ describe('AdvancedGasPriceModal', () => { userFeeLevel: 'custom', }), ); + expect(mockPersistGasFeePreference).toHaveBeenCalledWith( + simpleSendTransaction, + { + userFeeLevel: 'custom', + }, + ); expect(mockHandleCloseModals).toHaveBeenCalledTimes(1); }); @@ -91,6 +102,13 @@ describe('AdvancedGasPriceModal', () => { userFeeLevel: 'custom', }), ); + expect(mockPersistGasFeePreference).toHaveBeenCalledWith( + simpleSendTransaction, + { + userFeeLevel: 'custom', + gasPrice: '0x37e11d600', + }, + ); }); it('calls navigateToEstimatesModal when the back button is pressed', () => { diff --git a/app/components/Views/confirmations/components/modals/advanced-gas-price-modal/advanced-gas-price-modal.tsx b/app/components/Views/confirmations/components/modals/advanced-gas-price-modal/advanced-gas-price-modal.tsx index 07c3810ad9e4..e22c2341a7c5 100644 --- a/app/components/Views/confirmations/components/modals/advanced-gas-price-modal/advanced-gas-price-modal.tsx +++ b/app/components/Views/confirmations/components/modals/advanced-gas-price-modal/advanced-gas-price-modal.tsx @@ -2,7 +2,10 @@ import React, { useCallback, useMemo, useState } from 'react'; import { View } from 'react-native'; import { Hex } from '@metamask/utils'; import { pickBy } from 'lodash'; -import { TransactionMeta } from '@metamask/transaction-controller'; +import { + TransactionMeta, + UserFeeLevel, +} from '@metamask/transaction-controller'; import { useStyles } from '../../../../../../component-library/hooks'; import { @@ -19,6 +22,7 @@ import { GasPriceInput } from '../../../components/gas/gas-price-input'; import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; import BottomModal from '../../UI/bottom-modal'; import styleSheet from './advanced-gas-price-modal.styles'; +import { usePersistGasFeePreference } from '../../../hooks/gas/usePersistGasFeePreference'; export const AdvancedGasPriceModal = ({ setActiveModal, @@ -29,6 +33,7 @@ export const AdvancedGasPriceModal = ({ }) => { const { styles } = useStyles(styleSheet, {}); const transactionMeta = useTransactionMetadataRequest() as TransactionMeta; + const persistGasFeePreference = usePersistGasFeePreference(); const { gas, gasPrice } = transactionMeta?.txParams || {}; @@ -48,11 +53,15 @@ export const AdvancedGasPriceModal = ({ const handleSaveClick = useCallback(() => { updateTransactionGasFees(transactionMeta.id, { - userFeeLevel: 'custom', + userFeeLevel: UserFeeLevel.CUSTOM, ...pickBy(gasParams, Boolean), }); + persistGasFeePreference(transactionMeta, { + userFeeLevel: UserFeeLevel.CUSTOM, + ...pickBy({ gasPrice: gasParams.gasPrice }, Boolean), + }); handleCloseModals(); - }, [transactionMeta.id, gasParams, handleCloseModals]); + }, [transactionMeta, gasParams, persistGasFeePreference, handleCloseModals]); const navigateToEstimatesModal = useCallback(() => { setActiveModal(GasModalType.ESTIMATES); diff --git a/app/components/Views/confirmations/hooks/gas/useGasFeeEstimateLevelOptions.test.ts b/app/components/Views/confirmations/hooks/gas/useGasFeeEstimateLevelOptions.test.ts index 0e2a7b278476..a51faea2edc5 100644 --- a/app/components/Views/confirmations/hooks/gas/useGasFeeEstimateLevelOptions.test.ts +++ b/app/components/Views/confirmations/hooks/gas/useGasFeeEstimateLevelOptions.test.ts @@ -14,6 +14,8 @@ import { updateTransactionGasFees } from '../../../../../util/transaction-contro import { useGasFeeEstimateLevelOptions } from './useGasFeeEstimateLevelOptions'; import '../../utils/time'; +const mockPersistGasFeePreference = jest.fn(); + jest.mock('../../../../../util/transaction-controller'); jest.mock('../../utils/time', () => ({ toHumanEstimatedTimeRange: jest.fn().mockReturnValue('~1 min'), @@ -21,6 +23,9 @@ jest.mock('../../utils/time', () => ({ jest.mock('../transactions/useTransactionMetadataRequest'); jest.mock('./useFeeCalculations'); jest.mock('./useGasFeeEstimates'); +jest.mock('./usePersistGasFeePreference', () => ({ + usePersistGasFeePreference: jest.fn(() => mockPersistGasFeePreference), +})); describe('useGasFeeEstimateLevelOptions', () => { const mockUseTransactionMetadataRequest = jest.mocked( @@ -425,6 +430,12 @@ describe('useGasFeeEstimateLevelOptions', () => { expect(mockUpdateTransactionGasFees).toHaveBeenCalledWith('test-id', { userFeeLevel: 'low', }); + expect(mockPersistGasFeePreference).toHaveBeenCalledWith( + transactionWithLegacyEstimates, + { + userFeeLevel: 'low', + }, + ); expect(mockHandleCloseModals).toHaveBeenCalled(); }); @@ -499,6 +510,12 @@ describe('useGasFeeEstimateLevelOptions', () => { expect(mockUpdateTransactionGasFees).toHaveBeenCalledWith('test-id', { userFeeLevel: 'high', }); + expect(mockPersistGasFeePreference).toHaveBeenCalledWith( + transactionWithFeeMarketEstimates, + { + userFeeLevel: 'high', + }, + ); expect(mockHandleCloseModals).toHaveBeenCalled(); }); diff --git a/app/components/Views/confirmations/hooks/gas/useGasFeeEstimateLevelOptions.ts b/app/components/Views/confirmations/hooks/gas/useGasFeeEstimateLevelOptions.ts index c394687844c5..e953a1b1222a 100644 --- a/app/components/Views/confirmations/hooks/gas/useGasFeeEstimateLevelOptions.ts +++ b/app/components/Views/confirmations/hooks/gas/useGasFeeEstimateLevelOptions.ts @@ -15,6 +15,7 @@ import { useFeeCalculations } from './useFeeCalculations'; import { updateTransactionGasFees } from '../../../../../util/transaction-controller'; import { type GasOption } from '../../types/gas'; import { EMPTY_VALUE_STRING } from '../../constants/gas'; +import { usePersistGasFeePreference } from './usePersistGasFeePreference'; const HEX_ZERO = '0x0'; @@ -31,6 +32,7 @@ export const useGasFeeEstimateLevelOptions = ({ gasFeeEstimates: GasFeeEstimates; }; const { gasFeeEstimates, id, userFeeLevel } = transactionMeta; + const persistGasFeePreference = usePersistGasFeePreference(); const transactionGasFeeEstimates = gasFeeEstimates as TransactionGasFeeEstimates; @@ -48,9 +50,10 @@ export const useGasFeeEstimateLevelOptions = ({ updateTransactionGasFees(id, { userFeeLevel: level, }); + persistGasFeePreference(transactionMeta, { userFeeLevel: level }); handleCloseModals(); }, - [id, handleCloseModals], + [id, transactionMeta, persistGasFeePreference, handleCloseModals], ); const options: GasOption[] = []; diff --git a/app/components/Views/confirmations/hooks/gas/useGasPriceEstimateOption.test.ts b/app/components/Views/confirmations/hooks/gas/useGasPriceEstimateOption.test.ts index 1f82b87d2cca..3aaf46e307da 100644 --- a/app/components/Views/confirmations/hooks/gas/useGasPriceEstimateOption.test.ts +++ b/app/components/Views/confirmations/hooks/gas/useGasPriceEstimateOption.test.ts @@ -14,10 +14,15 @@ import { useGasFeeEstimates } from './useGasFeeEstimates'; import { updateTransactionGasFees } from '../../../../../util/transaction-controller'; import { useGasPriceEstimateOption } from './useGasPriceEstimateOption'; +const mockPersistGasFeePreference = jest.fn(); + jest.mock('../../../../../util/transaction-controller'); jest.mock('../transactions/useTransactionMetadataRequest'); jest.mock('./useFeeCalculations'); jest.mock('./useGasFeeEstimates'); +jest.mock('./usePersistGasFeePreference', () => ({ + usePersistGasFeePreference: jest.fn(() => mockPersistGasFeePreference), +})); describe('useGasPriceEstimateOption', () => { const mockUseTransactionMetadataRequest = jest.mocked( @@ -296,6 +301,12 @@ describe('useGasPriceEstimateOption', () => { userFeeLevel: 'medium', gasPrice: '0x1', }); + expect(mockPersistGasFeePreference).toHaveBeenCalledWith( + transactionWithGasPriceEstimates, + { + userFeeLevel: 'medium', + }, + ); expect(mockHandleCloseModals).toHaveBeenCalled(); }); @@ -349,6 +360,12 @@ describe('useGasPriceEstimateOption', () => { maxFeePerGas: '0x1', maxPriorityFeePerGas: '0x1', }); + expect(mockPersistGasFeePreference).toHaveBeenCalledWith( + transactionWithGasPriceEstimates, + { + userFeeLevel: 'medium', + }, + ); expect(mockHandleCloseModals).toHaveBeenCalled(); }); }); diff --git a/app/components/Views/confirmations/hooks/gas/useGasPriceEstimateOption.ts b/app/components/Views/confirmations/hooks/gas/useGasPriceEstimateOption.ts index 1b0e7ce2d498..364894fa30ab 100644 --- a/app/components/Views/confirmations/hooks/gas/useGasPriceEstimateOption.ts +++ b/app/components/Views/confirmations/hooks/gas/useGasPriceEstimateOption.ts @@ -13,6 +13,7 @@ import { useGasFeeEstimates } from './useGasFeeEstimates'; import { useFeeCalculations } from './useFeeCalculations'; import { type GasOption } from '../../types/gas'; import { EMPTY_VALUE_STRING } from '../../constants/gas'; +import { usePersistGasFeePreference } from './usePersistGasFeePreference'; const HEX_ZERO = '0x0'; @@ -23,6 +24,7 @@ export const useGasPriceEstimateOption = ({ }): GasOption[] => { const transactionMeta = useTransactionMetadataRequest() as TransactionMeta; const { calculateGasEstimate } = useFeeCalculations(transactionMeta); + const persistGasFeePreference = usePersistGasFeePreference(); const { gasFeeEstimates, @@ -71,11 +73,14 @@ export const useGasPriceEstimateOption = ({ userFeeLevel: 'medium', ...gasPropertiesToUpdate, }); + persistGasFeePreference(transactionMeta, { userFeeLevel: 'medium' }); handleCloseModals(); }, [ id, + transactionMeta, transactionGasFeeEstimates, transactionEnvelopeType, + persistGasFeePreference, handleCloseModals, ]); diff --git a/app/components/Views/confirmations/hooks/gas/usePersistGasFeePreference.test.ts b/app/components/Views/confirmations/hooks/gas/usePersistGasFeePreference.test.ts new file mode 100644 index 000000000000..81e64d3b3ac2 --- /dev/null +++ b/app/components/Views/confirmations/hooks/gas/usePersistGasFeePreference.test.ts @@ -0,0 +1,57 @@ +import { renderHook } from '@testing-library/react-hooks'; +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import { usePersistGasFeePreference } from './usePersistGasFeePreference'; +import Engine from '../../../../../core/Engine'; + +const mockSetAdvancedGasFee = jest.mocked( + Engine.context.PreferencesController.setAdvancedGasFee, +); + +describe('usePersistGasFeePreference', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('persists gas fee preferences for the transaction account and chain', () => { + const transactionMeta = { + chainId: '0x1', + txParams: { + from: '0x123', + }, + } as unknown as TransactionMeta; + + const { result } = renderHook(() => usePersistGasFeePreference()); + + result.current(transactionMeta, { + userFeeLevel: 'custom', + maxBaseFee: '0x1', + priorityFee: '0x2', + }); + + expect(mockSetAdvancedGasFee).toHaveBeenCalledWith({ + account: '0x123', + chainId: '0x1', + gasFeePreferences: { + userFeeLevel: 'custom', + maxBaseFee: '0x1', + priorityFee: '0x2', + }, + }); + }); + + it('does not persist without an account', () => { + const transactionMeta = { + chainId: '0x1', + txParams: {}, + } as unknown as TransactionMeta; + + const { result } = renderHook(() => usePersistGasFeePreference()); + + result.current(transactionMeta, { + userFeeLevel: 'medium', + }); + + expect(mockSetAdvancedGasFee).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/confirmations/hooks/gas/usePersistGasFeePreference.ts b/app/components/Views/confirmations/hooks/gas/usePersistGasFeePreference.ts new file mode 100644 index 000000000000..09d2958772fc --- /dev/null +++ b/app/components/Views/confirmations/hooks/gas/usePersistGasFeePreference.ts @@ -0,0 +1,29 @@ +import { useCallback } from 'react'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import Engine from '../../../../../core/Engine'; +import type { AdvancedGasFeePreferences } from '../../../../../core/Engine/controllers/preferences-controller-types'; + +export function usePersistGasFeePreference() { + return useCallback( + ( + transactionMeta: TransactionMeta | undefined, + gasFeePreferences: AdvancedGasFeePreferences, + ) => { + const account = transactionMeta?.txParams?.from as Hex | undefined; + const chainId = transactionMeta?.chainId; + + if (!account || !chainId) { + return; + } + + Engine.context.PreferencesController.setAdvancedGasFee({ + account, + chainId, + gasFeePreferences, + }); + }, + [], + ); +} diff --git a/app/components/Views/confirmations/hooks/send/useSendActions.ts b/app/components/Views/confirmations/hooks/send/useSendActions.ts index 30c4cd060ced..3a1bfc735750 100644 --- a/app/components/Views/confirmations/hooks/send/useSendActions.ts +++ b/app/components/Views/confirmations/hooks/send/useSendActions.ts @@ -42,23 +42,29 @@ export const useSendActions = () => { // Context update is not immediate when submitting from the recipient list // so we use the passed recipientAddress or fall back to the context value const toAddress = recipientAddress || to; + if (isEvmSendType) { - submitEvmTransaction({ - asset: asset as AssetType, - chainId: chainId as Hex, - from: from as Hex, - to: toAddress as Hex, - value: normalizeAmount(value), - }); - navigation.navigate( - Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, - { - params: { - maxValueMode, + try { + await submitEvmTransaction({ + asset: asset as AssetType, + chainId: chainId as Hex, + from: from as Hex, + to: toAddress as Hex, + value: normalizeAmount(value), + }); + + navigation.navigate( + Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + { + params: { + maxValueMode, + }, + loader: ConfirmationLoader.Transfer, }, - loader: ConfirmationLoader.Transfer, - }, - ); + ); + } catch (error) { + Alert.alert(strings('send.transaction_error')); + } } else { try { const result = (await sendMultichainTransactionForReview( diff --git a/app/components/Views/confirmations/utils/send.ts b/app/components/Views/confirmations/utils/send.ts index b30ac8fecb9d..2a1fa1bad103 100644 --- a/app/components/Views/confirmations/utils/send.ts +++ b/app/components/Views/confirmations/utils/send.ts @@ -287,6 +287,7 @@ export const submitEvmTransaction = async ({ const { NetworkController } = Engine.context; const networkClientId = NetworkController.findNetworkClientIdByChainId(chainId); + const trxnParams = prepareEVMTransaction(asset, { from, to, value }); let transactionType; @@ -306,13 +307,15 @@ export const submitEvmTransaction = async ({ networkClientId, ); - await addTransaction(trxnParams, { + const { transactionMeta } = await addTransaction(trxnParams, { origin: MMM_ORIGIN, isInternal: true, networkClientId, type: transactionType, securityAlertResponse, }); + + return transactionMeta; }; export function toTokenMinimalUnit(tokenValue: string, decimals: number) { diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index 2bcfef774547..1d9a2ee0c424 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -128,6 +128,7 @@ import { logEngineCreation } from './utils/logger'; import { initMessengerClients } from './utils'; import { accountsControllerInit } from './controllers/accounts-controller'; import { accountTreeControllerInit } from '../../multichain-accounts/controllers/account-tree-controller'; +import { approvalControllerInit } from './controllers/approval-controller-init'; import { bridgeControllerInit } from './controllers/bridge-controller/bridge-controller-init'; import { bridgeStatusControllerInit } from './controllers/bridge-status-controller/bridge-status-controller-init'; import { multichainNetworkControllerInit } from './controllers/multichain-network-controller/multichain-network-controller-init'; @@ -317,6 +318,7 @@ export class Engine { RemoteFeatureFlagController: remoteFeatureFlagControllerInit, NetworkController: networkControllerInit, AccountsController: accountsControllerInit, + ApprovalController: approvalControllerInit, PermissionController: permissionControllerInit, ///: BEGIN:ONLY_INCLUDE_IF(snaps) SubjectMetadataController: subjectMetadataControllerInit, @@ -423,7 +425,7 @@ export class Engine { messengerClientsByName.RemoteFeatureFlagController; const accountsController = messengerClientsByName.AccountsController; const accountTreeController = messengerClientsByName.AccountTreeController; - const approvalController = this.#wallet.getInstance('ApprovalController'); + const approvalController = messengerClientsByName.ApprovalController; const assetsContractController = messengerClientsByName.AssetsContractController; const accountTrackerController = diff --git a/app/core/Engine/controllers/approval-controller-init.ts b/app/core/Engine/controllers/approval-controller-init.ts new file mode 100644 index 000000000000..3bde09e235f1 --- /dev/null +++ b/app/core/Engine/controllers/approval-controller-init.ts @@ -0,0 +1,33 @@ +import { + ApprovalController, + type ApprovalControllerMessenger, +} from '@metamask/approval-controller'; +import { ApprovalType } from '@metamask/controller-utils'; +import { DIALOG_APPROVAL_TYPES } from '@metamask/snaps-rpc-methods'; + +import type { MessengerClientInitFunction } from '../types'; +import { ApprovalTypes } from '../../RPCMethods/RPCMethodMiddleware'; + +export const approvalControllerInit: MessengerClientInitFunction< + ApprovalController, + ApprovalControllerMessenger +> = ({ controllerMessenger, persistedState }) => { + const controller = new ApprovalController({ + messenger: controllerMessenger, + // Mobile drives approvals through state, so opening approval UI is handled elsewhere. + showApprovalRequest: () => undefined, + state: persistedState.ApprovalController, + typesExcludedFromRateLimiting: [ + ApprovalType.Transaction, + ApprovalType.WatchAsset, + ApprovalTypes.SMART_TRANSACTION_STATUS, + + // Allow one flavor of snap_dialog to be queued. + DIALOG_APPROVAL_TYPES.default, + ], + }); + + return { + controller, + }; +}; diff --git a/app/core/Engine/controllers/preferences-controller-init.test.ts b/app/core/Engine/controllers/preferences-controller-init.test.ts index 060a55d970ae..2e45e8a2cf94 100644 --- a/app/core/Engine/controllers/preferences-controller-init.test.ts +++ b/app/core/Engine/controllers/preferences-controller-init.test.ts @@ -44,6 +44,7 @@ describe('PreferencesControllerInit', () => { ipfsGateway: 'https://dweb.link/ipfs/', securityAlertsEnabled: true, smartTransactionsOptInStatus: true, + advancedGasFee: {}, tokenSortConfig: { key: 'tokenFiatAmount', order: 'dsc', diff --git a/app/core/Engine/controllers/preferences-controller-init.ts b/app/core/Engine/controllers/preferences-controller-init.ts index 547b4844c19b..fbd99a83da21 100644 --- a/app/core/Engine/controllers/preferences-controller-init.ts +++ b/app/core/Engine/controllers/preferences-controller-init.ts @@ -4,6 +4,13 @@ import { type PreferencesControllerMessenger, } from '@metamask/preferences-controller'; import AppConstants from '../../AppConstants'; +import { + ADVANCED_GAS_FEE_METADATA, + type MutablePreferencesControllerWithSavedGasFees, + type PreferencesControllerStateUpdater, + type PreferencesControllerWithSavedGasFees, + type PreferencesStateWithSavedGasFees, +} from './preferences-controller-types'; /** * Initialize the preferences controller. @@ -13,15 +20,18 @@ import AppConstants from '../../AppConstants'; * @returns The initialized controller. */ export const preferencesControllerInit: MessengerClientInitFunction< - PreferencesController, + PreferencesControllerWithSavedGasFees, PreferencesControllerMessenger > = ({ controllerMessenger, persistedState }) => { + const persistedPreferencesState = persistedState.PreferencesController as + | Partial + | undefined; + const controller = new PreferencesController({ messenger: controllerMessenger, state: { ipfsGateway: AppConstants.IPFS_DEFAULT_GATEWAY_URL, - useTokenDetection: - persistedState?.PreferencesController?.useTokenDetection ?? true, + useTokenDetection: persistedPreferencesState?.useTokenDetection ?? true, useNftDetection: true, displayNftMedia: true, securityAlertsEnabled: true, @@ -31,11 +41,56 @@ export const preferencesControllerInit: MessengerClientInitFunction< order: 'dsc', sortCallback: 'stringNumeric', }, - ...persistedState.PreferencesController, - }, + ...persistedPreferencesState, + advancedGasFee: persistedPreferencesState?.advancedGasFee ?? {}, + } as Partial, }); + const controllerWithSavedGasFees = + controller as unknown as MutablePreferencesControllerWithSavedGasFees; + + controllerWithSavedGasFees.metadata = { + ...controllerWithSavedGasFees.metadata, + advancedGasFee: ADVANCED_GAS_FEE_METADATA, + }; + + controllerWithSavedGasFees.setAdvancedGasFee = ({ + account, + chainId, + gasFeePreferences, + }) => { + const updateControllerState = controllerWithSavedGasFees.update.bind( + controllerWithSavedGasFees, + ) as PreferencesControllerStateUpdater; + + updateControllerState((state) => { + const normalizedAccount = account.toLowerCase(); + const chainPreferences = state.advancedGasFee[chainId] ?? {}; + + if (!gasFeePreferences) { + const { + [normalizedAccount]: _removedPreference, + ...remainingChainPreferences + } = chainPreferences; + + state.advancedGasFee = { + ...state.advancedGasFee, + [chainId]: remainingChainPreferences, + }; + return; + } + + state.advancedGasFee = { + ...state.advancedGasFee, + [chainId]: { + ...chainPreferences, + [normalizedAccount]: gasFeePreferences, + }, + }; + }); + }; + return { - controller, + controller: controllerWithSavedGasFees, }; }; diff --git a/app/core/Engine/controllers/preferences-controller-types.ts b/app/core/Engine/controllers/preferences-controller-types.ts new file mode 100644 index 000000000000..bd45bf2a4176 --- /dev/null +++ b/app/core/Engine/controllers/preferences-controller-types.ts @@ -0,0 +1,52 @@ +import type { + StateMetadataConstraint, + StatePropertyMetadataConstraint, +} from '@metamask/base-controller'; +import type { + PreferencesController, + PreferencesState, +} from '@metamask/preferences-controller'; +import type { Hex } from '@metamask/utils'; + +export type AdvancedGasFeePreferences = Record & { + userFeeLevel: string; +}; + +export type AdvancedGasFeePreferencesByChain = Record< + string, + Record +>; + +export type PreferencesStateWithSavedGasFees = PreferencesState & { + advancedGasFee: AdvancedGasFeePreferencesByChain; +}; + +export interface SetAdvancedGasFeeParams { + account: Hex; + chainId: Hex; + gasFeePreferences?: AdvancedGasFeePreferences; +} + +export type PreferencesControllerWithSavedGasFees = PreferencesController & { + metadata: StateMetadataConstraint; + state: PreferencesStateWithSavedGasFees; + setAdvancedGasFee(params: SetAdvancedGasFeeParams): void; +}; + +export type PreferencesControllerStateUpdater = ( + callback: ( + state: PreferencesStateWithSavedGasFees, + ) => void | PreferencesStateWithSavedGasFees, +) => void; + +export type MutablePreferencesControllerWithSavedGasFees = + PreferencesControllerWithSavedGasFees & { + update: PreferencesControllerStateUpdater; + }; + +export const ADVANCED_GAS_FEE_METADATA: StatePropertyMetadataConstraint = { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: true, +}; diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/gas.test.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/gas.test.ts index eccb5ec8f98e..c94e1773d86e 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/gas.test.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/gas.test.ts @@ -1,4 +1,7 @@ -import { TransactionMeta } from '@metamask/transaction-controller'; +import { + TransactionMeta, + UserFeeLevel, +} from '@metamask/transaction-controller'; import { getNativeTokenAddress } from '@metamask/assets-controllers'; import { getGasMetricsProperties } from './gas'; @@ -113,6 +116,16 @@ describe('getGasMetricsProperties', () => { expect(result.properties.gas_fee_selected).toBe('medium'); }); + it('returns dapp_proposed as gas_fee_selected when the dapp suggested fee is selected', () => { + const request = createMockRequest({ + userFeeLevel: UserFeeLevel.DAPP_SUGGESTED, + }); + + const result = getGasMetricsProperties(request); + + expect(result.properties.gas_fee_selected).toBe('dapp_proposed'); + }); + it('returns gas_payment_tokens_available from gasFeeTokens', () => { const request = createMockRequest({ gasFeeTokens: [ diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/gas.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/gas.ts index 1fecc3c869c0..153eea4537d5 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/gas.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/gas.ts @@ -1,5 +1,6 @@ import { GasFeeEstimateLevel, + UserFeeLevel, type TransactionMeta, } from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; @@ -68,7 +69,10 @@ export function getGasMetricsProperties({ properties: { gas_estimation_failed: !gasFeeEstimatesLoaded, gas_fee_presented: presentedGasFeeOption, - gas_fee_selected: userFeeLevel, + gas_fee_selected: + userFeeLevel === UserFeeLevel.DAPP_SUGGESTED + ? 'dapp_proposed' + : userFeeLevel, gas_insufficient_native_asset, gas_paid_with, gas_payment_tokens_available, diff --git a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts index 61777400c5be..232a9a168ee7 100644 --- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts +++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts @@ -1,11 +1,13 @@ import { NetworkController } from '@metamask/network-controller'; import { + GasFeeEstimateLevel, PublishHook, TransactionController, TransactionControllerMessenger, TransactionControllerOptions, TransactionMeta, TransactionType, + UserFeeLevel, type PublishBatchHookTransaction, } from '@metamask/transaction-controller'; @@ -123,11 +125,18 @@ function buildInitRequestMock( > { const { predictControllerMock: providedPredictControllerMock, + preferencesState: providedPreferencesState, ...requestOverrides } = initRequestProperties; const predictControllerMock = (providedPredictControllerMock as ControllerMock | undefined) ?? buildControllerMock(); + const preferencesState = providedPreferencesState ?? { + privacyMode: false, + securityAlertsEnabled: true, + useTransactionSimulations: true, + advancedGasFee: {}, + }; const initMessenger = new ExtendedMessenger({ namespace: MOCK_ANY_NAMESPACE, }); @@ -145,11 +154,7 @@ function buildInitRequestMock( } if (actionType === 'PreferencesController:getState') { - return { - privacyMode: false, - securityAlertsEnabled: true, - useTransactionSimulations: true, - }; + return preferencesState; } if ( @@ -308,6 +313,102 @@ describe('Transaction Controller Init', () => { expect(optionFn?.()).toBe(true); }); + describe('getSavedGasFees', () => { + const account = '0x123'; + + function getSavedGasFeesOption(gasFeePreferences: Record) { + return testConstructorOption( + 'getSavedGasFees', + {}, + { + preferencesState: { + privacyMode: false, + securityAlertsEnabled: true, + useTransactionSimulations: true, + advancedGasFee: { + '0x1': { + [account]: gasFeePreferences, + }, + }, + }, + }, + ); + } + + it('returns saved custom gas fees for the transaction chain and account', () => { + const optionFn = getSavedGasFeesOption({ + userFeeLevel: UserFeeLevel.CUSTOM, + maxBaseFee: '0x1', + priorityFee: '0x2', + gasPrice: '0x3', + }); + + expect( + optionFn?.({ + ...MOCK_TRANSACTION_META, + txParams: { + ...MOCK_TRANSACTION_META.txParams, + from: '0x123', + }, + }), + ).toEqual({ + level: UserFeeLevel.CUSTOM, + maxBaseFee: '0x1', + priorityFee: '0x2', + gasPrice: '0x3', + }); + }); + + it('returns saved estimate level gas fees for the transaction chain and account', () => { + const optionFn = getSavedGasFeesOption({ + userFeeLevel: GasFeeEstimateLevel.Low, + }); + + expect(optionFn?.(MOCK_TRANSACTION_META)).toEqual({ + level: GasFeeEstimateLevel.Low, + }); + }); + + it('normalizes the transaction account before lookup', () => { + const optionFn = getSavedGasFeesOption({ + userFeeLevel: GasFeeEstimateLevel.High, + }); + + expect( + optionFn?.({ + ...MOCK_TRANSACTION_META, + txParams: { + ...MOCK_TRANSACTION_META.txParams, + from: '0x123'.toUpperCase(), + }, + }), + ).toEqual({ + level: GasFeeEstimateLevel.High, + }); + }); + + it('returns undefined for MM Pay transactions', () => { + const optionFn = getSavedGasFeesOption({ + userFeeLevel: GasFeeEstimateLevel.Low, + }); + + expect( + optionFn?.({ + ...MOCK_TRANSACTION_META, + metamaskPay: {} as TransactionMeta['metamaskPay'], + }), + ).toBeUndefined(); + }); + + it('returns undefined for unsupported saved fee levels', () => { + const optionFn = getSavedGasFeesOption({ + userFeeLevel: 'dappSuggested', + }); + + expect(optionFn?.(MOCK_TRANSACTION_META)).toBeUndefined(); + }); + }); + describe('beforePublish hook', () => { it('delegates to PredictController beforePublish', async () => { const predictControllerMock = buildControllerMock(); 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 407ff93f8123..8a06e2adf160 100644 --- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts +++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts @@ -1,6 +1,9 @@ import { + GasFeeEstimateLevel, TransactionController, TransactionType, + UserFeeLevel, + type SavedGasFees, type TransactionControllerMessenger, type TransactionMeta, TransactionControllerOptions, @@ -33,6 +36,7 @@ import { isSendBundleSupported } from '../../../../util/transactions/sentinel-ap import { ORIGIN_METAMASK } from '@metamask/controller-utils'; import { hasTransactionType } from '../../../../components/Views/confirmations/utils/transaction'; import { getTransactionControllerHooks } from '../../../../util/transactions/hooks'; +import type { PreferencesStateWithSavedGasFees } from '../preferences-controller-types'; export const TransactionControllerInit: MessengerClientInitFunction< TransactionController, @@ -97,6 +101,8 @@ export const TransactionControllerInit: MessengerClientInitFunction< isSimulationEnabled: () => initMessenger.call('PreferencesController:getState') .useTransactionSimulations, + getSavedGasFees: (transactionMeta) => + getSavedGasFees(transactionMeta, initMessenger), messenger: controllerMessenger, publicKeyEIP7702: AppConstants.EIP_7702_PUBLIC_KEY as Hex | undefined, state: persistedState.TransactionController, @@ -128,6 +134,67 @@ function isFirstTimeInteractionEnabled( ); } +function getSavedGasFees( + transactionMeta: TransactionMeta, + initMessenger: TransactionControllerInitMessenger, +): SavedGasFees | undefined { + const account = transactionMeta.txParams.from?.toLowerCase(); + + if (!account || transactionMeta.metamaskPay) { + return undefined; + } + + const preferencesState = initMessenger.call( + 'PreferencesController:getState', + ) as PreferencesStateWithSavedGasFees; + + const savedGasFeePreference = + preferencesState.advancedGasFee?.[transactionMeta.chainId]?.[account]; + + if (!savedGasFeePreference) { + return undefined; + } + + const savedGasFeeLevel = getSavedGasFeeLevel( + savedGasFeePreference.userFeeLevel, + ); + + if (!savedGasFeeLevel) { + return undefined; + } + + const savedGasFees: SavedGasFees = { + level: savedGasFeeLevel, + }; + + if (savedGasFeePreference.maxBaseFee) { + savedGasFees.maxBaseFee = savedGasFeePreference.maxBaseFee; + } + + if (savedGasFeePreference.priorityFee) { + savedGasFees.priorityFee = savedGasFeePreference.priorityFee; + } + + if (savedGasFeePreference.gasPrice) { + savedGasFees.gasPrice = savedGasFeePreference.gasPrice; + } + + return savedGasFees; +} + +function getSavedGasFeeLevel( + userFeeLevel: string, +): SavedGasFees['level'] | undefined { + const savedGasFeeLevels = [ + GasFeeEstimateLevel.Low, + GasFeeEstimateLevel.Medium, + GasFeeEstimateLevel.High, + UserFeeLevel.CUSTOM, + ] as const; + + return savedGasFeeLevels.find((level) => level === userFeeLevel); +} + function getKeyringController(messenger: TransactionControllerInitMessenger) { return { getKeyringForAccount: (address: string) => diff --git a/app/core/Engine/messengers/approval-controller-messenger.ts b/app/core/Engine/messengers/approval-controller-messenger.ts new file mode 100644 index 000000000000..12623a6f7150 --- /dev/null +++ b/app/core/Engine/messengers/approval-controller-messenger.ts @@ -0,0 +1,22 @@ +import type { ApprovalControllerMessenger } from '@metamask/approval-controller'; +import { + Messenger, + type MessengerActions, + type MessengerEvents, +} from '@metamask/messenger'; + +import type { RootMessenger } from '../types'; + +export function getApprovalControllerMessenger( + rootMessenger: RootMessenger, +): ApprovalControllerMessenger { + return new Messenger< + 'ApprovalController', + MessengerActions, + MessengerEvents, + RootMessenger + >({ + namespace: 'ApprovalController', + parent: rootMessenger, + }); +} diff --git a/app/core/Engine/messengers/index.ts b/app/core/Engine/messengers/index.ts index 4e6ee7adf847..de8636057368 100644 --- a/app/core/Engine/messengers/index.ts +++ b/app/core/Engine/messengers/index.ts @@ -8,6 +8,7 @@ import { getMultichainNetworkControllerMessenger } from './multichain-network-co import { getNetworkEnablementControllerMessenger } from './network-enablement-controller-messenger/network-enablement-controller-messenger'; import { getCurrencyRateControllerMessenger } from './currency-rate-controller-messenger/currency-rate-controller-messenger'; import { getAppMetadataControllerMessenger } from './app-metadata-controller-messenger'; +import { getApprovalControllerMessenger } from './approval-controller-messenger'; import { getDeFiPositionsControllerInitMessenger, getDeFiPositionsControllerMessenger, @@ -181,6 +182,10 @@ export const MESSENGER_FACTORIES = { getMessenger: getAddressBookControllerMessenger, getInitMessenger: noop, }, + ApprovalController: { + getMessenger: getApprovalControllerMessenger, + getInitMessenger: noop, + }, ConnectivityController: { getMessenger: getConnectivityControllerMessenger, getInitMessenger: noop, diff --git a/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts b/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts index 655aca55e53b..e1f0f42f2bec 100644 --- a/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts +++ b/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts @@ -46,6 +46,7 @@ import { BridgeStatusControllerActions, BridgeStatusControllerEvents, } from '@metamask/bridge-status-controller'; +import type { GasFeeControllerFetchGasFeeEstimatesAction } from '@metamask/gas-fee-controller'; import { DelegationControllerSignDelegationAction } from '@metamask/delegation-controller'; import { AccountTrackerControllerGetStateAction, @@ -113,6 +114,7 @@ type InitMessengerActions = | BridgeStatusControllerActions | CurrencyRateControllerActions | DelegationControllerSignDelegationAction + | GasFeeControllerFetchGasFeeEstimatesAction | NetworkControllerFindNetworkClientIdByChainIdAction | NetworkControllerGetNetworkClientByIdAction | KeyringControllerGetKeyringForAccountAction @@ -181,6 +183,7 @@ export function getTransactionControllerInitMessenger( 'BridgeStatusController:submitTx', 'CurrencyRateController:getState', 'DelegationController:signDelegation', + 'GasFeeController:fetchGasFeeEstimates', 'NetworkController:findNetworkClientIdByChainId', 'NetworkController:getEIP1559Compatibility', 'NetworkController:getNetworkClientById', diff --git a/app/core/Engine/types.ts b/app/core/Engine/types.ts index 0faa5e54d19e..0dead7938862 100644 --- a/app/core/Engine/types.ts +++ b/app/core/Engine/types.ts @@ -118,11 +118,13 @@ import { PhishingControllerState, } from '@metamask/phishing-controller'; import { - PreferencesController, PreferencesControllerActions, PreferencesControllerEvents, - PreferencesState, } from '@metamask/preferences-controller'; +import type { + PreferencesControllerWithSavedGasFees, + PreferencesStateWithSavedGasFees, +} from './controllers/preferences-controller-types'; import { RampsController, RampsControllerState, @@ -766,7 +768,7 @@ export type MessengerClients = { PermissionController: PermissionController; SelectedNetworkController: SelectedNetworkController; PhishingController: PhishingController; - PreferencesController: PreferencesController; + PreferencesController: PreferencesControllerWithSavedGasFees; RampsController: RampsController; RemoteFeatureFlagController: RemoteFeatureFlagController; TokenBalancesController: TokenBalancesController; @@ -858,7 +860,7 @@ export type EngineState = { KeyringController: KeyringControllerState; NetworkController: NetworkState; NetworkEnablementController: NetworkEnablementControllerState; - PreferencesController: PreferencesState; + PreferencesController: PreferencesStateWithSavedGasFees; RemoteFeatureFlagController: RemoteFeatureFlagControllerState; RampsController: RampsControllerState; PhishingController: PhishingControllerState; @@ -951,6 +953,7 @@ export type MessengerClientsToInitialize = ///: END:ONLY_INCLUDE_IF | 'AccountTrackerController' | 'AddressBookController' + | 'ApprovalController' | 'AssetsContractController' | 'AssetsController' | 'ConnectivityController' diff --git a/app/core/Engine/wallet-init/initialization.ts b/app/core/Engine/wallet-init/initialization.ts index 4cbb41f80c43..b86a33100950 100644 --- a/app/core/Engine/wallet-init/initialization.ts +++ b/app/core/Engine/wallet-init/initialization.ts @@ -1,12 +1,9 @@ import { Wallet } from '@metamask/wallet'; import { Json } from '@metamask/utils'; -import { ApprovalType } from '@metamask/controller-utils'; -import { DIALOG_APPROVAL_TYPES } from '@metamask/snaps-rpc-methods'; import { getKeyringBuilders } from './keyrings'; import { RootMessenger } from '../types'; import { Encryptor, LEGACY_DERIVATION_OPTIONS } from '../../Encryptor'; import { mobileStorageAdapter } from '../utils/storage-service-utils'; -import { ApprovalTypes } from '../../RPCMethods/RPCMethodMiddleware'; export function initializeWallet({ messenger, @@ -23,18 +20,6 @@ export function initializeWallet({ messenger, state, instanceOptions: { - approvalController: { - // Mobile drives approvals through state, so `showApprovalRequest` is a - // no-op. It is omitted here because the wallet defaults it to a no-op. - typesExcludedFromRateLimiting: [ - ApprovalType.Transaction, - ApprovalType.WatchAsset, - ApprovalTypes.SMART_TRANSACTION_STATUS, - - // Allow one flavor of snap_dialog to be queued. - DIALOG_APPROVAL_TYPES.default, - ], - }, keyringController: { encryptor, keyringBuilders: getKeyringBuilders(messenger), diff --git a/app/core/__mocks__/MockedEngine.ts b/app/core/__mocks__/MockedEngine.ts index 41831b38b8c1..a80aa443ad76 100644 --- a/app/core/__mocks__/MockedEngine.ts +++ b/app/core/__mocks__/MockedEngine.ts @@ -67,6 +67,7 @@ export const mockedEngine = { }, PreferencesController: { state: {}, + setAdvancedGasFee: jest.fn(), }, SelectedNetworkController: { getProviderAndBlockTracker: jest.fn(),