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