diff --git a/app/components/Nav/App/index.js b/app/components/Nav/App/index.js index af69b0cbafd..eb5c617217c 100644 --- a/app/components/Nav/App/index.js +++ b/app/components/Nav/App/index.js @@ -106,6 +106,7 @@ import generateUserSettingsAnalyticsMetaData from '../../../util/metrics/UserSet import OnboardingSuccess from '../../Views/OnboardingSuccess'; import DefaultSettings from '../../Views/OnboardingSuccess/DefaultSettings'; import BasicFunctionalityModal from '../../UI/BasicFunctionality/BasicFunctionalityModal/BasicFunctionalityModal'; +import SmartTransactionsOptInModal from '../../Views/SmartTransactionsOptInModal/SmartTranactionsOptInModal'; const clearStackNavigatorOptions = { headerShown: false, @@ -590,6 +591,10 @@ const App = ({ userLoggedIn }) => { component={ModalMandatory} /> + { }; const trackSwaps = useCallback( - async (event, transactionMeta) => { + async (event, transactionMeta, swapsTransactions) => { try { - const { TransactionController } = Engine.context; - const newSwapsTransactions = props.swapsTransactions; + const { TransactionController, SmartTransactionsController } = + Engine.context; + const newSwapsTransactions = swapsTransactions; const swapTransaction = newSwapsTransactions[transactionMeta.id]; const { sentAt, @@ -166,12 +170,20 @@ const RootRPCMethodsUI = (props) => { delete newSwapsTransactions[transactionMeta.id].analytics; delete newSwapsTransactions[transactionMeta.id].paramsForAnalytics; + const smartTransactionMetricsProperties = + getSmartTransactionMetricsProperties( + SmartTransactionsController, + transactionMeta, + ); + const parameters = { ...analyticsParams, time_to_mine: timeToMine, estimated_vs_used_gasRatio: estimatedVsUsedGasRatio, quote_vs_executionRatio: quoteVsExecutionRatio, token_to_amount_received: tokenToAmountReceived.toString(), + is_smart_transaction: props.shouldUseSmartTransaction, + ...smartTransactionMetricsProperties, }; trackAnonymousEvent(event, parameters); @@ -184,7 +196,7 @@ const RootRPCMethodsUI = (props) => { }, [ props.selectedAddress, - props.swapsTransactions, + props.shouldUseSmartTransaction, trackEvent, trackAnonymousEvent, ], @@ -193,6 +205,7 @@ const RootRPCMethodsUI = (props) => { const autoSign = useCallback( async (transactionMeta) => { const { TransactionController, KeyringController } = Engine.context; + const swapsTransactions = props.swapsTransactions; try { TransactionController.hub.once( `${transactionMeta.id}:finished`, @@ -203,8 +216,12 @@ const RootRPCMethodsUI = (props) => { assetType: transactionMeta.txParams.assetType, }); } else { - if (props.swapsTransactions[transactionMeta.id]?.analytics) { - trackSwaps(MetaMetricsEvents.SWAP_FAILED, transactionMeta); + if (swapsTransactions[transactionMeta.id]?.analytics) { + trackSwaps( + MetaMetricsEvents.SWAP_FAILED, + transactionMeta, + swapsTransactions, + ); } throw transactionMeta.error; } @@ -213,8 +230,15 @@ const RootRPCMethodsUI = (props) => { TransactionController.hub.once( `${transactionMeta.id}:confirmed`, (transactionMeta) => { - if (props.swapsTransactions[transactionMeta.id]?.analytics) { - trackSwaps(MetaMetricsEvents.SWAP_COMPLETED, transactionMeta); + if ( + swapsTransactions[transactionMeta.id]?.analytics && + swapsTransactions[transactionMeta.id]?.paramsForAnalytics + ) { + trackSwaps( + MetaMetricsEvents.SWAP_COMPLETED, + transactionMeta, + swapsTransactions, + ); } }, ); @@ -242,7 +266,10 @@ const RootRPCMethodsUI = (props) => { Engine.acceptPendingApproval(transactionMeta.id); } } catch (error) { - if (!error?.message.startsWith(KEYSTONE_TX_CANCELED)) { + if ( + !error?.message.startsWith(KEYSTONE_TX_CANCELED) && + !error?.message.startsWith(STX_NO_HASH_ERROR) + ) { Alert.alert( strings('transactions.transaction_error'), error && error.message, @@ -264,7 +291,14 @@ const RootRPCMethodsUI = (props) => { const to = transactionMeta.txParams.to?.toLowerCase(); const { data } = transactionMeta.txParams; - if (isSwapTransaction(data, transactionMeta.origin, to, props.chainId)) { + if ( + getIsSwapApproveOrSwapTransaction( + data, + transactionMeta.origin, + to, + props.chainId, + ) + ) { autoSign(transactionMeta); } else { const { @@ -441,6 +475,10 @@ RootRPCMethodsUI.propTypes = { * Chain id */ chainId: PropTypes.string, + /** + * If smart transactions should be used + */ + shouldUseSmartTransaction: PropTypes.bool, }; const mapStateToProps = (state) => ({ @@ -450,6 +488,7 @@ const mapStateToProps = (state) => ({ swapsTransactions: state.engine.backgroundState.TransactionController.swapsTransactions || {}, providerType: selectProviderType(state), + shouldUseSmartTransaction: selectShouldUseSmartTransaction(state), }); const mapDispatchToProps = (dispatch) => ({ diff --git a/app/components/UI/Notification/TransactionNotification/index.js b/app/components/UI/Notification/TransactionNotification/index.js index 1eb7693fc38..46d0a25a856 100644 --- a/app/components/UI/Notification/TransactionNotification/index.js +++ b/app/components/UI/Notification/TransactionNotification/index.js @@ -110,6 +110,7 @@ function TransactionNotification(props) { onClose, transactions, animatedTimingStart, + smartTransactions, } = props; const [transactionDetails, setTransactionDetails] = useState(undefined); @@ -265,6 +266,7 @@ function TransactionNotification(props) { getTransactionInfo(); }, [ transactions, + smartTransactions, currentNotification.transaction.id, transactionAction, props, @@ -272,6 +274,15 @@ function TransactionNotification(props) { useEffect(() => onCloseNotification(), [onCloseNotification]); + // Don't show submitted notification for STX b/c we only know when it's confirmed, + // o/w a submitted notification will show up after it's confirmed, then a confirmed notification will show up immediately after + if (tx.status === 'submitted') { + const smartTx = smartTransactions.find((stx) => stx.txHash === tx.hash); + if (smartTx) { + return null; + } + } + return ( <> ({ - accounts: selectAccounts(state), - selectedAddress: selectSelectedAddress(state), - transactions: state.engine.backgroundState.TransactionController.transactions, - ticker: selectTicker(state), - chainId: selectChainId(state), - tokens: selectTokensByAddress(state), - collectibleContracts: collectibleContractsSelector(state), - contractExchangeRates: selectContractExchangeRates(state), - conversionRate: selectConversionRate(state), - currentCurrency: selectCurrentCurrency(state), - primaryCurrency: state.settings.primaryCurrency, - swapsTransactions: - state.engine.backgroundState.TransactionController.swapsTransactions || {}, - swapsTokens: state.engine.backgroundState.SwapsController.tokens, -}); +const mapStateToProps = (state) => { + const chainId = selectChainId(state); + + const { + SmartTransactionsController, + TransactionController, + SwapsController, + } = state.engine.backgroundState; + + const smartTransactions = + SmartTransactionsController?.smartTransactionsState?.smartTransactions?.[ + chainId + ] || []; + + return { + accounts: selectAccounts(state), + selectedAddress: selectSelectedAddress(state), + transactions: TransactionController.transactions, + ticker: selectTicker(state), + chainId, + tokens: selectTokensByAddress(state), + collectibleContracts: collectibleContractsSelector(state), + contractExchangeRates: selectContractExchangeRates(state), + conversionRate: selectConversionRate(state), + currentCurrency: selectCurrentCurrency(state), + primaryCurrency: state.settings.primaryCurrency, + swapsTransactions: TransactionController.swapsTransactions || {}, + swapsTokens: SwapsController.tokens, + smartTransactions, + }; +}; export default connect(mapStateToProps)(TransactionNotification); diff --git a/app/components/UI/Swaps/QuotesView.js b/app/components/UI/Swaps/QuotesView.js index 14faa6ae1ef..ce56a8fba5f 100644 --- a/app/components/UI/Swaps/QuotesView.js +++ b/app/components/UI/Swaps/QuotesView.js @@ -100,6 +100,7 @@ import { useMetrics } from '../../../components/hooks/useMetrics'; import { addTransaction } from '../../../util/transaction-controller'; import trackErrorAsAnalytics from '../../../util/metrics/TrackError/trackErrorAsAnalytics'; import { selectGasFeeEstimates } from '../../../selectors/confirmTransaction'; +import { selectShouldUseSmartTransaction } from '../../../selectors/smartTransactionsController'; const POLLING_INTERVAL = 30000; const SLIPPAGE_BUCKETS = { @@ -388,6 +389,7 @@ function SwapsQuotesView({ usedCustomGas, setRecipient, resetTransaction, + shouldUseSmartTransaction, }) { const navigation = useNavigation(); /* Get params from navigation */ @@ -926,11 +928,13 @@ function SwapsQuotesView({ origin: process.env.MM_FOX_CODE, }, ); + updateSwapsTransactions( transactionMeta, approvalTransactionMetaId, newSwapsTransactions, ); + setRecipient(selectedAddress); await addTokenToAssetsController(destinationToken); await addTokenToAssetsController(sourceToken); @@ -990,7 +994,7 @@ function SwapsQuotesView({ 16, ).toString(10), }; - if (isHardwareAddress) { + if (isHardwareAddress || shouldUseSmartTransaction) { TransactionController.hub.once( `${transactionMeta.id}:confirmed`, (transactionMeta) => { @@ -1019,6 +1023,7 @@ function SwapsQuotesView({ selectedAddress, setRecipient, resetTransaction, + shouldUseSmartTransaction, ], ); @@ -1032,6 +1037,7 @@ function SwapsQuotesView({ startSwapAnalytics(selectedQuote, selectedAddress); const { TransactionController } = Engine.context; + const newSwapsTransactions = TransactionController.state.swapsTransactions || {}; let approvalTransactionMetaId; @@ -1050,12 +1056,17 @@ function SwapsQuotesView({ } } - handleSwapTransaction( - TransactionController, - newSwapsTransactions, - approvalTransactionMetaId, - isHardwareAddress, - ); + if ( + !shouldUseSmartTransaction || + (shouldUseSmartTransaction && !approvalTransaction) + ) { + handleSwapTransaction( + TransactionController, + newSwapsTransactions, + approvalTransactionMetaId, + isHardwareAddress, + ); + } navigation.dangerouslyGetParent()?.pop(); }, [ @@ -1066,6 +1077,7 @@ function SwapsQuotesView({ handleApprovaltransaction, handleSwapTransaction, navigation, + shouldUseSmartTransaction, ]); const onEditQuoteTransactionsGas = useCallback(() => { @@ -2299,6 +2311,7 @@ SwapsQuotesView.propTypes = { usedCustomGas: PropTypes.object, setRecipient: PropTypes.func, resetTransaction: PropTypes.func, + shouldUseSmartTransaction: PropTypes.bool, }; const mapStateToProps = (state) => ({ @@ -2331,6 +2344,7 @@ const mapStateToProps = (state) => ({ usedCustomGas: state.engine.backgroundState.SwapsController.usedCustomGas, primaryCurrency: state.settings.primaryCurrency, swapsTokens: swapsTokensSelector(state), + shouldUseSmartTransaction: selectShouldUseSmartTransaction(state), }); const mapDispatchToProps = (dispatch) => ({ diff --git a/app/components/UI/Swaps/SwapsLiveness.ts b/app/components/UI/Swaps/SwapsLiveness.ts index 1e8aba15469..c95922d1f94 100644 --- a/app/components/UI/Swaps/SwapsLiveness.ts +++ b/app/components/UI/Swaps/SwapsLiveness.ts @@ -8,46 +8,42 @@ import { setSwapsLiveness, swapsLivenessSelector, } from '../../../reducers/swaps'; -import Device from '../../../util/device'; import Logger from '../../../util/Logger'; import useInterval from '../../hooks/useInterval'; import { isSwapsAllowed } from './utils'; import { EngineState } from '../../../selectors/types'; const POLLING_FREQUENCY = AppConstants.SWAPS.LIVENESS_POLLING_FREQUENCY; + function SwapLiveness() { const isLive = useSelector(swapsLivenessSelector); const chainId = useSelector((state: EngineState) => selectChainId(state)); const dispatch = useDispatch(); const setLiveness = useCallback( - (liveness, currentChainId) => { - dispatch(setSwapsLiveness(liveness, currentChainId)); + (_chainId, featureFlags) => { + dispatch(setSwapsLiveness(_chainId, featureFlags)); }, [dispatch], ); const checkLiveness = useCallback(async () => { try { - const data = await swapsUtils.fetchSwapsFeatureLiveness( + const featureFlags = await swapsUtils.fetchSwapsFeatureFlags( chainId, AppConstants.SWAPS.CLIENT_ID, ); - const isIphone = Device.isIos(); - const isAndroid = Device.isAndroid(); - const featureFlagKey = isIphone - ? 'mobileActiveIOS' - : isAndroid - ? 'mobileActiveAndroid' - : 'mobileActive'; - const liveness = - // @ts-expect-error interface mismatch - typeof data === 'boolean' ? data : data?.[featureFlagKey] ?? false; - setLiveness(liveness, chainId); + + setLiveness(chainId, featureFlags); } catch (error) { Logger.error(error as any, 'Swaps: error while fetching swaps liveness'); - setLiveness(false, chainId); + setLiveness(chainId, null); } }, [setLiveness, chainId]); + // Need to check swap feature flags once on load, so we can use it for STX + useEffect(() => { + checkLiveness(); + }, [checkLiveness]); + useEffect(() => { if (isSwapsAllowed(chainId) && !isLive) { checkLiveness(); diff --git a/app/components/UI/Swaps/components/TokenSelectModal.js b/app/components/UI/Swaps/components/TokenSelectModal.js index 6bafa6fec98..0236d5a44ec 100644 --- a/app/components/UI/Swaps/components/TokenSelectModal.js +++ b/app/components/UI/Swaps/components/TokenSelectModal.js @@ -186,7 +186,8 @@ function TokenSelectModal({ initialTokens?.length > 0 ? initialTokens.filter( (token) => - !excludedAddresses.includes(token.address?.toLowerCase()), + typeof token !== 'undefined' && + !excludedAddresses.includes(token?.address?.toLowerCase()), ) : filteredTokens, [excludedAddresses, filteredTokens, initialTokens], diff --git a/app/components/UI/Swaps/index.js b/app/components/UI/Swaps/index.js index d06f0ad9f48..665bfdcfe09 100644 --- a/app/components/UI/Swaps/index.js +++ b/app/components/UI/Swaps/index.js @@ -31,6 +31,7 @@ import { swapsUtils } from '@metamask/swaps-controller'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { + getFeatureFlagChainId, setSwapsHasOnboarded, setSwapsLiveness, swapsControllerTokens, @@ -88,6 +89,7 @@ import { } from '../../../../wdio/screen-objects/testIDs/Screens/QuoteView.js'; import { getDecimalChainId } from '../../../util/networks'; import { useMetrics } from '../../../components/hooks/useMetrics'; +import { getSwapsLiveness } from '../../../reducers/swaps/utils'; const createStyles = (colors) => StyleSheet.create({ @@ -260,20 +262,14 @@ function SwapsAmountView({ useEffect(() => { (async () => { try { - const data = await swapsUtils.fetchSwapsFeatureLiveness( - chainId, + const featureFlags = await swapsUtils.fetchSwapsFeatureFlags( + getFeatureFlagChainId(chainId), AppConstants.SWAPS.CLIENT_ID, ); - const isIphone = Device.isIos(); - const isAndroid = Device.isAndroid(); - const featureFlagKey = isIphone - ? 'mobileActiveIOS' - : isAndroid - ? 'mobileActiveAndroid' - : 'mobileActive'; - const liveness = - typeof data === 'boolean' ? data : data?.[featureFlagKey] ?? false; - setLiveness(liveness, chainId); + + const liveness = getSwapsLiveness(featureFlags, chainId); + setLiveness(chainId, featureFlags); + if (liveness) { // Triggered when a user enters the MetaMask Swap feature InteractionManager.runAfterInteractions(() => { @@ -295,7 +291,7 @@ function SwapsAmountView({ } } catch (error) { Logger.error(error, 'Swaps: error while fetching swaps liveness'); - setLiveness(false, chainId); + setLiveness(chainId, null); navigation.pop(); } })(); @@ -1028,8 +1024,8 @@ const mapStateToProps = (state) => ({ const mapDispatchToProps = (dispatch) => ({ setHasOnboarded: (hasOnboarded) => dispatch(setSwapsHasOnboarded(hasOnboarded)), - setLiveness: (liveness, chainId) => - dispatch(setSwapsLiveness(liveness, chainId)), + setLiveness: (chainId, featureFlags) => + dispatch(setSwapsLiveness(chainId, featureFlags)), }); export default connect(mapStateToProps, mapDispatchToProps)(SwapsAmountView); diff --git a/app/components/UI/Swaps/utils/index.js b/app/components/UI/Swaps/utils/index.js index 7a6780c519b..1d05659d412 100644 --- a/app/components/UI/Swaps/utils/index.js +++ b/app/components/UI/Swaps/utils/index.js @@ -3,6 +3,7 @@ import BigNumber from 'bignumber.js'; import { swapsUtils } from '@metamask/swaps-controller'; import { strings } from '../../../../../locales/i18n'; import AppConstants from '../../../../core/AppConstants'; +import { NETWORKS_CHAIN_ID } from '../../../../constants/network'; const { ETH_CHAIN_ID, @@ -28,6 +29,16 @@ const allowedChainIds = [ SWAPS_TESTNET_CHAIN_ID, ]; +export const allowedTestnetChainIds = [ + NETWORKS_CHAIN_ID.GOERLI, + NETWORKS_CHAIN_ID.SEPOLIA, +]; + +// TODO uncomment this when we are done QA. This is to let ppl test on sepolia +// if (__DEV__) { +allowedChainIds.push(...allowedTestnetChainIds); +// } + export function isSwapsAllowed(chainId) { if (!AppConstants.SWAPS.ACTIVE) { return false; diff --git a/app/components/UI/TemplateRenderer/SafeComponentList.ts b/app/components/UI/TemplateRenderer/SafeComponentList.ts index 33202030a3f..35751a2064c 100644 --- a/app/components/UI/TemplateRenderer/SafeComponentList.ts +++ b/app/components/UI/TemplateRenderer/SafeComponentList.ts @@ -4,6 +4,7 @@ import SheetHeader from '../../../component-library/components/Sheet/SheetHeader import Text from '../../../component-library/components/Texts/Text'; import Icon from '../../../component-library/components/Icons/Icon'; import BottomSheetFooter from '../../../component-library/components/BottomSheets/BottomSheetFooter'; +import SmartTransactionStatus from '../../Views/SmartTransactionStatus/SmartTransactionStatus'; import { View } from 'react-native'; export const safeComponentList = { @@ -11,6 +12,7 @@ export const safeComponentList = { Button, Icon, SheetHeader, + SmartTransactionStatus, Text, View, }; diff --git a/app/components/UI/TransactionElement/TransactionDetails/index.js b/app/components/UI/TransactionElement/TransactionDetails/index.js index 230a7bd049d..ded80f167bd 100644 --- a/app/components/UI/TransactionElement/TransactionDetails/index.js +++ b/app/components/UI/TransactionElement/TransactionDetails/index.js @@ -40,6 +40,7 @@ import { selectTokensByAddress } from '../../../../selectors/tokensController'; import { selectContractExchangeRates } from '../../../../selectors/tokenRatesController'; import { selectSelectedAddress } from '../../../../selectors/preferencesController'; import { regex } from '../../../../../app/util/regex'; +import { selectShouldUseSmartTransaction } from '../../../../selectors/smartTransactionsController'; const createStyles = (colors) => StyleSheet.create({ @@ -123,6 +124,11 @@ class TransactionDetails extends PureComponent { swapsTransactions: PropTypes.object, swapsTokens: PropTypes.array, primaryCurrency: PropTypes.string, + + /** + * Boolean that indicates if smart transaction should be used + */ + shouldUseSmartTransaction: PropTypes.bool, }; state = { @@ -300,11 +306,14 @@ class TransactionDetails extends PureComponent { const { chainId, transactionObject: { status, time, txParams }, + shouldUseSmartTransaction, } = this.props; const { updatedTransactionDetails } = this.state; const styles = this.getStyles(); - const renderTxActions = status === 'submitted' || status === 'approved'; + const renderTxActions = + (status === 'submitted' || status === 'approved') && + !shouldUseSmartTransaction; const { rpcBlockExplorer } = this.state; return updatedTransactionDetails ? ( @@ -425,6 +434,7 @@ const mapStateToProps = (state) => ({ swapsTransactions: state.engine.backgroundState.TransactionController.swapsTransactions || {}, swapsTokens: state.engine.backgroundState.SwapsController.tokens, + shouldUseSmartTransaction: selectShouldUseSmartTransaction(state), }); TransactionDetails.contextType = ThemeContext; diff --git a/app/components/UI/TransactionElement/index.js b/app/components/UI/TransactionElement/index.js index 0dcd7bf6ef6..a916b87399e 100644 --- a/app/components/UI/TransactionElement/index.js +++ b/app/components/UI/TransactionElement/index.js @@ -317,14 +317,15 @@ class TransactionElement extends PureComponent { selectedAddress, isQRHardwareAccount, isLedgerAccount, - tx: { time, status }, + tx: { time, status, isSmartTransaction }, } = this.props; const { colors, typography } = this.context || mockTheme; const styles = createStyles(colors, typography); const { value, fiatValue = false, actionKey } = transactionElement; const renderNormalActions = - status === 'submitted' || - (status === 'approved' && !isQRHardwareAccount && !isLedgerAccount); + (status === 'submitted' || + (status === 'approved' && !isQRHardwareAccount && !isLedgerAccount)) && + !isSmartTransaction; const renderUnsignedQRActions = status === 'approved' && isQRHardwareAccount; const renderLedgerActions = status === 'approved' && isLedgerAccount; diff --git a/app/components/UI/TransactionElement/utils.js b/app/components/UI/TransactionElement/utils.js index cf9a091e686..bc391969aa1 100644 --- a/app/components/UI/TransactionElement/utils.js +++ b/app/components/UI/TransactionElement/utils.js @@ -681,7 +681,7 @@ function decodeSwapsTx(args) { contractExchangeRates, assetSymbol, } = args; - const swapTransaction = (swapsTransactions && swapsTransactions[id]) || {}; + const swapTransaction = swapsTransactions?.[id] || {}; const totalGas = calculateTotalGas({ ...txParams, gas: swapTransaction.gasUsed || gas, @@ -872,6 +872,7 @@ export default async function decodeTransaction(args) { ...args, actionKey, }); + if (transactionElement && transactionDetails) return [transactionElement, transactionDetails]; } diff --git a/app/components/UI/Transactions/index.js b/app/components/UI/Transactions/index.js index 97f6ed5829d..1d8c34ddc8d 100644 --- a/app/components/UI/Transactions/index.js +++ b/app/components/UI/Transactions/index.js @@ -742,9 +742,12 @@ class Transactions extends PureComponent { const { cancelConfirmDisabled, speedUpConfirmDisabled } = this.state; const { colors, typography } = this.context || mockTheme; const styles = createStyles(colors, typography); + const transactions = submittedTransactions && submittedTransactions.length - ? submittedTransactions.concat(confirmedTransactions) + ? submittedTransactions + .sort((a, b) => b.time - a.time) + .concat(confirmedTransactions) : this.props.transactions; const renderSpeedUpGas = () => { diff --git a/app/components/Views/Asset/index.js b/app/components/Views/Asset/index.js index 8db88541c21..49c19a64986 100644 --- a/app/components/Views/Asset/index.js +++ b/app/components/Views/Asset/index.js @@ -485,6 +485,9 @@ class Asset extends PureComponent { }; const goToSwaps = () => { + // Pop asset screen first as it's very slow when trying to load the STX status modal if we don't + navigation.pop(); + navigation.navigate(Routes.SWAPS, { screen: 'SwapsAmountView', params: { diff --git a/app/components/Views/Settings/AdvancedSettings/index.js b/app/components/Views/Settings/AdvancedSettings/index.js index 558e5a31885..14dd6b069a0 100644 --- a/app/components/Views/Settings/AdvancedSettings/index.js +++ b/app/components/Views/Settings/AdvancedSettings/index.js @@ -1,7 +1,7 @@ // Third party dependencies. import PropTypes from 'prop-types'; import React, { PureComponent } from 'react'; -import { SafeAreaView, StyleSheet, Switch, View } from 'react-native'; +import { Linking, SafeAreaView, StyleSheet, Switch, View } from 'react-native'; import { connect } from 'react-redux'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; import { isTokenDetectionSupportedForNetwork } from '@metamask/assets-controllers/dist/assetsUtil'; @@ -33,8 +33,10 @@ import { mockTheme, ThemeContext } from '../../../../util/theme'; import { selectChainId } from '../../../../selectors/networkController'; import { selectDisabledRpcMethodPreferences, + selectSmartTransactionsOptInStatus, selectUseTokenDetection, } from '../../../../selectors/preferencesController'; +import { selectSmartTransactionsEnabled } from '../../../../selectors/smartTransactionsController'; import Routes from '../../../../constants/navigation/Routes'; import { MetaMetricsEvents } from '../../../../core/Analytics'; @@ -54,6 +56,7 @@ import Banner, { } from '../../../../component-library/components/Banners/Banner'; import { withMetricsAwareness } from '../../../../components/hooks/useMetrics'; import { wipeTransactions } from '../../../../util/transaction-controller'; +import AppConstants from '../../../../../app/core/AppConstants'; const createStyles = (colors) => StyleSheet.create({ @@ -191,6 +194,10 @@ class AdvancedSettings extends PureComponent { * Metrics injected by withMetricsAwareness HOC */ metrics: PropTypes.object, + /** + * Boolean that checks if smart transactions is enabled + */ + smartTransactionsOptInStatus: PropTypes.bool, }; scrollView = React.createRef(); @@ -220,7 +227,7 @@ class AdvancedSettings extends PureComponent { ); }; - componentDidMount = () => { + componentDidMount = async () => { this.updateNavBar(); this.mounted = true; // Workaround https://github.com/facebook/react-native/issues/9958 @@ -354,6 +361,22 @@ class AdvancedSettings extends PureComponent { ); }; + toggleSmartTransactionsOptInStatus = (smartTransactionsOptInStatus) => { + const { PreferencesController } = Engine.context; + PreferencesController.setSmartTransactionsOptInStatus( + smartTransactionsOptInStatus, + ); + + this.props.metrics.trackEvent(MetaMetricsEvents.SMART_TRANSACTION_OPT_IN, { + stx_opt_in: smartTransactionsOptInStatus, + location: 'Advanced Settings', + }); + }; + + openLinkAboutStx = () => { + Linking.openURL(AppConstants.URLS.SMART_TXS); + }; + render = () => { const { showHexData, @@ -361,6 +384,7 @@ class AdvancedSettings extends PureComponent { setShowHexData, setShowCustomNonce, enableEthSign, + smartTransactionsOptInStatus, } = this.props; const { resetModalVisible } = this.state; const { styles, colors } = this.getStyles(); @@ -541,6 +565,45 @@ class AdvancedSettings extends PureComponent { style={styles.accessory} /> + + + + + {strings('app_settings.smart_transactions_opt_in_heading')} + + + + + + + + {strings('app_settings.smart_transactions_opt_in_desc')}{' '} + + {strings('app_settings.smart_transactions_learn_more')} + + + @@ -557,6 +620,8 @@ const mapStateToProps = (state) => ({ fullState: state, isTokenDetectionEnabled: selectUseTokenDetection(state), chainId: selectChainId(state), + smartTransactionsOptInStatus: selectSmartTransactionsOptInStatus(state), + smartTransactionsEnabled: selectSmartTransactionsEnabled(state), }); const mapDispatchToProps = (dispatch) => ({ diff --git a/app/components/Views/Settings/AdvancedSettings/index.test.tsx b/app/components/Views/Settings/AdvancedSettings/index.test.tsx index b84158122cc..0010d40fc65 100644 --- a/app/components/Views/Settings/AdvancedSettings/index.test.tsx +++ b/app/components/Views/Settings/AdvancedSettings/index.test.tsx @@ -10,12 +10,16 @@ import { Store, AnyAction } from 'redux'; import Routes from '../../../../constants/navigation/Routes'; import Engine from '../../../../core/Engine'; import initialBackgroundState from '../../../../util/test/initial-background-state.json'; +import Device from '../../../../util/device'; + +const originalFetch = global.fetch; const mockStore = configureMockStore(); let initialState: any; let store: Store; const mockNavigate = jest.fn(); let mockSetDisabledRpcMethodPreference: jest.Mock; +let mockSetSmartTransactionsOptInStatus: jest.Mock; beforeEach(() => { initialState = { @@ -27,6 +31,7 @@ beforeEach(() => { store = mockStore(initialState); mockNavigate.mockClear(); mockSetDisabledRpcMethodPreference.mockClear(); + mockSetSmartTransactionsOptInStatus.mockClear(); }); jest.mock('@react-navigation/native', () => { @@ -43,11 +48,13 @@ const mockEngine = Engine; jest.mock('../../../../core/Engine', () => { mockSetDisabledRpcMethodPreference = jest.fn(); + mockSetSmartTransactionsOptInStatus = jest.fn(); return { init: () => mockEngine.init({}), context: { PreferencesController: { setDisabledRpcMethodPreference: mockSetDisabledRpcMethodPreference, + setSmartTransactionsOptInStatus: mockSetSmartTransactionsOptInStatus, }, }, }; @@ -145,4 +152,47 @@ describe('AdvancedSettings', () => { false, ); }); + + describe('Smart Transactions Opt In', () => { + afterEach(() => { + global.fetch = originalFetch; + }); + + Device.isIos = jest.fn().mockReturnValue(true); + Device.isAndroid = jest.fn().mockReturnValue(false); + + it('should render smart transactions opt in switch off by default', async () => { + const { findByLabelText } = renderWithProvider( + , + { + state: initialState, + }, + ); + + const switchElement = await findByLabelText( + strings('app_settings.smart_transactions_opt_in_heading'), + ); + expect(switchElement.props.value).toBe(false); + }); + it('should update smartTransactionsOptInStatus when smart transactions opt in is pressed', async () => { + const { findByLabelText } = renderWithProvider( + , + { + state: initialState, + }, + ); + + const switchElement = await findByLabelText( + strings('app_settings.smart_transactions_opt_in_heading'), + ); + + fireEvent(switchElement, 'onValueChange', true); + + expect(mockSetSmartTransactionsOptInStatus).toBeCalledWith(true); + }); + }); }); diff --git a/app/components/Views/SmartTransactionStatus/LoopingScrollAnimation.tsx b/app/components/Views/SmartTransactionStatus/LoopingScrollAnimation.tsx new file mode 100644 index 00000000000..d75346070b5 --- /dev/null +++ b/app/components/Views/SmartTransactionStatus/LoopingScrollAnimation.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +// eslint-disable-next-line import/no-namespace +import * as Animatable from 'react-native-animatable'; + +const styles = StyleSheet.create({ + wrapper: { + flexDirection: 'row', + }, +}); + +interface Props { + children: React.ReactNode; + /** + * The width of the children. Usually an image or an svg. + */ + width: number; +} + +/** + * This component will do a perfect loop scroll animation on the children + */ +const LoopingScrollAnimation = ({ children, width }: Props) => ( + + {/* Duplicate the children so we can position the animation to start at the beginning of the 2nd child */} + {/* Then we end on the start of the 1st child to get a perfect loop */} + + {children} + {children} + + +); + +export default LoopingScrollAnimation; diff --git a/app/components/Views/SmartTransactionStatus/ProgressBar.tsx b/app/components/Views/SmartTransactionStatus/ProgressBar.tsx new file mode 100644 index 00000000000..c2d14e6cfca --- /dev/null +++ b/app/components/Views/SmartTransactionStatus/ProgressBar.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { useTheme } from '../../../util/theme'; + +interface Props { + percentComplete: number; +} + +const borderRadius = 5; + +const ProgressBar = ({ percentComplete }: Props) => { + const { colors } = useTheme(); + const styles = StyleSheet.create({ + wrapper: { + height: 5, + width: '80%', + borderRadius, + backgroundColor: colors.background.pressed, + }, + progressBar: { + height: '100%', + borderRadius, + backgroundColor: colors.primary.default, + width: `${percentComplete}%`, + }, + }); + + return ( + + + + ); +}; + +export default ProgressBar; diff --git a/app/components/Views/SmartTransactionStatus/SmartTransactionStatus.test.tsx b/app/components/Views/SmartTransactionStatus/SmartTransactionStatus.test.tsx new file mode 100644 index 00000000000..e16d6c1d1ff --- /dev/null +++ b/app/components/Views/SmartTransactionStatus/SmartTransactionStatus.test.tsx @@ -0,0 +1,718 @@ +import React from 'react'; +import SmartTransactionStatus, { + FALLBACK_STX_ESTIMATED_DEADLINE_SEC, + showRemainingTimeInMinAndSec, +} from './SmartTransactionStatus'; +import renderWithProvider from '../../../util/test/renderWithProvider'; +import initialBackgroundState from '../../../util/test/initial-background-state.json'; +import { strings } from '../../../../locales/i18n'; +import Routes from '../../../constants/navigation/Routes'; +import { fireEvent } from '@testing-library/react-native'; +import { SmartTransactionStatuses } from '@metamask/smart-transactions-controller/dist/types'; + +const initialState = { + engine: { + backgroundState: initialBackgroundState, + }, +}; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + navigate: mockNavigate, + }), + }; +}); + +const PENDING_APPROVALS = { + Dapp: { + pending: { + id: '8pJ0jVaREyCysgt8DeHcO', + origin: 'pancakeswap.finance', + type: 'smart_transaction_status', + time: 1711401024472, + requestData: null, + requestState: { + smartTransaction: { status: 'pending', creationTime: 1711401024472 }, + isDapp: true, + isInSwapFlow: false, + isSwapApproveTx: false, + isSwapTransaction: false, + }, + expectsResult: false, + }, + success: { + id: '8pJ0jVaREyCysgt8DeHcO', + origin: 'pancakeswap.finance', + type: 'smart_transaction_status', + time: 1711401024472, + requestData: null, + requestState: { + smartTransaction: { + statusMetadata: { + cancellationFeeWei: 0, + cancellationReason: 'not_cancelled', + deadlineRatio: 0, + isSettled: true, + minedTx: 'success', + wouldRevertMessage: null, + minedHash: + '0x36b10b5000b3a0babfaf205742e7c04076d4289863d43a3e44ef559fb31bfa37', + type: 'sentinel', + }, + status: 'success', + cancellable: false, + uuid: '18fa4b26-eaec-11ee-b819-aa13775ea356', + }, + isDapp: true, + isInSwapFlow: false, + isSwapApproveTx: false, + isSwapTransaction: false, + }, + expectsResult: false, + }, + cancelled: { + id: '8pJ0jVaREyCysgt8DeHcO', + origin: 'pancakeswap.finance', + type: 'smart_transaction_status', + time: 1711401024472, + requestData: null, + requestState: { + smartTransaction: { + status: SmartTransactionStatuses.CANCELLED, + creationTime: 1711401024472, + }, + isDapp: true, + isInSwapFlow: false, + isSwapApproveTx: false, + isSwapTransaction: false, + }, + expectsResult: false, + }, + unknown: { + id: '8pJ0jVaREyCysgt8DeHcO', + origin: 'pancakeswap.finance', + type: 'smart_transaction_status', + time: 1711401024472, + requestData: null, + requestState: { + smartTransaction: { + status: SmartTransactionStatuses.UNKNOWN, + creationTime: 1711401024472, + }, + isDapp: true, + isInSwapFlow: false, + isSwapApproveTx: false, + isSwapTransaction: false, + }, + expectsResult: false, + }, + }, + Send: { + pending: { + id: 'Ws4jw14OTsnPpX30B0hso', + origin: 'MetaMask Mobile', + type: 'smart_transaction_status', + time: 1711395354068, + requestData: null, + requestState: { + smartTransaction: { status: 'pending', creationTime: 1711395354068 }, + isDapp: false, + isInSwapFlow: false, + isSwapApproveTx: false, + isSwapTransaction: false, + }, + expectsResult: false, + }, + success: { + id: 'Ws4jw14OTsnPpX30B0hso', + origin: 'MetaMask Mobile', + type: 'smart_transaction_status', + time: 1711395354068, + requestData: null, + requestState: { + smartTransaction: { + statusMetadata: { + cancellationFeeWei: 0, + cancellationReason: 'not_cancelled', + deadlineRatio: 0, + isSettled: true, + minedTx: 'success', + wouldRevertMessage: null, + minedHash: + '0x1e788766537a84f0979f1a85f3cb8f5ff01c6df4134d9a59c403b3ffef0812ec', + type: 'sentinel', + }, + status: 'success', + cancellable: false, + uuid: 'e4e20f5b-eade-11ee-b531-c223837ee6b7', + }, + isDapp: false, + isInSwapFlow: false, + isSwapApproveTx: false, + isSwapTransaction: false, + }, + expectsResult: false, + }, + cancelled: { + id: 'Ws4jw14OTsnPpX30B0hso', + origin: 'MetaMask Mobile', + type: 'smart_transaction_status', + time: 1711395354068, + requestData: null, + requestState: { + smartTransaction: { + status: SmartTransactionStatuses.CANCELLED, + creationTime: 1711395354068, + }, + isDapp: false, + isInSwapFlow: false, + isSwapApproveTx: false, + isSwapTransaction: false, + }, + expectsResult: false, + }, + unknown: { + id: 'Ws4jw14OTsnPpX30B0hso', + origin: 'MetaMask Mobile', + type: 'smart_transaction_status', + time: 1711395354068, + requestData: null, + requestState: { + smartTransaction: { + status: SmartTransactionStatuses.UNKNOWN, + creationTime: 1711395354068, + }, + isDapp: false, + isInSwapFlow: false, + isSwapApproveTx: false, + isSwapTransaction: false, + }, + expectsResult: false, + }, + }, + Swap: { + pending: { + id: 'UdluGaS-UDJ7G9CIrXrCc', + origin: 'EXAMPLE_FOX_CODE', + type: 'smart_transaction_status', + time: 1711401929050, + requestData: null, + requestState: { + smartTransaction: { status: 'pending', creationTime: 1711401929050 }, + isDapp: false, + isInSwapFlow: true, + isSwapApproveTx: false, + isSwapTransaction: true, + }, + expectsResult: false, + }, + success: { + id: 'UdluGaS-UDJ7G9CIrXrCc', + origin: 'EXAMPLE_FOX_CODE', + type: 'smart_transaction_status', + time: 1711401929050, + requestData: null, + requestState: { + smartTransaction: { + statusMetadata: { + cancellationFeeWei: 0, + cancellationReason: 'not_cancelled', + deadlineRatio: 0, + isSettled: true, + minedTx: 'success', + wouldRevertMessage: null, + minedHash: + '0x3e7e8ade8c1b847f574e6440d2ee0358fba0d5c92c6c29fd7afcf1ea18bc595b', + type: 'sentinel', + }, + status: 'success', + cancellable: false, + uuid: '343b1e2d-eaee-11ee-86e5-3a4eb35f9cf6', + }, + isDapp: false, + isInSwapFlow: true, + isSwapApproveTx: false, + isSwapTransaction: true, + }, + expectsResult: false, + }, + cancelled: { + id: 'UdluGaS-UDJ7G9CIrXrCc', + origin: 'EXAMPLE_FOX_CODE', + type: 'smart_transaction_status', + time: 1711401929050, + requestData: null, + requestState: { + smartTransaction: { + status: SmartTransactionStatuses.CANCELLED, + creationTime: 1711401929050, + }, + isDapp: false, + isInSwapFlow: true, + isSwapApproveTx: false, + isSwapTransaction: true, + }, + expectsResult: false, + }, + unknown: { + id: 'UdluGaS-UDJ7G9CIrXrCc', + origin: 'EXAMPLE_FOX_CODE', + type: 'smart_transaction_status', + time: 1711401929050, + requestData: null, + requestState: { + smartTransaction: { + status: SmartTransactionStatuses.UNKNOWN, + creationTime: 1711401929050, + }, + isDapp: false, + isInSwapFlow: true, + isSwapApproveTx: false, + isSwapTransaction: true, + }, + expectsResult: false, + }, + }, +}; + +describe('SmartTransactionStatus', () => { + afterEach(() => { + mockNavigate.mockReset(); + }); + + describe('showRemainingTimeInMinAndSec', () => { + it('should return "0:00" when input is not an integer', () => { + expect(showRemainingTimeInMinAndSec(1.5)).toEqual('0:00'); + expect(showRemainingTimeInMinAndSec(NaN)).toEqual('0:00'); + }); + + it('should return minutes and seconds when input is an integer', () => { + expect(showRemainingTimeInMinAndSec(90)).toEqual('1:30'); + expect(showRemainingTimeInMinAndSec(60)).toEqual('1:00'); + expect(showRemainingTimeInMinAndSec(59)).toEqual('0:59'); + expect(showRemainingTimeInMinAndSec(0)).toEqual('0:00'); + }); + }); + + describe('Component', () => { + describe('pending', () => { + it('should render estimated deadline countdown when STX is being submitted', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const header = getByText( + strings('smart_transactions.status_submitting_header'), + ); + expect(header).toBeDefined(); + }); + it('should render max deadline countdown when STX past estimated time', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const header = getByText( + strings( + 'smart_transactions.status_submitting_past_estimated_deadline_header', + ), + ); + expect(header).toBeDefined(); + }); + }); + + describe('success', () => { + it('should render success when STX has success status', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const header = getByText( + strings('smart_transactions.status_success_header'), + ); + expect(header).toBeDefined(); + }); + + describe('dapp tx', () => { + it('should navigate to Activity page on press of primary button', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const primaryButton = getByText( + strings('smart_transactions.view_activity'), + ); + fireEvent.press(primaryButton); + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); + }); + it('should close the Status page on press of secondary button', () => { + const onConfirm = jest.fn(); + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const secondaryButton = getByText( + strings('smart_transactions.return_to_dapp', { + dappName: PENDING_APPROVALS.Dapp.success.origin, + }), + ); + fireEvent.press(secondaryButton); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(onConfirm).toHaveBeenCalled(); + }); + }); + + describe('send tx', () => { + it('should navigate to Send page on press of primary button', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const primaryButton = getByText( + strings('smart_transactions.create_new', { + txType: strings('smart_transactions.send'), + }), + ); + fireEvent.press(primaryButton); + expect(mockNavigate).toHaveBeenCalledWith('SendFlowView'); + }); + it('should navigate to Activity page on press of secondary button', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const secondaryButton = getByText( + strings('smart_transactions.view_activity'), + ); + fireEvent.press(secondaryButton); + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); + }); + }); + + describe('MM Swaps flow tx', () => { + it('should navigate to Swaps page on press of primary button', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const primaryButton = getByText( + strings('smart_transactions.create_new', { + txType: strings('smart_transactions.swap'), + }), + ); + fireEvent.press(primaryButton); + expect(mockNavigate).toHaveBeenCalledWith(Routes.SWAPS); + }); + it('should navigate to Activity page on press of secondary button', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const secondaryButton = getByText( + strings('smart_transactions.view_activity'), + ); + fireEvent.press(secondaryButton); + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); + }); + }); + }); + + describe('cancelled', () => { + it('should render cancelled when STX has cancelled status', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const header = getByText( + strings('smart_transactions.status_cancelled_header'), + ); + expect(header).toBeDefined(); + }); + + describe('dapp tx', () => { + it('should close the Status page on press of secondary button', () => { + const onConfirm = jest.fn(); + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const secondaryButton = getByText( + strings('smart_transactions.return_to_dapp', { + dappName: PENDING_APPROVALS.Dapp.cancelled.origin, + }), + ); + fireEvent.press(secondaryButton); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(onConfirm).toHaveBeenCalled(); + }); + }); + describe('send tx', () => { + it('should navigate to Send page on press of primary button', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const primaryButton = getByText( + strings('smart_transactions.try_again'), + ); + fireEvent.press(primaryButton); + expect(mockNavigate).toHaveBeenCalledWith('SendFlowView'); + }); + it('should navigate to Activity page on press of secondary button', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const secondaryButton = getByText( + strings('smart_transactions.view_activity'), + ); + fireEvent.press(secondaryButton); + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); + }); + }); + describe('MM Swaps flow tx', () => { + it('should navigate to Swaps page on press of primary button', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const primaryButton = getByText( + strings('smart_transactions.try_again'), + ); + fireEvent.press(primaryButton); + expect(mockNavigate).toHaveBeenCalledWith(Routes.SWAPS); + }); + it('should navigate to Activity page on press of secondary button', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const secondaryButton = getByText( + strings('smart_transactions.view_activity'), + ); + fireEvent.press(secondaryButton); + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); + }); + }); + }); + + describe('failed', () => { + it('should render failed when STX has unknown status', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const header = getByText( + strings('smart_transactions.status_failed_header'), + ); + expect(header).toBeDefined(); + }); + + describe('dapp tx', () => { + it('should close the Status page on press of secondary button', () => { + const onConfirm = jest.fn(); + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const secondaryButton = getByText( + strings('smart_transactions.return_to_dapp', { + dappName: PENDING_APPROVALS.Dapp.unknown.origin, + }), + ); + fireEvent.press(secondaryButton); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(onConfirm).toHaveBeenCalled(); + }); + }); + describe('send tx', () => { + it('should close the Status page on press of primary button', () => { + const onConfirm = jest.fn(); + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const primaryButton = getByText( + strings('smart_transactions.try_again'), + ); + fireEvent.press(primaryButton); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(onConfirm).toHaveBeenCalled(); + }); + it('should navigate to Activity page on press of secondary button', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const secondaryButton = getByText( + strings('smart_transactions.view_activity'), + ); + fireEvent.press(secondaryButton); + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); + }); + }); + describe('MM Swaps flow tx', () => { + it('should close the Status page on press of primary button', () => { + const onConfirm = jest.fn(); + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const primaryButton = getByText( + strings('smart_transactions.try_again'), + ); + fireEvent.press(primaryButton); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(onConfirm).toHaveBeenCalled(); + }); + it('should navigate to Activity page on press of secondary button', () => { + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + const secondaryButton = getByText( + strings('smart_transactions.view_activity'), + ); + fireEvent.press(secondaryButton); + expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); + }); + }); + }); + }); +}); diff --git a/app/components/Views/SmartTransactionStatus/SmartTransactionStatus.tsx b/app/components/Views/SmartTransactionStatus/SmartTransactionStatus.tsx new file mode 100644 index 00000000000..c99d82cf9b9 --- /dev/null +++ b/app/components/Views/SmartTransactionStatus/SmartTransactionStatus.tsx @@ -0,0 +1,396 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { strings } from '../../../../locales/i18n'; +import Icon, { + IconColor, + IconName, + IconSize, +} from '../../../component-library/components/Icons/Icon'; +import ProgressBar from './ProgressBar'; +import { useTheme } from '../../../util/theme'; +import { + Hex, + SmartTransaction, + SmartTransactionStatuses, +} from '@metamask/smart-transactions-controller/dist/types'; +import { useSelector } from 'react-redux'; +import { selectProviderConfig } from '../../../selectors/networkController'; +import { useNavigation } from '@react-navigation/native'; +import Button, { + ButtonVariants, +} from '../../../component-library/components/Buttons/Button'; +import Routes from '../../../constants/navigation/Routes'; +import TransactionBackgroundTop from '../../../images/transaction-background-top.svg'; +import TransactionBackgroundBottom from '../../../images/transaction-background-bottom.svg'; +import LoopingScrollAnimation from './LoopingScrollAnimation'; +import { hexToDecimal } from '../../../util/conversions'; +import useRemainingTime from './useRemainingTime'; +import { ThemeColors } from '@metamask/design-tokens/dist/types/js/themes/types'; + +const getPortfolioStxLink = (chainId: Hex, uuid: string) => { + const chainIdDec = hexToDecimal(chainId); + return `https://portfolio.metamask.io/networks/${chainIdDec}/smart-transactions/${uuid}?referrer=mobile`; +}; + +interface Props { + requestState: { + smartTransaction: SmartTransaction; + isDapp: boolean; + isInSwapFlow: boolean; + }; + origin: string; + onConfirm: () => void; +} + +export const FALLBACK_STX_ESTIMATED_DEADLINE_SEC = 45; +export const FALLBACK_STX_MAX_DEADLINE_SEC = 150; + +export const showRemainingTimeInMinAndSec = ( + remainingTimeInSec: number, +): string => { + if (!Number.isInteger(remainingTimeInSec)) { + return '0:00'; + } + const minutes = Math.floor(remainingTimeInSec / 60); + const seconds = remainingTimeInSec % 60; + + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +}; + +interface getDisplayValuesArgs { + status: string | undefined; + isStxPending: boolean; + isStxPastEstimatedDeadline: boolean; + timeLeftForPendingStxInSec: number; + isDapp: boolean; + isInSwapFlow: boolean; + origin: string; + viewActivity: () => void; + closeStatusPage: () => void; + createNewSwap: () => void; + createNewSend: () => void; +} + +const getDisplayValuesAndHandlers = ({ + status, + isStxPending, + isStxPastEstimatedDeadline, + timeLeftForPendingStxInSec, + isDapp, + isInSwapFlow, + origin, + viewActivity, + closeStatusPage, + createNewSwap, + createNewSend, +}: getDisplayValuesArgs) => { + const returnTextDapp = strings('smart_transactions.return_to_dapp', { + dappName: origin, + }); + const returnTextMM = strings('smart_transactions.try_again'); + + // Set icon, header, desc, and buttons + let icon; + let iconColor; + let header; + let description; + let primaryButtonText; + let secondaryButtonText; + let handlePrimaryButtonPress; + let handleSecondaryButtonPress; + + if (isStxPending && isStxPastEstimatedDeadline) { + icon = IconName.Clock; + iconColor = IconColor.Primary; + header = strings( + 'smart_transactions.status_submitting_past_estimated_deadline_header', + ); + description = strings( + 'smart_transactions.status_submitting_past_estimated_deadline_description', + { + timeLeft: showRemainingTimeInMinAndSec(timeLeftForPendingStxInSec), + }, + ); + } else if (isStxPending) { + icon = IconName.Clock; + iconColor = IconColor.Primary; + header = strings('smart_transactions.status_submitting_header'); + description = strings('smart_transactions.status_submitting_description', { + timeLeft: showRemainingTimeInMinAndSec(timeLeftForPendingStxInSec), + }); + } else if (status === SmartTransactionStatuses.SUCCESS) { + icon = IconName.Confirmation; + iconColor = IconColor.Success; + header = strings('smart_transactions.status_success_header'); + description = undefined; + + if (isDapp) { + primaryButtonText = strings('smart_transactions.view_activity'); + handlePrimaryButtonPress = viewActivity; + secondaryButtonText = returnTextDapp; + handleSecondaryButtonPress = closeStatusPage; + } else { + if (isInSwapFlow) { + primaryButtonText = strings('smart_transactions.create_new', { + txType: strings('smart_transactions.swap'), + }); + handlePrimaryButtonPress = createNewSwap; + } else { + primaryButtonText = strings('smart_transactions.create_new', { + txType: strings('smart_transactions.send'), + }); + handlePrimaryButtonPress = createNewSend; + } + + secondaryButtonText = strings('smart_transactions.view_activity'); + handleSecondaryButtonPress = viewActivity; + } + } else if (status?.startsWith(SmartTransactionStatuses.CANCELLED)) { + icon = IconName.Danger; + iconColor = IconColor.Error; + header = strings('smart_transactions.status_cancelled_header'); + description = strings('smart_transactions.status_cancelled_description'); + + if (isDapp) { + secondaryButtonText = returnTextDapp; + handleSecondaryButtonPress = closeStatusPage; + } else { + primaryButtonText = returnTextMM; + + if (isInSwapFlow) { + handlePrimaryButtonPress = createNewSwap; + } else { + handlePrimaryButtonPress = createNewSend; + } + + secondaryButtonText = strings('smart_transactions.view_activity'); + handleSecondaryButtonPress = viewActivity; + } + } else { + // Reverted or unknown statuses (tx failed) + icon = IconName.Danger; + iconColor = IconColor.Error; + header = strings('smart_transactions.status_failed_header'); + description = strings('smart_transactions.status_failed_description'); + + if (isDapp) { + secondaryButtonText = returnTextDapp; + handleSecondaryButtonPress = closeStatusPage; + } else { + primaryButtonText = returnTextMM; + handlePrimaryButtonPress = closeStatusPage; + secondaryButtonText = strings('smart_transactions.view_activity'); + handleSecondaryButtonPress = viewActivity; + } + } + + return { + icon, + iconColor, + header, + description, + primaryButtonText, + secondaryButtonText, + handlePrimaryButtonPress, + handleSecondaryButtonPress, + }; +}; + +const createStyles = (colors: ThemeColors) => + StyleSheet.create({ + wrapper: { + height: '82%', + display: 'flex', + justifyContent: 'center', + paddingVertical: 4, + paddingHorizontal: 8, + }, + content: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + gap: 20, + flex: 1, + }, + textWrapper: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + gap: 10, + }, + header: { + textAlign: 'center', + fontWeight: 'bold', + fontSize: 18, + color: colors.text.default, + }, + desc: { + textAlign: 'center', + color: colors.text.alternative, + }, + link: { + color: colors.primary.default, + }, + close: { + position: 'absolute', + top: 20, + right: 20, + zIndex: 100, + }, + buttonWrapper: { + display: 'flex', + width: '100%', + gap: 10, + }, + button: { + width: '100%', + }, + }); + +const SmartTransactionStatus = ({ + requestState: { smartTransaction, isDapp, isInSwapFlow }, + origin, + onConfirm, +}: Props) => { + const { status, creationTime, uuid } = smartTransaction; + const providerConfig = useSelector(selectProviderConfig); + + const navigation = useNavigation(); + const { colors } = useTheme(); + const styles = createStyles(colors); + + const isStxPending = status === SmartTransactionStatuses.PENDING; + + const { + timeLeftForPendingStxInSec, + stxDeadlineSec, + isStxPastEstimatedDeadline, + } = useRemainingTime({ + creationTime, + isStxPending, + }); + + const viewActivity = () => { + onConfirm(); + navigation.navigate(Routes.TRANSACTIONS_VIEW); + }; + + const closeStatusPage = () => { + onConfirm(); + }; + + const createNewSwap = () => { + onConfirm(); + navigation.navigate(Routes.SWAPS); + }; + + const createNewSend = () => { + onConfirm(); + navigation.navigate('SendFlowView'); + }; + + const { + icon, + iconColor, + header, + description, + primaryButtonText, + secondaryButtonText, + handlePrimaryButtonPress, + handleSecondaryButtonPress, + } = getDisplayValuesAndHandlers({ + status, + isStxPending, + isStxPastEstimatedDeadline, + timeLeftForPendingStxInSec, + isDapp, + isInSwapFlow, + origin, + viewActivity, + closeStatusPage, + createNewSwap, + createNewSend, + }); + + // Set block explorer link and show explorer on click + const txUrl = getPortfolioStxLink(providerConfig.chainId, uuid); + + const onViewTransaction = () => { + navigation.navigate('Webview', { + screen: 'SimpleWebview', + params: { + url: txUrl, + }, + }); + // Close SmartTransactionStatus + onConfirm(); + }; + + const percentComplete = + (1 - timeLeftForPendingStxInSec / stxDeadlineSec) * 100; + + const PrimaryButton = () => + handlePrimaryButtonPress ? ( + + ) : null; + + const SecondaryButton = () => + handleSecondaryButtonPress ? ( + + ) : null; + + const ViewTransactionLink = () => ( + + + {strings('smart_transactions.view_transaction')} + + + ); + + return ( + + + + + + + + + + + + {header} + {isStxPending && } + + {description && {description}} + + + + + + + + + + + + + + ); +}; + +export default SmartTransactionStatus; diff --git a/app/components/Views/SmartTransactionStatus/useRemainingTime.ts b/app/components/Views/SmartTransactionStatus/useRemainingTime.ts new file mode 100644 index 00000000000..dea913b069e --- /dev/null +++ b/app/components/Views/SmartTransactionStatus/useRemainingTime.ts @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { selectSwapsChainFeatureFlags } from '../../../reducers/swaps'; + +export const FALLBACK_STX_ESTIMATED_DEADLINE_SEC = 45; +export const FALLBACK_STX_MAX_DEADLINE_SEC = 150; + +interface Props { + creationTime: number | undefined; + isStxPending: boolean; +} + +const useRemainingTime = ({ creationTime, isStxPending }: Props) => { + const swapFeatureFlags = useSelector(selectSwapsChainFeatureFlags); + + const [isStxPastEstimatedDeadline, setIsStxPastEstimatedDeadline] = + useState(false); + + const stxEstimatedDeadlineSec = + swapFeatureFlags?.smartTransactions?.expectedDeadline || + FALLBACK_STX_ESTIMATED_DEADLINE_SEC; + const stxMaxDeadlineSec = + swapFeatureFlags?.smartTransactions?.maxDeadline || + FALLBACK_STX_MAX_DEADLINE_SEC; + + // Calc time left for progress bar and timer display + const stxDeadlineSec = isStxPastEstimatedDeadline + ? stxMaxDeadlineSec + : stxEstimatedDeadlineSec; + + const [timeLeftForPendingStxInSec, setTimeLeftForPendingStxInSec] = useState( + stxEstimatedDeadlineSec, + ); + + useEffect(() => { + let intervalId: NodeJS.Timeout; + if (isStxPending && creationTime) { + const calculateRemainingTime = () => { + const secondsAfterStxSubmission = Math.round( + (Date.now() - creationTime) / 1000, + ); + if (secondsAfterStxSubmission > stxDeadlineSec) { + if (isStxPastEstimatedDeadline) { + setTimeLeftForPendingStxInSec(0); + clearInterval(intervalId); + return; + } + setIsStxPastEstimatedDeadline(true); + } + setTimeLeftForPendingStxInSec( + stxDeadlineSec - secondsAfterStxSubmission, + ); + }; + intervalId = setInterval(calculateRemainingTime, 1000); + calculateRemainingTime(); + } + + return () => clearInterval(intervalId); + }, [isStxPending, isStxPastEstimatedDeadline, creationTime, stxDeadlineSec]); + + return { + timeLeftForPendingStxInSec, + stxDeadlineSec, + isStxPastEstimatedDeadline, + }; +}; + +export default useRemainingTime; diff --git a/app/components/Views/SmartTransactionsOptInModal/SmartTranactionsOptInModal.tsx b/app/components/Views/SmartTransactionsOptInModal/SmartTranactionsOptInModal.tsx new file mode 100644 index 00000000000..fa4f5ce0f03 --- /dev/null +++ b/app/components/Views/SmartTransactionsOptInModal/SmartTranactionsOptInModal.tsx @@ -0,0 +1,296 @@ +import React, { useRef } from 'react'; +import { + StyleSheet, + View, + ScrollView, + Linking, + ImageBackground, +} from 'react-native'; +import { strings } from '../../../../locales/i18n'; +import Device from '../../../util/device'; +import AsyncStorage from '../../../store/async-storage-wrapper'; +import { CURRENT_APP_VERSION } from '../../../constants/storage'; +import { useTheme } from '../../../util/theme'; +import Text, { + TextColor, + TextVariant, +} from '../../../component-library/components/Texts/Text'; +import Icon, { + IconName, + IconSize, +} from '../../../component-library/components/Icons/Icon'; +import ReusableModal, { ReusableModalRef } from '../../UI/ReusableModal'; +import { Colors } from '../../../util/theme/models'; +import { SmartTransactionsOptInModalSelectorsIDs } from '../../../../e2e/selectors/Modals/SmartTransactionsOptInModal.selectors'; +import Engine from '../../../core/Engine'; +import Button, { + ButtonVariants, +} from '../../../component-library/components/Buttons/Button'; +import AppConstants from '../../../core/AppConstants'; +import backgroundImage from '../../../images/smart-transactions-opt-in-bg.png'; +import { MetaMetricsEvents, useMetrics } from '../../hooks/useMetrics'; +import { useDispatch } from 'react-redux'; +import { updateOptInModalAppVersionSeen } from '../../../core/redux/slices/smartTransactions'; + +const MODAL_MARGIN = 24; +const MODAL_PADDING = 24; +const screenWidth = Device.getDeviceWidth(); +const screenHeight = Device.getDeviceHeight(); +const itemWidth = screenWidth - MODAL_MARGIN * 2; +const maxItemHeight = screenHeight - 200; + +const createStyles = (colors: Colors) => + StyleSheet.create({ + scroll: { + maxHeight: maxItemHeight, + }, + content: { + gap: 16, + paddingHorizontal: MODAL_PADDING, + }, + buttons: { + gap: 10, + justifyContent: 'center', + }, + button: { + width: '100%', + textAlign: 'center', + }, + secondaryButtonText: { + color: colors.text.alternative, + }, + header: { + alignItems: 'center', + }, + descriptions: { + gap: 16, + }, + screen: { justifyContent: 'center', alignItems: 'center' }, + modal: { + backgroundColor: colors.background.default, + borderRadius: 10, + marginHorizontal: MODAL_MARGIN, + }, + bodyContainer: { + width: itemWidth, + paddingVertical: 16, + paddingBottom: 16, + }, + benefits: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: 8, + }, + benefit: { + width: '33%', + gap: 4, + alignItems: 'center', + }, + benefitIcon: { + width: 35, + height: 35, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 50, + backgroundColor: colors.primary.muted, + }, + benefitText: { + textAlign: 'center', + }, + backgroundImage: { + gap: 16, + height: 140, + justifyContent: 'center', + }, + }); + +interface Props { + iconName: IconName; + text: string[]; +} +const Benefit = ({ iconName, text }: Props) => { + const { colors } = useTheme(); + const styles = createStyles(colors); + + return ( + + + + + {text.map((t) => ( + + {t} + + ))} + + + ); +}; + +const SmartTransactionsOptInModal = () => { + const modalRef = useRef(null); + const { colors } = useTheme(); + const { trackEvent } = useMetrics(); + const dispatch = useDispatch(); + + const styles = createStyles(colors); + + const hasOptedIn = useRef(null); + + const dismissModal = async () => { + modalRef.current?.dismissModal(); + }; + + const optIn = () => { + Engine.context.PreferencesController.setSmartTransactionsOptInStatus(true); + trackEvent(MetaMetricsEvents.SMART_TRANSACTION_OPT_IN, { + stx_opt_in: true, + location: 'SmartTransactionsOptInModal', + }); + + hasOptedIn.current = true; + dismissModal(); + }; + + const optOut = () => { + Engine.context.PreferencesController.setSmartTransactionsOptInStatus(false); + trackEvent(MetaMetricsEvents.SMART_TRANSACTION_OPT_IN, { + stx_opt_in: false, + location: 'SmartTransactionsOptInModal', + }); + + hasOptedIn.current = false; + dismissModal(); + }; + + const handleDismiss = async () => { + // Opt out of STX if no prior decision made + if (hasOptedIn.current === null) { + optOut(); + } + + // Save the current app version as the last app version seen + const version = await AsyncStorage.getItem(CURRENT_APP_VERSION); + dispatch(updateOptInModalAppVersionSeen(version)); + }; + + const Header = () => ( + + + {strings('whats_new.stx.header')} + + + ); + + const Benefits = () => ( + + + + + + ); + + const Descriptions = () => ( + + {strings('whats_new.stx.description_1')} + + {strings('whats_new.stx.description_2')}{' '} + { + Linking.openURL(AppConstants.URLS.SMART_TXS); + }} + > + {strings('whats_new.stx.learn_more')} + + + + ); + + const PrimaryButton = () => ( + + ); + + const SecondaryButton = () => ( + + ); + + return ( + + + + +
+ + + + {/* Content */} + + + + + + + + + + + + + + ); +}; + +export default SmartTransactionsOptInModal; diff --git a/app/components/Views/SmartTransactionsOptInModal/SmartTransactionsOptInModal.test.tsx b/app/components/Views/SmartTransactionsOptInModal/SmartTransactionsOptInModal.test.tsx new file mode 100644 index 00000000000..d4800d49d1c --- /dev/null +++ b/app/components/Views/SmartTransactionsOptInModal/SmartTransactionsOptInModal.test.tsx @@ -0,0 +1,151 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import SmartTransactionsOptInModal from './SmartTranactionsOptInModal'; +import renderWithProvider from '../../../util/test/renderWithProvider'; +import initialBackgroundState from '../../../util/test/initial-background-state.json'; +import { strings } from '../../../../locales/i18n'; +import Engine from '../../../core/Engine'; +import { shouldShowWhatsNewModal } from '../../../util/onboarding'; +import { updateOptInModalAppVersionSeen } from '../../../core/redux/slices/smartTransactions'; + +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + navigate: mockNavigate, + goBack: jest.fn(), + }), + }; +}); + +jest.mock('../../../core/Engine', () => ({ + context: { + PreferencesController: { + setSmartTransactionsOptInStatus: jest.fn(), + }, + }, +})); + +const VERSION = '1.0.0'; +jest.mock('../../../store/async-storage-wrapper', () => ({ + getItem: jest.fn(() => VERSION), + setItem: jest.fn(), + removeItem: jest.fn(), +})); + +jest.mock('../../../util/onboarding', () => ({ + shouldShowWhatsNewModal: jest.fn(), +})); + +jest.mock('../../../core/redux/slices/smartTransactions', () => ({ + updateOptInModalAppVersionSeen: jest.fn(() => ({ type: 'hello' })), +})); + +const initialState = { + engine: { + backgroundState: initialBackgroundState, + }, +}; + +function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('SmartTransactionsOptInModal', () => { + afterEach(() => { + mockNavigate.mockReset(); + }); + + it('should render properly', () => { + const { getByText } = renderWithProvider(, { + state: initialState, + }); + + const header = getByText(strings('whats_new.stx.header')); + expect(header).toBeDefined(); + + const description1 = getByText(strings('whats_new.stx.description_1')); + expect(description1).toBeDefined(); + + const description2 = getByText(strings('whats_new.stx.description_2'), { + exact: false, + }); + expect(description2).toBeDefined(); + + const primaryButton = getByText(strings('whats_new.stx.primary_button')); + expect(primaryButton).toBeDefined(); + + const secondaryButton = getByText( + strings('whats_new.stx.secondary_button'), + ); + expect(secondaryButton).toBeDefined(); + }); + it('should opt user in when primary button is pressed', () => { + const { getByText } = renderWithProvider(, { + state: initialState, + }); + + const primaryButton = getByText(strings('whats_new.stx.primary_button')); + fireEvent.press(primaryButton); + + expect( + Engine.context.PreferencesController.setSmartTransactionsOptInStatus, + ).toHaveBeenCalledWith(true); + }); + it('should opt user out when secondary button is pressed', () => { + const { getByText } = renderWithProvider(, { + state: initialState, + }); + + const secondaryButton = getByText( + strings('whats_new.stx.secondary_button'), + ); + fireEvent.press(secondaryButton); + + expect( + Engine.context.PreferencesController.setSmartTransactionsOptInStatus, + ).toHaveBeenCalledWith(false); + }); + it('should update last app version seen on primary button press', () => { + const { getByText } = renderWithProvider(, { + state: initialState, + }); + + const primaryButton = getByText(strings('whats_new.stx.primary_button')); + fireEvent.press(primaryButton); + + expect(updateOptInModalAppVersionSeen).toHaveBeenCalledWith(VERSION); + }); + it('should update last app version seen on secondary button press', () => { + const { getByText } = renderWithProvider(, { + state: initialState, + }); + + const secondaryButton = getByText( + strings('whats_new.stx.secondary_button'), + ); + fireEvent.press(secondaryButton); + + expect(updateOptInModalAppVersionSeen).toHaveBeenCalledWith(VERSION); + }); + + it("should not navigate to What's New modal", async () => { + (shouldShowWhatsNewModal as jest.Mock).mockImplementation( + async () => false, + ); + + const { getByText } = renderWithProvider(, { + state: initialState, + }); + + const primaryButton = getByText(strings('whats_new.stx.primary_button')); + fireEvent.press(primaryButton); + + await wait(10); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/TransactionsView/index.js b/app/components/Views/TransactionsView/index.js index 5c31b2d9bf7..c86ae07e68d 100644 --- a/app/components/Views/TransactionsView/index.js +++ b/app/components/Views/TransactionsView/index.js @@ -35,6 +35,8 @@ import { import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/WalletView.selectors'; import { store } from '../../../store'; import { NETWORK_ID_LOADING } from '../../../core/redux/slices/inpageProvider'; +import { selectPendingSmartTransactionsBySender } from '../../../selectors/smartTransactionsController'; +import { selectNonReplacedTransactions } from '../../../selectors/transactionController'; const styles = StyleSheet.create({ wrapper: { @@ -66,7 +68,6 @@ const TransactionsView = ({ const addedAccountTime = identities[selectedAddress]?.importTime; const submittedTxs = []; - const newPendingTxs = []; const confirmedTxs = []; const submittedNonces = []; @@ -97,11 +98,9 @@ const TransactionsView = ({ case TX_SUBMITTED: case TX_SIGNED: case TX_UNAPPROVED: + case TX_PENDING: submittedTxs.push(tx); return false; - case TX_PENDING: - newPendingTxs.push(tx); - break; case TX_CONFIRMED: confirmedTxs.push(tx); break; @@ -220,16 +219,31 @@ TransactionsView.propTypes = { chainId: PropTypes.string, }; -const mapStateToProps = (state) => ({ - conversionRate: selectConversionRate(state), - currentCurrency: selectCurrentCurrency(state), - tokens: selectTokens(state), - selectedAddress: selectSelectedAddress(state), - identities: selectIdentities(state), - transactions: state.engine.backgroundState.TransactionController.transactions, - networkType: selectProviderType(state), - chainId: selectChainId(state), -}); +const mapStateToProps = (state) => { + const selectedAddress = selectSelectedAddress(state); + const chainId = selectChainId(state); + + // Remove duplicate confirmed STX + // for replaced txs, only hide the ones that are confirmed + const nonReplacedTransactions = selectNonReplacedTransactions(state); + + const pendingSmartTransactions = + selectPendingSmartTransactionsBySender(state); + + return { + conversionRate: selectConversionRate(state), + currentCurrency: selectCurrentCurrency(state), + tokens: selectTokens(state), + selectedAddress, + identities: selectIdentities(state), + transactions: [ + ...nonReplacedTransactions, + ...pendingSmartTransactions, + ].sort((a, b) => b.time - a.time), + networkType: selectProviderType(state), + chainId, + }; +}; const mapDispatchToProps = (dispatch) => ({ showAlert: (config) => dispatch(showAlert(config)), diff --git a/app/components/Views/Wallet/index.test.tsx b/app/components/Views/Wallet/index.test.tsx index a7e471d1527..2f12b4c6d54 100644 --- a/app/components/Views/Wallet/index.test.tsx +++ b/app/components/Views/Wallet/index.test.tsx @@ -51,6 +51,11 @@ jest.mock('../../../core/Engine', () => ({ })); const mockInitialState = { + networkOnboarded: { + networkOnboardedState: { + '0x1': true, + }, + }, swaps: { '0x1': { isLive: true }, hasOnboarded: false, isLive: true }, wizard: { step: 0, diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index b52b339ac7d..a407eca90a8 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -10,6 +10,7 @@ import { StyleSheet, View, TextStyle, + InteractionManager, Linking, } from 'react-native'; import type { Theme } from '@metamask/design-tokens'; @@ -43,11 +44,15 @@ import { getTicker } from '../../../util/transactions'; import OnboardingWizard from '../../UI/OnboardingWizard'; import ErrorBoundary from '../ErrorBoundary'; import { useTheme } from '../../../util/theme'; -import { shouldShowWhatsNewModal } from '../../../util/onboarding'; +import { + shouldShowSmartTransactionsOptInModal, + shouldShowWhatsNewModal, +} from '../../../util/onboarding'; import Logger from '../../../util/Logger'; import Routes from '../../../constants/navigation/Routes'; import { getDecimalChainId, + getIsNetworkOnboarded, getNetworkImageSource, getNetworkNameFromProviderConfig, } from '../../../util/networks'; @@ -73,6 +78,7 @@ import Text, { import { useMetrics } from '../../../components/hooks/useMetrics'; import { useAccounts } from '../../hooks/useAccounts'; import { RootState } from 'app/reducers'; +import usePrevious from '../../hooks/usePrevious'; const createStyles = ({ colors, typography }: Theme) => StyleSheet.create({ @@ -163,7 +169,7 @@ const Wallet = ({ * Provider configuration for the current selected network */ const providerConfig = useSelector(selectProviderConfig); - + const prevChainId = usePrevious(providerConfig.chainId); /** * Is basic functionality enabled */ @@ -214,6 +220,13 @@ const Wallet = ({ currentToast, ]); + /** + * Network onboarding state + */ + const networkOnboardingState = useSelector( + (state: any) => state.networkOnboarded.networkOnboardedState, + ); + /** * An object representing the currently selected account. */ @@ -267,13 +280,22 @@ const Wallet = ({ const { colors: themeColors } = useTheme(); /** - * Check to see if we need to show What's New modal + * Check to see if we need to show What's New modal and Smart Transactions Opt In modal */ useEffect(() => { - if (wizardStep > 0) { - // Do not check since it will conflict with the onboarding wizard + const networkOnboarded = getIsNetworkOnboarded( + providerConfig.chainId, + networkOnboardingState, + ); + + if ( + wizardStep > 0 || + (!networkOnboarded && prevChainId !== providerConfig.chainId) + ) { + // Do not check since it will conflict with the onboarding wizard and/or network onboarding return; } + const checkWhatsNewModal = async () => { try { const shouldShowWhatsNew = await shouldShowWhatsNewModal(); @@ -286,8 +308,42 @@ const Wallet = ({ Logger.log(error, "Error while checking What's New modal!"); } }; - checkWhatsNewModal(); - }, [wizardStep, navigation]); + + // Show STX opt in modal before What's New modal + // Fired on the first load of the wallet and also on network switch + const checkSmartTransactionsOptInModal = async () => { + try { + const showShowStxOptInModal = + await shouldShowSmartTransactionsOptInModal( + providerConfig.chainId, + providerConfig.rpcUrl, + ); + if (showShowStxOptInModal) { + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.MODAL.SMART_TRANSACTIONS_OPT_IN, + }); + } else { + await checkWhatsNewModal(); + } + } catch (error) { + Logger.log( + error, + 'Error while checking Smart Tranasctions Opt In modal!', + ); + } + }; + + InteractionManager.runAfterInteractions(() => { + checkSmartTransactionsOptInModal(); + }); + }, [ + wizardStep, + navigation, + providerConfig.chainId, + providerConfig.rpcUrl, + networkOnboardingState, + prevChainId, + ]); useEffect( () => { diff --git a/app/components/Views/confirmations/Approval/index.js b/app/components/Views/confirmations/Approval/index.js index 88051521fd0..18925594446 100644 --- a/app/components/Views/confirmations/Approval/index.js +++ b/app/components/Views/confirmations/Approval/index.js @@ -42,6 +42,7 @@ import { } from '../../../../selectors/networkController'; import { selectSelectedAddress } from '../../../../selectors/preferencesController'; import { providerErrors } from '@metamask/rpc-errors'; +import { selectShouldUseSmartTransaction } from '../../../../selectors/smartTransactionsController'; import { getLedgerKeyring } from '../../../../core/Ledger/Ledger'; import ExtendedKeyringTypes from '../../../../constants/keyringTypes'; import { getBlockaidMetricsParams } from '../../../../util/blockaid'; @@ -49,6 +50,8 @@ import { getDecimalChainId } from '../../../../util/networks'; import { updateTransaction } from '../../../../util/transaction-controller'; import { withMetricsAwareness } from '../../../../components/hooks/useMetrics'; +import { STX_NO_HASH_ERROR } from '../../../../util/smart-transactions/smart-publish-hook'; +import { getSmartTransactionMetricsProperties } from '../../../../util/smart-transactions'; const REVIEW = 'review'; const EDIT = 'edit'; @@ -114,6 +117,11 @@ class Approval extends PureComponent { * Metrics injected by withMetricsAwareness HOC */ metrics: PropTypes.object, + + /** + * Boolean that indicates if smart transaction should be used + */ + shouldUseSmartTransaction: PropTypes.bool, }; state = { @@ -273,12 +281,14 @@ class Approval extends PureComponent { const { networkType, transaction: { selectedAsset, assetType }, + shouldUseSmartTransaction, } = this.props; return { view: APPROVAL, network: networkType, activeCurrency: selectedAsset.symbol || selectedAsset.contractName, assetType, + is_smart_transaction: shouldUseSmartTransaction, }; }; @@ -300,8 +310,25 @@ class Approval extends PureComponent { getAnalyticsParams = ({ gasEstimateType, gasSelected } = {}) => { try { - const { chainId, transaction, selectedAddress } = this.props; + const { + chainId, + transaction, + selectedAddress, + shouldUseSmartTransaction, + } = this.props; const { selectedAsset } = transaction; + const { TransactionController, SmartTransactionsController } = + Engine.context; + + const transactionMeta = TransactionController.getTransaction( + transaction.id, + ); + + const smartTransactionMetricsProperties = + getSmartTransactionMetricsProperties( + SmartTransactionsController, + transactionMeta, + ); return { account_type: getAddressAccountType(selectedAddress), @@ -317,6 +344,8 @@ class Approval extends PureComponent { : this.originIsWalletConnect ? AppConstants.REQUEST_SOURCES.WC : AppConstants.REQUEST_SOURCES.IN_APP_BROWSER, + is_smart_transaction: shouldUseSmartTransaction, + ...smartTransactionMetricsProperties, }; } catch (error) { return {}; @@ -400,6 +429,7 @@ class Approval extends PureComponent { transaction: { assetType, selectedAsset }, showCustomNonce, chainId, + shouldUseSmartTransaction, } = this.props; let { transaction } = this.props; const { nonce } = transaction; @@ -435,6 +465,12 @@ class Approval extends PureComponent { }); } + // For STX, don't wait for TxController to get finished event, since it will take some time to get hash for STX + if (shouldUseSmartTransaction) { + this.setState({ transactionHandled: true }); + this.props.hideModal(); + } + TransactionController.hub.once( `${transaction.id}:finished`, (transactionMeta) => { @@ -489,12 +525,17 @@ class Approval extends PureComponent { this.props.hideModal(); return; } + await ApprovalController.accept(transaction.id, undefined, { waitForResult: true, }); + this.showWalletConnectNotification(true); } catch (error) { - if (!error?.message.startsWith(KEYSTONE_TX_CANCELED)) { + if ( + !error?.message.startsWith(KEYSTONE_TX_CANCELED) && + !error?.message.startsWith(STX_NO_HASH_ERROR) + ) { Alert.alert( strings('transactions.transaction_error'), error && error.message, @@ -513,6 +554,7 @@ class Approval extends PureComponent { } this.setState({ transactionHandled: false }); } + this.props.metrics.trackEvent( MetaMetricsEvents.DAPP_TRANSACTION_COMPLETED, { @@ -651,6 +693,7 @@ const mapStateToProps = (state) => ({ showCustomNonce: state.settings.showCustomNonce, chainId: selectChainId(state), activeTabUrl: getActiveTabUrl(state), + shouldUseSmartTransaction: selectShouldUseSmartTransaction(state), }); const mapDispatchToProps = (dispatch) => ({ diff --git a/app/components/Views/confirmations/ApproveView/Approve/index.js b/app/components/Views/confirmations/ApproveView/Approve/index.js index 7d4f5a9008f..7f5459a12b3 100644 --- a/app/components/Views/confirmations/ApproveView/Approve/index.js +++ b/app/components/Views/confirmations/ApproveView/Approve/index.js @@ -75,6 +75,8 @@ import { updateTransaction } from '../../../../../util/transaction-controller'; import { withMetricsAwareness } from '../../../../../components/hooks/useMetrics'; import { selectGasFeeEstimates } from '../../../../../selectors/confirmTransaction'; import { selectGasFeeControllerEstimateType } from '../../../../../selectors/gasFeeController'; +import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; +import { STX_NO_HASH_ERROR } from '../../../../../util/smart-transactions/smart-publish-hook'; const EDIT = 'edit'; const REVIEW = 'review'; @@ -171,6 +173,10 @@ class Approve extends PureComponent { * Metrics injected by withMetricsAwareness HOC */ metrics: PropTypes.object, + /** + * Boolean that indicates if smart transaction should be used + */ + shouldUseSmartTransaction: PropTypes.bool, }; state = { @@ -500,7 +506,13 @@ class Approve extends PureComponent { onConfirm = async () => { const { TransactionController, KeyringController, ApprovalController } = Engine.context; - const { transactions, gasEstimateType, metrics, chainId } = this.props; + const { + transactions, + gasEstimateType, + metrics, + chainId, + shouldUseSmartTransaction, + } = this.props; const { legacyGasTransaction, transactionConfirmed, @@ -573,16 +585,23 @@ class Approve extends PureComponent { this.props.hideModal(); return; } + await ApprovalController.accept(transaction.id, undefined, { - waitForResult: true, + waitForResult: !shouldUseSmartTransaction, }); + if (shouldUseSmartTransaction) { + this.props.hideModal(); + } metrics.trackEvent( MetaMetricsEvents.APPROVAL_COMPLETED, this.getAnalyticsParams(), ); } catch (error) { - if (!error?.message.startsWith(KEYSTONE_TX_CANCELED)) { + if ( + !error?.message.startsWith(KEYSTONE_TX_CANCELED) && + !error?.message.startsWith(STX_NO_HASH_ERROR) + ) { Alert.alert( strings('transactions.transaction_error'), error && error.message, @@ -763,6 +782,7 @@ class Approve extends PureComponent { } if (!transaction.id) return null; + return ( ({ providerType: selectProviderType(state), providerRpcTarget: selectRpcUrl(state), networkConfigurations: selectNetworkConfigurations(state), + shouldUseSmartTransaction: selectShouldUseSmartTransaction(state), }); const mapDispatchToProps = (dispatch) => ({ diff --git a/app/components/Views/confirmations/Send/index.js b/app/components/Views/confirmations/Send/index.js index e7d5bfebc0b..43c96be83ab 100644 --- a/app/components/Views/confirmations/Send/index.js +++ b/app/components/Views/confirmations/Send/index.js @@ -63,6 +63,8 @@ import { } from '../../../../selectors/preferencesController'; import { providerErrors } from '@metamask/rpc-errors'; import { withMetricsAwareness } from '../../../../components/hooks/useMetrics'; +import { selectShouldUseSmartTransaction } from '../../../../selectors/smartTransactionsController'; +import { STX_NO_HASH_ERROR } from '../../../../util/smart-transactions/smart-publish-hook'; const REVIEW = 'review'; const EDIT = 'edit'; @@ -155,6 +157,10 @@ class Send extends PureComponent { * Metrics injected by withMetricsAwareness HOC */ metrics: PropTypes.object, + /** + * Boolean that indicates if smart transaction should be used + */ + shouldUseSmartTransaction: PropTypes.bool, }; state = { @@ -614,7 +620,10 @@ class Send extends PureComponent { this.removeNft(); }); } catch (error) { - if (!error?.message.startsWith(KEYSTONE_TX_CANCELED)) { + if ( + !error?.message.startsWith(KEYSTONE_TX_CANCELED) && + !error?.message.startsWith(STX_NO_HASH_ERROR) + ) { Alert.alert( strings('transactions.transaction_error'), error && error.message, @@ -689,6 +698,7 @@ class Send extends PureComponent { networkType, transaction, transaction: { selectedAsset, assetType }, + shouldUseSmartTransaction, } = this.props; return { @@ -700,6 +710,7 @@ class Send extends PureComponent { 'ETH', assetType, ...getBlockaidTransactionMetricsParams(transaction), + is_smart_transaction: shouldUseSmartTransaction, }; }; @@ -776,6 +787,7 @@ const mapStateToProps = (state) => ({ selectedAddress: selectSelectedAddress(state), dappTransactionModalVisible: state.modals.dappTransactionModalVisible, tokenList: selectTokenList(state), + shouldUseSmartTransaction: selectShouldUseSmartTransaction(state), }); const mapDispatchToProps = (dispatch) => ({ diff --git a/app/components/Views/confirmations/Send/index.test.tsx b/app/components/Views/confirmations/Send/index.test.tsx index 82c6bd146f8..81ec9ecff4a 100644 --- a/app/components/Views/confirmations/Send/index.test.tsx +++ b/app/components/Views/confirmations/Send/index.test.tsx @@ -132,6 +132,11 @@ const initialState = { internalTransactions: [], swapsTransactions: {}, }, + SmartTransactionsController: { + smartTransactionsState: { + liveness: true, + }, + }, GasFeeController: { gasFeeEstimates: {}, estimatedGasFeeTimeBounds: {}, diff --git a/app/components/Views/confirmations/SendFlow/Confirm/index.js b/app/components/Views/confirmations/SendFlow/Confirm/index.js index 56def0e00fd..ea6b95e0b5a 100644 --- a/app/components/Views/confirmations/SendFlow/Confirm/index.js +++ b/app/components/Views/confirmations/SendFlow/Confirm/index.js @@ -119,6 +119,9 @@ import { withMetricsAwareness } from '../../../../../components/hooks/useMetrics import { selectTransactionGasFeeEstimates } from '../../../../../selectors/confirmTransaction'; import { selectGasFeeControllerEstimateType } from '../../../../../selectors/gasFeeController'; import { updateTransaction } from '../../../../../util/transaction-controller'; +import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; +import { STX_NO_HASH_ERROR } from '../../../../../util/smart-transactions/smart-publish-hook'; +import { getSmartTransactionMetricsProperties } from '../../../../../util/smart-transactions'; const EDIT = 'edit'; const EDIT_NONCE = 'edit_nonce'; @@ -240,6 +243,10 @@ class Confirm extends PureComponent { * Set transaction ID */ setTransactionId: PropTypes.func, + /** + * Boolean that indicates if smart transaction should be used + */ + shouldUseSmartTransaction: PropTypes.bool, }; state = { @@ -279,10 +286,22 @@ class Confirm extends PureComponent { setProposedNonce(proposedNonce); }; - getAnalyticsParams = () => { + getAnalyticsParams = (transactionMeta) => { try { - const { selectedAsset, gasEstimateType, chainId } = this.props; + const { + selectedAsset, + gasEstimateType, + chainId, + shouldUseSmartTransaction, + } = this.props; const { gasSelected, fromSelectedAddress } = this.state; + const { SmartTransactionsController } = Engine.context; + + const smartTransactionMetricsProperties = + getSmartTransactionMetricsProperties( + SmartTransactionsController, + transactionMeta, + ); return { active_currency: { value: selectedAsset?.symbol, anonymous: true }, @@ -296,6 +315,9 @@ class Confirm extends PureComponent { : this.originIsWalletConnect ? AppConstants.REQUEST_SOURCES.WC : AppConstants.REQUEST_SOURCES.IN_APP_BROWSER, + + is_smart_transaction: shouldUseSmartTransaction, + ...smartTransactionMetricsProperties, }; } catch (error) { return {}; @@ -810,6 +832,7 @@ class Confirm extends PureComponent { navigation, resetTransaction, gasEstimateType, + shouldUseSmartTransaction, } = this.props; const { @@ -874,9 +897,18 @@ class Confirm extends PureComponent { } await KeyringController.resetQRKeyringState(); - await ApprovalController.accept(transactionMeta.id, undefined, { - waitForResult: true, - }); + + if (shouldUseSmartTransaction) { + await ApprovalController.accept(transactionMeta.id, undefined, { + waitForResult: false, + }); + navigation && navigation.dangerouslyGetParent()?.pop(); + } else { + await ApprovalController.accept(transactionMeta.id, undefined, { + waitForResult: true, + }); + } + await new Promise((resolve) => resolve(result)); if (transactionMeta.error) { @@ -892,16 +924,23 @@ class Confirm extends PureComponent { this.props.metrics.trackEvent( MetaMetricsEvents.SEND_TRANSACTION_COMPLETED, { - ...this.getAnalyticsParams(), + ...this.getAnalyticsParams(transactionMeta), ...getBlockaidTransactionMetricsParams(transaction), }, ); stopGasPolling(); resetTransaction(); - navigation && navigation.dangerouslyGetParent()?.pop(); + + if (!shouldUseSmartTransaction) { + // We popped it already earlier + navigation && navigation.dangerouslyGetParent()?.pop(); + } }); } catch (error) { - if (!error?.message.startsWith(KEYSTONE_TX_CANCELED)) { + if ( + !error?.message.startsWith(KEYSTONE_TX_CANCELED) && + !error?.message.startsWith(STX_NO_HASH_ERROR) + ) { Alert.alert( strings('transactions.transaction_error'), error && error.message, @@ -1173,6 +1212,7 @@ class Confirm extends PureComponent { chainId, gasEstimateType, isNativeTokenBuySupported, + shouldUseSmartTransaction, } = this.props; const { nonce } = this.props.transaction; const { @@ -1303,7 +1343,7 @@ class Confirm extends PureComponent { updateGasState={this.updateGasState} /> )} - {showCustomNonce && ( + {showCustomNonce && !shouldUseSmartTransaction && ( this.toggleConfirmationModal(EDIT_NONCE)} @@ -1407,6 +1447,7 @@ const mapStateToProps = (state) => ({ selectChainId(state), getRampNetworks(state), ), + shouldUseSmartTransaction: selectShouldUseSmartTransaction(state), }); const mapDispatchToProps = (dispatch) => ({ diff --git a/app/components/Views/confirmations/components/Approval/TemplateConfirmation/Templates/SmartTransactionStatus.ts b/app/components/Views/confirmations/components/Approval/TemplateConfirmation/Templates/SmartTransactionStatus.ts new file mode 100644 index 00000000000..a4b68f9c70d --- /dev/null +++ b/app/components/Views/confirmations/components/Approval/TemplateConfirmation/Templates/SmartTransactionStatus.ts @@ -0,0 +1,40 @@ +import { ApprovalRequest } from '@metamask/approval-controller'; +import { Actions } from '../TemplateConfirmation'; +import { ConfirmationTemplateValues, ConfirmationTemplate } from '.'; + +function getValues( + pendingApproval: ApprovalRequest, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + strings: (key: string, params?: Record) => string, + actions: Actions, +): ConfirmationTemplateValues { + return { + content: [ + { + key: 'smart-transaction-status', + // Added component to safe list: app/components/UI/TemplateRenderer/SafeComponentList.ts + // app/components/Views/SmartTransactionStatus/smart-transaction-status.tsx + element: 'SmartTransactionStatus', + props: { + requestState: pendingApproval.requestState, + origin: pendingApproval.origin, + onConfirm: actions.onConfirm, + }, + }, + ], + onConfirm: () => actions.onConfirm, + onCancel: () => { + // Need to stub out onCancel, otherwise the status modal will dismiss once the tx is complete + // This is called when the stx is done for some reason, ALSO called when user swipes down + // Cannot do onConfirm(), it will dismiss the status modal after tx complete, we want to keep it up after success + }, + hideCancelButton: true, + hideSubmitButton: true, + }; +} + +const smartTransactionStatus: ConfirmationTemplate = { + getValues, +}; + +export default smartTransactionStatus; diff --git a/app/components/Views/confirmations/components/Approval/TemplateConfirmation/Templates/index.ts b/app/components/Views/confirmations/components/Approval/TemplateConfirmation/Templates/index.ts index c1971934865..1bd52541bd7 100644 --- a/app/components/Views/confirmations/components/Approval/TemplateConfirmation/Templates/index.ts +++ b/app/components/Views/confirmations/components/Approval/TemplateConfirmation/Templates/index.ts @@ -1,5 +1,6 @@ import { omit, pick } from 'lodash'; import approvalResult from './ApprovalResult'; +import smartTransactionStatus from './SmartTransactionStatus'; import { ApprovalTypes } from '../../../../../../../core/RPCMethods/RPCMethodMiddleware'; import { Actions } from '../TemplateConfirmation'; import { Colors } from '../../../../../../../util/theme/models'; @@ -29,6 +30,7 @@ export interface ConfirmationTemplate { const APPROVAL_TEMPLATES: { [key: string]: ConfirmationTemplate } = { [ApprovalTypes.RESULT_SUCCESS]: approvalResult, [ApprovalTypes.RESULT_ERROR]: approvalResult, + [ApprovalTypes.SMART_TRANSACTION_STATUS]: smartTransactionStatus, }; export const TEMPLATED_CONFIRMATION_APPROVAL_TYPES = diff --git a/app/components/Views/confirmations/components/ApproveTransactionReview/index.js b/app/components/Views/confirmations/components/ApproveTransactionReview/index.js index 10c6a10ae8f..0d27e8d0bfd 100644 --- a/app/components/Views/confirmations/components/ApproveTransactionReview/index.js +++ b/app/components/Views/confirmations/components/ApproveTransactionReview/index.js @@ -97,6 +97,7 @@ import { ResultType } from '../BlockaidBanner/BlockaidBanner.types'; import TransactionBlockaidBanner from '../TransactionBlockaidBanner/TransactionBlockaidBanner'; import { regex } from '../../../../../util/regex'; import { withMetricsAwareness } from '../../../../../components/hooks/useMetrics'; +import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; const { ORIGIN_DEEPLINK, ORIGIN_QR_CODE } = AppConstants.DEEPLINKS; const POLLING_INTERVAL_ESTIMATED_L1_FEE = 30000; @@ -275,6 +276,10 @@ class ApproveTransactionReview extends PureComponent { * Metrics injected by withMetricsAwareness HOC */ metrics: PropTypes.object, + /** + * Boolean that indicates if smart transaction should be used + */ + shouldUseSmartTransaction: PropTypes.bool, }; state = { @@ -512,7 +517,12 @@ class ApproveTransactionReview extends PureComponent { getAnalyticsParams = () => { try { - const { chainId, transaction, onSetAnalyticsParams } = this.props; + const { + chainId, + transaction, + onSetAnalyticsParams, + shouldUseSmartTransaction, + } = this.props; const { token: { tokenSymbol }, originalApproveAmount, @@ -538,6 +548,7 @@ class ApproveTransactionReview extends PureComponent { : this.originIsWalletConnect ? AppConstants.REQUEST_SOURCES.WC : AppConstants.REQUEST_SOURCES.IN_APP_BROWSER, + is_smart_transaction: shouldUseSmartTransaction, }; // Send analytics params to parent component so it's available when cancelling and confirming onSetAnalyticsParams && onSetAnalyticsParams(params); @@ -1274,6 +1285,7 @@ const mapStateToProps = (state) => ({ selectChainId(state), getRampNetworks(state), ), + shouldUseSmartTransaction: selectShouldUseSmartTransaction(state), }); const mapDispatchToProps = (dispatch) => ({ diff --git a/app/components/Views/confirmations/components/TransactionReview/TransactionReviewInformation/index.js b/app/components/Views/confirmations/components/TransactionReview/TransactionReviewInformation/index.js index 4c6ea5aa446..a7c565fb357 100644 --- a/app/components/Views/confirmations/components/TransactionReview/TransactionReviewInformation/index.js +++ b/app/components/Views/confirmations/components/TransactionReview/TransactionReviewInformation/index.js @@ -62,6 +62,7 @@ import { isNetworkRampNativeTokenSupported } from '../../../../../../components/ import { getRampNetworks } from '../../../../../../reducers/fiatOrders'; import Routes from '../../../../../../constants/navigation/Routes'; import { withMetricsAwareness } from '../../../../../../components/hooks/useMetrics'; +import { selectShouldUseSmartTransaction } from '../../../../../../selectors/smartTransactionsController'; const createStyles = (colors) => StyleSheet.create({ @@ -236,6 +237,10 @@ class TransactionReviewInformation extends PureComponent { * Metrics injected by withMetricsAwareness HOC */ metrics: PropTypes.object, + /** + * Boolean that indicates if smart transaction should be used + */ + shouldUseSmartTransaction: PropTypes.bool, }; state = { @@ -652,6 +657,7 @@ class TransactionReviewInformation extends PureComponent { gasEstimateType, gasSelected, isNativeTokenBuySupported, + shouldUseSmartTransaction, } = this.props; const { nonce } = this.props.transaction; const colors = this.context.colors || mockTheme.colors; @@ -680,7 +686,7 @@ class TransactionReviewInformation extends PureComponent { warningMessage={strings('edit_gas_fee_eip1559.low_fee_warning')} /> )} - {showCustomNonce && ( + {showCustomNonce && !shouldUseSmartTransaction && ( )} {!!amountError && ( @@ -746,6 +752,7 @@ const mapStateToProps = (state) => ({ selectChainId(state), getRampNetworks(state), ), + shouldUseSmartTransaction: selectShouldUseSmartTransaction(state), }); const mapDispatchToProps = (dispatch) => ({ diff --git a/app/components/Views/confirmations/components/TransactionReview/index.js b/app/components/Views/confirmations/components/TransactionReview/index.js index 406b3609a08..d9166f01872 100644 --- a/app/components/Views/confirmations/components/TransactionReview/index.js +++ b/app/components/Views/confirmations/components/TransactionReview/index.js @@ -57,6 +57,7 @@ import AppConstants from '../../../../../core/AppConstants'; import TransactionBlockaidBanner from '../TransactionBlockaidBanner/TransactionBlockaidBanner'; import { ResultType } from '../BlockaidBanner/BlockaidBanner.types'; import { withMetricsAwareness } from '../../../../../components/hooks/useMetrics'; +import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; const POLLING_INTERVAL_ESTIMATED_L1_FEE = 30000; @@ -247,6 +248,10 @@ class TransactionReview extends PureComponent { * Metrics injected by withMetricsAwareness HOC */ metrics: PropTypes.object, + /** + * Boolean that indicates if smart transaction should be used + */ + shouldUseSmartTransaction: PropTypes.bool, }; state = { @@ -292,6 +297,7 @@ class TransactionReview extends PureComponent { chainId, tokenList, metrics, + shouldUseSmartTransaction, } = this.props; let { showHexData } = this.props; let assetAmount, conversionRate, fiatValue; @@ -323,7 +329,9 @@ class TransactionReview extends PureComponent { approveTransaction, }); - metrics.trackEvent(MetaMetricsEvents.TRANSACTIONS_CONFIRM_STARTED); + metrics.trackEvent(MetaMetricsEvents.TRANSACTIONS_CONFIRM_STARTED, { + is_smart_transaction: shouldUseSmartTransaction, + }); if (isMultiLayerFeeNetwork(chainId)) { this.fetchEstimatedL1Fee(); @@ -656,6 +664,7 @@ const mapStateToProps = (state) => ({ browser: state.browser, primaryCurrency: state.settings.primaryCurrency, tokenList: selectTokenList(state), + shouldUseSmartTransaction: selectShouldUseSmartTransaction(state), }); TransactionReview.contextType = ThemeContext; diff --git a/app/constants/storage.ts b/app/constants/storage.ts index 2f79734ae6c..77a41920c77 100644 --- a/app/constants/storage.ts +++ b/app/constants/storage.ts @@ -53,7 +53,6 @@ export const LAST_APP_VERSION = `${prefix}LastAppVersion`; export const CURRENT_APP_VERSION = `${prefix}CurrentAppVersion`; export const WHATS_NEW_APP_VERSION_SEEN = `${prefix}WhatsNewAppVersionSeen`; -export const SMART_TRANSACTIONS_OPT_IN_MODAL_APP_VERSION_SEEN = `${prefix}SmartTransactionsOptInModalAppVersionSeen`; export const REVIEW_EVENT_COUNT = 'reviewEventCount'; diff --git a/app/core/AppConstants.ts b/app/core/AppConstants.ts index 29964c1815f..717f4931466 100644 --- a/app/core/AppConstants.ts +++ b/app/core/AppConstants.ts @@ -121,7 +121,8 @@ export default { PRIVACY_POLICY_2024: 'https://consensys.io/privacy-policy', PRIVACY_BEST_PRACTICES: 'https://support.metamask.io/privacy-and-security/privacy-best-practices', - SMART_TXS: 'https://support.metamask.io/hc/en-us/articles/9184393821211', + SMART_TXS: + 'https://support.metamask.io/transactions-and-gas/transactions/smart-transactions/', }, ERRORS: { INFURA_BLOCKED_MESSAGE: diff --git a/app/core/Engine.test.js b/app/core/Engine.test.js index c66c1a6d54e..c801685c83d 100644 --- a/app/core/Engine.test.js +++ b/app/core/Engine.test.js @@ -23,6 +23,8 @@ describe('Engine', () => { expect(engine.context).toHaveProperty('TokenRatesController'); expect(engine.context).toHaveProperty('TokensController'); expect(engine.context).toHaveProperty('LoggingController'); + expect(engine.context).toHaveProperty('TransactionController'); + expect(engine.context).toHaveProperty('SmartTransactionsController'); }); it('calling Engine.init twice returns the same instance', () => { @@ -55,7 +57,39 @@ describe('Engine', () => { }, }; - expect(backgroundState).toStrictEqual(initialState); + expect(backgroundState).toStrictEqual({ + ...initialState, + + // JSON cannot store the value undefined, so we append it here + SmartTransactionsController: { + smartTransactionsState: { + fees: { + approvalTxFees: undefined, + tradeTxFees: undefined, + }, + feesByChainId: { + '0x1': { + approvalTxFees: undefined, + tradeTxFees: undefined, + }, + '0xaa36a7': { + approvalTxFees: undefined, + tradeTxFees: undefined, + }, + }, + liveness: true, + livenessByChainId: { + '0x1': true, + '0xaa36a7': true, + }, + smartTransactions: { + '0x1': [], + }, + userOptIn: undefined, + userOptInV2: undefined, + }, + }, + }); }); it('setSelectedAccount throws an error if no account exists for the given address', () => { diff --git a/app/core/Engine.ts b/app/core/Engine.ts index 1a3106fee1b..709b9aff853 100644 --- a/app/core/Engine.ts +++ b/app/core/Engine.ts @@ -194,6 +194,13 @@ import { networkIdUpdated, networkIdWillUpdate, } from '../core/redux/slices/inpageProvider'; +import SmartTransactionsController from '@metamask/smart-transactions-controller'; +import { NETWORKS_CHAIN_ID } from '../../app/constants/network'; +import { selectShouldUseSmartTransaction } from '../selectors/smartTransactionsController'; +import { selectSwapsChainFeatureFlags } from '../reducers/swaps'; +import { SmartTransactionStatuses } from '@metamask/smart-transactions-controller/dist/types'; +import { submitSmartTransactionHook } from '../util/smart-transactions/smart-publish-hook'; +import { SmartTransactionsControllerState } from '@metamask/smart-transactions-controller/dist/SmartTransactionsController'; const NON_EMPTY = 'NON_EMPTY'; @@ -280,6 +287,7 @@ export interface EngineState { TokenBalancesController: TokenBalancesControllerState; TokenRatesController: TokenRatesState; TransactionController: TransactionState; + SmartTransactionsController: SmartTransactionsControllerState; SwapsController: SwapsState; GasFeeController: GasFeeState; TokensController: TokensState; @@ -324,6 +332,7 @@ interface Controllers { TokenRatesController: TokenRatesController; TokensController: TokensController; TransactionController: TransactionController; + SmartTransactionsController: SmartTransactionsController; SignatureController: SignatureController; ///: BEGIN:ONLY_INCLUDE_IF(snaps) SnapController: SnapController; @@ -378,6 +387,9 @@ class Engine { ///: END:ONLY_INCLUDE_IF + transactionController: TransactionController; + smartTransactionsController: SmartTransactionsController; + /** * Creates a CoreController instance */ @@ -960,8 +972,131 @@ class Engine { preinstalledSnaps: PREINSTALLED_SNAPS, }); ///: END:ONLY_INCLUDE_IF + + this.transactionController = new TransactionController({ + // @ts-expect-error at this point in time the provider will be defined by the `networkController.initializeProvider` + blockTracker: networkController.getProviderAndBlockTracker().blockTracker, + disableSendFlowHistory: true, + disableHistory: true, + getGasFeeEstimates: () => gasFeeController.fetchGasFeeEstimates(), + getCurrentNetworkEIP1559Compatibility: + networkController.getEIP1559Compatibility.bind(networkController), + //@ts-expect-error Expected due to Transaction Controller do not have controller utils containing linea-sepolia data + // This can be removed when controller-utils be updated to v^9 + getNetworkState: () => networkController.state, + getSelectedAddress: () => accountsController.getSelectedAccount().address, + incomingTransactions: { + isEnabled: () => { + const currentHexChainId = + networkController.state.providerConfig.chainId; + + const showIncomingTransactions = + preferencesController?.state?.showIncomingTransactions; + + return Boolean( + hasProperty(showIncomingTransactions, currentChainId) && + showIncomingTransactions?.[currentHexChainId], + ); + }, + updateTransactions: true, + }, + // @ts-expect-error TODO: Resolve/patch mismatch between base-controller versions. Before: never, never. Now: string, string, which expects 3rd and 4th args to be informed for restrictedControllerMessengers + messenger: this.controllerMessenger.getRestricted({ + name: 'TransactionController', + allowedActions: [`${approvalController.name}:addRequest`], + }), + onNetworkStateChange: (listener) => + this.controllerMessenger.subscribe( + AppConstants.NETWORK_STATE_CHANGE_EVENT, + //@ts-expect-error This is because the network type is still missing linea-sepolia + // When transaction controller be updated to v^25 + listener, + ), + // @ts-expect-error at this point in time the provider will be defined by the `networkController.initializeProvider` + provider: networkController.getProviderAndBlockTracker().provider, + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + getExternalPendingTransactions: (address: string) => + this.smartTransactionsController.getTransactions({ + addressFrom: address, + status: SmartTransactionStatuses.PENDING, + }), + + hooks: { + publish: (transactionMeta) => { + const shouldUseSmartTransaction = selectShouldUseSmartTransaction( + store.getState(), + ); + + return submitSmartTransactionHook({ + transactionMeta, + transactionController: this.transactionController, + smartTransactionsController: this.smartTransactionsController, + shouldUseSmartTransaction, + approvalController, + featureFlags: selectSwapsChainFeatureFlags(store.getState()), + }); + }, + }, + }); + const codefiTokenApiV2 = new CodefiTokenPricesServiceV2(); + const smartTransactionsControllerTrackMetaMetricsEvent = (params: { + event: string; + category: string; + sensitiveProperties: any; + }) => { + const { event, category, ...restParams } = params; + + MetaMetrics.getInstance().trackEvent( + { + category, + properties: { + name: event, + }, + }, + restParams, + ); + }; + this.smartTransactionsController = new SmartTransactionsController( + { + confirmExternalTransaction: + this.transactionController.confirmExternalTransaction.bind( + this.transactionController, + ), + // @ts-expect-error this fine, STX controller has been downgraded to network controller v15 + getNetworkClientById: + networkController.getNetworkClientById.bind(networkController), + getNonceLock: this.transactionController.getNonceLock.bind( + this.transactionController, + ), + // @ts-expect-error txController.getTransactions only uses txMeta.status and txMeta.hash, which v13 TxController has + getTransactions: this.transactionController.getTransactions.bind( + this.transactionController, + ), + onNetworkStateChange: (listener) => + this.controllerMessenger.subscribe( + AppConstants.NETWORK_STATE_CHANGE_EVENT, + listener, + ), + + provider: networkController.getProviderAndBlockTracker() + .provider as any, + + trackMetaMetricsEvent: smartTransactionsControllerTrackMetaMetricsEvent, + }, + { + supportedChainIds: [ + NETWORKS_CHAIN_ID.MAINNET, + NETWORKS_CHAIN_ID.GOERLI, + NETWORKS_CHAIN_ID.SEPOLIA, + ], + }, + initialState.SmartTransactionsController, + ); + const controllers: Controllers[keyof Controllers][] = [ keyringController, accountTrackerController, @@ -1062,50 +1197,8 @@ class Engine { getNetworkClientById: networkController.getNetworkClientById.bind(networkController), }), - new TransactionController({ - // @ts-expect-error at this point in time the provider will be defined by the `networkController.initializeProvider` - blockTracker: - networkController.getProviderAndBlockTracker().blockTracker, - disableSendFlowHistory: true, - disableHistory: true, - getGasFeeEstimates: () => gasFeeController.fetchGasFeeEstimates(), - getCurrentNetworkEIP1559Compatibility: - networkController.getEIP1559Compatibility.bind(networkController), - //@ts-expect-error Expected due to Transaction Controller do not have controller utils containing linea-sepolia data - // This can be removed when controller-utils be updated to v^9 - getNetworkState: () => networkController.state, - getSelectedAddress: () => - accountsController.getSelectedAccount().address, - incomingTransactions: { - isEnabled: () => { - const currentHexChainId = - networkController.state.providerConfig.chainId; - - const showIncomingTransactions = - preferencesController?.state?.showIncomingTransactions; - - return Boolean( - hasProperty(showIncomingTransactions, currentChainId) && - showIncomingTransactions?.[currentHexChainId], - ); - }, - updateTransactions: true, - }, - // @ts-expect-error TODO: Resolve/patch mismatch between base-controller versions. Before: never, never. Now: string, string, which expects 3rd and 4th args to be informed for restrictedControllerMessengers - messenger: this.controllerMessenger.getRestricted({ - name: 'TransactionController', - allowedActions: [`${approvalController.name}:addRequest`], - }), - onNetworkStateChange: (listener) => - this.controllerMessenger.subscribe( - AppConstants.NETWORK_STATE_CHANGE_EVENT, - //@ts-expect-error This is because the network type is still missing linea-sepolia - // When transaction controller be updated to v^25 - listener, - ), - // @ts-expect-error at this point in time the provider will be defined by the `networkController.initializeProvider` - provider: networkController.getProviderAndBlockTracker().provider, - }), + this.transactionController, + this.smartTransactionsController, new SwapsController( { // @ts-expect-error TODO: Resolve mismatch between gas fee and swaps controller types @@ -1633,6 +1726,7 @@ export default { TokenBalancesController, TokenRatesController, TransactionController, + SmartTransactionsController, SwapsController, GasFeeController, TokensController, @@ -1674,6 +1768,7 @@ export default { TokenRatesController, TokensController, TransactionController, + SmartTransactionsController, SwapsController, GasFeeController, TokenDetectionController, diff --git a/app/core/EngineService/EngineService.test.ts b/app/core/EngineService/EngineService.test.ts index c028b8fd113..7e08b6d324b 100644 --- a/app/core/EngineService/EngineService.test.ts +++ b/app/core/EngineService/EngineService.test.ts @@ -52,6 +52,7 @@ jest.mock('../Engine', () => { TokenBalancesController: { subscribe: jest.fn() }, TokenRatesController: { subscribe: jest.fn() }, TransactionController: { subscribe: jest.fn() }, + SmartTransactionsController: { subscribe: jest.fn() }, SwapsController: { subscribe: jest.fn() }, TokenListController: { subscribe: jest.fn() }, CurrencyRateController: { subscribe: jest.fn() }, diff --git a/app/core/EngineService/EngineService.ts b/app/core/EngineService/EngineService.ts index e9f87fad5e3..67062a4e1ed 100644 --- a/app/core/EngineService/EngineService.ts +++ b/app/core/EngineService/EngineService.ts @@ -69,6 +69,7 @@ class EngineService { }, { name: 'TokenRatesController' }, { name: 'TransactionController' }, + { name: 'SmartTransactionsController' }, { name: 'SwapsController' }, { name: 'TokenListController', diff --git a/app/core/redux/slices/smartTransactions/index.test.ts b/app/core/redux/slices/smartTransactions/index.test.ts new file mode 100644 index 00000000000..f97ff4436a6 --- /dev/null +++ b/app/core/redux/slices/smartTransactions/index.test.ts @@ -0,0 +1,25 @@ +import reducer, { + updateOptInModalAppVersionSeen, + SmartTransactionsState, +} from '.'; + +describe('smartTransactions slice', () => { + // Define the initial state for your tests + const initialState: SmartTransactionsState = { + optInModalAppVersionSeen: null, + }; + + it('should handle initial state', () => { + expect(reducer(undefined, { type: 'unknown' })).toEqual({ + optInModalAppVersionSeen: null, + }); + }); + + it('should handle updateOptInModalAppVersionSeen', () => { + const actual = reducer( + initialState, + updateOptInModalAppVersionSeen('2.0.0'), + ); + expect(actual.optInModalAppVersionSeen).toEqual('2.0.0'); + }); +}); diff --git a/app/core/redux/slices/smartTransactions/index.ts b/app/core/redux/slices/smartTransactions/index.ts new file mode 100644 index 00000000000..f644375d10a --- /dev/null +++ b/app/core/redux/slices/smartTransactions/index.ts @@ -0,0 +1,34 @@ +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; + +export interface SmartTransactionsState { + optInModalAppVersionSeen: string | null; +} + +export const initialState: SmartTransactionsState = { + optInModalAppVersionSeen: null, +}; + +const name = 'smartTransactions'; + +const slice = createSlice({ + name, + initialState, + reducers: { + /** + * Updates the the app version seen for the opt in modal. + * @param state - The current state of the smartTransactions slice. + * @param action - An action with the new app version seen as payload. + */ + updateOptInModalAppVersionSeen: (state, action: PayloadAction) => { + state.optInModalAppVersionSeen = action.payload; + }, + }, +}); + +const { actions, reducer } = slice; + +export default reducer; + +// Actions / action-creators + +export const { updateOptInModalAppVersionSeen } = actions; diff --git a/app/reducers/index.ts b/app/reducers/index.ts index a298b0b51ab..610078f3001 100644 --- a/app/reducers/index.ts +++ b/app/reducers/index.ts @@ -26,6 +26,8 @@ import rpcEventReducer from './rpcEvents'; import accountsReducer from './accounts'; import sdkReducer from './sdk'; import inpageProviderReducer from '../core/redux/slices/inpageProvider'; +import smartTransactionsReducer from '../core/redux/slices/smartTransactions'; + /** * Infer state from a reducer * @@ -52,6 +54,7 @@ export interface RootState { settings: any; alert: any; transaction: any; + smartTransactions: StateFromReducer; user: any; wizard: any; onboarding: any; @@ -86,6 +89,7 @@ const rootReducer = combineReducers({ settings: settingsReducer, alert: alertReducer, transaction: transactionReducer, + smartTransactions: smartTransactionsReducer, user: userReducer, wizard: wizardReducer, onboarding: onboardingReducer, diff --git a/app/reducers/networkSelector/index.ts b/app/reducers/networkSelector/index.ts index b8a9edf85b0..bf3d2a76e2f 100644 --- a/app/reducers/networkSelector/index.ts +++ b/app/reducers/networkSelector/index.ts @@ -67,8 +67,8 @@ function networkOnboardReducer( networkUrl: '', }, networkOnboardedState: { - [action.payload]: true, ...state.networkOnboardedState, + [action.payload]: true, }, }; default: diff --git a/app/reducers/swaps/index.js b/app/reducers/swaps/index.js index 8ee2cb75b13..762072acb0e 100644 --- a/app/reducers/swaps/index.js +++ b/app/reducers/swaps/index.js @@ -7,6 +7,22 @@ import { lte } from '../../util/lodash'; import { selectChainId } from '../../selectors/networkController'; import { selectTokens } from '../../selectors/tokensController'; import { selectContractBalances } from '../../selectors/tokenBalancesController'; +import { getChainFeatureFlags, getSwapsLiveness } from './utils'; +import { allowedTestnetChainIds } from '../../components/UI/Swaps/utils'; +import { NETWORKS_CHAIN_ID } from '../../constants/network'; + +// If we are in dev and on a testnet, just use mainnet feature flags, +// since we don't have feature flags for testnets in the API +// export const getFeatureFlagChainId = (chainId: `0x${string}`) => +// __DEV__ && allowedTestnetChainIds.includes(chainId) +// ? NETWORKS_CHAIN_ID.MAINNET +// : chainId; + +// TODO remove this and restore the above when we are done QA. This is to let ppl test on sepolia +export const getFeatureFlagChainId = (chainId) => + allowedTestnetChainIds.includes(chainId) + ? NETWORKS_CHAIN_ID.MAINNET + : chainId; // * Constants export const SWAPS_SET_LIVENESS = 'SWAPS_SET_LIVENESS'; @@ -14,9 +30,9 @@ export const SWAPS_SET_HAS_ONBOARDED = 'SWAPS_SET_HAS_ONBOARDED'; const MAX_TOKENS_WITH_BALANCE = 5; // * Action Creator -export const setSwapsLiveness = (live, chainId) => ({ +export const setSwapsLiveness = (chainId, featureFlags) => ({ type: SWAPS_SET_LIVENESS, - payload: { live, chainId }, + payload: { chainId, featureFlags }, }); export const setSwapsHasOnboarded = (hasOnboarded) => ({ type: SWAPS_SET_HAS_ONBOARDED, @@ -56,6 +72,33 @@ export const swapsLivenessSelector = createSelector( (swapsState, chainId) => swapsState[chainId]?.isLive || false, ); +/** + * Returns if smart transactions are enabled in feature flags + */ +const DEVICE_KEY = 'mobileActive'; +export const swapsSmartTxFlagEnabled = createSelector( + swapsStateSelector, + (swapsState) => { + const globalFlags = swapsState.featureFlags; + + const isEnabled = Boolean( + globalFlags?.smart_transactions?.mobile_active && + globalFlags?.smartTransactions?.[DEVICE_KEY], + ); + + return isEnabled; + }, +); + +/** + * Returns the swaps feature flags + */ +export const selectSwapsChainFeatureFlags = createSelector( + swapsStateSelector, + chainIdSelector, + (swapsState, chainId) => swapsState[chainId].featureFlags, +); + /** * Returns the swaps onboarded state */ @@ -196,21 +239,52 @@ export const initialState = { isLive: true, // TODO: should we remove it? hasOnboarded: true, // TODO: Once we have updated UI / content for the modal, we should enable it again. + featureFlags: undefined, '0x1': { isLive: true, + featureFlags: undefined, }, }; function swapsReducer(state = initialState, action) { switch (action.type) { case SWAPS_SET_LIVENESS: { - const { live, chainId } = action.payload; + const { chainId: rawChainId, featureFlags } = action.payload; + const chainId = getFeatureFlagChainId(rawChainId); + const data = state[chainId]; + + const chainNoFlags = { + ...data, + featureFlags: undefined, + isLive: false, + }; + + if (!featureFlags) { + return { + ...state, + [chainId]: chainNoFlags, + [rawChainId]: chainNoFlags, + featureFlags: undefined, + }; + } + + const chainFeatureFlags = getChainFeatureFlags(featureFlags, chainId); + const liveness = getSwapsLiveness(featureFlags, chainId); + + const chain = { + ...data, + featureFlags: chainFeatureFlags, + isLive: liveness, + }; + return { ...state, - [chainId]: { - ...data, - isLive: live, + [chainId]: chain, + [rawChainId]: chain, + featureFlags: { + smart_transactions: featureFlags.smart_transactions, + smartTransactions: featureFlags.smartTransactions, }, }; } diff --git a/app/reducers/swaps/swaps.test.ts b/app/reducers/swaps/swaps.test.ts index 94fd3390d74..2ffec8be411 100644 --- a/app/reducers/swaps/swaps.test.ts +++ b/app/reducers/swaps/swaps.test.ts @@ -1,38 +1,285 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { cloneDeep } from 'lodash'; +import Device from '../../util/device'; import reducer, { initialState, SWAPS_SET_LIVENESS, SWAPS_SET_HAS_ONBOARDED, + swapsSmartTxFlagEnabled, } from './index'; const emptyAction = { type: null }; +const DEFAULT_FEATURE_FLAGS = { + ethereum: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + smartTransactions: { + expectedDeadline: 45, + maxDeadline: 150, + returnTxHashAsap: false, + }, + }, + bsc: { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + smartTransactions: {}, + }, + smart_transactions: { + mobile_active: false, + extension_active: true, + }, + smartTransactions: { + mobileActive: false, + extensionActive: true, + mobileActiveIOS: false, + mobileActiveAndroid: false, + }, +}; + describe('swaps reducer', () => { it('should return initial state', () => { const state = reducer(undefined, emptyAction); expect(state).toEqual(initialState); }); - it('should set liveness', () => { - const initalState = reducer(undefined, emptyAction); - const notLiveState = reducer(initalState, { - type: SWAPS_SET_LIVENESS, - payload: { live: false, chainId: '0x1' }, + describe('liveness', () => { + it('should set isLive to true for iOS when flag is true', () => { + Device.isIos = jest.fn().mockReturnValue(true); + Device.isAndroid = jest.fn().mockReturnValue(false); + + const initalState = reducer(undefined, emptyAction); + // @ts-ignore + const liveState = reducer(initalState, { + type: SWAPS_SET_LIVENESS, + payload: { + featureFlags: DEFAULT_FEATURE_FLAGS, + chainId: '0x1', + }, + }); + expect(liveState['0x1'].isLive).toBe(true); }); - expect(notLiveState['0x1'].isLive).toBe(false); - const liveState = reducer(initalState, { - type: SWAPS_SET_LIVENESS, - payload: { live: true, chainId: '0x1' }, + it('should set isLive to false for iOS when flag is false', () => { + Device.isIos = jest.fn().mockReturnValue(true); + Device.isAndroid = jest.fn().mockReturnValue(false); + + const initalState = reducer(undefined, emptyAction); + const featureFlags = cloneDeep(DEFAULT_FEATURE_FLAGS); + featureFlags.ethereum = { + mobile_active: false, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: false, + extensionActive: true, + mobileActiveIOS: false, + mobileActiveAndroid: true, + smartTransactions: { + expectedDeadline: 45, + maxDeadline: 150, + returnTxHashAsap: false, + }, + }; + + // @ts-ignore + const liveState = reducer(initalState, { + type: SWAPS_SET_LIVENESS, + payload: { + featureFlags, + chainId: '0x1', + }, + }); + expect(liveState['0x1'].isLive).toBe(false); + }); + it('should set isLive to true for Android when flag is true', () => { + Device.isIos = jest.fn().mockReturnValue(false); + Device.isAndroid = jest.fn().mockReturnValue(true); + + const initalState = reducer(undefined, emptyAction); + const featureFlags = cloneDeep(DEFAULT_FEATURE_FLAGS); + featureFlags.ethereum = { + mobile_active: true, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + smartTransactions: { + expectedDeadline: 45, + maxDeadline: 150, + returnTxHashAsap: false, + }, + }; + + // @ts-ignore + const liveState = reducer(initalState, { + type: SWAPS_SET_LIVENESS, + payload: { + featureFlags, + chainId: '0x1', + }, + }); + expect(liveState['0x1'].isLive).toBe(true); + }); + it('should set isLive to false for Android when flag is false', () => { + Device.isIos = jest.fn().mockReturnValue(false); + Device.isAndroid = jest.fn().mockReturnValue(true); + + const initalState = reducer(undefined, emptyAction); + const featureFlags = cloneDeep(DEFAULT_FEATURE_FLAGS); + featureFlags.ethereum = { + mobile_active: false, + extension_active: true, + fallback_to_v1: false, + fallbackToV1: false, + mobileActive: false, + extensionActive: true, + mobileActiveIOS: false, + mobileActiveAndroid: false, + smartTransactions: { + expectedDeadline: 45, + maxDeadline: 150, + returnTxHashAsap: false, + }, + }; + + // @ts-ignore + const liveState = reducer(initalState, { + type: SWAPS_SET_LIVENESS, + payload: { + featureFlags, + chainId: '0x1', + }, + }); + expect(liveState['0x1'].isLive).toBe(false); + }); + }); + + describe('swapsSmartTxFlagEnabled', () => { + it('should return true if smart transactions are enabled', () => { + const rootState = { + engine: { + backgroundState: { + NetworkController: { + providerConfig: { chainId: '0x1' }, + }, + }, + }, + swaps: cloneDeep(initialState), + }; + + rootState.swaps = { + // @ts-ignore + featureFlags: { + smart_transactions: { + mobile_active: true, + extension_active: true, + }, + smartTransactions: { + mobileActive: true, + extensionActive: true, + mobileActiveIOS: true, + mobileActiveAndroid: true, + }, + }, + '0x1': { + // @ts-ignore + featureFlags: { + smartTransactions: { + expectedDeadline: 45, + maxDeadline: 150, + returnTxHashAsap: false, + }, + }, + }, + }; + + const enabled = swapsSmartTxFlagEnabled(rootState); + expect(enabled).toEqual(true); + }); + + it('should return false if smart transactions flags are disabled', () => { + const rootState = { + engine: { + backgroundState: { + NetworkController: { + providerConfig: { chainId: '0x1' }, + }, + }, + }, + swaps: cloneDeep(initialState), + }; + + rootState.swaps = { + // @ts-ignore + featureFlags: { + smart_transactions: { + mobile_active: false, + extension_active: true, + }, + smartTransactions: { + mobileActive: false, + extensionActive: true, + mobileActiveIOS: false, + mobileActiveAndroid: false, + }, + }, + '0x1': { + // @ts-ignore + featureFlags: { + smartTransactions: { + expectedDeadline: 45, + maxDeadline: 150, + returnTxHashAsap: false, + }, + }, + }, + }; + + const enabled = swapsSmartTxFlagEnabled(rootState); + expect(enabled).toEqual(false); + }); + + it('should return false if smart transactions flags are undefined', () => { + const rootState = { + engine: { + backgroundState: { + NetworkController: { + providerConfig: { chainId: '0x1' }, + }, + }, + }, + swaps: initialState, + }; + + const enabled = swapsSmartTxFlagEnabled(rootState); + expect(enabled).toEqual(false); }); - expect(liveState['0x1'].isLive).toBe(true); }); it('should set has onboarded', () => { const initalState = reducer(undefined, emptyAction); + // @ts-ignore const notOnboardedState = reducer(initalState, { type: SWAPS_SET_HAS_ONBOARDED, payload: false, }); expect(notOnboardedState.hasOnboarded).toBe(false); + // @ts-ignore const liveState = reducer(initalState, { type: SWAPS_SET_HAS_ONBOARDED, payload: true, diff --git a/app/reducers/swaps/utils.ts b/app/reducers/swaps/utils.ts new file mode 100644 index 00000000000..3a3d7d1e002 --- /dev/null +++ b/app/reducers/swaps/utils.ts @@ -0,0 +1,48 @@ +import { FeatureFlags } from '@metamask/swaps-controller/dist/swapsInterfaces'; +import Device from '../../util/device'; +import { CHAIN_ID_TO_NAME_MAP } from '@metamask/swaps-controller/dist/constants'; + +export const getChainFeatureFlags = ( + featureFlags: FeatureFlags, + chainId: `0x${string}`, +) => { + const chainName = CHAIN_ID_TO_NAME_MAP[chainId]; + const chainFeatureFlags = featureFlags[chainName]; + return chainFeatureFlags; +}; + +type FeatureFlagDeviceKey = + | 'mobileActiveIOS' + | 'mobileActiveAndroid' + | 'mobileActive'; +export const getFeatureFlagDeviceKey: () => FeatureFlagDeviceKey = () => { + const isIphone = Device.isIos(); + const isAndroid = Device.isAndroid(); + + let featureFlagDeviceKey: FeatureFlagDeviceKey; + if (isIphone) { + featureFlagDeviceKey = 'mobileActiveIOS'; + } else if (isAndroid) { + featureFlagDeviceKey = 'mobileActiveAndroid'; + } else { + featureFlagDeviceKey = 'mobileActive'; + } + + return featureFlagDeviceKey; +}; + +export const getSwapsLiveness = ( + featureFlags: FeatureFlags, + chainId: `0x${string}`, +) => { + const chainFeatureFlags = getChainFeatureFlags(featureFlags, chainId); + const featureFlagKey = getFeatureFlagDeviceKey(); + + const liveness = + // @ts-expect-error interface mismatch + typeof featureFlagsByChainId === 'boolean' + ? chainFeatureFlags + : chainFeatureFlags?.[featureFlagKey] ?? false; + + return liveness; +}; diff --git a/app/selectors/preferencesController.ts b/app/selectors/preferencesController.ts index 8eba9a6eca4..026297b6f8e 100644 --- a/app/selectors/preferencesController.ts +++ b/app/selectors/preferencesController.ts @@ -104,3 +104,9 @@ export const selectIsSecurityAlertsEnabled = createSelector( } ).securityAlertsEnabled, ); + +export const selectSmartTransactionsOptInStatus = createSelector( + selectPreferencesControllerState, + (preferencesControllerState: PreferencesState) => + preferencesControllerState.smartTransactionsOptInStatus, +); diff --git a/app/selectors/smartTransactionsController.test.ts b/app/selectors/smartTransactionsController.test.ts new file mode 100644 index 00000000000..a69c64febaa --- /dev/null +++ b/app/selectors/smartTransactionsController.test.ts @@ -0,0 +1,125 @@ +import { + selectShouldUseSmartTransaction, + selectSmartTransactionsEnabled, +} from './smartTransactionsController'; +import initialBackgroundState from '../util/test/initial-background-state.json'; +import { isHardwareAccount } from '../util/address'; +import { cloneDeep } from 'lodash'; + +jest.mock('../util/address', () => ({ + isHardwareAccount: jest.fn(() => false), +})); + +// Default state is setup to be on mainnet, with smart transactions enabled and opted into +const getDefaultState = () => { + const defaultState: any = { + engine: { + backgroundState: cloneDeep(initialBackgroundState), + }, + swaps: { + featureFlags: { + smart_transactions: { + mobile_active: false, + extension_active: true, + }, + smartTransactions: { + mobileActive: false, + extensionActive: true, + mobileActiveIOS: false, + mobileActiveAndroid: false, + }, + }, + '0x1': { + isLive: true, + featureFlags: { + smartTransactions: { + expectedDeadline: 45, + maxDeadline: 160, + returnTxHashAsap: false, + }, + }, + }, + }, + }; + defaultState.engine.backgroundState.PreferencesController.selectedAddress = + '0xabc'; + defaultState.engine.backgroundState.NetworkController.providerConfig = { + rpcUrl: undefined, // default rpc for chain 0x1 + chainId: '0x1', + }; + defaultState.engine.backgroundState.SmartTransactionsController.smartTransactionsState.liveness = + true; + defaultState.engine.backgroundState.PreferencesController.smartTransactionsOptInStatus = + true; + + return defaultState; +}; + +describe('SmartTransactionsController Selectors', () => { + describe('getSmartTransactionsEnabled', () => { + it.each([ + ['an empty object', {}], + ['undefined', undefined], + ])( + 'should return false if smart transactions feature flags are not enabled when smartTransactions is %s', + (_testCaseName, smartTransactions) => { + const state = getDefaultState(); + state.swaps['0x1'].smartTransactions = smartTransactions; + + const enabled = selectSmartTransactionsEnabled(state); + expect(enabled).toEqual(false); + }, + ); + it('should return false if smart transactions liveness is false', () => { + const state = getDefaultState(); + state.engine.backgroundState.SmartTransactionsController.smartTransactionsState.liveness = + false; + const enabled = selectSmartTransactionsEnabled(state); + expect(enabled).toEqual(false); + }); + it('should return false if address is hardware account', () => { + (isHardwareAccount as jest.Mock).mockReturnValueOnce(true); + const state = getDefaultState(); + const enabled = selectSmartTransactionsEnabled(state); + expect(enabled).toEqual(false); + }); + it('should return false if is mainnet and not the default RPC', () => { + const state = getDefaultState(); + state.engine.backgroundState.NetworkController.providerConfig.rpcUrl = + 'https://example.com'; + const enabled = selectSmartTransactionsEnabled(state); + expect(enabled).toEqual(false); + }); + it('should return true if smart transactions are enabled', () => { + const state = getDefaultState(); + state.swaps.featureFlags.smart_transactions.mobile_active = true; + state.swaps.featureFlags.smartTransactions.mobileActive = true; + + const enabled = selectSmartTransactionsEnabled(state); + expect(enabled).toEqual(true); + }); + }); + describe('getShouldUseSmartTransaction', () => { + it('should return false if smart transactions are not opted into', () => { + const state = getDefaultState(); + state.engine.backgroundState.PreferencesController.smartTransactionsOptInStatus = + false; + const shouldUseSmartTransaction = selectShouldUseSmartTransaction(state); + expect(shouldUseSmartTransaction).toEqual(false); + }); + it('should return false if smart transactions are not enabled', () => { + const state = getDefaultState(); + state.swaps['0x1'].smartTransactions = {}; + const shouldUseSmartTransaction = selectShouldUseSmartTransaction(state); + expect(shouldUseSmartTransaction).toEqual(false); + }); + it('should return true if smart transactions are enabled and opted into', () => { + const state = getDefaultState(); + state.swaps.featureFlags.smart_transactions.mobile_active = true; + state.swaps.featureFlags.smartTransactions.mobileActive = true; + + const shouldUseSmartTransaction = selectShouldUseSmartTransaction(state); + expect(shouldUseSmartTransaction).toEqual(true); + }); + }); +}); diff --git a/app/selectors/smartTransactionsController.ts b/app/selectors/smartTransactionsController.ts new file mode 100644 index 00000000000..94e20c8e4d8 --- /dev/null +++ b/app/selectors/smartTransactionsController.ts @@ -0,0 +1,92 @@ +import { NETWORKS_CHAIN_ID } from '../constants/network'; +import { + selectSelectedAddress, + selectSmartTransactionsOptInStatus, +} from './preferencesController'; +import { RootState } from '../reducers'; +import { swapsSmartTxFlagEnabled } from '../reducers/swaps'; +import { isHardwareAccount } from '../util/address'; +import { selectChainId, selectProviderConfig } from './networkController'; +import { + SmartTransaction, + SmartTransactionStatuses, +} from '@metamask/smart-transactions-controller/dist/types'; + +export const ALLOWED_SMART_TRANSACTIONS_CHAIN_IDS = [ + NETWORKS_CHAIN_ID.MAINNET, + NETWORKS_CHAIN_ID.GOERLI, + NETWORKS_CHAIN_ID.SEPOLIA, +]; +export const selectSmartTransactionsEnabled = (state: RootState) => { + const selectedAddress = selectSelectedAddress(state); + const addrIshardwareAccount = isHardwareAccount(selectedAddress); + const chainId = selectChainId(state); + const providerConfigRpcUrl = selectProviderConfig(state).rpcUrl; + + const isAllowedNetwork = + ALLOWED_SMART_TRANSACTIONS_CHAIN_IDS.includes(chainId); + + // E.g. if a user has a Mainnet Flashbots RPC, we do not want to bypass it + // Only want to bypass on default mainnet RPC + const canBypassRpc = + chainId === NETWORKS_CHAIN_ID.MAINNET + ? providerConfigRpcUrl === undefined + : true; + + const smartTransactionsFeatureFlagEnabled = swapsSmartTxFlagEnabled(state); + + const smartTransactionsLiveness = + state.engine.backgroundState.SmartTransactionsController + .smartTransactionsState?.liveness; + + return Boolean( + isAllowedNetwork && + canBypassRpc && + !addrIshardwareAccount && + smartTransactionsFeatureFlagEnabled && + smartTransactionsLiveness, + ); +}; +export const selectShouldUseSmartTransaction = (state: RootState) => { + const isSmartTransactionsEnabled = selectSmartTransactionsEnabled(state); + const smartTransactionsOptInStatus = + selectSmartTransactionsOptInStatus(state); + + return isSmartTransactionsEnabled && smartTransactionsOptInStatus; +}; + +export const selectPendingSmartTransactionsBySender = (state: RootState) => { + const selectedAddress = selectSelectedAddress(state); + const chainId = selectChainId(state); + + const smartTransactions: SmartTransaction[] = + state.engine.backgroundState.SmartTransactionsController + ?.smartTransactionsState?.smartTransactions?.[chainId] || []; + + const pendingSmartTransactions = + smartTransactions + ?.filter((stx) => { + const { txParams } = stx; + return ( + txParams?.from.toLowerCase() === selectedAddress.toLowerCase() && + ![ + SmartTransactionStatuses.SUCCESS, + SmartTransactionStatuses.CANCELLED, + ].includes(stx.status as SmartTransactionStatuses) + ); + }) + .map((stx) => ({ + ...stx, + // stx.uuid is one from sentinel API, not the same as tx.id which is generated client side + // Doesn't matter too much because we only care about the pending stx, confirmed txs are handled like normal + // However, this does make it impossible to read Swap data from TxController.swapsTransactions as that relies on client side tx.id + // To fix that we do transactionController.update({ swapsTransactions: newSwapsTransactions }) in app/util/smart-transactions/smart-tx.ts + id: stx.uuid, + status: stx.status?.startsWith(SmartTransactionStatuses.CANCELLED) + ? SmartTransactionStatuses.CANCELLED + : stx.status, + isSmartTransaction: true, + })) ?? []; + + return pendingSmartTransactions; +}; diff --git a/app/selectors/transactionController.ts b/app/selectors/transactionController.ts index a86514680af..ce8ba2ec0b5 100644 --- a/app/selectors/transactionController.ts +++ b/app/selectors/transactionController.ts @@ -10,8 +10,13 @@ const selectTransactionsStrict = createSelector( (transactionControllerState) => transactionControllerState.transactions, ); -// eslint-disable-next-line import/prefer-default-export export const selectTransactions = createDeepEqualSelector( selectTransactionsStrict, (transactions) => transactions, ); + +export const selectNonReplacedTransactions = createDeepEqualSelector( + selectTransactionsStrict, + (transactions) => + transactions.filter((tx) => !(tx.replacedBy && tx.replacedById && tx.hash)), +); diff --git a/app/util/onboarding/index.test.ts b/app/util/onboarding/index.test.ts index ec529b26cd2..6ad370d73e3 100644 --- a/app/util/onboarding/index.test.ts +++ b/app/util/onboarding/index.test.ts @@ -1,13 +1,28 @@ import { shouldShowSmartTransactionsOptInModal } from './index'; import AsyncStorage from '../../store/async-storage-wrapper'; import { NETWORKS_CHAIN_ID } from '../../constants/network'; +import { store } from '../../store'; + +const getMockState = (optInModalAppVersionSeen: string | null) => ({ + smartTransactions: { + optInModalAppVersionSeen, + }, +}); jest.mock('../../store/async-storage-wrapper'); +jest.mock('../../store', () => ({ + store: { + getState: jest.fn(() => getMockState(null)), + dispatch: jest.fn(), + }, +})); + describe('shouldShowSmartTransactionOptInModal', () => { beforeEach(() => { // Clear all instances and calls to constructor and all methods: (AsyncStorage.getItem as jest.Mock).mockClear(); + (store.getState as jest.Mock).mockClear(); }); test.each([ @@ -25,9 +40,8 @@ describe('shouldShowSmartTransactionOptInModal', () => { ); it('should return false if user has seen the modal', async () => { - (AsyncStorage.getItem as jest.Mock) - .mockResolvedValueOnce('7.16.0') // versionSeen - .mockResolvedValueOnce('7.16.0'); // currentAppVersion + (AsyncStorage.getItem as jest.Mock).mockResolvedValueOnce('7.24.0'); // currentAppVersion + (store.getState as jest.Mock).mockReturnValueOnce(getMockState('7.24.0')); // versionSeen const result = await shouldShowSmartTransactionsOptInModal( NETWORKS_CHAIN_ID.MAINNET, @@ -37,9 +51,8 @@ describe('shouldShowSmartTransactionOptInModal', () => { }); it('should return false if app version is not correct', async () => { - (AsyncStorage.getItem as jest.Mock) - .mockResolvedValueOnce(null) // versionSeen - .mockResolvedValueOnce('7.15.0'); // currentAppVersion + (AsyncStorage.getItem as jest.Mock).mockResolvedValueOnce('7.0.0'); // currentAppVersion + (store.getState as jest.Mock).mockReturnValueOnce(getMockState(null)); // versionSeen const result = await shouldShowSmartTransactionsOptInModal( NETWORKS_CHAIN_ID.MAINNET, @@ -48,10 +61,9 @@ describe('shouldShowSmartTransactionOptInModal', () => { expect(result).toBe(false); }); - it('should return true if all conditions are met', async () => { - (AsyncStorage.getItem as jest.Mock) - .mockResolvedValueOnce(null) // versionSeen - .mockResolvedValueOnce('7.16.0'); // currentAppVersion + it('should return true if has not seen and is on mainnet with default RPC url', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValueOnce('7.24.0'); // currentAppVersion + (store.getState as jest.Mock).mockReturnValueOnce(getMockState(null)); // versionSeen const result = await shouldShowSmartTransactionsOptInModal( NETWORKS_CHAIN_ID.MAINNET, diff --git a/app/util/onboarding/index.ts b/app/util/onboarding/index.ts index 3601e1544e6..3346aa6bff8 100644 --- a/app/util/onboarding/index.ts +++ b/app/util/onboarding/index.ts @@ -2,20 +2,20 @@ import compareVersions from 'compare-versions'; import { WHATS_NEW_APP_VERSION_SEEN, - SMART_TRANSACTIONS_OPT_IN_MODAL_APP_VERSION_SEEN, CURRENT_APP_VERSION, LAST_APP_VERSION, } from '../../constants/storage'; import { whatsNewList } from '../../components/UI/WhatsNewModal'; import AsyncStorage from '../../store/async-storage-wrapper'; import { NETWORKS_CHAIN_ID } from '../../constants/network'; +import { store } from '../../store'; const isVersionSeenAndGreaterThanMinAppVersion = ( - versionSeen: string, + versionSeen: string | null, minAppVersion: string, ) => !!versionSeen && compareVersions.compare(versionSeen, minAppVersion, '>='); -const STX_OPT_IN_MIN_APP_VERSION = '7.16.0'; +const STX_OPT_IN_MIN_APP_VERSION = '7.24.0'; /** * @@ -38,12 +38,12 @@ export const shouldShowSmartTransactionsOptInModal = async ( return false; } - // Check if user has seen - const versionSeen = await AsyncStorage.getItem( - SMART_TRANSACTIONS_OPT_IN_MODAL_APP_VERSION_SEEN, - ); + const versionSeen = + store.getState().smartTransactions.optInModalAppVersionSeen; + const currentAppVersion = await AsyncStorage.getItem(CURRENT_APP_VERSION); + // Check if user has seen const seen = isVersionSeenAndGreaterThanMinAppVersion( versionSeen, STX_OPT_IN_MIN_APP_VERSION, diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 59fcf2591a1..e2e1d771a4f 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -119,7 +119,8 @@ "0x505": true, "0x64": true }, - "isIpfsGatewayEnabled": true + "isIpfsGatewayEnabled": true, + "smartTransactionsOptInStatus": false }, "TokenBalancesController": { "contractBalances": {} @@ -134,6 +135,11 @@ "submitHistory": [], "transactions": [] }, + "SmartTransactionsController": { + "smartTransactionsState": { + "liveness": true + } + }, "SnapController": { "snapStates": {}, "snaps": {}, diff --git a/app/util/test/initial-root-state.ts b/app/util/test/initial-root-state.ts index 175f0dc246c..8b7b7f6e1fb 100644 --- a/app/util/test/initial-root-state.ts +++ b/app/util/test/initial-root-state.ts @@ -3,6 +3,7 @@ import type { EngineState } from '../../core/Engine'; import { initialState as initialFiatOrdersState } from '../../reducers/fiatOrders'; import { initialState as initialSecurityState } from '../../reducers/security'; import { initialState as initialInpageProvider } from '../../core/redux/slices/inpageProvider'; +import { initialState as initialSmartTransactions } from '../../core/redux/slices/smartTransactions'; import initialBackgroundState from './initial-background-state.json'; // Cast because TypeScript is incorrectly inferring the type of this JSON object @@ -19,6 +20,7 @@ const initialRootState: RootState = { settings: undefined, alert: undefined, transaction: undefined, + smartTransactions: initialSmartTransactions, user: {}, wizard: undefined, onboarding: undefined, diff --git a/patches/@metamask+preferences-controller+8.0.0.patch b/patches/@metamask+preferences-controller+8.0.0.patch index 560745168d6..71dd951f18e 100644 --- a/patches/@metamask+preferences-controller+8.0.0.patch +++ b/patches/@metamask+preferences-controller+8.0.0.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@metamask/preferences-controller/dist/PreferencesController.d.ts b/node_modules/@metamask/preferences-controller/dist/PreferencesController.d.ts -index bfbfe66..c19399e 100644 +index bfbfe66..e9c8232 100644 --- a/node_modules/@metamask/preferences-controller/dist/PreferencesController.d.ts +++ b/node_modules/@metamask/preferences-controller/dist/PreferencesController.d.ts @@ -71,7 +71,8 @@ export declare type PreferencesState = { @@ -12,7 +12,18 @@ index bfbfe66..c19399e 100644 /** * Controls whether "security alerts" are enabled */ -@@ -216,11 +217,16 @@ export declare class PreferencesController extends BaseController; +@@ -216,11 +221,16 @@ export declare class PreferencesController extends BaseController { ++ state.smartTransactionsOptInStatus = smartTransactionsOptInStatus; ++ }); ++ } + } + exports.PreferencesController = PreferencesController; + _PreferencesController_instances = new WeakSet(), _PreferencesController_syncIdentities = function _PreferencesController_syncIdentities(addresses) { diff --git a/node_modules/@metamask/preferences-controller/dist/constants.d.ts b/node_modules/@metamask/preferences-controller/dist/constants.d.ts index cb9a3d4..5662d1c 100644 --- a/node_modules/@metamask/preferences-controller/dist/constants.d.ts diff --git a/patches/@metamask+swaps-controller+6.9.3.patch b/patches/@metamask+swaps-controller+6.9.3.patch index 14238be18eb..11b97016349 100644 --- a/patches/@metamask+swaps-controller+6.9.3.patch +++ b/patches/@metamask+swaps-controller+6.9.3.patch @@ -113,19 +113,79 @@ index 9d8a521..601fd18 100644 exports.CHAIN_ID_TO_NAME_MAP = { [exports.ETH_CHAIN_ID]: 'ethereum', [exports.BSC_CHAIN_ID]: 'bsc', +diff --git a/node_modules/@metamask/swaps-controller/dist/swapsInterfaces.d.ts b/node_modules/@metamask/swaps-controller/dist/swapsInterfaces.d.ts +index 43f7356..5416244 100644 +--- a/node_modules/@metamask/swaps-controller/dist/swapsInterfaces.d.ts ++++ b/node_modules/@metamask/swaps-controller/dist/swapsInterfaces.d.ts +@@ -19,14 +19,51 @@ export interface SwapsToken extends SwapsAsset { + occurrences?: number; + iconUrl?: string; + } +-export interface NetworkFeatureFlags { ++ ++export type NetworkFeatureFlags = { + mobile_active: boolean; + extension_active: boolean; + fallback_to_v1?: boolean; +-} ++ }; + export interface NetworksFeatureStatus { + [network: string]: NetworkFeatureFlags; + } ++ ++export type NetworkFeatureFlagsAll = { ++ mobile_active: boolean; ++ extension_active: boolean; ++ fallback_to_v1?: boolean; ++ fallbackToV1: boolean; ++ mobileActive: boolean; ++ extensionActive: boolean; ++ mobileActiveIOS: boolean; ++ mobileActiveAndroid: boolean; ++ ++ smartTransactions: { ++ expectedDeadline: number; ++ maxDeadline: number; ++ returnTxHashAsap: boolean; ++ }; ++}; ++ ++export type NetworksFeatureStatusAll = { ++ [network: string]: NetworkFeatureFlagsAll; ++ }; ++ ++export interface GlobalFeatureFlags { ++ smart_transactions: { ++ mobile_active: boolean; ++ extension_active: boolean; ++ }; ++ smartTransactions: { ++ mobileActive: boolean; ++ extensionActive: boolean; ++ mobileActiveIOS: boolean; ++ mobileActiveAndroid: boolean; ++ }; ++} ++export type FeatureFlags = NetworksFeatureStatusAll & GlobalFeatureFlags; ++ + /** + * Metadata needed to fetch quotes + * diff --git a/node_modules/@metamask/swaps-controller/dist/swapsUtil.d.ts b/node_modules/@metamask/swaps-controller/dist/swapsUtil.d.ts -index 0204cab..b2f7c19 100644 +index 0204cab..30b3979 100644 --- a/node_modules/@metamask/swaps-controller/dist/swapsUtil.d.ts +++ b/node_modules/@metamask/swaps-controller/dist/swapsUtil.d.ts -@@ -1,6 +1,7 @@ +@@ -1,7 +1,8 @@ import { Transaction } from '@metamask/controllers'; import { AbortSignal } from 'abort-controller'; import { BigNumber } from 'bignumber.js'; +-import { APIAggregatorMetadata, SwapsAsset, SwapsToken, APIType, Quote, APIFetchQuotesParams, QuoteValues, TransactionReceipt, NetworkFeatureFlags } from './swapsInterfaces'; +import { Hex } from '@metamask/utils'; - import { APIAggregatorMetadata, SwapsAsset, SwapsToken, APIType, Quote, APIFetchQuotesParams, QuoteValues, TransactionReceipt, NetworkFeatureFlags } from './swapsInterfaces'; ++import { APIAggregatorMetadata, SwapsAsset, SwapsToken, APIType, Quote, APIFetchQuotesParams, QuoteValues, TransactionReceipt, NetworkFeatureFlags, FeatureFlags } from './swapsInterfaces'; export * from './constants'; export declare enum SwapsError { -@@ -14,27 +15,27 @@ export declare enum SwapsError { + QUOTES_EXPIRED_ERROR = "quotes-expired", +@@ -14,27 +15,28 @@ export declare enum SwapsError { SWAPS_ALLOWANCE_TIMEOUT = "swaps-allowance-timeout", SWAPS_ALLOWANCE_ERROR = "swaps-allowance-error" } @@ -155,6 +215,7 @@ index 0204cab..b2f7c19 100644 -export declare function fetchSwapsFeatureLiveness(chainId: string, clientId?: string): Promise; +export declare function fetchTopAssets(chainId: Hex, clientId?: string): Promise; +export declare function fetchSwapsFeatureLiveness(chainId: Hex, clientId?: string): Promise; ++export declare function fetchSwapsFeatureFlags(chainId: string, clientId?: string): Promise; /** * Fetches gas prices from API URL * @param chainId Current chainId @@ -166,7 +227,7 @@ index 0204cab..b2f7c19 100644 proposedGasPrice: string; fastGasPrice: string; diff --git a/node_modules/@metamask/swaps-controller/dist/swapsUtil.js b/node_modules/@metamask/swaps-controller/dist/swapsUtil.js -index 54a32d5..3fa4343 100644 +index 54a32d5..566b299 100644 --- a/node_modules/@metamask/swaps-controller/dist/swapsUtil.js +++ b/node_modules/@metamask/swaps-controller/dist/swapsUtil.js @@ -14,6 +14,7 @@ exports.constructTxParams = exports.estimateGas = exports.calcTokenAmount = expo @@ -206,3 +267,15 @@ index 54a32d5..3fa4343 100644 default: throw new Error('getBaseApiURL requires an api call type'); } +@@ -172,6 +174,11 @@ async function fetchSwapsFeatureLiveness(chainId, clientId) { + return status[networkName]; + } + exports.fetchSwapsFeatureLiveness = fetchSwapsFeatureLiveness; ++async function fetchSwapsFeatureFlags(chainId, clientId) { ++ const status = await handleFetch(exports.getBaseApiURL(swapsInterfaces_1.APIType.FEATURE_FLAG, chainId), { method: 'GET', headers: getClientIdHeader(clientId) }); ++ return status; ++} ++exports.fetchSwapsFeatureFlags = fetchSwapsFeatureFlags; + /** + * Fetches gas prices from API URL + * @param chainId Current chainId