diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json
index 7e0da8d1c..ba8a7c370 100644
--- a/app/_locales/en/messages.json
+++ b/app/_locales/en/messages.json
@@ -432,6 +432,9 @@
"message": "To $1 a transaction the gas fee must be increased by at least 10% for it to be recognized by the network.",
"description": "$1 is string 'cancel' or 'speed up'"
},
+ "cancelSwap": {
+ "message": "Cancel swap"
+ },
"cancellationGasFee": {
"message": "Cancellation Gas Fee"
},
@@ -694,6 +697,9 @@
"customToken": {
"message": "Custom Token"
},
+ "customerSupport": {
+ "message": "customer support"
+ },
"dappSuggested": {
"message": "Site suggested"
},
@@ -982,6 +988,9 @@
"enableOpenSeaAPIDescription": {
"message": "Use OpenSea's API to fetch NFT data. NFT auto-detection relies on OpenSea's API, and will not be available when this is turned off."
},
+ "enableSmartTransactions": {
+ "message": "Enable smart transactions"
+ },
"enableToken": {
"message": "enable $1",
"description": "$1 is a token symbol, e.g. ETH"
@@ -1993,6 +2002,9 @@
"noThanks": {
"message": "No Thanks"
},
+ "noThanksVariant2": {
+ "message": "No, thanks."
+ },
"noTransactions": {
"message": "You have no transactions"
},
@@ -2295,6 +2307,9 @@
"message": "Preferred Ledger Connection Type",
"description": "A header for a dropdown in the advanced section of settings. Appears above the ledgerConnectionPreferenceDescription message"
},
+ "preparingSwap": {
+ "message": "Preparing swap..."
+ },
"prev": {
"message": "Prev"
},
@@ -2737,6 +2752,9 @@
"slow": {
"message": "Slow"
},
+ "smartTransaction": {
+ "message": "Smart transaction"
+ },
"snapAccess": {
"message": "$1 snap has access to:",
"description": "$1 represents the name of the snap"
@@ -2871,6 +2889,86 @@
"storePhrase": {
"message": "Store this phrase in a password manager like 1Password."
},
+ "stxAreHere": {
+ "message": "Smart transactions are here!"
+ },
+ "stxBenefit1": {
+ "message": "Decrease transaction costs"
+ },
+ "stxBenefit2": {
+ "message": "Reduce failures & minimize costs"
+ },
+ "stxBenefit3": {
+ "message": "Protect from front-running"
+ },
+ "stxBenefit4": {
+ "message": "Eliminate stuck transactions"
+ },
+ "stxCancelled": {
+ "message": "Swap would have failed"
+ },
+ "stxCancelledDescription": {
+ "message": "Your transaction would have failed and was canceled to protect you from paying unnecessary gas fees."
+ },
+ "stxCancelledSubDescription": {
+ "message": "Try your swap again. We’ll be here to protect you against similar risks next time."
+ },
+ "stxDescription": {
+ "message": "Smart transactions use MetaMask smart contracts to simulate transactions before submitting in order to..."
+ },
+ "stxFailure": {
+ "message": "Swap failed"
+ },
+ "stxFailureDescription": {
+ "message": "Sudden market changes can cause failures. If the problem persists, please reach out to $1.",
+ "description": "This message is shown to a user if their swap fails. The $1 will be replaced by support.metamask.io"
+ },
+ "stxFallbackToNormal": {
+ "message": "You can still swap using the normal method or wait for cheaper gas fees and less failures with smart transactions."
+ },
+ "stxPendingFinalizing": {
+ "message": "Finalizing..."
+ },
+ "stxPendingOptimizingGas": {
+ "message": "Optimizing gas..."
+ },
+ "stxPendingPrivatelySubmitting": {
+ "message": "Privately submitting the Swap..."
+ },
+ "stxSubDescription": {
+ "message": "Enabling allows MetaMask to simulate transactions, proactively cancel bad transactions and sign MetaMask Swaps transactions for you."
+ },
+ "stxSuccess": {
+ "message": "Swap complete!"
+ },
+ "stxSuccessDescription": {
+ "message": "Your $1 is now available.",
+ "description": "$1 is a token symbol, e.g. ETH"
+ },
+ "stxTooltip": {
+ "message": "Simulate transactions before submitting to decrease transaction costs and reduce failures."
+ },
+ "stxTryRegular": {
+ "message": "Try a regular swap."
+ },
+ "stxUnavailable": {
+ "message": "Smart transactions temporarily unavailable"
+ },
+ "stxUnknown": {
+ "message": "Status unknown"
+ },
+ "stxUnknownDescription": {
+ "message": "A transaction has been successful but we’re unsure what it is. This may be due to submitting another transaction while this swap was processing."
+ },
+ "stxUserCancelled": {
+ "message": "Swap canceled"
+ },
+ "stxUserCancelledDescription": {
+ "message": "Your transaction has been canceled and you did not pay any unnecessary gas fees."
+ },
+ "stxYouCanOptOut": {
+ "message": "You can opt-out in advanced settings any time."
+ },
"submit": {
"message": "Submit"
},
@@ -2910,6 +3008,10 @@
"message": "You need $1 more $2 to complete this swap",
"description": "Tells the user how many more of a given token they need for a specific swap. $1 is an amount of tokens and $2 is the token symbol."
},
+ "swapApproveNeedMoreTokensSmartTransactions": {
+ "message": "You need more $1 to complete this swap using smart transactions.",
+ "description": "Tells the user that they need more of a certain token ($1) before they can complete the swap via smart transactions."
+ },
"swapBestOfNQuotes": {
"message": "Best of $1 quotes.",
"description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen"
@@ -2918,6 +3020,10 @@
"message": "No tokens available matching $1",
"description": "Tells the user that a given search string does not match any tokens in our token lists. $1 can be any string of text"
},
+ "swapCompleteIn": {
+ "message": "Swap complete in <",
+ "description": "'<' means 'less than', e.g. Swap complete in < 2:59"
+ },
"swapConfirmWithHwWallet": {
"message": "Confirm with your hardware wallet"
},
diff --git a/app/images/logo/metamask-smart-transactions@4x.png b/app/images/logo/metamask-smart-transactions@4x.png
new file mode 100644
index 000000000..636576495
Binary files /dev/null and b/app/images/logo/metamask-smart-transactions@4x.png differ
diff --git a/app/images/transaction-background-bottom.svg b/app/images/transaction-background-bottom.svg
new file mode 100644
index 000000000..f2b1fd04d
--- /dev/null
+++ b/app/images/transaction-background-bottom.svg
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/images/transaction-background-top.svg b/app/images/transaction-background-top.svg
new file mode 100644
index 000000000..8a343cfd8
--- /dev/null
+++ b/app/images/transaction-background-top.svg
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/scripts/controllers/swaps.js b/app/scripts/controllers/swaps.js
index e3d0198e4..5684c8ba4 100644
--- a/app/scripts/controllers/swaps.js
+++ b/app/scripts/controllers/swaps.js
@@ -41,6 +41,8 @@ const POLL_COUNT_LIMIT = 3;
// If for any reason the MetaSwap API fails to provide a refresh time,
// provide a reasonable fallback to avoid further errors
const FALLBACK_QUOTE_REFRESH_TIME = MINUTE;
+const FALLBACK_SMART_TRANSACTION_REFRESH_TIME = SECOND * 10;
+const FALLBACK_SMART_TRANSACTIONS_DEADLINE = 180;
function calculateGasEstimateWithRefund(
maxGas = MAX_GAS_LIMIT,
@@ -84,6 +86,9 @@ const initialState = {
saveFetchedQuotes: false,
swapsQuoteRefreshTime: FALLBACK_QUOTE_REFRESH_TIME,
swapsQuotePrefetchingRefreshTime: FALLBACK_QUOTE_REFRESH_TIME,
+ swapsStxBatchStatusRefreshTime: FALLBACK_SMART_TRANSACTION_REFRESH_TIME,
+ swapsStxGetTransactionsRefreshTime: FALLBACK_SMART_TRANSACTION_REFRESH_TIME,
+ swapsFeatureFlags: {},
},
};
@@ -134,7 +139,9 @@ export default class SwapsController {
if (
!refreshRates ||
typeof refreshRates.quotes !== 'number' ||
- typeof refreshRates.quotesPrefetching !== 'number'
+ typeof refreshRates.quotesPrefetching !== 'number' ||
+ typeof refreshRates.stxGetTransactions !== 'number' ||
+ typeof refreshRates.stxBatchStatus !== 'number'
) {
throw new Error(
`MetaMask - invalid response for refreshRates: ${response}`,
@@ -144,6 +151,9 @@ export default class SwapsController {
return {
quotes: refreshRates.quotes * 1000,
quotesPrefetching: refreshRates.quotesPrefetching * 1000,
+ stxGetTransactions: refreshRates.stxGetTransactions * 1000,
+ stxBatchStatus: refreshRates.stxBatchStatus * 1000,
+ stxStatusDeadline: refreshRates.stxStatusDeadline,
};
}
@@ -164,6 +174,15 @@ export default class SwapsController {
swapsRefreshRates?.quotes || FALLBACK_QUOTE_REFRESH_TIME,
swapsQuotePrefetchingRefreshTime:
swapsRefreshRates?.quotesPrefetching || FALLBACK_QUOTE_REFRESH_TIME,
+ swapsStxGetTransactionsRefreshTime:
+ swapsRefreshRates?.stxGetTransactions ||
+ FALLBACK_SMART_TRANSACTION_REFRESH_TIME,
+ swapsStxBatchStatusRefreshTime:
+ swapsRefreshRates?.stxBatchStatus ||
+ FALLBACK_SMART_TRANSACTION_REFRESH_TIME,
+ swapsStxStatusDeadline:
+ swapsRefreshRates?.stxStatusDeadline ||
+ FALLBACK_SMART_TRANSACTIONS_DEADLINE,
},
});
}
@@ -572,6 +591,13 @@ export default class SwapsController {
});
}
+ setSwapsFeatureFlags(swapsFeatureFlags) {
+ const { swapsState } = this.store.getState();
+ this.store.updateState({
+ swapsState: { ...swapsState, swapsFeatureFlags },
+ });
+ }
+
resetPostFetchState() {
const { swapsState } = this.store.getState();
this.store.updateState({
@@ -583,6 +609,7 @@ export default class SwapsController {
swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime,
swapsQuotePrefetchingRefreshTime:
swapsState.swapsQuotePrefetchingRefreshTime,
+ swapsFeatureFlags: swapsState.swapsFeatureFlags,
},
});
clearTimeout(this.pollingTimeout);
diff --git a/app/scripts/controllers/swaps.test.js b/app/scripts/controllers/swaps.test.js
index 85890eeb2..3a13bf942 100644
--- a/app/scripts/controllers/swaps.test.js
+++ b/app/scripts/controllers/swaps.test.js
@@ -131,8 +131,11 @@ const EMPTY_INIT_STATE = {
topAggId: null,
routeState: '',
swapsFeatureIsLive: true,
+ swapsFeatureFlags: {},
swapsQuoteRefreshTime: 60000,
swapsQuotePrefetchingRefreshTime: 60000,
+ swapsStxBatchStatusRefreshTime: 10000,
+ swapsStxGetTransactionsRefreshTime: 10000,
swapsUserFeeLevel: '',
saveFetchedQuotes: false,
},
@@ -840,6 +843,9 @@ describe('SwapsController', function () {
swapsQuoteRefreshTime: old.swapsQuoteRefreshTime,
swapsQuotePrefetchingRefreshTime:
old.swapsQuotePrefetchingRefreshTime,
+ swapsStxGetTransactionsRefreshTime:
+ old.swapsStxGetTransactionsRefreshTime,
+ swapsStxBatchStatusRefreshTime: old.swapsStxBatchStatusRefreshTime,
});
});
@@ -885,15 +891,21 @@ describe('SwapsController', function () {
const tokens = 'test';
const fetchParams = 'test';
const swapsFeatureIsLive = false;
+ const swapsFeatureFlags = {};
const swapsQuoteRefreshTime = 0;
const swapsQuotePrefetchingRefreshTime = 0;
+ const swapsStxBatchStatusRefreshTime = 0;
+ const swapsStxGetTransactionsRefreshTime = 0;
swapsController.store.updateState({
swapsState: {
tokens,
fetchParams,
swapsFeatureIsLive,
+ swapsFeatureFlags,
swapsQuoteRefreshTime,
swapsQuotePrefetchingRefreshTime,
+ swapsStxBatchStatusRefreshTime,
+ swapsStxGetTransactionsRefreshTime,
},
});
diff --git a/app/scripts/controllers/transactions/index.js b/app/scripts/controllers/transactions/index.js
index be27febe3..04eae26f8 100644
--- a/app/scripts/controllers/transactions/index.js
+++ b/app/scripts/controllers/transactions/index.js
@@ -20,7 +20,10 @@ import {
} from '../../lib/util';
import { TRANSACTION_NO_CONTRACT_ERROR_KEY } from '../../../../ui/helpers/constants/error-keys';
import { getSwapsTokensReceivedFromTxMeta } from '../../../../ui/pages/swaps/swaps.util';
-import { hexWEIToDecGWEI } from '../../../../ui/helpers/utils/conversions.util';
+import {
+ hexWEIToDecGWEI,
+ decimalToHex,
+} from '../../../../ui/helpers/utils/conversions.util';
import {
TRANSACTION_STATUSES,
TRANSACTION_TYPES,
@@ -65,6 +68,8 @@ const SWAP_TRANSACTION_TYPES = [
* @typedef {import('../../../../shared/constants/transaction').TransactionMetaMetricsEventString} TransactionMetaMetricsEventString
*/
+const METRICS_STATUS_FAILED = 'failed on-chain';
+
/**
* @typedef {Object} CustomGasSettings
* @property {string} [gas] - The gas limit to use for the transaction
@@ -143,9 +148,15 @@ export default class TransactionController extends EventEmitter {
this.nonceTracker = new NonceTracker({
provider: this.provider,
blockTracker: this.blockTracker,
- getPendingTransactions: this.txStateManager.getPendingTransactions.bind(
- this.txStateManager,
- ),
+ getPendingTransactions: (...args) => {
+ const pendingTransactions = this.txStateManager.getPendingTransactions(
+ ...args,
+ );
+ const externalPendingTransactions = opts.getExternalPendingTransactions(
+ ...args,
+ );
+ return [...pendingTransactions, ...externalPendingTransactions];
+ },
getConfirmedTransactions: this.txStateManager.getConfirmedTransactions.bind(
this.txStateManager,
),
@@ -956,6 +967,72 @@ export default class TransactionController extends EventEmitter {
}
}
+ async approveTransactionsWithSameNonce(listOfTxParams = []) {
+ if (listOfTxParams.length === 0) {
+ return '';
+ }
+
+ const initialTx = listOfTxParams[0];
+ const common = await this.getCommonConfiguration(initialTx.from);
+ const initialTxAsEthTx = TransactionFactory.fromTxData(initialTx, {
+ common,
+ });
+ const initialTxAsSerializedHex = bufferToHex(initialTxAsEthTx.serialize());
+
+ if (this.inProcessOfSigning.has(initialTxAsSerializedHex)) {
+ return '';
+ }
+ this.inProcessOfSigning.add(initialTxAsSerializedHex);
+ let rawTxes, nonceLock;
+ try {
+ // TODO: we should add a check to verify that all transactions have the same from address
+ const fromAddress = initialTx.from;
+ nonceLock = await this.nonceTracker.getNonceLock(fromAddress);
+ const nonce = nonceLock.nextNonce;
+
+ rawTxes = await Promise.all(
+ listOfTxParams.map((txParams) => {
+ txParams.nonce = addHexPrefix(nonce.toString(16));
+ return this.signExternalTransaction(txParams);
+ }),
+ );
+ } catch (err) {
+ log.error(err);
+ // must set transaction to submitted/failed before releasing lock
+ // continue with error chain
+ throw err;
+ } finally {
+ if (nonceLock) {
+ nonceLock.releaseLock();
+ }
+ this.inProcessOfSigning.delete(initialTxAsSerializedHex);
+ }
+ return rawTxes;
+ }
+
+ async signExternalTransaction(_txParams) {
+ const normalizedTxParams = txUtils.normalizeTxParams(_txParams);
+ // add network/chain id
+ const chainId = this.getChainId();
+ const type = isEIP1559Transaction({ txParams: normalizedTxParams })
+ ? TRANSACTION_ENVELOPE_TYPES.FEE_MARKET
+ : TRANSACTION_ENVELOPE_TYPES.LEGACY;
+ const txParams = {
+ ...normalizedTxParams,
+ type,
+ gasLimit: normalizedTxParams.gas,
+ chainId: addHexPrefix(decimalToHex(chainId)),
+ };
+ // sign tx
+ const fromAddress = txParams.from;
+ const common = await this.getCommonConfiguration(fromAddress);
+ const unsignedEthTx = TransactionFactory.fromTxData(txParams, { common });
+ const signedEthTx = await this.signEthTx(unsignedEthTx, fromAddress);
+
+ const rawTx = bufferToHex(signedEthTx.serialize());
+ return rawTx;
+ }
+
/**
* adds the chain id and signs the transaction and set the status to signed
*
@@ -1054,12 +1131,7 @@ export default class TransactionController extends EventEmitter {
}
try {
- // It seems that sometimes the numerical values being returned from
- // this.query.getTransactionReceipt are BN instances and not strings.
- const gasUsed =
- typeof txReceipt.gasUsed === 'string'
- ? txReceipt.gasUsed
- : txReceipt.gasUsed.toString(16);
+ const gasUsed = txUtils.normalizeTxReceiptGasUsed(txReceipt.gasUsed);
txMeta.txReceipt = {
...txReceipt,
@@ -1086,7 +1158,79 @@ export default class TransactionController extends EventEmitter {
}
if (txReceipt.status === '0x0') {
- metricsParams.status = 'failed on-chain';
+ metricsParams.status = METRICS_STATUS_FAILED;
+ // metricsParams.error = TODO: figure out a way to get the on-chain failure reason
+ }
+
+ this._trackTransactionMetricsEvent(
+ txMeta,
+ TRANSACTION_EVENTS.FINALIZED,
+ metricsParams,
+ );
+
+ this.txStateManager.updateTransaction(
+ txMeta,
+ 'transactions#confirmTransaction - add txReceipt',
+ );
+
+ if (txMeta.type === TRANSACTION_TYPES.SWAP) {
+ const postTxBalance = await this.query.getBalance(txMeta.txParams.from);
+ const latestTxMeta = this.txStateManager.getTransaction(txId);
+
+ const approvalTxMeta = latestTxMeta.approvalTxId
+ ? this.txStateManager.getTransaction(latestTxMeta.approvalTxId)
+ : null;
+
+ latestTxMeta.postTxBalance = postTxBalance.toString(16);
+
+ this.txStateManager.updateTransaction(
+ latestTxMeta,
+ 'transactions#confirmTransaction - add postTxBalance',
+ );
+
+ this._trackSwapsMetrics(latestTxMeta, approvalTxMeta);
+ }
+ } catch (err) {
+ log.error(err);
+ }
+ }
+
+ async confirmExternalTransaction(txMeta, txReceipt, baseFeePerGas) {
+ // add external transaction
+ await this.txStateManager.addExternalTransaction(txMeta);
+
+ if (!txMeta) {
+ return;
+ }
+
+ const txId = txMeta.id;
+
+ try {
+ const gasUsed = txUtils.normalizeTxReceiptGasUsed(txReceipt.gasUsed);
+
+ txMeta.txReceipt = {
+ ...txReceipt,
+ gasUsed,
+ };
+
+ if (baseFeePerGas) {
+ txMeta.baseFeePerGas = baseFeePerGas;
+ }
+
+ this.txStateManager.setTxStatusConfirmed(txId);
+ this._markNonceDuplicatesDropped(txId);
+
+ const { submittedTime } = txMeta;
+ const metricsParams = { gas_used: gasUsed };
+
+ if (submittedTime) {
+ metricsParams.completion_time = this._getTransactionCompletionTime(
+ submittedTime,
+ );
+ }
+
+ if (txReceipt.status === '0x0') {
+ metricsParams.status = METRICS_STATUS_FAILED;
// metricsParams.error = TODO: figure out a way to get the on-chain failure reason
}
@@ -1478,13 +1622,13 @@ export default class TransactionController extends EventEmitter {
.round(2)}%`
: null;
- const estimatedVsUsedGasRatio = `${new BigNumber(
- txMeta.txReceipt.gasUsed,
- 16,
- )
- .div(txMeta.swapMetaData.estimated_gas, 10)
- .times(100)
- .round(2)}%`;
+ const estimatedVsUsedGasRatio =
+ txMeta.txReceipt.gasUsed && txMeta.swapMetaData.estimated_gas
+ ? `${new BigNumber(txMeta.txReceipt.gasUsed, 16)
+ .div(txMeta.swapMetaData.estimated_gas, 10)
+ .times(100)
+ .round(2)}%`
+ : null;
this._trackMetaMetricsEvent({
event: 'Swap Completed',
diff --git a/app/scripts/controllers/transactions/lib/util.js b/app/scripts/controllers/transactions/lib/util.js
index 12b87e2c1..9a19ca573 100644
--- a/app/scripts/controllers/transactions/lib/util.js
+++ b/app/scripts/controllers/transactions/lib/util.js
@@ -264,6 +264,44 @@ export function validateRecipient(txParams) {
return txParams;
}
+export const validateConfirmedExternalTransaction = ({
+ txMeta,
+ pendingTransactions,
+ confirmedTransactions,
+} = {}) => {
+ if (!txMeta || !txMeta.txParams) {
+ throw ethErrors.rpc.invalidParams(
+ '"txMeta" or "txMeta.txParams" is missing',
+ );
+ }
+ if (txMeta.status !== TRANSACTION_STATUSES.CONFIRMED) {
+ throw ethErrors.rpc.invalidParams(
+ 'External transaction status should be "confirmed"',
+ );
+ }
+ const externalTxNonce = txMeta.txParams.nonce;
+ if (pendingTransactions && pendingTransactions.length > 0) {
+ const foundPendingTxByNonce = pendingTransactions.find(
+ (el) => el.txParams?.nonce === externalTxNonce,
+ );
+ if (foundPendingTxByNonce) {
+ throw ethErrors.rpc.invalidParams(
+ 'External transaction nonce should not be in pending txs',
+ );
+ }
+ }
+ if (confirmedTransactions && confirmedTransactions.length > 0) {
+ const foundConfirmedTxByNonce = confirmedTransactions.find(
+ (el) => el.txParams?.nonce === externalTxNonce,
+ );
+ if (foundConfirmedTxByNonce) {
+ throw ethErrors.rpc.invalidParams(
+ 'External transaction nonce should not be in confirmed txs',
+ );
+ }
+ }
+};
+
/**
* Returns a list of final states
*
@@ -277,3 +315,15 @@ export function getFinalStates() {
TRANSACTION_STATUSES.DROPPED, // the tx nonce was already used
];
}
+
+/**
+ * Normalizes tx receipt gas used to be a hexadecimal string.
+ * It seems that sometimes the numerical values being returned from
+ * this.query.getTransactionReceipt are BN instances and not strings.
+ *
+ * @param {string or BN instance} gasUsed
+ * @returns normalized gas used as hexadecimal string
+ */
+export function normalizeTxReceiptGasUsed(gasUsed) {
+ return typeof gasUsed === 'string' ? gasUsed : gasUsed.toString(16);
+}
diff --git a/app/scripts/controllers/transactions/tx-state-manager.js b/app/scripts/controllers/transactions/tx-state-manager.js
index 74ba5dca0..879aac56c 100644
--- a/app/scripts/controllers/transactions/tx-state-manager.js
+++ b/app/scripts/controllers/transactions/tx-state-manager.js
@@ -11,7 +11,11 @@ import {
replayHistory,
snapshotFromTxMeta,
} from './lib/tx-state-history-helpers';
-import { getFinalStates, normalizeAndValidateTxParams } from './lib/util';
+import {
+ getFinalStates,
+ normalizeAndValidateTxParams,
+ validateConfirmedExternalTransaction,
+} from './lib/util';
/**
* TransactionStatuses reimported from the shared transaction constants file
@@ -266,6 +270,19 @@ export default class TransactionStateManager extends EventEmitter {
return txMeta;
}
+ addExternalTransaction(txMeta) {
+ const fromAddress = txMeta?.txParams?.from;
+ const confirmedTransactions = this.getConfirmedTransactions(fromAddress);
+ const pendingTransactions = this.getPendingTransactions(fromAddress);
+ validateConfirmedExternalTransaction({
+ txMeta,
+ pendingTransactions,
+ confirmedTransactions,
+ });
+ this._addTransactionsToState([txMeta]);
+ return txMeta;
+ }
+
/**
* @param {number} txId
* @returns {TransactionMeta} the txMeta who matches the given id if none found
diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js
index b1d783e6b..7c9b935ee 100644
--- a/app/scripts/metamask-controller.js
+++ b/app/scripts/metamask-controller.js
@@ -35,6 +35,7 @@ import {
AssetsContractController,
CollectibleDetectionController,
} from '@metamask/controllers';
+import SmartTransactionsController from '@metamask/smart-transactions-controller';
import {
PermissionController,
SubjectMetadataController,
@@ -696,6 +697,9 @@ export default class MetamaskController extends EventEmitter {
getEIP1559GasFeeEstimates: this.gasFeeController.fetchGasFeeEstimates.bind(
this.gasFeeController,
),
+ getExternalPendingTransactions: this.getExternalPendingTransactions.bind(
+ this,
+ ),
});
this.txController.on('newUnapprovedTx', () => opts.showUserConfirmation());
@@ -831,6 +835,24 @@ export default class MetamaskController extends EventEmitter {
this.gasFeeController,
),
});
+ this.smartTransactionsController = new SmartTransactionsController({
+ onNetworkStateChange: this.networkController.store.subscribe.bind(
+ this.networkController.store,
+ ),
+ getNetwork: this.networkController.getNetworkState.bind(
+ this.networkController,
+ ),
+ getNonceLock: this.txController.nonceTracker.getNonceLock.bind(
+ this.txController.nonceTracker,
+ ),
+ confirmExternalTransaction: this.txController.confirmExternalTransaction.bind(
+ this.txController,
+ ),
+ provider: this.provider,
+ trackMetaMetricsEvent: this.metaMetricsController.trackEvent.bind(
+ this.metaMetricsController,
+ ),
+ });
// ensure accountTracker updates balances after network change
this.networkController.on(NETWORK_EVENTS.NETWORK_DID_CHANGE, () => {
@@ -871,6 +893,7 @@ export default class MetamaskController extends EventEmitter {
GasFeeController: this.gasFeeController,
TokenListController: this.tokenListController,
TokensController: this.tokensController,
+ SmartTransactionsController: this.smartTransactionsController,
CollectiblesController: this.collectiblesController,
///: BEGIN:ONLY_INCLUDE_IN(flask)
SnapController: this.snapController,
@@ -910,6 +933,7 @@ export default class MetamaskController extends EventEmitter {
GasFeeController: this.gasFeeController,
TokenListController: this.tokenListController,
TokensController: this.tokensController,
+ SmartTransactionsController: this.smartTransactionsController,
CollectiblesController: this.collectiblesController,
///: BEGIN:ONLY_INCLUDE_IN(flask)
SnapController: this.snapController,
@@ -1257,6 +1281,7 @@ export default class MetamaskController extends EventEmitter {
swapsController,
threeBoxController,
tokensController,
+ smartTransactionsController,
txController,
} = this;
@@ -1479,6 +1504,9 @@ export default class MetamaskController extends EventEmitter {
updateAndApproveTransaction: txController.updateAndApproveTransaction.bind(
txController,
),
+ approveTransactionsWithSameNonce: txController.approveTransactionsWithSameNonce.bind(
+ txController,
+ ),
createCancelTransaction: this.createCancelTransaction.bind(this),
createSpeedUpTransaction: this.createSpeedUpTransaction.bind(this),
estimateGas: this.estimateGas.bind(this),
@@ -1618,6 +1646,9 @@ export default class MetamaskController extends EventEmitter {
swapsController,
),
setSwapsLiveness: swapsController.setSwapsLiveness.bind(swapsController),
+ setSwapsFeatureFlags: swapsController.setSwapsFeatureFlags.bind(
+ swapsController,
+ ),
setSwapsUserFeeLevel: swapsController.setSwapsUserFeeLevel.bind(
swapsController,
),
@@ -1625,6 +1656,32 @@ export default class MetamaskController extends EventEmitter {
swapsController,
),
+ // Smart Transactions
+ setSmartTransactionsOptInStatus: smartTransactionsController.setOptInState.bind(
+ smartTransactionsController,
+ ),
+ fetchSmartTransactionFees: smartTransactionsController.getFees.bind(
+ smartTransactionsController,
+ ),
+ estimateSmartTransactionsGas: smartTransactionsController.estimateGas.bind(
+ smartTransactionsController,
+ ),
+ submitSignedTransactions: smartTransactionsController.submitSignedTransactions.bind(
+ smartTransactionsController,
+ ),
+ cancelSmartTransaction: smartTransactionsController.cancelSmartTransaction.bind(
+ smartTransactionsController,
+ ),
+ fetchSmartTransactionsLiveness: smartTransactionsController.fetchLiveness.bind(
+ smartTransactionsController,
+ ),
+ updateSmartTransaction: smartTransactionsController.updateSmartTransaction.bind(
+ smartTransactionsController,
+ ),
+ setStatusRefreshInterval: smartTransactionsController.setStatusRefreshInterval.bind(
+ smartTransactionsController,
+ ),
+
// MetaMetrics
trackMetaMetricsEvent: metaMetricsController.trackEvent.bind(
metaMetricsController,
@@ -3519,6 +3576,13 @@ export default class MetamaskController extends EventEmitter {
// MISCELLANEOUS
//=============================================================================
+ getExternalPendingTransactions(address) {
+ return this.smartTransactionsController.getTransactions({
+ addressFrom: address,
+ status: 'pending',
+ });
+ }
+
/**
* Returns the nonce that will be associated with a transaction once approved
*
diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json
index b4a93c2e5..0dfa74914 100644
--- a/lavamoat/browserify/beta/policy.json
+++ b/lavamoat/browserify/beta/policy.json
@@ -563,10 +563,7 @@
"ethereumjs-wallet": true,
"ethers": true,
"ethjs-unit": true,
- "ethjs-util": true,
"events": true,
- "human-standard-collectible-abi": true,
- "human-standard-token-abi": true,
"immer": true,
"isomorphic-fetch": true,
"jsonschema": true,
@@ -698,6 +695,25 @@
"events": true
}
},
+ "@metamask/smart-transactions-controller": {
+ "globals": {
+ "URLSearchParams": true,
+ "clearInterval": true,
+ "console.error": true,
+ "console.log": true,
+ "fetch": true,
+ "setInterval": true,
+ "setTimeout": true
+ },
+ "packages": {
+ "@metamask/controllers": true,
+ "bignumber.js": true,
+ "ethers": true,
+ "fast-json-patch": true,
+ "isomorphic-fetch": true,
+ "lodash": true
+ }
+ },
"@metamask/snap-controllers": {
"globals": {
"URL": true,
@@ -1935,7 +1951,6 @@
"addEventListener": true,
"browser": true,
"clearInterval": true,
- "console.warn": true,
"open": true,
"setInterval": true
},
diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json
index d5a9a9310..9f7e933db 100644
--- a/lavamoat/browserify/flask/policy.json
+++ b/lavamoat/browserify/flask/policy.json
@@ -563,10 +563,7 @@
"ethereumjs-wallet": true,
"ethers": true,
"ethjs-unit": true,
- "ethjs-util": true,
"events": true,
- "human-standard-collectible-abi": true,
- "human-standard-token-abi": true,
"immer": true,
"isomorphic-fetch": true,
"jsonschema": true,
@@ -717,6 +714,25 @@
"events": true
}
},
+ "@metamask/smart-transactions-controller": {
+ "globals": {
+ "URLSearchParams": true,
+ "clearInterval": true,
+ "console.error": true,
+ "console.log": true,
+ "fetch": true,
+ "setInterval": true,
+ "setTimeout": true
+ },
+ "packages": {
+ "@metamask/controllers": true,
+ "bignumber.js": true,
+ "ethers": true,
+ "fast-json-patch": true,
+ "isomorphic-fetch": true,
+ "lodash": true
+ }
+ },
"@metamask/snap-controllers": {
"globals": {
"URL": true,
@@ -1954,7 +1970,6 @@
"addEventListener": true,
"browser": true,
"clearInterval": true,
- "console.warn": true,
"open": true,
"setInterval": true
},
diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json
index b4a93c2e5..0dfa74914 100644
--- a/lavamoat/browserify/main/policy.json
+++ b/lavamoat/browserify/main/policy.json
@@ -563,10 +563,7 @@
"ethereumjs-wallet": true,
"ethers": true,
"ethjs-unit": true,
- "ethjs-util": true,
"events": true,
- "human-standard-collectible-abi": true,
- "human-standard-token-abi": true,
"immer": true,
"isomorphic-fetch": true,
"jsonschema": true,
@@ -698,6 +695,25 @@
"events": true
}
},
+ "@metamask/smart-transactions-controller": {
+ "globals": {
+ "URLSearchParams": true,
+ "clearInterval": true,
+ "console.error": true,
+ "console.log": true,
+ "fetch": true,
+ "setInterval": true,
+ "setTimeout": true
+ },
+ "packages": {
+ "@metamask/controllers": true,
+ "bignumber.js": true,
+ "ethers": true,
+ "fast-json-patch": true,
+ "isomorphic-fetch": true,
+ "lodash": true
+ }
+ },
"@metamask/snap-controllers": {
"globals": {
"URL": true,
@@ -1935,7 +1951,6 @@
"addEventListener": true,
"browser": true,
"clearInterval": true,
- "console.warn": true,
"open": true,
"setInterval": true
},
diff --git a/lavamoat/build-system/policy.json b/lavamoat/build-system/policy.json
index e95acc4d6..7d6167325 100644
--- a/lavamoat/build-system/policy.json
+++ b/lavamoat/build-system/policy.json
@@ -1052,6 +1052,16 @@
"buffer-equal": true
}
},
+ "are-we-there-yet": {
+ "builtin": {
+ "events.EventEmitter": true,
+ "util.inherits": true
+ },
+ "packages": {
+ "delegates": true,
+ "readable-stream": true
+ }
+ },
"arr-diff": {
"packages": {
"arr-flatten": true,
@@ -1460,6 +1470,7 @@
"anymatch": true,
"async-each": true,
"braces": true,
+ "fsevents": true,
"glob-parent": true,
"inherits": true,
"is-binary-path": true,
@@ -1726,6 +1737,16 @@
"through2": true
}
},
+ "detect-libc": {
+ "builtin": {
+ "child_process.spawnSync": true,
+ "fs.readdirSync": true,
+ "os.platform": true
+ },
+ "globals": {
+ "process.env": true
+ }
+ },
"detective": {
"packages": {
"acorn-node": true,
@@ -2429,6 +2450,45 @@
"process.version": true
}
},
+ "fsevents": {
+ "builtin": {
+ "events.EventEmitter": true,
+ "fs.stat": true,
+ "path.join": true,
+ "util.inherits": true
+ },
+ "globals": {
+ "__dirname": true,
+ "process.nextTick": true,
+ "process.platform": true,
+ "setImmediate": true
+ },
+ "native": true,
+ "packages": {
+ "node-pre-gyp": true
+ }
+ },
+ "gauge": {
+ "builtin": {
+ "util.format": true
+ },
+ "globals": {
+ "clearInterval": true,
+ "process": true,
+ "setImmediate": true,
+ "setInterval": true
+ },
+ "packages": {
+ "aproba": true,
+ "console-control-strings": true,
+ "has-unicode": true,
+ "object-assign": true,
+ "signal-exit": true,
+ "string-width": true,
+ "strip-ansi": true,
+ "wide-align": true
+ }
+ },
"get-assigned-identifiers": {
"builtin": {
"assert.equal": true
@@ -2807,6 +2867,16 @@
"process.argv": true
}
},
+ "has-unicode": {
+ "builtin": {
+ "os.type": true
+ },
+ "globals": {
+ "process.env.LANG": true,
+ "process.env.LC_ALL": true,
+ "process.env.LC_CTYPE": true
+ }
+ },
"has-value": {
"packages": {
"get-value": true,
@@ -2978,6 +3048,11 @@
"is-plain-object": true
}
},
+ "is-fullwidth-code-point": {
+ "packages": {
+ "number-is-nan": true
+ }
+ },
"is-glob": {
"packages": {
"is-extglob": true
@@ -3508,6 +3583,56 @@
"setTimeout": true
}
},
+ "node-pre-gyp": {
+ "builtin": {
+ "events.EventEmitter": true,
+ "fs.existsSync": true,
+ "fs.readFileSync": true,
+ "fs.renameSync": true,
+ "path.dirname": true,
+ "path.existsSync": true,
+ "path.join": true,
+ "path.resolve": true,
+ "url.parse": true,
+ "url.resolve": true,
+ "util.inherits": true
+ },
+ "globals": {
+ "__dirname": true,
+ "console.log": true,
+ "process.arch": true,
+ "process.cwd": true,
+ "process.env": true,
+ "process.platform": true,
+ "process.version.substr": true,
+ "process.versions": true
+ },
+ "packages": {
+ "detect-libc": true,
+ "nopt": true,
+ "npmlog": true,
+ "rimraf": true,
+ "semver": true
+ }
+ },
+ "nopt": {
+ "builtin": {
+ "path": true,
+ "stream.Stream": true,
+ "url": true
+ },
+ "globals": {
+ "console": true,
+ "process.argv": true,
+ "process.env.DEBUG_NOPT": true,
+ "process.env.NOPT_DEBUG": true,
+ "process.platform": true
+ },
+ "packages": {
+ "abbrev": true,
+ "osenv": true
+ }
+ },
"normalize-package-data": {
"builtin": {
"url.parse": true,
@@ -3535,6 +3660,22 @@
"once": true
}
},
+ "npmlog": {
+ "builtin": {
+ "events.EventEmitter": true,
+ "util": true
+ },
+ "globals": {
+ "process.nextTick": true,
+ "process.stderr": true
+ },
+ "packages": {
+ "are-we-there-yet": true,
+ "console-control-strings": true,
+ "gauge": true,
+ "set-blocking": true
+ }
+ },
"object-copy": {
"packages": {
"copy-descriptor": true,
@@ -3616,6 +3757,54 @@
"readable-stream": true
}
},
+ "os-homedir": {
+ "builtin": {
+ "os.homedir": true
+ },
+ "globals": {
+ "process.env": true,
+ "process.getuid": true,
+ "process.platform": true
+ }
+ },
+ "os-tmpdir": {
+ "globals": {
+ "process.env.SystemRoot": true,
+ "process.env.TEMP": true,
+ "process.env.TMP": true,
+ "process.env.TMPDIR": true,
+ "process.env.windir": true,
+ "process.platform": true
+ }
+ },
+ "osenv": {
+ "builtin": {
+ "child_process.exec": true,
+ "path": true
+ },
+ "globals": {
+ "process.env.COMPUTERNAME": true,
+ "process.env.ComSpec": true,
+ "process.env.EDITOR": true,
+ "process.env.HOSTNAME": true,
+ "process.env.PATH": true,
+ "process.env.PROMPT": true,
+ "process.env.PS1": true,
+ "process.env.Path": true,
+ "process.env.SHELL": true,
+ "process.env.USER": true,
+ "process.env.USERDOMAIN": true,
+ "process.env.USERNAME": true,
+ "process.env.VISUAL": true,
+ "process.env.path": true,
+ "process.nextTick": true,
+ "process.platform": true
+ },
+ "packages": {
+ "os-homedir": true,
+ "os-tmpdir": true
+ }
+ },
"p-limit": {
"packages": {
"p-try": true
@@ -4325,6 +4514,12 @@
"lru-cache": true
}
},
+ "set-blocking": {
+ "globals": {
+ "process.stderr": true,
+ "process.stdout": true
+ }
+ },
"set-value": {
"packages": {
"extend-shallow": true,
@@ -4588,6 +4783,7 @@
},
"string-width": {
"packages": {
+ "code-point-at": true,
"emoji-regex": true,
"is-fullwidth-code-point": true,
"strip-ansi": true
@@ -5240,6 +5436,11 @@
"isexe": true
}
},
+ "wide-align": {
+ "packages": {
+ "string-width": true
+ }
+ },
"write": {
"builtin": {
"fs.createWriteStream": true,
diff --git a/package.json b/package.json
index 9637d463a..ee4d7e2cf 100644
--- a/package.json
+++ b/package.json
@@ -121,6 +121,7 @@
"@metamask/providers": "^8.1.1",
"@metamask/rpc-methods": "^0.9.0",
"@metamask/slip44": "^2.0.0",
+ "@metamask/smart-transactions-controller": "^1.9.1",
"@metamask/snap-controllers": "^0.9.0",
"@ngraveio/bc-ur": "^1.1.6",
"@popperjs/core": "^2.4.0",
diff --git a/patches/@metamask+smart-transactions-controller++fast-json-patch+3.1.0.patch b/patches/@metamask+smart-transactions-controller++fast-json-patch+3.1.0.patch
new file mode 100644
index 000000000..97762d5a9
--- /dev/null
+++ b/patches/@metamask+smart-transactions-controller++fast-json-patch+3.1.0.patch
@@ -0,0 +1,13 @@
+diff --git a/node_modules/@metamask/smart-transactions-controller/node_modules/fast-json-patch/commonjs/helpers.js b/node_modules/@metamask/smart-transactions-controller/node_modules/fast-json-patch/commonjs/helpers.js
+index 0ac28b4..d048c0a 100644
+--- a/node_modules/@metamask/smart-transactions-controller/node_modules/fast-json-patch/commonjs/helpers.js
++++ b/node_modules/@metamask/smart-transactions-controller/node_modules/fast-json-patch/commonjs/helpers.js
+@@ -21,7 +21,7 @@ var _hasOwnProperty = Object.prototype.hasOwnProperty;
+ function hasOwnProperty(obj, key) {
+ return _hasOwnProperty.call(obj, key);
+ }
+-exports.hasOwnProperty = hasOwnProperty;
++Object.defineProperty(exports, "hasOwnProperty", { value: hasOwnProperty });
+ function _objectKeys(obj) {
+ if (Array.isArray(obj)) {
+ var keys = new Array(obj.length);
diff --git a/shared/constants/swaps.js b/shared/constants/swaps.js
index 5af39669a..2b035bd12 100644
--- a/shared/constants/swaps.js
+++ b/shared/constants/swaps.js
@@ -123,6 +123,11 @@ export const ALLOWED_SWAPS_CHAIN_IDS = {
[AVALANCHE_CHAIN_ID]: true,
};
+export const ALLOWED_SMART_TRANSACTIONS_CHAIN_IDS = [
+ MAINNET_CHAIN_ID,
+ RINKEBY_CHAIN_ID,
+];
+
export const SWAPS_CHAINID_CONTRACT_ADDRESS_MAP = {
[MAINNET_CHAIN_ID]: MAINNET_CONTRACT_ADDRESS,
[SWAPS_TESTNET_CHAIN_ID]: TESTNET_CONTRACT_ADDRESS,
diff --git a/shared/constants/transaction.js b/shared/constants/transaction.js
index 929e2bf71..8ac9defc1 100644
--- a/shared/constants/transaction.js
+++ b/shared/constants/transaction.js
@@ -55,6 +55,7 @@ export const TRANSACTION_TYPES = {
DEPLOY_CONTRACT: 'contractDeployment',
SWAP: 'swap',
SWAP_APPROVAL: 'swapApproval',
+ SMART: 'smart',
SIGN: MESSAGE_TYPE.ETH_SIGN,
SIGN_TYPED_DATA: MESSAGE_TYPE.ETH_SIGN_TYPED_DATA,
PERSONAL_SIGN: MESSAGE_TYPE.PERSONAL_SIGN,
@@ -128,6 +129,7 @@ export const TRANSACTION_STATUSES = {
FAILED: 'failed',
DROPPED: 'dropped',
CONFIRMED: 'confirmed',
+ PENDING: 'pending',
};
/**
@@ -150,6 +152,23 @@ export const TRANSACTION_GROUP_STATUSES = {
PENDING: 'pending',
};
+/**
+ * Statuses that are specific to Smart Transactions.
+ *
+ * @typedef {Object} SmartTransactionStatuses
+ * @property {'cancelled'} CANCELLED - It can be cancelled for various reasons.
+ * @property {'pending'} PENDING - Smart transaction is being processed.
+ */
+
+/**
+ * @type {SmartTransactionStatuses}
+ */
+export const SMART_TRANSACTION_STATUSES = {
+ CANCELLED: 'cancelled',
+ PENDING: 'pending',
+ SUCCESS: 'success',
+};
+
/**
* Transaction Group Category is a MetaMask construct to categorize the intent
* of a group of transactions for purposes of displaying in the UI
diff --git a/test/jest/mock-store.js b/test/jest/mock-store.js
index 3039cc1a4..443ccbe2c 100644
--- a/test/jest/mock-store.js
+++ b/test/jest/mock-store.js
@@ -1,5 +1,55 @@
import { MAINNET_CHAIN_ID } from '../../shared/constants/network';
+const createGetSmartTransactionFeesApiResponse = () => {
+ return {
+ cancelFees: [
+ { maxFeePerGas: 2100001000, maxPriorityFeePerGas: 466503987 },
+ { maxFeePerGas: 2310003200, maxPriorityFeePerGas: 513154852 },
+ { maxFeePerGas: 2541005830, maxPriorityFeePerGas: 564470851 },
+ { maxFeePerGas: 2795108954, maxPriorityFeePerGas: 620918500 },
+ { maxFeePerGas: 3074622644, maxPriorityFeePerGas: 683010971 },
+ { maxFeePerGas: 3382087983, maxPriorityFeePerGas: 751312751 },
+ { maxFeePerGas: 3720300164, maxPriorityFeePerGas: 826444778 },
+ { maxFeePerGas: 4092333900, maxPriorityFeePerGas: 909090082 },
+ { maxFeePerGas: 4501571383, maxPriorityFeePerGas: 1000000000 },
+ { maxFeePerGas: 4951733023, maxPriorityFeePerGas: 1100001000 },
+ { maxFeePerGas: 5446911277, maxPriorityFeePerGas: 1210002200 },
+ { maxFeePerGas: 5991607851, maxPriorityFeePerGas: 1331003630 },
+ { maxFeePerGas: 6590774628, maxPriorityFeePerGas: 1464105324 },
+ { maxFeePerGas: 7249858682, maxPriorityFeePerGas: 1610517320 },
+ { maxFeePerGas: 7974851800, maxPriorityFeePerGas: 1771570663 },
+ { maxFeePerGas: 8772344955, maxPriorityFeePerGas: 1948729500 },
+ { maxFeePerGas: 9649588222, maxPriorityFeePerGas: 2143604399 },
+ { maxFeePerGas: 10614556694, maxPriorityFeePerGas: 2357966983 },
+ { maxFeePerGas: 11676022978, maxPriorityFeePerGas: 2593766039 },
+ ],
+ feeEstimate: 42000000000000,
+ fees: [
+ { maxFeePerGas: 2310003200, maxPriorityFeePerGas: 513154852 },
+ { maxFeePerGas: 2541005830, maxPriorityFeePerGas: 564470850 },
+ { maxFeePerGas: 2795108954, maxPriorityFeePerGas: 620918500 },
+ { maxFeePerGas: 3074622644, maxPriorityFeePerGas: 683010970 },
+ { maxFeePerGas: 3382087983, maxPriorityFeePerGas: 751312751 },
+ { maxFeePerGas: 3720300163, maxPriorityFeePerGas: 826444777 },
+ { maxFeePerGas: 4092333900, maxPriorityFeePerGas: 909090082 },
+ { maxFeePerGas: 4501571382, maxPriorityFeePerGas: 999999999 },
+ { maxFeePerGas: 4951733022, maxPriorityFeePerGas: 1100001000 },
+ { maxFeePerGas: 5446911277, maxPriorityFeePerGas: 1210002200 },
+ { maxFeePerGas: 5991607851, maxPriorityFeePerGas: 1331003630 },
+ { maxFeePerGas: 6590774627, maxPriorityFeePerGas: 1464105324 },
+ { maxFeePerGas: 7249858681, maxPriorityFeePerGas: 1610517320 },
+ { maxFeePerGas: 7974851800, maxPriorityFeePerGas: 1771570662 },
+ { maxFeePerGas: 8772344954, maxPriorityFeePerGas: 1948729500 },
+ { maxFeePerGas: 9649588222, maxPriorityFeePerGas: 2143604398 },
+ { maxFeePerGas: 10614556693, maxPriorityFeePerGas: 2357966982 },
+ { maxFeePerGas: 11676022977, maxPriorityFeePerGas: 2593766039 },
+ { maxFeePerGas: 12843636951, maxPriorityFeePerGas: 2853145236 },
+ ],
+ gasLimit: 21000,
+ gasUsed: 21000,
+ };
+};
+
export const createSwapsMockStore = () => {
return {
swaps: {
@@ -21,6 +71,11 @@ export const createSwapsMockStore = () => {
fromToken: 'ETH',
},
metamask: {
+ networkDetails: {
+ EIPS: {
+ 1559: false,
+ },
+ },
provider: {
chainId: MAINNET_CHAIN_ID,
},
@@ -96,6 +151,12 @@ export const createSwapsMockStore = () => {
},
],
swapsState: {
+ swapsFeatureFlags: {
+ smartTransactions: {
+ mobileActive: true,
+ extensionActive: true,
+ },
+ },
quotes: {
TEST_AGG_1: {
trade: {
@@ -290,6 +351,44 @@ export const createSwapsMockStore = () => {
occurrences: 11,
},
},
+ smartTransactionsState: {
+ userOptIn: true,
+ liveness: true,
+ fees: createGetSmartTransactionFeesApiResponse(),
+ smartTransactions: {
+ [MAINNET_CHAIN_ID]: [
+ {
+ uuid: 'uuid2',
+ status: 'success',
+ statusMetadata: {
+ cancellationFeeWei: 36777567771000,
+ cancellationReason: 'not_cancelled',
+ deadlineRatio: 0.6400288486480713,
+ minedHash:
+ '0x55ad39634ee10d417b6e190cfd3736098957e958879cffe78f1f00f4fd2654d6',
+ minedTx: 'success',
+ },
+ },
+ {
+ uuid: 'uuid2',
+ status: 'pending',
+ statusMetadata: {
+ cancellationFeeWei: 36777567771000,
+ cancellationReason: 'not_cancelled',
+ deadlineRatio: 0.6400288486480713,
+ minedHash:
+ '0x55ad39634ee10d417b6e190cfd3736098957e958879cffe78f1f00f4fd2654d6',
+ minedTx: 'success',
+ },
+ },
+ ],
+ },
+ estimatedGas: {
+ txData: {
+ feeEstimate: 5435000587128155,
+ },
+ },
+ },
},
appState: {
modal: {
diff --git a/ui/components/app/transaction-detail/transaction-detail.component.js b/ui/components/app/transaction-detail/transaction-detail.component.js
index 57d4a9df8..bb17e72ae 100644
--- a/ui/components/app/transaction-detail/transaction-detail.component.js
+++ b/ui/components/app/transaction-detail/transaction-detail.component.js
@@ -12,13 +12,14 @@ export default function TransactionDetail({
rows = [],
onEdit,
userAcknowledgedGasMissing = false,
+ disableEditGasFeeButton = false,
}) {
const t = useI18nContext();
const { supportsEIP1559V2 } = useGasFeeContext();
return (
- {supportsEIP1559V2 && (
+ {supportsEIP1559V2 && !disableEditGasFeeButton && (
+
+ }
+ subtitle={
+
+
+
+ {subtitle}
+
+
+ }
+ >
+ {displayedStatusKey === TRANSACTION_GROUP_STATUSES.PENDING &&
+ showCancelSwapLink && (
+
+ {
+ e?.preventDefault();
+ dispatch(cancelSwapsSmartTransaction(smartTransaction.uuid));
+ setCancelSwapLinkClicked(true);
+ }}
+ />
+
+ )}
+
+ >
+ );
+}
+
+SmartTransactionListItem.propTypes = {
+ smartTransaction: PropTypes.object.isRequired,
+ isEarliestNonce: PropTypes.bool,
+};
diff --git a/ui/components/app/transaction-list/transaction-list.component.js b/ui/components/app/transaction-list/transaction-list.component.js
index 2660be340..8a21da314 100644
--- a/ui/components/app/transaction-list/transaction-list.component.js
+++ b/ui/components/app/transaction-list/transaction-list.component.js
@@ -8,6 +8,7 @@ import {
import { getCurrentChainId } from '../../../selectors';
import { useI18nContext } from '../../../hooks/useI18nContext';
import TransactionListItem from '../transaction-list-item';
+import SmartTransactionListItem from '../transaction-list-item/smart-transaction-list-item.component';
import Button from '../../ui/button';
import { TOKEN_CATEGORY_HASH } from '../../../helpers/constants/transactions';
import { SWAPS_CHAINID_CONTRACT_ADDRESS_MAP } from '../../../../shared/constants/swaps';
@@ -114,38 +115,53 @@ export default function TransactionList({
[],
);
- const pendingLength = pendingTransactions.length;
-
return (
- {pendingLength > 0 && (
+ {pendingTransactions.length > 0 && (
{`${t('queue')} (${pendingTransactions.length})`}
- {pendingTransactions.map((transactionGroup, index) => (
-
- ))}
+ {pendingTransactions.map((transactionGroup, index) =>
+ transactionGroup.initialTransaction.transactionType ===
+ TRANSACTION_TYPES.SMART ? (
+
+ ) : (
+
+ ),
+ )}
)}
- {pendingLength > 0 ? (
+ {pendingTransactions.length > 0 ? (
{t('history')}
) : null}
{completedTransactions.length > 0 ? (
completedTransactions
.slice(0, limit)
- .map((transactionGroup, index) => (
-
- ))
+ .map((transactionGroup, index) =>
+ transactionGroup.initialTransaction?.transactionType ===
+ 'smart' ? (
+
+ ) : (
+
+ ),
+ )
) : (
diff --git a/ui/ducks/app/app.js b/ui/ducks/app/app.js
index cc43bf999..228b3eb5b 100644
--- a/ui/ducks/app/app.js
+++ b/ui/ducks/app/app.js
@@ -52,6 +52,8 @@ export default function reduceApp(state = {}, action) {
testKey: null,
},
gasLoadingAnimationIsShowing: false,
+ smartTransactionsError: null,
+ smartTransactionsErrorMessageDismissed: false,
ledgerWebHidConnectedStatus: WEBHID_CONNECTED_STATUSES.UNKNOWN,
ledgerTransportStatus: TRANSPORT_STATES.NONE,
newNetworkAdded: '',
@@ -96,6 +98,19 @@ export default function reduceApp(state = {}, action) {
qrCodeData: action.value,
};
+ // Smart Transactions errors.
+ case actionConstants.SET_SMART_TRANSACTIONS_ERROR:
+ return {
+ ...appState,
+ smartTransactionsError: action.payload,
+ };
+
+ case actionConstants.DISMISS_SMART_TRANSACTIONS_ERROR_MESSAGE:
+ return {
+ ...appState,
+ smartTransactionsErrorMessageDismissed: true,
+ };
+
// modal methods:
case actionConstants.MODAL_OPEN: {
const { name, ...modalProps } = action.payload;
diff --git a/ui/ducks/app/app.test.js b/ui/ducks/app/app.test.js
index f920cb91b..e52aebf0f 100644
--- a/ui/ducks/app/app.test.js
+++ b/ui/ducks/app/app.test.js
@@ -329,4 +329,12 @@ describe('App State', () => {
expect(state.isMouseUser).toStrictEqual(true);
});
+
+ it('smart transactions - SET_SMART_TRANSACTIONS_ERROR', () => {
+ const state = reduceApp(metamaskState, {
+ type: actions.SET_SMART_TRANSACTIONS_ERROR,
+ payload: 'Server Side Error',
+ });
+ expect(state.smartTransactionsError).toStrictEqual('Server Side Error');
+ });
});
diff --git a/ui/ducks/swaps/swaps.js b/ui/ducks/swaps/swaps.js
index 7ad50192f..84d26b2a2 100644
--- a/ui/ducks/swaps/swaps.js
+++ b/ui/ducks/swaps/swaps.js
@@ -21,9 +21,17 @@ import {
updateTransaction,
resetBackgroundSwapsState,
setSwapsLiveness,
+ setSwapsFeatureFlags,
setSelectedQuoteAggId,
setSwapsTxGasLimit,
cancelTx,
+ fetchSmartTransactionsLiveness,
+ signAndSendSmartTransaction,
+ updateSmartTransaction,
+ setSmartTransactionsRefreshInterval,
+ fetchSmartTransactionFees,
+ estimateSmartTransactionsGas,
+ cancelSmartTransaction,
} from '../../store/actions';
import {
AWAITING_SIGNATURES_ROUTE,
@@ -32,12 +40,15 @@ import {
LOADING_QUOTES_ROUTE,
SWAPS_ERROR_ROUTE,
SWAPS_MAINTENANCE_ROUTE,
+ SMART_TRANSACTION_STATUS_ROUTE,
} from '../../helpers/constants/routes';
import {
fetchSwapsFeatureFlags,
fetchSwapsGasPrices,
isContractAddressValid,
getSwapsLivenessForNetwork,
+ parseSmartTransactionsError,
+ stxErrorTypes,
} from '../../pages/swaps/swaps.util';
import { calcGasTotal } from '../../pages/send/send.utils';
import {
@@ -65,8 +76,12 @@ import {
CONTRACT_DATA_DISABLED_ERROR,
SWAP_FAILED_ERROR,
SWAPS_FETCH_ORDER_CONFLICT,
+ ALLOWED_SMART_TRANSACTIONS_CHAIN_IDS,
} from '../../../shared/constants/swaps';
-import { TRANSACTION_TYPES } from '../../../shared/constants/transaction';
+import {
+ TRANSACTION_TYPES,
+ SMART_TRANSACTION_STATUSES,
+} from '../../../shared/constants/transaction';
import { getGasFeeEstimates } from '../metamask/metamask';
const GAS_PRICES_LOADING_STATES = {
@@ -100,6 +115,9 @@ const initialState = {
priceEstimates: {},
fallBackPrice: null,
},
+ currentSmartTransactionsError: '',
+ currentSmartTransactionsErrorMessageDismissed: false,
+ swapsSTXLoading: false,
};
const slice = createSlice({
@@ -179,6 +197,18 @@ const slice = createSlice({
retrievedFallbackSwapsGasPrice: (state, action) => {
state.customGas.fallBackPrice = action.payload;
},
+ setCurrentSmartTransactionsError: (state, action) => {
+ const errorType = stxErrorTypes.includes(action.payload)
+ ? action.payload
+ : stxErrorTypes[0];
+ state.currentSmartTransactionsError = errorType;
+ },
+ dismissCurrentSmartTransactionsErrorMessage: (state) => {
+ state.currentSmartTransactionsErrorMessageDismissed = true;
+ },
+ setSwapsSTXSubmitLoading: (state, action) => {
+ state.swapsSTXLoading = action.payload || false;
+ },
},
});
@@ -202,6 +232,8 @@ export const getFromTokenInputValue = (state) =>
export const getIsFeatureFlagLoaded = (state) =>
state.swaps.isFeatureFlagLoaded;
+export const getSwapsSTXLoading = (state) => state.swaps.swapsSTXLoading;
+
export const getMaxSlippage = (state) => state.swaps.maxSlippage;
export const getTopAssets = (state) => state.swaps.topAssets;
@@ -234,6 +266,12 @@ export const getSwapGasPriceEstimateData = (state) =>
export const getSwapsFallbackGasPrice = (state) =>
state.swaps.customGas.fallBackPrice;
+export const getCurrentSmartTransactionsError = (state) =>
+ state.swaps.currentSmartTransactionsError;
+
+export const getCurrentSmartTransactionsErrorMessageDismissed = (state) =>
+ state.swaps.currentSmartTransactionsErrorMessageDismissed;
+
export function shouldShowCustomPriceTooLowWarning(state) {
const { average } = getSwapGasPriceEstimateData(state);
@@ -263,6 +301,37 @@ const getSwapsState = (state) => state.metamask.swapsState;
export const getSwapsFeatureIsLive = (state) =>
state.metamask.swapsState.swapsFeatureIsLive;
+export const getSmartTransactionsError = (state) =>
+ state.appState.smartTransactionsError;
+
+export const getSmartTransactionsErrorMessageDismissed = (state) =>
+ state.appState.smartTransactionsErrorMessageDismissed;
+
+export const getSmartTransactionsEnabled = (state) => {
+ const hardwareWalletUsed = isHardwareWallet(state);
+ const chainId = getCurrentChainId(state);
+ const isAllowedNetwork = ALLOWED_SMART_TRANSACTIONS_CHAIN_IDS.includes(
+ chainId,
+ );
+ const smartTransactionsFeatureFlagEnabled =
+ state.metamask.swapsState?.swapsFeatureFlags?.smartTransactions
+ ?.extensionActive;
+ const smartTransactionsLiveness =
+ state.metamask.smartTransactionsState?.liveness;
+ return Boolean(
+ isAllowedNetwork &&
+ !hardwareWalletUsed &&
+ smartTransactionsFeatureFlagEnabled &&
+ smartTransactionsLiveness,
+ );
+};
+
+export const getCurrentSmartTransactionsEnabled = (state) => {
+ const smartTransactionsEnabled = getSmartTransactionsEnabled(state);
+ const currentSmartTransactionsError = getCurrentSmartTransactionsError(state);
+ return smartTransactionsEnabled && !currentSmartTransactionsError;
+};
+
export const getSwapsQuoteRefreshTime = (state) =>
state.metamask.swapsState.swapsQuoteRefreshTime;
@@ -342,6 +411,51 @@ export const getApproveTxParams = (state) => {
return { ...approvalNeeded, gasPrice, data };
};
+export const getSmartTransactionsOptInStatus = (state) => {
+ return state.metamask.smartTransactionsState?.userOptIn;
+};
+
+export const getCurrentSmartTransactions = (state) => {
+ return state.metamask.smartTransactionsState?.smartTransactions?.[
+ getCurrentChainId(state)
+ ];
+};
+
+export const getPendingSmartTransactions = (state) => {
+ const currentSmartTransactions = getCurrentSmartTransactions(state);
+ if (!currentSmartTransactions || currentSmartTransactions.length === 0) {
+ return [];
+ }
+ return currentSmartTransactions.filter(
+ (stx) => stx.status === SMART_TRANSACTION_STATUSES.PENDING,
+ );
+};
+
+export const getSmartTransactionFees = (state) => {
+ return state.metamask.smartTransactionsState?.fees;
+};
+
+export const getSmartTransactionEstimatedGas = (state) => {
+ return state.metamask.smartTransactionsState?.estimatedGas;
+};
+
+export const getSwapsRefreshStates = (state) => {
+ const {
+ swapsQuoteRefreshTime,
+ swapsQuotePrefetchingRefreshTime,
+ swapsStxGetTransactionsRefreshTime,
+ swapsStxBatchStatusRefreshTime,
+ swapsStxStatusDeadline,
+ } = state.metamask.swapsState;
+ return {
+ quoteRefreshTime: swapsQuoteRefreshTime,
+ quotePrefetchingRefreshTime: swapsQuotePrefetchingRefreshTime,
+ stxGetTransactionsRefreshTime: swapsStxGetTransactionsRefreshTime,
+ stxBatchStatusRefreshTime: swapsStxBatchStatusRefreshTime,
+ stxStatusDeadline: swapsStxStatusDeadline,
+ };
+};
+
// Actions / action-creators
const {
@@ -367,10 +481,14 @@ const {
swapCustomGasModalLimitEdited,
retrievedFallbackSwapsGasPrice,
swapCustomGasModalClosed,
+ setCurrentSmartTransactionsError,
+ dismissCurrentSmartTransactionsErrorMessage,
+ setSwapsSTXSubmitLoading,
} = actions;
export {
clearSwapsState,
+ dismissCurrentSmartTransactionsErrorMessage,
setAggregatorMetadata,
setBalanceError,
setFetchingQuotes,
@@ -430,19 +548,27 @@ export const fetchAndSetSwapsGasPriceInfo = () => {
};
};
-export const fetchSwapsLiveness = () => {
+export const fetchSwapsLivenessAndFeatureFlags = () => {
return async (dispatch, getState) => {
let swapsLivenessForNetwork = {
swapsFeatureIsLive: false,
};
+ const chainId = getCurrentChainId(getState());
try {
const swapsFeatureFlags = await fetchSwapsFeatureFlags();
+ await dispatch(setSwapsFeatureFlags(swapsFeatureFlags));
+ if (ALLOWED_SMART_TRANSACTIONS_CHAIN_IDS.includes(chainId)) {
+ await dispatch(fetchSmartTransactionsLiveness());
+ }
swapsLivenessForNetwork = getSwapsLivenessForNetwork(
swapsFeatureFlags,
- getCurrentChainId(getState()),
+ chainId,
);
} catch (error) {
- log.error('Failed to fetch Swaps liveness, defaulting to false.', error);
+ log.error(
+ 'Failed to fetch Swaps feature flags and Swaps liveness, defaulting to false.',
+ error,
+ );
}
await dispatch(setSwapsLiveness(swapsLivenessForNetwork));
dispatch(setIsFeatureFlagLoaded(true));
@@ -565,6 +691,11 @@ export const fetchQuotesAndSetQuoteState = (
const networkAndAccountSupports1559 = checkNetworkAndAccountSupports1559(
state,
);
+ const smartTransactionsOptInStatus = getSmartTransactionsOptInStatus(state);
+ const smartTransactionsEnabled = getSmartTransactionsEnabled(state);
+ const currentSmartTransactionsEnabled = getCurrentSmartTransactionsEnabled(
+ state,
+ );
metaMetricsEvent({
event: 'Quotes Requested',
category: 'swaps',
@@ -577,6 +708,9 @@ export const fetchQuotesAndSetQuoteState = (
custom_slippage: maxSlippage !== 2,
is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType,
+ stx_enabled: smartTransactionsEnabled,
+ current_stx_enabled: currentSmartTransactionsEnabled,
+ stx_user_opt_in: smartTransactionsOptInStatus,
anonymizedData: true,
},
});
@@ -628,6 +762,9 @@ export const fetchQuotesAndSetQuoteState = (
custom_slippage: maxSlippage !== 2,
is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType,
+ stx_enabled: smartTransactionsEnabled,
+ current_stx_enabled: currentSmartTransactionsEnabled,
+ stx_user_opt_in: smartTransactionsOptInStatus,
},
});
dispatch(setSwapsErrorKey(QUOTES_NOT_AVAILABLE_ERROR));
@@ -653,6 +790,9 @@ export const fetchQuotesAndSetQuoteState = (
available_quotes: Object.values(fetchedQuotes)?.length,
is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType,
+ stx_enabled: smartTransactionsEnabled,
+ current_stx_enabled: currentSmartTransactionsEnabled,
+ stx_user_opt_in: smartTransactionsOptInStatus,
anonymizedData: true,
},
});
@@ -675,6 +815,153 @@ export const fetchQuotesAndSetQuoteState = (
};
};
+export const signAndSendSwapsSmartTransaction = ({
+ unsignedTransaction,
+ metaMetricsEvent,
+ history,
+}) => {
+ return async (dispatch, getState) => {
+ dispatch(setSwapsSTXSubmitLoading(true));
+ const state = getState();
+ const fetchParams = getFetchParams(state);
+ const { metaData, value: swapTokenValue, slippage } = fetchParams;
+ const { sourceTokenInfo = {}, destinationTokenInfo = {} } = metaData;
+ const usedQuote = getUsedQuote(state);
+ const swapsRefreshStates = getSwapsRefreshStates(state);
+ const chainId = getCurrentChainId(state);
+
+ dispatch(
+ setSmartTransactionsRefreshInterval(
+ swapsRefreshStates?.stxBatchStatusRefreshTime,
+ ),
+ );
+
+ const usedTradeTxParams = usedQuote.trade;
+
+ // update stx with data
+ const destinationValue = calcTokenAmount(
+ usedQuote.destinationAmount,
+ destinationTokenInfo.decimals || 18,
+ ).toPrecision(8);
+ const smartTransactionsOptInStatus = getSmartTransactionsOptInStatus(state);
+ const smartTransactionsEnabled = getSmartTransactionsEnabled(state);
+ const currentSmartTransactionsEnabled = getCurrentSmartTransactionsEnabled(
+ state,
+ );
+ const swapMetaData = {
+ token_from: sourceTokenInfo.symbol,
+ token_from_amount: String(swapTokenValue),
+ token_to: destinationTokenInfo.symbol,
+ token_to_amount: destinationValue,
+ slippage,
+ custom_slippage: slippage !== 2,
+ best_quote_source: getTopQuote(state)?.aggregator,
+ available_quotes: getQuotes(state)?.length,
+ other_quote_selected:
+ usedQuote.aggregator !== getTopQuote(state)?.aggregator,
+ other_quote_selected_source:
+ usedQuote.aggregator === getTopQuote(state)?.aggregator
+ ? ''
+ : usedQuote.aggregator,
+ average_savings: usedQuote.savings?.total,
+ performance_savings: usedQuote.savings?.performance,
+ fee_savings: usedQuote.savings?.fee,
+ median_metamask_fee: usedQuote.savings?.medianMetaMaskFee,
+ stx_enabled: smartTransactionsEnabled,
+ current_stx_enabled: currentSmartTransactionsEnabled,
+ stx_user_opt_in: smartTransactionsOptInStatus,
+ };
+ metaMetricsEvent({
+ event: 'STX Swap Started',
+ category: 'swaps',
+ sensitiveProperties: swapMetaData,
+ });
+
+ if (!isContractAddressValid(usedTradeTxParams.to, chainId)) {
+ captureMessage('Invalid contract address', {
+ extra: {
+ token_from: swapMetaData.token_from,
+ token_to: swapMetaData.token_to,
+ contract_address: usedTradeTxParams.to,
+ },
+ });
+ await dispatch(setSwapsErrorKey(SWAP_FAILED_ERROR));
+ history.push(SWAPS_ERROR_ROUTE);
+ return;
+ }
+
+ const approveTxParams = getApproveTxParams(state);
+ let approvalTxUuid;
+ try {
+ if (approveTxParams) {
+ const updatedApproveTxParams = {
+ ...approveTxParams,
+ value: '0x0',
+ };
+ const smartTransactionApprovalFees = await dispatch(
+ fetchSwapsSmartTransactionFees(updatedApproveTxParams),
+ );
+ updatedApproveTxParams.gas = `0x${decimalToHex(
+ smartTransactionApprovalFees?.gasLimit || 0,
+ )}`;
+ approvalTxUuid = await dispatch(
+ signAndSendSmartTransaction({
+ unsignedTransaction: updatedApproveTxParams,
+ smartTransactionFees: smartTransactionApprovalFees,
+ }),
+ );
+ }
+
+ const smartTransactionFees = await dispatch(
+ fetchSwapsSmartTransactionFees(unsignedTransaction),
+ );
+ const uuid = await dispatch(
+ signAndSendSmartTransaction({
+ unsignedTransaction,
+ smartTransactionFees,
+ }),
+ );
+
+ const destinationTokenAddress = destinationTokenInfo.address;
+ const destinationTokenDecimals = destinationTokenInfo.decimals;
+ const destinationTokenSymbol = destinationTokenInfo.symbol;
+ const sourceTokenSymbol = sourceTokenInfo.symbol;
+ await dispatch(
+ updateSmartTransaction(uuid, {
+ origin: 'metamask',
+ destinationTokenAddress,
+ destinationTokenDecimals,
+ destinationTokenSymbol,
+ sourceTokenSymbol,
+ swapMetaData,
+ swapTokenValue,
+ type: TRANSACTION_TYPES.SWAP,
+ }),
+ );
+ if (approvalTxUuid) {
+ await dispatch(
+ updateSmartTransaction(approvalTxUuid, {
+ origin: 'metamask',
+ type: TRANSACTION_TYPES.SWAP_APPROVAL,
+ sourceTokenSymbol,
+ }),
+ );
+ }
+ history.push(SMART_TRANSACTION_STATUS_ROUTE);
+ dispatch(setSwapsSTXSubmitLoading(false));
+ } catch (e) {
+ console.log('signAndSendSwapsSmartTransaction error', e);
+ const {
+ swaps: { isFeatureFlagLoaded },
+ } = getState();
+ if (e.message.startsWith('Fetch error:') && isFeatureFlagLoaded) {
+ const errorObj = parseSmartTransactionsError(e.message);
+ dispatch(setCurrentSmartTransactionsError(errorObj?.type));
+ }
+ }
+ };
+};
+
export const signAndSendTransactions = (history, metaMetricsEvent) => {
return async (dispatch, getState) => {
const state = getState();
@@ -786,7 +1073,8 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => {
conversionRate: usdConversionRate,
numberOfDecimals: 6,
});
-
+ const smartTransactionsOptInStatus = getSmartTransactionsOptInStatus(state);
+ const smartTransactionsEnabled = getSmartTransactionsEnabled(state);
const swapMetaData = {
token_from: sourceTokenInfo.symbol,
token_from_amount: String(swapTokenValue),
@@ -812,6 +1100,8 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => {
median_metamask_fee: usedQuote.savings?.medianMetaMaskFee,
is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: getHardwareWalletType(state),
+ stx_enabled: smartTransactionsEnabled,
+ stx_user_opt_in: smartTransactionsOptInStatus,
};
if (networkAndAccountSupports1559) {
swapMetaData.max_fee_per_gas = maxFeePerGas;
@@ -985,3 +1275,60 @@ export function fetchMetaSwapsGasPriceEstimates() {
return priceEstimates;
};
}
+
+export function fetchSwapsSmartTransactionFees(unsignedTransaction) {
+ return async (dispatch, getState) => {
+ const {
+ swaps: { isFeatureFlagLoaded },
+ } = getState();
+ try {
+ return await dispatch(fetchSmartTransactionFees(unsignedTransaction));
+ } catch (e) {
+ if (e.message.startsWith('Fetch error:') && isFeatureFlagLoaded) {
+ const errorObj = parseSmartTransactionsError(e.message);
+ dispatch(setCurrentSmartTransactionsError(errorObj?.type));
+ }
+ }
+ return null;
+ };
+}
+
+export function estimateSwapsSmartTransactionsGas(
+ unsignedTransaction,
+ approveTxParams,
+) {
+ return async (dispatch, getState) => {
+ const {
+ swaps: { isFeatureFlagLoaded, swapsSTXLoading },
+ } = getState();
+ if (swapsSTXLoading) {
+ return;
+ }
+ try {
+ await dispatch(
+ estimateSmartTransactionsGas(unsignedTransaction, approveTxParams),
+ );
+ } catch (e) {
+ if (e.message.startsWith('Fetch error:') && isFeatureFlagLoaded) {
+ const errorObj = parseSmartTransactionsError(e.message);
+ dispatch(setCurrentSmartTransactionsError(errorObj?.type));
+ }
+ }
+ };
+}
+
+export function cancelSwapsSmartTransaction(uuid) {
+ return async (dispatch, getState) => {
+ try {
+ await dispatch(cancelSmartTransaction(uuid));
+ } catch (e) {
+ const {
+ swaps: { isFeatureFlagLoaded },
+ } = getState();
+ if (e.message.startsWith('Fetch error:') && isFeatureFlagLoaded) {
+ const errorObj = parseSmartTransactionsError(e.message);
+ dispatch(setCurrentSmartTransactionsError(errorObj?.type));
+ }
+ }
+ };
+}
diff --git a/ui/ducks/swaps/swaps.test.js b/ui/ducks/swaps/swaps.test.js
index e766e484e..657ac1dab 100644
--- a/ui/ducks/swaps/swaps.test.js
+++ b/ui/ducks/swaps/swaps.test.js
@@ -1,12 +1,20 @@
import nock from 'nock';
import { MOCKS, createSwapsMockStore } from '../../../test/jest';
-import { setSwapsLiveness } from '../../store/actions';
+import { setSwapsLiveness, setSwapsFeatureFlags } from '../../store/actions';
import { setStorageItem } from '../../helpers/utils/storage-helpers';
+import {
+ MAINNET_CHAIN_ID,
+ RINKEBY_CHAIN_ID,
+ BSC_CHAIN_ID,
+ POLYGON_CHAIN_ID,
+} from '../../../shared/constants/network';
import * as swaps from './swaps';
jest.mock('../../store/actions.js', () => ({
setSwapsLiveness: jest.fn(),
+ setSwapsFeatureFlags: jest.fn(),
+ fetchSmartTransactionsLiveness: jest.fn(),
}));
const providerState = {
@@ -23,7 +31,7 @@ describe('Ducks - Swaps', () => {
nock.cleanAll();
});
- describe('fetchSwapsLiveness', () => {
+ describe('fetchSwapsLivenessAndFeatureFlags', () => {
const cleanFeatureFlagApiCache = () => {
setStorageItem(
'cachedFetch:https://api2.metaswap.codefi.network/featureFlags',
@@ -66,13 +74,14 @@ describe('Ducks - Swaps', () => {
const featureFlagApiNock = mockFeatureFlagsApiResponse({
featureFlagsResponse,
});
- const swapsLiveness = await swaps.fetchSwapsLiveness()(
+ const swapsLiveness = await swaps.fetchSwapsLivenessAndFeatureFlags()(
mockDispatch,
createGetState(),
);
expect(featureFlagApiNock.isDone()).toBe(true);
- expect(mockDispatch).toHaveBeenCalledTimes(2);
+ expect(mockDispatch).toHaveBeenCalledTimes(4);
expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness);
+ expect(setSwapsFeatureFlags).toHaveBeenCalledWith(featureFlagsResponse);
expect(swapsLiveness).toMatchObject(expectedSwapsLiveness);
});
@@ -86,13 +95,14 @@ describe('Ducks - Swaps', () => {
const featureFlagApiNock = mockFeatureFlagsApiResponse({
featureFlagsResponse,
});
- const swapsLiveness = await swaps.fetchSwapsLiveness()(
+ const swapsLiveness = await swaps.fetchSwapsLivenessAndFeatureFlags()(
mockDispatch,
createGetState(),
);
expect(featureFlagApiNock.isDone()).toBe(true);
- expect(mockDispatch).toHaveBeenCalledTimes(2);
+ expect(mockDispatch).toHaveBeenCalledTimes(4);
expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness);
+ expect(setSwapsFeatureFlags).toHaveBeenCalledWith(featureFlagsResponse);
expect(swapsLiveness).toMatchObject(expectedSwapsLiveness);
});
@@ -107,13 +117,14 @@ describe('Ducks - Swaps', () => {
const featureFlagApiNock = mockFeatureFlagsApiResponse({
featureFlagsResponse,
});
- const swapsLiveness = await swaps.fetchSwapsLiveness()(
+ const swapsLiveness = await swaps.fetchSwapsLivenessAndFeatureFlags()(
mockDispatch,
createGetState(),
);
expect(featureFlagApiNock.isDone()).toBe(true);
- expect(mockDispatch).toHaveBeenCalledTimes(2);
+ expect(mockDispatch).toHaveBeenCalledTimes(4);
expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness);
+ expect(setSwapsFeatureFlags).toHaveBeenCalledWith(featureFlagsResponse);
expect(swapsLiveness).toMatchObject(expectedSwapsLiveness);
});
@@ -125,7 +136,7 @@ describe('Ducks - Swaps', () => {
const featureFlagApiNock = mockFeatureFlagsApiResponse({
replyWithError: true,
});
- const swapsLiveness = await swaps.fetchSwapsLiveness()(
+ const swapsLiveness = await swaps.fetchSwapsLivenessAndFeatureFlags()(
mockDispatch,
createGetState(),
);
@@ -144,18 +155,22 @@ describe('Ducks - Swaps', () => {
const featureFlagApiNock = mockFeatureFlagsApiResponse({
featureFlagsResponse,
});
- await swaps.fetchSwapsLiveness()(mockDispatch, createGetState());
+ await swaps.fetchSwapsLivenessAndFeatureFlags()(
+ mockDispatch,
+ createGetState(),
+ );
expect(featureFlagApiNock.isDone()).toBe(true);
const featureFlagApiNock2 = mockFeatureFlagsApiResponse({
featureFlagsResponse,
});
- const swapsLiveness = await swaps.fetchSwapsLiveness()(
+ const swapsLiveness = await swaps.fetchSwapsLivenessAndFeatureFlags()(
mockDispatch,
createGetState(),
);
expect(featureFlagApiNock2.isDone()).toBe(false); // Second API call wasn't made, cache was used instead.
- expect(mockDispatch).toHaveBeenCalledTimes(4);
+ expect(mockDispatch).toHaveBeenCalledTimes(8);
expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness);
+ expect(setSwapsFeatureFlags).toHaveBeenCalledWith(featureFlagsResponse);
expect(swapsLiveness).toMatchObject(expectedSwapsLiveness);
});
});
@@ -221,4 +236,91 @@ describe('Ducks - Swaps', () => {
);
});
});
+
+ describe('getSmartTransactionsEnabled', () => {
+ it('returns true if feature flag is enabled, not a HW and is Ethereum network', () => {
+ const state = createSwapsMockStore();
+ expect(swaps.getSmartTransactionsEnabled(state)).toBe(true);
+ });
+
+ it('returns false if feature flag is disabled, not a HW and is Ethereum network', () => {
+ const state = createSwapsMockStore();
+ state.metamask.swapsState.swapsFeatureFlags.smartTransactions.extensionActive = false;
+ expect(swaps.getSmartTransactionsEnabled(state)).toBe(false);
+ });
+
+ it('returns false if feature flag is enabled, not a HW, STX liveness is false and is Ethereum network', () => {
+ const state = createSwapsMockStore();
+ state.metamask.smartTransactionsState.liveness = false;
+ expect(swaps.getSmartTransactionsEnabled(state)).toBe(false);
+ });
+
+ it('returns false if feature flag is enabled, is a HW and is Ethereum network', () => {
+ const state = createSwapsMockStore();
+ state.metamask.keyrings[0].type = 'Trezor Hardware';
+ expect(swaps.getSmartTransactionsEnabled(state)).toBe(false);
+ });
+
+ it('returns false if feature flag is enabled, not a HW and is Polygon network', () => {
+ const state = createSwapsMockStore();
+ state.metamask.provider.chainId = POLYGON_CHAIN_ID;
+ expect(swaps.getSmartTransactionsEnabled(state)).toBe(false);
+ });
+
+ it('returns false if feature flag is enabled, not a HW and is BSC network', () => {
+ const state = createSwapsMockStore();
+ state.metamask.provider.chainId = BSC_CHAIN_ID;
+ expect(swaps.getSmartTransactionsEnabled(state)).toBe(false);
+ });
+
+ it('returns true if feature flag is enabled, not a HW and is Rinkeby network', () => {
+ const state = createSwapsMockStore();
+ state.metamask.provider.chainId = RINKEBY_CHAIN_ID;
+ expect(swaps.getSmartTransactionsEnabled(state)).toBe(true);
+ });
+
+ it('returns false if feature flag is missing', () => {
+ const state = createSwapsMockStore();
+ state.metamask.swapsState.swapsFeatureFlags = {};
+ expect(swaps.getSmartTransactionsEnabled(state)).toBe(false);
+ });
+ });
+
+ describe('getSmartTransactionsOptInStatus', () => {
+ it('returns STX opt in status', () => {
+ const state = createSwapsMockStore();
+ expect(swaps.getSmartTransactionsOptInStatus(state)).toBe(true);
+ });
+ });
+
+ describe('getCurrentSmartTransactions', () => {
+ it('returns current smart transactions', () => {
+ const state = createSwapsMockStore();
+ expect(swaps.getCurrentSmartTransactions(state)).toMatchObject(
+ state.metamask.smartTransactionsState.smartTransactions[
+ MAINNET_CHAIN_ID
+ ],
+ );
+ });
+ });
+
+ describe('getPendingSmartTransactions', () => {
+ it('returns pending smart transactions', () => {
+ const state = createSwapsMockStore();
+ const pendingSmartTransactions = swaps.getPendingSmartTransactions(state);
+ expect(pendingSmartTransactions).toHaveLength(1);
+ expect(pendingSmartTransactions[0].uuid).toBe('uuid2');
+ expect(pendingSmartTransactions[0].status).toBe('pending');
+ });
+ });
+
+ describe('getSmartTransactionFees', () => {
+ it('returns unsigned transactions and estimates', () => {
+ const state = createSwapsMockStore();
+ const smartTransactionFees = swaps.getSmartTransactionFees(state);
+ expect(smartTransactionFees).toMatchObject(
+ state.metamask.smartTransactionsState.fees,
+ );
+ });
+ });
});
diff --git a/ui/helpers/constants/routes.js b/ui/helpers/constants/routes.js
index 6ddc4ba26..7fe043e3f 100644
--- a/ui/helpers/constants/routes.js
+++ b/ui/helpers/constants/routes.js
@@ -41,6 +41,7 @@ const BUILD_QUOTE_ROUTE = '/swaps/build-quote';
const VIEW_QUOTE_ROUTE = '/swaps/view-quote';
const LOADING_QUOTES_ROUTE = '/swaps/loading-quotes';
const AWAITING_SIGNATURES_ROUTE = '/swaps/awaiting-signatures';
+const SMART_TRANSACTION_STATUS_ROUTE = '/swaps/smart-transaction-status';
const AWAITING_SWAP_ROUTE = '/swaps/awaiting-swap';
const SWAPS_ERROR_ROUTE = '/swaps/swaps-error';
const SWAPS_MAINTENANCE_ROUTE = '/swaps/maintenance';
@@ -237,6 +238,7 @@ export {
AWAITING_SIGNATURES_ROUTE,
SWAPS_ERROR_ROUTE,
SWAPS_MAINTENANCE_ROUTE,
+ SMART_TRANSACTION_STATUS_ROUTE,
ADD_COLLECTIBLE_ROUTE,
ONBOARDING_ROUTE,
ONBOARDING_HELP_US_IMPROVE_ROUTE,
diff --git a/ui/helpers/constants/transactions.js b/ui/helpers/constants/transactions.js
index aeeda8bdf..b7f932054 100644
--- a/ui/helpers/constants/transactions.js
+++ b/ui/helpers/constants/transactions.js
@@ -7,6 +7,7 @@ export const PENDING_STATUS_HASH = {
[TRANSACTION_STATUSES.UNAPPROVED]: true,
[TRANSACTION_STATUSES.APPROVED]: true,
[TRANSACTION_STATUSES.SUBMITTED]: true,
+ [TRANSACTION_STATUSES.PENDING]: true,
};
export const PRIORITY_STATUS_HASH = {
diff --git a/ui/pages/swaps/awaiting-signatures/awaiting-signatures.js b/ui/pages/swaps/awaiting-signatures/awaiting-signatures.js
index e55a0867a..24cc9ab32 100644
--- a/ui/pages/swaps/awaiting-signatures/awaiting-signatures.js
+++ b/ui/pages/swaps/awaiting-signatures/awaiting-signatures.js
@@ -9,6 +9,8 @@ import {
getFetchParams,
getApproveTxParams,
prepareToLeaveSwaps,
+ getSmartTransactionsOptInStatus,
+ getSmartTransactionsEnabled,
} from '../../../ducks/swaps/swaps';
import {
isHardwareWallet,
@@ -41,6 +43,10 @@ export default function AwaitingSignatures() {
const approveTxParams = useSelector(getApproveTxParams, shallowEqual);
const hardwareWalletUsed = useSelector(isHardwareWallet);
const hardwareWalletType = useSelector(getHardwareWalletType);
+ const smartTransactionsOptInStatus = useSelector(
+ getSmartTransactionsOptInStatus,
+ );
+ const smartTransactionsEnabled = useSelector(getSmartTransactionsEnabled);
const needsTwoConfirmations = Boolean(approveTxParams);
const awaitingSignaturesEvent = useNewMetricEvent({
@@ -55,6 +61,8 @@ export default function AwaitingSignatures() {
custom_slippage: fetchParams?.slippage === 2,
is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType,
+ stx_enabled: smartTransactionsEnabled,
+ stx_user_opt_in: smartTransactionsOptInStatus,
},
category: 'swaps',
});
diff --git a/ui/pages/swaps/awaiting-swap/awaiting-swap.js b/ui/pages/swaps/awaiting-swap/awaiting-swap.js
index 658ed7175..7f1ca51eb 100644
--- a/ui/pages/swaps/awaiting-swap/awaiting-swap.js
+++ b/ui/pages/swaps/awaiting-swap/awaiting-swap.js
@@ -28,6 +28,8 @@ import {
navigateBackToBuildQuote,
prepareForRetryGetQuotes,
prepareToLeaveSwaps,
+ getSmartTransactionsOptInStatus,
+ getSmartTransactionsEnabled,
getFromTokenInputValue,
getMaxSlippage,
} from '../../../ducks/swaps/swaps';
@@ -104,6 +106,10 @@ export default function AwaitingSwap({
const hardwareWalletUsed = useSelector(isHardwareWallet);
const hardwareWalletType = useSelector(getHardwareWalletType);
+ const smartTransactionsOptInStatus = useSelector(
+ getSmartTransactionsOptInStatus,
+ );
+ const smartTransactionsEnabled = useSelector(getSmartTransactionsEnabled);
const sensitiveProperties = {
token_from: sourceTokenInfo?.symbol,
token_from_amount: fetchParams?.value,
@@ -114,6 +120,8 @@ export default function AwaitingSwap({
gas_fees: feeinUnformattedFiat,
is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType,
+ stx_enabled: smartTransactionsEnabled,
+ stx_user_opt_in: smartTransactionsOptInStatus,
};
const quotesExpiredEvent = useNewMetricEvent({
event: 'Quotes Timed Out',
diff --git a/ui/pages/swaps/build-quote/build-quote.js b/ui/pages/swaps/build-quote/build-quote.js
index 04fb4553a..363edd5e7 100644
--- a/ui/pages/swaps/build-quote/build-quote.js
+++ b/ui/pages/swaps/build-quote/build-quote.js
@@ -19,7 +19,18 @@ import DropdownSearchList from '../dropdown-search-list';
import SlippageButtons from '../slippage-buttons';
import { getTokens, getConversionRate } from '../../../ducks/metamask/metamask';
import InfoTooltip from '../../../components/ui/info-tooltip';
+import Popover from '../../../components/ui/popover';
+import Button from '../../../components/ui/button';
import ActionableMessage from '../../../components/ui/actionable-message/actionable-message';
+import Box from '../../../components/ui/box';
+import Typography from '../../../components/ui/typography';
+import {
+ TYPOGRAPHY,
+ DISPLAY,
+ FLEX_DIRECTION,
+ FONT_WEIGHT,
+ COLORS,
+} from '../../../helpers/constants/design-system';
import {
VIEW_QUOTE_ROUTE,
LOADING_QUOTES_ROUTE,
@@ -40,10 +51,14 @@ import {
setFromTokenError,
setMaxSlippage,
setReviewSwapClickedTimestamp,
+ getSmartTransactionsOptInStatus,
+ getSmartTransactionsEnabled,
+ getCurrentSmartTransactionsEnabled,
getFromTokenInputValue,
getFromTokenError,
getMaxSlippage,
getIsFeatureFlagLoaded,
+ getCurrentSmartTransactionsError,
} from '../../../ducks/swaps/swaps';
import {
getSwapsDefaultToken,
@@ -53,6 +68,8 @@ import {
getRpcPrefsForCurrentProvider,
getUseTokenDetection,
getTokenList,
+ isHardwareWallet,
+ getHardwareWalletType,
} from '../../../selectors';
import {
@@ -84,6 +101,7 @@ import {
setBackgroundSwapRouteState,
clearSwapsQuotes,
stopPollingForQuotes,
+ setSmartTransactionsOptInStatus,
} from '../../../store/actions';
import {
countDecimals,
@@ -140,8 +158,33 @@ export default function BuildQuote({
const tokenConversionRates = useSelector(getTokenExchangeRates, isEqual);
const conversionRate = useSelector(getConversionRate);
+ const hardwareWalletUsed = useSelector(isHardwareWallet);
+ const hardwareWalletType = useSelector(getHardwareWalletType);
+ const smartTransactionsOptInStatus = useSelector(
+ getSmartTransactionsOptInStatus,
+ );
+ const smartTransactionsEnabled = useSelector(getSmartTransactionsEnabled);
+ const currentSmartTransactionsEnabled = useSelector(
+ getCurrentSmartTransactionsEnabled,
+ );
+ const smartTransactionsOptInPopoverDisplayed =
+ smartTransactionsOptInStatus !== undefined;
+ const currentSmartTransactionsError = useSelector(
+ getCurrentSmartTransactionsError,
+ );
const currentCurrency = useSelector(getCurrentCurrency);
+ const showSmartTransactionsOptInPopover =
+ smartTransactionsEnabled && !smartTransactionsOptInPopoverDisplayed;
+
+ const onCloseSmartTransactionsOptInPopover = (e) => {
+ e?.preventDefault();
+ setSmartTransactionsOptInStatus(false);
+ };
+
+ const onEnableSmartTransactionsClick = () =>
+ setSmartTransactionsOptInStatus(true);
+
const fetchParamsFromToken = isSwapsDefaultTokenSymbol(
sourceTokenInfo?.symbol,
chainId,
@@ -402,10 +445,23 @@ export default function BuildQuote({
fromTokenBalance,
]);
+ const buildQuotePageLoadedEvent = useNewMetricEvent({
+ event: 'Build Quote Page Loaded',
+ category: 'swaps',
+ sensitiveProperties: {
+ is_hardware_wallet: hardwareWalletUsed,
+ hardware_wallet_type: hardwareWalletType,
+ stx_enabled: smartTransactionsEnabled,
+ current_stx_enabled: currentSmartTransactionsEnabled,
+ stx_user_opt_in: smartTransactionsOptInStatus,
+ },
+ });
+
useEffect(() => {
dispatch(resetSwapsPostFetchState());
dispatch(setReviewSwapClickedTimestamp());
- }, [dispatch]);
+ buildQuotePageLoadedEvent();
+ }, [dispatch, buildQuotePageLoadedEvent]);
const BlockExplorerLink = () => {
return (
@@ -493,11 +549,87 @@ export default function BuildQuote({
fromTokenInputValue,
fromTokenAddress,
toTokenAddress,
+ smartTransactionsOptInStatus,
]);
return (
+ {showSmartTransactionsOptInPopover && (
+
+
+ {t('enableSmartTransactions')}
+
+
+
+
+ {t('noThanksVariant2')}
+
+
+
+ >
+ }
+ footerClassName="smart-transactions-popover__footer"
+ className="smart-transactions-popover"
+ >
+
+
+
+
+
+ {t('stxDescription')}
+
+
+ {t('stxBenefit1')}
+ {t('stxBenefit2')}
+ {t('stxBenefit3')}
+ {t('stxBenefit4')}
+
+
+ {t('stxSubDescription')}
+
+ {t('stxYouCanOptOut')}
+
+
+
+
+ )}
{t('swapSwapFrom')}
{!isSwapsDefaultTokenSymbol(fromTokenSymbol, chainId) && (
@@ -683,6 +815,10 @@ export default function BuildQuote({
}}
maxAllowedSlippage={MAX_ALLOWED_SLIPPAGE}
currentSlippage={maxSlippage}
+ smartTransactionsEnabled={smartTransactionsEnabled}
+ smartTransactionsOptInStatus={smartTransactionsOptInStatus}
+ setSmartTransactionsOptInStatus={setSmartTransactionsOptInStatus}
+ currentSmartTransactionsError={currentSmartTransactionsError}
/>
)}
diff --git a/ui/pages/swaps/build-quote/index.scss b/ui/pages/swaps/build-quote/index.scss
index f8cb43258..b53754263 100644
--- a/ui/pages/swaps/build-quote/index.scss
+++ b/ui/pages/swaps/build-quote/index.scss
@@ -173,3 +173,41 @@
width: 100%;
}
}
+
+@keyframes slide-in {
+ 100% { transform: translateY(0%); }
+}
+
+.smart-transactions-popover {
+ transform: translateY(-100%);
+ animation: slide-in 0.5s forwards;
+
+ &__content {
+ flex-direction: column;
+
+ ul {
+ list-style: inside;
+ }
+
+ a {
+ color: var(--Blue-500);
+ cursor: pointer;
+ }
+ }
+
+ &__footer {
+ flex-direction: column;
+ flex: 1;
+ align-items: center;
+ border-top: 0;
+
+ button {
+ border-radius: 50px;
+ }
+
+ a {
+ font-size: inherit;
+ padding-bottom: 0;
+ }
+ }
+}
diff --git a/ui/pages/swaps/dropdown-search-list/dropdown-search-list.js b/ui/pages/swaps/dropdown-search-list/dropdown-search-list.js
index 110505551..c4759b578 100644
--- a/ui/pages/swaps/dropdown-search-list/dropdown-search-list.js
+++ b/ui/pages/swaps/dropdown-search-list/dropdown-search-list.js
@@ -24,6 +24,10 @@ import {
} from '../../../selectors/selectors';
import { SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../shared/constants/swaps';
import { getURLHostName } from '../../../helpers/utils/util';
+import {
+ getSmartTransactionsOptInStatus,
+ getSmartTransactionsEnabled,
+} from '../../../ducks/swaps/swaps';
export default function DropdownSearchList({
searchListClassName,
@@ -55,6 +59,10 @@ export default function DropdownSearchList({
const hardwareWalletType = useSelector(getHardwareWalletType);
const chainId = useSelector(getCurrentChainId);
const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider);
+ const smartTransactionsOptInStatus = useSelector(
+ getSmartTransactionsOptInStatus,
+ );
+ const smartTransactionsEnabled = useSelector(getSmartTransactionsEnabled);
const tokenImportedEvent = useNewMetricEvent({
event: 'Token Imported',
@@ -64,6 +72,8 @@ export default function DropdownSearchList({
chain_id: chainId,
is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType,
+ stx_enabled: smartTransactionsEnabled,
+ stx_user_opt_in: smartTransactionsOptInStatus,
},
category: 'swaps',
});
diff --git a/ui/pages/swaps/fee-card/fee-card.js b/ui/pages/swaps/fee-card/fee-card.js
index c7638893c..625ea6cbd 100644
--- a/ui/pages/swaps/fee-card/fee-card.js
+++ b/ui/pages/swaps/fee-card/fee-card.js
@@ -35,6 +35,8 @@ export default function FeeCard({
numberOfQuotes,
onQuotesClick,
chainId,
+ smartTransactionsOptInStatus,
+ smartTransactionsEnabled,
isBestQuote,
supportsEIP1559V2 = false,
}) {
@@ -74,11 +76,15 @@ export default function FeeCard({
) : (
<>
@@ -133,14 +139,16 @@ export default function FeeCard({
{t('maxFee')}
{`: ${secondaryFee.maxFee}`}
- {!supportsEIP1559V2 && (
-
onFeeCardMaxRowClick()}
- >
- {t('edit')}
-
- )}
+ {!supportsEIP1559V2 &&
+ (!smartTransactionsEnabled ||
+ !smartTransactionsOptInStatus) && (
+
onFeeCardMaxRowClick()}
+ >
+ {t('edit')}
+
+ )}
>
)
}
@@ -213,6 +221,8 @@ FeeCard.propTypes = {
onQuotesClick: PropTypes.func.isRequired,
numberOfQuotes: PropTypes.number.isRequired,
chainId: PropTypes.string.isRequired,
+ smartTransactionsOptInStatus: PropTypes.bool,
+ smartTransactionsEnabled: PropTypes.bool,
isBestQuote: PropTypes.bool.isRequired,
supportsEIP1559V2: PropTypes.bool,
};
diff --git a/ui/pages/swaps/fee-card/fee-card.test.js b/ui/pages/swaps/fee-card/fee-card.test.js
index f87078d96..218fba458 100644
--- a/ui/pages/swaps/fee-card/fee-card.test.js
+++ b/ui/pages/swaps/fee-card/fee-card.test.js
@@ -134,6 +134,26 @@ describe('FeeCard', () => {
).toMatchSnapshot();
});
+ it('renders the component with Smart Transactions enabled and user opted in', () => {
+ const store = configureMockStore(middleware)(createSwapsMockStore());
+ const props = createProps({
+ smartTransactionsOptInStatus: true,
+ smartTransactionsEnabled: true,
+ maxPriorityFeePerGasDecGWEI: '3',
+ maxFeePerGasDecGWEI: '4',
+ });
+ const { getByText, queryByTestId } = renderWithProvider(
+
,
+ store,
+ );
+ expect(getByText('Best of 6 quotes.')).toBeInTheDocument();
+ expect(getByText('Estimated gas fee')).toBeInTheDocument();
+ expect(getByText(props.primaryFee.fee)).toBeInTheDocument();
+ expect(getByText(props.secondaryFee.fee)).toBeInTheDocument();
+ expect(getByText(`: ${props.secondaryFee.maxFee}`)).toBeInTheDocument();
+ expect(queryByTestId('fee-card__edit-link')).not.toBeInTheDocument();
+ });
+
it('renders the component with EIP-1559 V2 enabled', () => {
useGasFeeEstimates.mockImplementation(() => ({ gasFeeEstimates: {} }));
useSelector.mockImplementation((selector) => {
diff --git a/ui/pages/swaps/index.js b/ui/pages/swaps/index.js
index 316e6fc34..06e9587a3 100644
--- a/ui/pages/swaps/index.js
+++ b/ui/pages/swaps/index.js
@@ -32,17 +32,25 @@ import {
getSwapsFeatureIsLive,
prepareToLeaveSwaps,
fetchAndSetSwapsGasPriceInfo,
- fetchSwapsLiveness,
+ fetchSwapsLivenessAndFeatureFlags,
getReviewSwapClickedTimestamp,
+ getPendingSmartTransactions,
+ getSmartTransactionsOptInStatus,
+ getSmartTransactionsEnabled,
+ getCurrentSmartTransactionsError,
+ dismissCurrentSmartTransactionsErrorMessage,
+ getCurrentSmartTransactionsErrorMessageDismissed,
navigateBackToBuildQuote,
} from '../../ducks/swaps/swaps';
import {
checkNetworkAndAccountSupports1559,
currentNetworkTxListSelector,
+ getSwapsDefaultToken,
} from '../../selectors';
import {
AWAITING_SIGNATURES_ROUTE,
AWAITING_SWAP_ROUTE,
+ SMART_TRANSACTION_STATUS_ROUTE,
BUILD_QUOTE_ROUTE,
VIEW_QUOTE_ROUTE,
LOADING_QUOTES_ROUTE,
@@ -70,6 +78,7 @@ import { useNewMetricEvent } from '../../hooks/useMetricEvent';
import { useGasFeeEstimates } from '../../hooks/useGasFeeEstimates';
import FeatureToggledRoute from '../../helpers/higher-order-components/feature-toggled-route';
import { TRANSACTION_STATUSES } from '../../../shared/constants/transaction';
+import ActionableMessage from '../../components/ui/actionable-message';
import {
fetchTokens,
fetchTopAssets,
@@ -77,6 +86,7 @@ import {
fetchAggregatorMetadata,
} from './swaps.util';
import AwaitingSignatures from './awaiting-signatures';
+import SmartTransactionStatus from './smart-transaction-status';
import AwaitingSwap from './awaiting-swap';
import LoadingQuote from './loading-swaps-quotes';
import BuildQuote from './build-quote';
@@ -92,6 +102,8 @@ export default function Swap() {
const isAwaitingSignaturesRoute = pathname === AWAITING_SIGNATURES_ROUTE;
const isSwapsErrorRoute = pathname === SWAPS_ERROR_ROUTE;
const isLoadingQuotesRoute = pathname === LOADING_QUOTES_ROUTE;
+ const isSmartTransactionStatusRoute =
+ pathname === SMART_TRANSACTION_STATUS_ROUTE;
const isViewQuoteRoute = pathname === VIEW_QUOTE_ROUTE;
const fetchParams = useSelector(getFetchParams, isEqual);
@@ -112,10 +124,24 @@ export default function Swap() {
const networkAndAccountSupports1559 = useSelector(
checkNetworkAndAccountSupports1559,
);
+ const defaultSwapsToken = useSelector(getSwapsDefaultToken, isEqual);
const tokenList = useSelector(getTokenList, isEqual);
const listTokenValues = shuffle(Object.values(tokenList));
const reviewSwapClickedTimestamp = useSelector(getReviewSwapClickedTimestamp);
+ const pendingSmartTransactions = useSelector(getPendingSmartTransactions);
const reviewSwapClicked = Boolean(reviewSwapClickedTimestamp);
+ const smartTransactionsOptInStatus = useSelector(
+ getSmartTransactionsOptInStatus,
+ );
+ const smartTransactionsEnabled = useSelector(getSmartTransactionsEnabled);
+ const currentSmartTransactionsError = useSelector(
+ getCurrentSmartTransactionsError,
+ );
+ const smartTransactionsErrorMessageDismissed = useSelector(
+ getCurrentSmartTransactionsErrorMessageDismissed,
+ );
+ const showSmartTransactionsErrorMessage =
+ currentSmartTransactionsError && !smartTransactionsErrorMessageDismissed;
if (networkAndAccountSupports1559) {
// This will pre-load gas fees before going to the View Quote page.
@@ -217,6 +243,8 @@ export default function Swap() {
current_screen: pathname.match(/\/swaps\/(.+)/u)[1],
is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType,
+ stx_enabled: smartTransactionsEnabled,
+ stx_user_opt_in: smartTransactionsOptInStatus,
},
});
const exitEventRef = useRef();
@@ -227,10 +255,10 @@ export default function Swap() {
});
useEffect(() => {
- const fetchSwapsLivenessWrapper = async () => {
- await dispatch(fetchSwapsLiveness());
+ const fetchSwapsLivenessAndFeatureFlagsWrapper = async () => {
+ await dispatch(fetchSwapsLivenessAndFeatureFlags());
};
- fetchSwapsLivenessWrapper();
+ fetchSwapsLivenessAndFeatureFlagsWrapper();
return () => {
exitEventRef.current();
};
@@ -260,10 +288,37 @@ export default function Swap() {
return () => window.removeEventListener('beforeunload', fn);
}, [dispatch, isLoadingQuotesRoute]);
+ const errorStxEvent = useNewMetricEvent({
+ event: 'Error Smart Transactions',
+ category: 'swaps',
+ sensitiveProperties: {
+ token_from: fetchParams?.sourceTokenInfo?.symbol,
+ token_from_amount: fetchParams?.value,
+ request_type: fetchParams?.balanceError,
+ token_to: fetchParams?.destinationTokenInfo?.symbol,
+ slippage: fetchParams?.slippage,
+ custom_slippage: fetchParams?.slippage !== 2,
+ current_screen: pathname.match(/\/swaps\/(.+)/u)[1],
+ is_hardware_wallet: hardwareWalletUsed,
+ hardware_wallet_type: hardwareWalletType,
+ stx_enabled: smartTransactionsEnabled,
+ stx_user_opt_in: smartTransactionsOptInStatus,
+ stx_error: currentSmartTransactionsError,
+ },
+ });
+ useEffect(() => {
+ if (currentSmartTransactionsError) {
+ errorStxEvent();
+ }
+ }, [errorStxEvent, currentSmartTransactionsError]);
+
if (!isSwapsChain) {
return
;
}
+ const isStxNotEnoughFundsError =
+ currentSmartTransactionsError === 'not_enough_funds';
+
return (
@@ -286,10 +341,60 @@ export default function Swap() {
history.push(DEFAULT_ROUTE);
}}
>
- {!isAwaitingSwapRoute && !isAwaitingSignaturesRoute && t('cancel')}
+ {!isAwaitingSwapRoute &&
+ !isAwaitingSignaturesRoute &&
+ !isSmartTransactionStatusRoute &&
+ t('cancel')}
+ {showSmartTransactionsErrorMessage && (
+
+ {t('swapApproveNeedMoreTokensSmartTransactions', [
+ defaultSwapsToken.symbol,
+ ])}{' '}
+
+ dispatch(dismissCurrentSmartTransactionsErrorMessage())
+ }
+ style={{
+ textDecoration: 'underline',
+ cursor: 'pointer',
+ }}
+ >
+ {t('stxTryRegular')}
+
+
+ ) : (
+
+
+ {t('stxUnavailable')}
+
+
{t('stxFallbackToNormal')}
+
+ )
+ }
+ className={
+ isStxNotEnoughFundsError
+ ? 'swaps__error-message'
+ : 'actionable-message--left-aligned actionable-message--warning swaps__error-message'
+ }
+ primaryAction={
+ isStxNotEnoughFundsError
+ ? null
+ : {
+ label: t('dismiss'),
+ onClick: () =>
+ dispatch(dismissCurrentSmartTransactionsErrorMessage()),
+ }
+ }
+ withRightButton
+ />
+ )}
{
+ if (
+ pendingSmartTransactions.length > 0 &&
+ routeState === 'smartTransactionStatus'
+ ) {
+ return (
+
+ );
+ }
if (Object.values(quotes).length) {
return (
@@ -395,6 +510,13 @@ export default function Swap() {
return ;
}}
/>
+ {
+ return ;
+ }}
+ />
{
const t = useContext(I18nContext);
return (
@@ -55,19 +56,21 @@ const QuoteDetails = ({
{` ${destinationTokenSymbol}`}
-
-
- {t('swapEstimatedNetworkFees')}
-
-
-
-
{feeInEth}
-
{` (${networkFees})`}
+ {!hideEstimatedGasFee && (
+
+
+ {t('swapEstimatedNetworkFees')}
+
+
+
+ {feeInEth}
+ {` (${networkFees})`}
+
-
+ )}
{t('swapSource')}
@@ -105,6 +108,7 @@ QuoteDetails.propTypes = {
feeInEth: PropTypes.string.isRequired,
networkFees: PropTypes.string.isRequired,
metaMaskFee: PropTypes.number.isRequired,
+ hideEstimatedGasFee: PropTypes.bool,
};
export default QuoteDetails;
diff --git a/ui/pages/swaps/select-quote-popover/select-quote-popover.js b/ui/pages/swaps/select-quote-popover/select-quote-popover.js
index c4f9e8977..1b19bfecb 100644
--- a/ui/pages/swaps/select-quote-popover/select-quote-popover.js
+++ b/ui/pages/swaps/select-quote-popover/select-quote-popover.js
@@ -14,6 +14,7 @@ const SelectQuotePopover = ({
swapToSymbol,
initialAggId,
onQuoteDetailsIsOpened,
+ hideEstimatedGasFee,
}) => {
const t = useContext(I18nContext);
@@ -105,10 +106,14 @@ const SelectQuotePopover = ({
setSortDirection={setSortDirection}
sortColumn={sortColumn}
setSortColumn={setSortColumn}
+ hideEstimatedGasFee={hideEstimatedGasFee}
/>
)}
{contentView === 'quoteDetails' && viewingAgg && (
-
+
)}
@@ -123,6 +128,7 @@ SelectQuotePopover.propTypes = {
quoteDataRows: PropTypes.arrayOf(QUOTE_DATA_ROWS_PROPTYPES_SHAPE),
initialAggId: PropTypes.string,
onQuoteDetailsIsOpened: PropTypes.func,
+ hideEstimatedGasFee: PropTypes.bool.isRequired,
};
export default SelectQuotePopover;
diff --git a/ui/pages/swaps/select-quote-popover/sort-list/sort-list.js b/ui/pages/swaps/select-quote-popover/sort-list/sort-list.js
index df952cad0..58064e365 100644
--- a/ui/pages/swaps/select-quote-popover/sort-list/sort-list.js
+++ b/ui/pages/swaps/select-quote-popover/sort-list/sort-list.js
@@ -32,6 +32,7 @@ export default function SortList({
setSortDirection,
sortColumn = null,
setSortColumn,
+ hideEstimatedGasFee,
}) {
const t = useContext(I18nContext);
const [noRowHover, setRowNowHover] = useState(false);
@@ -97,12 +98,16 @@ export default function SortList({
className="select-quote-popover__column-header select-quote-popover__network-fees select-quote-popover__network-fees-header"
onClick={() => onColumnHeaderClick('rawNetworkFees')}
>
-
{t('swapEstimatedNetworkFees')}
-
-
+ {!hideEstimatedGasFee && (
+ <>
+
{t('swapEstimatedNetworkFees')}
+
+
+ >
+ )}
- {networkFees}
+ {!hideEstimatedGasFee && networkFees}
+
+
`;
exports[`SlippageButtons renders the component with initial props 2`] = `
@@ -18,16 +21,63 @@ exports[`SlippageButtons renders the component with initial props 2`] = `
role="radiogroup"
>
2%
+
+ 3%
+
+
+ custom
+
+
+`;
+
+exports[`SlippageButtons renders the component with the Smart Transaction opt-in button available 1`] = `
+
+`;
+
+exports[`SlippageButtons renders the component with the Smart Transaction opt-in button available 2`] = `
+
+ 2%
+
+
diff --git a/ui/pages/swaps/slippage-buttons/index.scss b/ui/pages/swaps/slippage-buttons/index.scss
index be5b061db..dfc34ab2c 100644
--- a/ui/pages/swaps/slippage-buttons/index.scss
+++ b/ui/pages/swaps/slippage-buttons/index.scss
@@ -7,17 +7,22 @@
&__header {
display: flex;
align-items: center;
+ color: var(--Blue-500);
margin-bottom: 0;
margin-left: auto;
margin-right: auto;
background: unset;
- margin-bottom: 8px;
+
+ &--open {
+ margin-bottom: 8px;
+ }
}
&__header-text {
@include H6;
margin-right: 6px;
+ color: var(--Blue-500);
font-weight: 900;
}
diff --git a/ui/pages/swaps/slippage-buttons/slippage-buttons.js b/ui/pages/swaps/slippage-buttons/slippage-buttons.js
index 66a11f43b..c2b3986f1 100644
--- a/ui/pages/swaps/slippage-buttons/slippage-buttons.js
+++ b/ui/pages/swaps/slippage-buttons/slippage-buttons.js
@@ -5,11 +5,25 @@ import { I18nContext } from '../../../contexts/i18n';
import ButtonGroup from '../../../components/ui/button-group';
import Button from '../../../components/ui/button';
import InfoTooltip from '../../../components/ui/info-tooltip';
+import ToggleButton from '../../../components/ui/toggle-button';
+import Box from '../../../components/ui/box';
+import Typography from '../../../components/ui/typography';
+import {
+ TYPOGRAPHY,
+ FONT_WEIGHT,
+ ALIGN_ITEMS,
+ DISPLAY,
+} from '../../../helpers/constants/design-system';
+import { smartTransactionsErrorMessages } from '../swaps.util';
export default function SlippageButtons({
onSelect,
maxAllowedSlippage,
currentSlippage,
+ smartTransactionsEnabled,
+ smartTransactionsOptInStatus,
+ setSmartTransactionsOptInStatus,
+ currentSmartTransactionsError,
}) {
const t = useContext(I18nContext);
const [customValue, setCustomValue] = useState(() => {
@@ -33,6 +47,9 @@ export default function SlippageButtons({
}
return 1; // Choose activeButtonIndex = 1 for 3% slippage by default.
});
+ const [open, setOpen] = useState(() => {
+ return currentSlippage !== 3; // Only open Advanced Options by default if it's not default 3% slippage.
+ });
const [inputRef, setInputRef] = useState(null);
let errorText = '';
@@ -68,94 +85,146 @@ export default function SlippageButtons({
return (
-
+
setOpen(!open)}
+ className={classnames('slippage-buttons__header', {
+ 'slippage-buttons__header--open': open,
+ })}
+ >
{t('swapsAdvancedOptions')}
-
+ {open ? (
+
+ ) : (
+
+ )}
+
-
-
-
- {t('swapsMaxSlippage')}
+ {open && (
+ <>
+
+
+
+ {t('swapsMaxSlippage')}
+
+
+
+
+ {
+ setCustomValue('');
+ setEnteringCustomValue(false);
+ setActiveButtonIndex(0);
+ onSelect(2);
+ }}
+ >
+ 2%
+
+ {
+ setCustomValue('');
+ setEnteringCustomValue(false);
+ setActiveButtonIndex(1);
+ onSelect(3);
+ }}
+ >
+ 3%
+
+ {
+ setActiveButtonIndex(2);
+ setEnteringCustomValue(true);
+ }}
+ >
+ {enteringCustomValue ? (
+
+ {
+ setCustomValue(event.target.value);
+ onSelect(Number(event.target.value));
+ }}
+ type="number"
+ step="0.1"
+ ref={setInputRef}
+ onBlur={() => {
+ setEnteringCustomValue(false);
+ }}
+ value={customValue || ''}
+ />
+
+ ) : (
+ customValueText
+ )}
+ {(customValue || enteringCustomValue) && (
+ %
+ )}
+
+
-
-
-
- {
- setCustomValue('');
- setEnteringCustomValue(false);
- setActiveButtonIndex(0);
- onSelect(2);
- }}
- >
- 2%
-
- {
- setCustomValue('');
- setEnteringCustomValue(false);
- setActiveButtonIndex(1);
- onSelect(3);
- }}
- >
- 3%
-
- {
- setActiveButtonIndex(2);
- setEnteringCustomValue(true);
- }}
- >
- {enteringCustomValue ? (
-
+
- {
- setCustomValue(event.target.value);
- onSelect(Number(event.target.value));
- }}
- type="number"
- step="0.1"
- ref={setInputRef}
- onBlur={() => {
- setEnteringCustomValue(false);
- }}
- value={customValue || ''}
- />
-
- ) : (
- customValueText
- )}
- {(customValue || enteringCustomValue) && (
- %
- )}
-
-
-
+
+ {t('smartTransaction')}
+
+ {currentSmartTransactionsError ? (
+
+ ) : (
+
+ )}
+
+
{
+ setSmartTransactionsOptInStatus(!value);
+ }}
+ offLabel={t('off')}
+ onLabel={t('on')}
+ disabled={Boolean(currentSmartTransactionsError)}
+ />
+
+ )}
+ >
+ )}
{errorText && (
{errorText}
)}
@@ -168,4 +237,8 @@ SlippageButtons.propTypes = {
onSelect: PropTypes.func.isRequired,
maxAllowedSlippage: PropTypes.number.isRequired,
currentSlippage: PropTypes.number,
+ smartTransactionsEnabled: PropTypes.bool.isRequired,
+ smartTransactionsOptInStatus: PropTypes.object,
+ setSmartTransactionsOptInStatus: PropTypes.func,
+ currentSmartTransactionsError: PropTypes.string,
};
diff --git a/ui/pages/swaps/slippage-buttons/slippage-buttons.test.js b/ui/pages/swaps/slippage-buttons/slippage-buttons.test.js
index 60ceeff4c..f14fdbb37 100644
--- a/ui/pages/swaps/slippage-buttons/slippage-buttons.test.js
+++ b/ui/pages/swaps/slippage-buttons/slippage-buttons.test.js
@@ -7,14 +7,15 @@ const createProps = (customProps = {}) => {
return {
onSelect: jest.fn(),
maxAllowedSlippage: 15,
- currentSlippage: 3,
+ currentSlippage: 2,
+ smartTransactionsEnabled: false,
...customProps,
};
};
describe('SlippageButtons', () => {
it('renders the component with initial props', () => {
- const { getByText } = renderWithProvider(
+ const { getByText, queryByText } = renderWithProvider(
,
);
expect(getByText('2%')).toBeInTheDocument();
@@ -27,5 +28,23 @@ describe('SlippageButtons', () => {
expect(
document.querySelector('.slippage-buttons__button-group'),
).toMatchSnapshot();
+ expect(queryByText('Smart transaction')).not.toBeInTheDocument();
+ });
+
+ it('renders the component with the Smart Transaction opt-in button available', () => {
+ const { getByText } = renderWithProvider(
+ ,
+ );
+ expect(getByText('2%')).toBeInTheDocument();
+ expect(getByText('3%')).toBeInTheDocument();
+ expect(getByText('custom')).toBeInTheDocument();
+ expect(getByText('Advanced Options')).toBeInTheDocument();
+ expect(
+ document.querySelector('.slippage-buttons__header'),
+ ).toMatchSnapshot();
+ expect(
+ document.querySelector('.slippage-buttons__button-group'),
+ ).toMatchSnapshot();
+ expect(getByText('Smart transaction')).toBeInTheDocument();
});
});
diff --git a/ui/pages/swaps/smart-transaction-status/__snapshots__/arrow-icon.test.js.snap b/ui/pages/swaps/smart-transaction-status/__snapshots__/arrow-icon.test.js.snap
new file mode 100644
index 000000000..fc7245b88
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/__snapshots__/arrow-icon.test.js.snap
@@ -0,0 +1,18 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ArrowIcon renders the ArrowIcon component 1`] = `
+
+`;
diff --git a/ui/pages/swaps/smart-transaction-status/__snapshots__/canceled-icon.test.js.snap b/ui/pages/swaps/smart-transaction-status/__snapshots__/canceled-icon.test.js.snap
new file mode 100644
index 000000000..00aef8022
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/__snapshots__/canceled-icon.test.js.snap
@@ -0,0 +1,24 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`CanceledIcon renders the CanceledIcon component 1`] = `
+
+`;
diff --git a/ui/pages/swaps/smart-transaction-status/__snapshots__/reverted-icon.test.js.snap b/ui/pages/swaps/smart-transaction-status/__snapshots__/reverted-icon.test.js.snap
new file mode 100644
index 000000000..bb26107a3
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/__snapshots__/reverted-icon.test.js.snap
@@ -0,0 +1,22 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`RevertedIcon renders the RevertedIcon component 1`] = `
+
+`;
diff --git a/ui/pages/swaps/smart-transaction-status/__snapshots__/success-icon.test.js.snap b/ui/pages/swaps/smart-transaction-status/__snapshots__/success-icon.test.js.snap
new file mode 100644
index 000000000..9df4a837a
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/__snapshots__/success-icon.test.js.snap
@@ -0,0 +1,18 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SuccessIcon renders the SuccessIcon component 1`] = `
+
+`;
diff --git a/ui/pages/swaps/smart-transaction-status/__snapshots__/timer-icon.test.js.snap b/ui/pages/swaps/smart-transaction-status/__snapshots__/timer-icon.test.js.snap
new file mode 100644
index 000000000..393317824
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/__snapshots__/timer-icon.test.js.snap
@@ -0,0 +1,18 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TimerIcon renders the TimerIcon component 1`] = `
+
+`;
diff --git a/ui/pages/swaps/smart-transaction-status/__snapshots__/unknown-icon.test.js.snap b/ui/pages/swaps/smart-transaction-status/__snapshots__/unknown-icon.test.js.snap
new file mode 100644
index 000000000..f79373635
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/__snapshots__/unknown-icon.test.js.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`UnknownIcon renders the UnknownIcon component 1`] = `
+
+`;
diff --git a/ui/pages/swaps/smart-transaction-status/arrow-icon.js b/ui/pages/swaps/smart-transaction-status/arrow-icon.js
new file mode 100644
index 000000000..54526c62b
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/arrow-icon.js
@@ -0,0 +1,18 @@
+import React from 'react';
+
+export default function ArrowIcon() {
+ return (
+
+
+
+ );
+}
diff --git a/ui/pages/swaps/smart-transaction-status/arrow-icon.test.js b/ui/pages/swaps/smart-transaction-status/arrow-icon.test.js
new file mode 100644
index 000000000..015239159
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/arrow-icon.test.js
@@ -0,0 +1,11 @@
+import React from 'react';
+
+import { renderWithProvider } from '../../../../test/jest';
+import ArrowIcon from './arrow-icon';
+
+describe('ArrowIcon', () => {
+ it('renders the ArrowIcon component', () => {
+ const { container } = renderWithProvider( );
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/ui/pages/swaps/smart-transaction-status/canceled-icon.js b/ui/pages/swaps/smart-transaction-status/canceled-icon.js
new file mode 100644
index 000000000..9d40bc2d7
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/canceled-icon.js
@@ -0,0 +1,24 @@
+import React from 'react';
+
+export default function CanceledIcon() {
+ return (
+
+
+
+
+ );
+}
diff --git a/ui/pages/swaps/smart-transaction-status/canceled-icon.test.js b/ui/pages/swaps/smart-transaction-status/canceled-icon.test.js
new file mode 100644
index 000000000..10c374497
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/canceled-icon.test.js
@@ -0,0 +1,11 @@
+import React from 'react';
+
+import { renderWithProvider } from '../../../../test/jest';
+import CanceledIcon from './canceled-icon';
+
+describe('CanceledIcon', () => {
+ it('renders the CanceledIcon component', () => {
+ const { container } = renderWithProvider( );
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/ui/pages/swaps/smart-transaction-status/index.js b/ui/pages/swaps/smart-transaction-status/index.js
new file mode 100644
index 000000000..7c6846fff
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/index.js
@@ -0,0 +1 @@
+export { default } from './smart-transaction-status';
diff --git a/ui/pages/swaps/smart-transaction-status/index.scss b/ui/pages/swaps/smart-transaction-status/index.scss
new file mode 100644
index 000000000..07399de61
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/index.scss
@@ -0,0 +1,84 @@
+@keyframes shift {
+ to {
+ background-position: 100% 0;
+ }
+}
+
+.smart-transaction-status {
+ display: flex;
+ flex-flow: column;
+ align-items: center;
+ flex: 1;
+ width: 100%;
+
+ &__loading-bar-container {
+ height: 3px;
+ background: var(--Grey-100);
+ display: flex;
+ margin-top: 12px;
+ margin-bottom: 28px;
+ }
+
+ &__loading-bar {
+ height: 3px;
+ background: var(--Blue-500);
+ transition: width 0.5s linear;
+ }
+
+ div {
+ text-align: center;
+ }
+
+ &__content {
+ flex-flow: column;
+ width: 100%;
+ }
+
+ &__background-animation {
+ position: relative;
+ left: -88px;
+ background-repeat: repeat;
+ background-position: 0 0;
+
+ &--top {
+ width: 1634px;
+ height: 54px;
+ background-size: 817px 54px;
+ background-image: url('/images/transaction-background-top.svg');
+ animation: shift 19s linear infinite;
+ }
+
+ &--bottom {
+ width: 1600px;
+ height: 62px;
+ background-size: 800px 62px;
+ background-image: url('/images/transaction-background-bottom.svg');
+ animation: shift 22s linear infinite;
+ }
+ }
+
+ a {
+ color: var(--Blue-500);
+ }
+
+ &__support-link {
+ color: var(--Blue-500);
+ margin-top: 24px;
+ cursor: pointer;
+ }
+
+ &__cancel-swap-link {
+ font-size: $font-size-h7;
+ }
+
+ &__swaps-footer {
+ .btn-secondary {
+ color: var(--ui-4);
+ border: 1px solid var(--ui-4);
+ }
+ }
+
+ &__remaining-time {
+ font-variant-numeric: tabular-nums;
+ }
+}
diff --git a/ui/pages/swaps/smart-transaction-status/reverted-icon.js b/ui/pages/swaps/smart-transaction-status/reverted-icon.js
new file mode 100644
index 000000000..57b6bfb3f
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/reverted-icon.js
@@ -0,0 +1,22 @@
+import React from 'react';
+
+export default function RevertedIcon() {
+ return (
+
+
+
+
+ );
+}
diff --git a/ui/pages/swaps/smart-transaction-status/reverted-icon.test.js b/ui/pages/swaps/smart-transaction-status/reverted-icon.test.js
new file mode 100644
index 000000000..9a6cd2b22
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/reverted-icon.test.js
@@ -0,0 +1,11 @@
+import React from 'react';
+
+import { renderWithProvider } from '../../../../test/jest';
+import RevertedIcon from './reverted-icon';
+
+describe('RevertedIcon', () => {
+ it('renders the RevertedIcon component', () => {
+ const { container } = renderWithProvider( );
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/ui/pages/swaps/smart-transaction-status/smart-transaction-status.js b/ui/pages/swaps/smart-transaction-status/smart-transaction-status.js
new file mode 100644
index 000000000..b605495d3
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/smart-transaction-status.js
@@ -0,0 +1,409 @@
+import React, { useContext, useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { useHistory } from 'react-router-dom';
+
+import { I18nContext } from '../../../contexts/i18n';
+import { useNewMetricEvent } from '../../../hooks/useMetricEvent';
+import {
+ getFetchParams,
+ prepareToLeaveSwaps,
+ getCurrentSmartTransactions,
+ getSelectedQuote,
+ getTopQuote,
+ getSmartTransactionsOptInStatus,
+ getSmartTransactionsEnabled,
+ getCurrentSmartTransactionsEnabled,
+ getSwapsRefreshStates,
+ cancelSwapsSmartTransaction,
+} from '../../../ducks/swaps/swaps';
+import {
+ isHardwareWallet,
+ getHardwareWalletType,
+} from '../../../selectors/selectors';
+import {
+ DEFAULT_ROUTE,
+ BUILD_QUOTE_ROUTE,
+} from '../../../helpers/constants/routes';
+import Typography from '../../../components/ui/typography';
+import Box from '../../../components/ui/box';
+import UrlIcon from '../../../components/ui/url-icon';
+import {
+ BLOCK_SIZES,
+ COLORS,
+ TYPOGRAPHY,
+ JUSTIFY_CONTENT,
+ DISPLAY,
+ FONT_WEIGHT,
+ ALIGN_ITEMS,
+} from '../../../helpers/constants/design-system';
+import {
+ stopPollingForQuotes,
+ setBackgroundSwapRouteState,
+} from '../../../store/actions';
+import { SMART_TRANSACTION_STATUSES } from '../../../../shared/constants/transaction';
+
+import SwapsFooter from '../swaps-footer';
+import { calcTokenAmount } from '../../../helpers/utils/token-util';
+import { showRemainingTimeInMinAndSec } from '../swaps.util';
+import SuccessIcon from './success-icon';
+import RevertedIcon from './reverted-icon';
+import CanceledIcon from './canceled-icon';
+import UnknownIcon from './unknown-icon';
+import ArrowIcon from './arrow-icon';
+import TimerIcon from './timer-icon';
+
+export default function SmartTransactionStatus() {
+ const [cancelSwapLinkClicked, setCancelSwapLinkClicked] = useState(false);
+ const t = useContext(I18nContext);
+ const history = useHistory();
+ const dispatch = useDispatch();
+ const fetchParams = useSelector(getFetchParams) || {};
+ const { destinationTokenInfo = {}, sourceTokenInfo = {} } =
+ fetchParams?.metaData || {};
+ const hardwareWalletUsed = useSelector(isHardwareWallet);
+ const hardwareWalletType = useSelector(getHardwareWalletType);
+ const needsTwoConfirmations = true;
+ const selectedQuote = useSelector(getSelectedQuote);
+ const topQuote = useSelector(getTopQuote);
+ const usedQuote = selectedQuote || topQuote;
+ const currentSmartTransactions = useSelector(getCurrentSmartTransactions);
+ const smartTransactionsOptInStatus = useSelector(
+ getSmartTransactionsOptInStatus,
+ );
+ const swapsRefreshRates = useSelector(getSwapsRefreshStates);
+ const smartTransactionsEnabled = useSelector(getSmartTransactionsEnabled);
+ const currentSmartTransactionsEnabled = useSelector(
+ getCurrentSmartTransactionsEnabled,
+ );
+ let smartTransactionStatus = SMART_TRANSACTION_STATUSES.PENDING;
+ let latestSmartTransaction = {};
+ let latestSmartTransactionUuid;
+
+ if (currentSmartTransactions && currentSmartTransactions.length > 0) {
+ latestSmartTransaction =
+ currentSmartTransactions[currentSmartTransactions.length - 1];
+ latestSmartTransactionUuid = latestSmartTransaction?.uuid;
+ smartTransactionStatus =
+ latestSmartTransaction?.status || SMART_TRANSACTION_STATUSES.PENDING;
+ }
+
+ const [timeLeftForPendingStxInSec, setTimeLeftForPendingStxInSec] = useState(
+ swapsRefreshRates.stxStatusDeadline,
+ );
+
+ const sensitiveProperties = {
+ needs_two_confirmations: needsTwoConfirmations,
+ token_from: sourceTokenInfo?.symbol,
+ token_from_amount: fetchParams?.value,
+ token_to: destinationTokenInfo?.symbol,
+ request_type: fetchParams?.balanceError ? 'Quote' : 'Order',
+ slippage: fetchParams?.slippage,
+ custom_slippage: fetchParams?.slippage === 2,
+ is_hardware_wallet: hardwareWalletUsed,
+ hardware_wallet_type: hardwareWalletType,
+ stx_uuid: latestSmartTransactionUuid,
+ stx_enabled: smartTransactionsEnabled,
+ current_stx_enabled: currentSmartTransactionsEnabled,
+ stx_user_opt_in: smartTransactionsOptInStatus,
+ };
+
+ let destinationValue;
+ if (usedQuote?.destinationAmount) {
+ destinationValue = calcTokenAmount(
+ usedQuote?.destinationAmount,
+ destinationTokenInfo.decimals,
+ ).toPrecision(8);
+ }
+
+ const stxStatusPageLoadedEvent = useNewMetricEvent({
+ event: 'STX Status Page Loaded',
+ category: 'swaps',
+ sensitiveProperties,
+ });
+
+ const cancelSmartTransactionEvent = useNewMetricEvent({
+ event: 'Cancel STX',
+ category: 'swaps',
+ sensitiveProperties,
+ });
+
+ const isSmartTransactionPending =
+ smartTransactionStatus === SMART_TRANSACTION_STATUSES.PENDING;
+ const showCloseButtonOnly =
+ isSmartTransactionPending ||
+ smartTransactionStatus === SMART_TRANSACTION_STATUSES.SUCCESS;
+
+ useEffect(() => {
+ stxStatusPageLoadedEvent();
+ // eslint-disable-next-line
+ }, []);
+
+ useEffect(() => {
+ let intervalId;
+ if (isSmartTransactionPending && latestSmartTransactionUuid) {
+ const calculateRemainingTime = () => {
+ const secondsAfterStxSubmission = Math.round(
+ (Date.now() - latestSmartTransaction.time) / 1000,
+ );
+ if (secondsAfterStxSubmission > swapsRefreshRates.stxStatusDeadline) {
+ setTimeLeftForPendingStxInSec(0);
+ clearInterval(intervalId);
+ return;
+ }
+ setTimeLeftForPendingStxInSec(
+ swapsRefreshRates.stxStatusDeadline - secondsAfterStxSubmission,
+ );
+ };
+ intervalId = setInterval(calculateRemainingTime, 1000);
+ calculateRemainingTime();
+ }
+
+ return () => clearInterval(intervalId);
+ }, [
+ dispatch,
+ isSmartTransactionPending,
+ latestSmartTransactionUuid,
+ latestSmartTransaction.time,
+ swapsRefreshRates.stxStatusDeadline,
+ ]);
+
+ useEffect(() => {
+ dispatch(setBackgroundSwapRouteState('smartTransactionStatus'));
+ setTimeout(() => {
+ // We don't need to poll for quotes on the status page.
+ dispatch(stopPollingForQuotes());
+ }, 1000); // Stop polling for quotes after 1s.
+ }, [dispatch]);
+
+ let headerText = t('stxPendingOptimizingGas');
+ let description;
+ let subDescription;
+ let icon;
+ if (isSmartTransactionPending) {
+ if (timeLeftForPendingStxInSec < 120) {
+ headerText = t('stxPendingFinalizing');
+ } else if (timeLeftForPendingStxInSec < 150) {
+ headerText = t('stxPendingPrivatelySubmitting');
+ }
+ }
+ if (smartTransactionStatus === SMART_TRANSACTION_STATUSES.SUCCESS) {
+ headerText = t('stxSuccess');
+ description = t('stxSuccessDescription', [destinationTokenInfo?.symbol]);
+ icon = ;
+ } else if (smartTransactionStatus === 'cancelled_user_cancelled') {
+ headerText = t('stxUserCancelled');
+ description = t('stxUserCancelledDescription');
+ icon = ;
+ } else if (
+ smartTransactionStatus.startsWith('cancelled') ||
+ smartTransactionStatus.includes('deadline_missed')
+ ) {
+ headerText = t('stxCancelled');
+ description = t('stxCancelledDescription');
+ subDescription = t('stxCancelledSubDescription');
+ icon = ;
+ } else if (smartTransactionStatus === 'unknown') {
+ headerText = t('stxUnknown');
+ description = t('stxUnknownDescription');
+ icon = ;
+ } else if (smartTransactionStatus === 'reverted') {
+ headerText = t('stxFailure');
+ description = t('stxFailureDescription', [
+
+ {t('customerSupport')}
+ ,
+ ]);
+ icon = ;
+ }
+
+ const showCancelSwapLink =
+ latestSmartTransaction.cancellable && !cancelSwapLinkClicked;
+
+ const CancelSwap = () => {
+ return (
+
+ {
+ e?.preventDefault();
+ setCancelSwapLinkClicked(true); // We want to hide it after a user clicks on it.
+ cancelSmartTransactionEvent();
+ dispatch(cancelSwapsSmartTransaction(latestSmartTransactionUuid));
+ }}
+ >
+ {t('cancelSwap')}
+
+
+ );
+ };
+
+ return (
+
+
+
+
+ {`${fetchParams?.value && Number(fetchParams.value).toFixed(5)} `}
+
+
+ {sourceTokenInfo?.symbol}
+
+
+
+
+
+
+
+ {`~${destinationValue && Number(destinationValue).toFixed(5)} `}
+
+
+ {destinationTokenInfo?.symbol}
+
+
+
+ {icon && (
+
+ {icon}
+
+ )}
+ {isSmartTransactionPending && (
+
+
+
+ {`${t('swapCompleteIn')} `}
+
+
+ {showRemainingTimeInMinAndSec(timeLeftForPendingStxInSec)}
+
+
+ )}
+
+ {headerText}
+
+ {isSmartTransactionPending && (
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+
+ {subDescription && (
+
+ {subDescription}
+
+ )}
+
+ {showCancelSwapLink &&
+ latestSmartTransactionUuid &&
+ isSmartTransactionPending &&
}
+
{
+ if (showCloseButtonOnly) {
+ await dispatch(prepareToLeaveSwaps());
+ history.push(DEFAULT_ROUTE);
+ } else {
+ history.push(BUILD_QUOTE_ROUTE);
+ }
+ }}
+ onCancel={async () => {
+ await dispatch(prepareToLeaveSwaps());
+ history.push(DEFAULT_ROUTE);
+ }}
+ submitText={showCloseButtonOnly ? t('close') : t('tryAgain')}
+ hideCancel={showCloseButtonOnly}
+ cancelText={t('close')}
+ className="smart-transaction-status__swaps-footer"
+ />
+
+ );
+}
diff --git a/ui/pages/swaps/smart-transaction-status/smart-transaction-status.stories.js b/ui/pages/swaps/smart-transaction-status/smart-transaction-status.stories.js
new file mode 100644
index 000000000..ce93a3717
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/smart-transaction-status.stories.js
@@ -0,0 +1,10 @@
+import React from 'react';
+import SmartTransactionStatus from './smart-transaction-status';
+
+export default {
+ title: 'SmartTransactionStatus',
+};
+
+export const SmartTransactionStatusComponent = () => {
+ return ;
+};
diff --git a/ui/pages/swaps/smart-transaction-status/smart-transaction-status.test.js b/ui/pages/swaps/smart-transaction-status/smart-transaction-status.test.js
new file mode 100644
index 000000000..091ded904
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/smart-transaction-status.test.js
@@ -0,0 +1,25 @@
+import React from 'react';
+import configureMockStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+
+import {
+ renderWithProvider,
+ createSwapsMockStore,
+ setBackgroundConnection,
+} from '../../../../test/jest';
+import SmartTransactionStatus from '.';
+
+const middleware = [thunk];
+setBackgroundConnection({
+ stopPollingForQuotes: jest.fn(),
+ setBackgroundSwapRouteState: jest.fn(),
+});
+
+describe('SmartTransactionStatus', () => {
+ it('renders the component with initial props', () => {
+ const store = configureMockStore(middleware)(createSwapsMockStore());
+ const { getByText } = renderWithProvider( , store);
+ expect(getByText('Optimizing gas...')).toBeInTheDocument();
+ expect(getByText('Close')).toBeInTheDocument();
+ });
+});
diff --git a/ui/pages/swaps/smart-transaction-status/success-icon.js b/ui/pages/swaps/smart-transaction-status/success-icon.js
new file mode 100644
index 000000000..fd6496b66
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/success-icon.js
@@ -0,0 +1,18 @@
+import React from 'react';
+
+export default function SuccessIcon() {
+ return (
+
+
+
+ );
+}
diff --git a/ui/pages/swaps/smart-transaction-status/success-icon.test.js b/ui/pages/swaps/smart-transaction-status/success-icon.test.js
new file mode 100644
index 000000000..df6c4fdf8
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/success-icon.test.js
@@ -0,0 +1,11 @@
+import React from 'react';
+
+import { renderWithProvider } from '../../../../test/jest';
+import SuccessIcon from './success-icon';
+
+describe('SuccessIcon', () => {
+ it('renders the SuccessIcon component', () => {
+ const { container } = renderWithProvider( );
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/ui/pages/swaps/smart-transaction-status/timer-icon.js b/ui/pages/swaps/smart-transaction-status/timer-icon.js
new file mode 100644
index 000000000..d5f625aee
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/timer-icon.js
@@ -0,0 +1,18 @@
+import React from 'react';
+
+export default function TimerIcon() {
+ return (
+
+
+
+ );
+}
diff --git a/ui/pages/swaps/smart-transaction-status/timer-icon.test.js b/ui/pages/swaps/smart-transaction-status/timer-icon.test.js
new file mode 100644
index 000000000..e1cd9d628
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/timer-icon.test.js
@@ -0,0 +1,11 @@
+import React from 'react';
+
+import { renderWithProvider } from '../../../../test/jest';
+import TimerIcon from './timer-icon';
+
+describe('TimerIcon', () => {
+ it('renders the TimerIcon component', () => {
+ const { container } = renderWithProvider( );
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/ui/pages/swaps/smart-transaction-status/unknown-icon.js b/ui/pages/swaps/smart-transaction-status/unknown-icon.js
new file mode 100644
index 000000000..d4750ba2d
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/unknown-icon.js
@@ -0,0 +1,25 @@
+import React from 'react';
+
+export default function UnknownIcon() {
+ return (
+
+
+
+
+ );
+}
diff --git a/ui/pages/swaps/smart-transaction-status/unknown-icon.test.js b/ui/pages/swaps/smart-transaction-status/unknown-icon.test.js
new file mode 100644
index 000000000..ea759dcca
--- /dev/null
+++ b/ui/pages/swaps/smart-transaction-status/unknown-icon.test.js
@@ -0,0 +1,11 @@
+import React from 'react';
+
+import { renderWithProvider } from '../../../../test/jest';
+import UnknownIcon from './unknown-icon';
+
+describe('UnknownIcon', () => {
+ it('renders the UnknownIcon component', () => {
+ const { container } = renderWithProvider( );
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/ui/pages/swaps/swaps-footer/swaps-footer.js b/ui/pages/swaps/swaps-footer/swaps-footer.js
index 18192bfe1..422b32da6 100644
--- a/ui/pages/swaps/swaps-footer/swaps-footer.js
+++ b/ui/pages/swaps/swaps-footer/swaps-footer.js
@@ -14,6 +14,7 @@ export default function SwapsFooter({
showTermsOfService,
showTopBorder,
className = '',
+ cancelText,
}) {
const t = useContext(I18nContext);
@@ -27,7 +28,7 @@ export default function SwapsFooter({
{
return `${v2ApiBaseUrl}/networks/${chainIdDecimal}`;
};
+const TEST_CHAIN_IDS = [RINKEBY_CHAIN_ID, LOCALHOST_CHAIN_ID];
+
export const getBaseApi = function (type, chainId = MAINNET_CHAIN_ID) {
// eslint-disable-next-line no-param-reassign
- chainId = chainId === RINKEBY_CHAIN_ID ? MAINNET_CHAIN_ID : chainId;
+ chainId = TEST_CHAIN_IDS.includes(chainId) ? MAINNET_CHAIN_ID : chainId;
const baseUrl = getBaseUrlForNewSwapsApi(type, chainId);
const chainIdDecimal = chainId && parseInt(chainId, 16);
if (!baseUrl) {
@@ -492,6 +495,34 @@ export async function fetchSwapsGasPrices(chainId) {
};
}
+export const getFeeForSmartTransaction = ({
+ chainId,
+ currentCurrency,
+ conversionRate,
+ nativeCurrencySymbol,
+ feeInWeiDec,
+}) => {
+ const feeInWeiHex = decimalToHex(feeInWeiDec);
+ const ethFee = getValueFromWeiHex({
+ value: feeInWeiHex,
+ toDenomination: ETH_SYMBOL,
+ numberOfDecimals: 5,
+ });
+ const rawNetworkFees = getValueFromWeiHex({
+ value: feeInWeiHex,
+ toCurrency: currentCurrency,
+ conversionRate,
+ numberOfDecimals: 2,
+ });
+ const formattedNetworkFee = formatCurrency(rawNetworkFees, currentCurrency);
+ const chainCurrencySymbolToUse =
+ nativeCurrencySymbol || SWAPS_CHAINID_DEFAULT_TOKEN_MAP[chainId].symbol;
+ return {
+ feeInFiat: formattedNetworkFee,
+ feeInEth: `${ethFee} ${chainCurrencySymbolToUse}`,
+ };
+};
+
export function getRenderableNetworkFeesForQuote({
tradeGas,
approveGas,
@@ -553,6 +584,8 @@ export function quotesToRenderableData(
approveGas,
tokenConversionRates,
chainId,
+ smartTransactionEstimatedGas,
+ nativeCurrencySymbol,
) {
return Object.values(quotes).map((quote) => {
const {
@@ -577,11 +610,16 @@ export function quotesToRenderableData(
destinationTokenInfo.decimals,
).toPrecision(8);
- const {
+ let feeInFiat = null;
+ let feeInEth = null;
+ let rawNetworkFees = null;
+ let rawEthFee = null;
+
+ ({
feeInFiat,
+ feeInEth,
rawNetworkFees,
rawEthFee,
- feeInEth,
} = getRenderableNetworkFeesForQuote({
tradeGas: gasEstimateWithRefund || decimalToHex(averageGas || 800000),
approveGas,
@@ -592,7 +630,17 @@ export function quotesToRenderableData(
sourceSymbol: sourceTokenInfo.symbol,
sourceAmount,
chainId,
- });
+ }));
+
+ if (smartTransactionEstimatedGas) {
+ ({ feeInFiat, feeInEth } = getFeeForSmartTransaction({
+ chainId,
+ currentCurrency,
+ conversionRate,
+ nativeCurrencySymbol,
+ estimatedFeeInWeiDec: smartTransactionEstimatedGas.feeEstimate,
+ }));
+ }
const slippageMultiplier = new BigNumber(100 - slippage).div(100);
const minimumAmountReceived = new BigNumber(destinationValue)
@@ -845,3 +893,31 @@ export const countDecimals = (value) => {
}
return value.toString().split('.')[1]?.length || 0;
};
+
+export const showRemainingTimeInMinAndSec = (remainingTimeInSec) => {
+ if (!Number.isInteger(remainingTimeInSec)) {
+ return '0:00';
+ }
+ const minutes = Math.floor(remainingTimeInSec / 60);
+ const seconds = remainingTimeInSec % 60;
+ return `${minutes}:${seconds.toString().padStart(2, '0')}`;
+};
+
+export const stxErrorTypes = ['unavailable', 'not_enough_funds'];
+
+const smartTransactionsErrorMap = {
+ unavailable: 'Smart Transactions are temporarily unavailable.',
+ not_enough_funds: 'Not enough funds for a smart transaction.',
+};
+
+export const smartTransactionsErrorMessages = (errorType) => {
+ return (
+ smartTransactionsErrorMap[errorType] ||
+ smartTransactionsErrorMap.unavailable
+ );
+};
+
+export const parseSmartTransactionsError = (errorMessage) => {
+ const errorJson = errorMessage.slice(12);
+ return JSON.parse(errorJson.trim());
+};
diff --git a/ui/pages/swaps/swaps.util.test.js b/ui/pages/swaps/swaps.util.test.js
index 7815ce08e..62c140435 100644
--- a/ui/pages/swaps/swaps.util.test.js
+++ b/ui/pages/swaps/swaps.util.test.js
@@ -38,6 +38,7 @@ import {
getSwapsLivenessForNetwork,
countDecimals,
shouldEnableDirectWrapping,
+ showRemainingTimeInMinAndSec,
} from './swaps.util';
jest.mock('../../helpers/utils/storage-helpers.js', () => ({
@@ -545,4 +546,25 @@ describe('Swaps Util', () => {
).toBe(false);
});
});
+
+ describe('showRemainingTimeInMinAndSec', () => {
+ it('returns 0:00 if we do not pass an integer', () => {
+ expect(showRemainingTimeInMinAndSec('5')).toBe('0:00');
+ });
+
+ it('returns 0:05 if 5 seconds are remaining', () => {
+ expect(showRemainingTimeInMinAndSec(5)).toBe('0:05');
+ });
+
+ it('returns 2:59', () => {
+ expect(showRemainingTimeInMinAndSec(179)).toBe('2:59');
+ });
+ });
+
+ describe('getFeeForSmartTransaction', () => {
+ it('returns estimated for for STX', () => {
+ // TODO: Implement tests for this function.
+ expect(true).toBe(true);
+ });
+ });
});
diff --git a/ui/pages/swaps/view-quote/index.scss b/ui/pages/swaps/view-quote/index.scss
index 90b037741..b50f0b9ef 100644
--- a/ui/pages/swaps/view-quote/index.scss
+++ b/ui/pages/swaps/view-quote/index.scss
@@ -5,6 +5,15 @@
flex: 1;
width: 100%;
+ &::after { // Hide preloaded images.
+ position: absolute;
+ width: 0;
+ height: 0;
+ overflow: hidden;
+ z-index: -1;
+ content: url('/images/transaction-background-top.svg') url('/images/transaction-background-bottom.svg'); // Preload images for the STX status page.
+ }
+
&__content {
display: flex;
flex-flow: column;
diff --git a/ui/pages/swaps/view-quote/view-quote.js b/ui/pages/swaps/view-quote/view-quote.js
index d47343900..622985580 100644
--- a/ui/pages/swaps/view-quote/view-quote.js
+++ b/ui/pages/swaps/view-quote/view-quote.js
@@ -35,6 +35,15 @@ import {
swapsQuoteSelected,
getSwapsQuoteRefreshTime,
getReviewSwapClickedTimestamp,
+ getSmartTransactionsOptInStatus,
+ signAndSendSwapsSmartTransaction,
+ getSwapsRefreshStates,
+ getSmartTransactionsEnabled,
+ getCurrentSmartTransactionsError,
+ getCurrentSmartTransactionsErrorMessageDismissed,
+ getSwapsSTXLoading,
+ estimateSwapsSmartTransactionsGas,
+ getSmartTransactionEstimatedGas,
} from '../../../ducks/swaps/swaps';
import {
conversionRateSelector,
@@ -80,6 +89,7 @@ import {
hexToDecimal,
getValueFromWeiHex,
decGWEIToHexWEI,
+ hexWEIToDecGWEI,
addHexes,
} from '../../../helpers/utils/conversions.util';
import { GasFeeContextProvider } from '../../../contexts/gasFee';
@@ -93,6 +103,7 @@ import ActionableMessage from '../../../components/ui/actionable-message/actiona
import {
quotesToRenderableData,
getRenderableNetworkFeesForQuote,
+ getFeeForSmartTransaction,
} from '../swaps.util';
import { useTokenTracker } from '../../../hooks/useTokenTracker';
import { QUOTES_EXPIRED_ERROR } from '../../../../shared/constants/swaps';
@@ -102,8 +113,12 @@ import {
} from '../../../../shared/constants/gas';
import CountdownTimer from '../countdown-timer';
import SwapsFooter from '../swaps-footer';
+import PulseLoader from '../../../components/ui/pulse-loader'; // TODO: Replace this with a different loading component.
+import Box from '../../../components/ui/box';
import ViewQuotePriceDifference from './view-quote-price-difference';
+let intervalId;
+
export default function ViewQuote() {
const history = useHistory();
const dispatch = useDispatch();
@@ -168,6 +183,63 @@ export default function ViewQuote() {
const chainId = useSelector(getCurrentChainId);
const nativeCurrencySymbol = useSelector(getNativeCurrency);
const reviewSwapClickedTimestamp = useSelector(getReviewSwapClickedTimestamp);
+ const smartTransactionsOptInStatus = useSelector(
+ getSmartTransactionsOptInStatus,
+ );
+ const smartTransactionsEnabled = useSelector(getSmartTransactionsEnabled);
+ const swapsSTXLoading = useSelector(getSwapsSTXLoading);
+ const currentSmartTransactionsError = useSelector(
+ getCurrentSmartTransactionsError,
+ );
+ const currentSmartTransactionsErrorMessageDismissed = useSelector(
+ getCurrentSmartTransactionsErrorMessageDismissed,
+ );
+ const currentSmartTransactionsEnabled =
+ smartTransactionsEnabled &&
+ !(
+ currentSmartTransactionsError &&
+ (currentSmartTransactionsError !== 'not_enough_funds' ||
+ currentSmartTransactionsErrorMessageDismissed)
+ );
+ const smartTransactionEstimatedGas = useSelector(
+ getSmartTransactionEstimatedGas,
+ );
+ const swapsRefreshRates = useSelector(getSwapsRefreshStates);
+ const unsignedTransaction = usedQuote.trade;
+
+ useEffect(() => {
+ if (currentSmartTransactionsEnabled && smartTransactionsOptInStatus) {
+ const unsignedTx = {
+ from: unsignedTransaction.from,
+ to: unsignedTransaction.to,
+ value: unsignedTransaction.value,
+ data: unsignedTransaction.data,
+ gas: unsignedTransaction.gas,
+ chainId,
+ };
+ intervalId = setInterval(() => {
+ dispatch(
+ estimateSwapsSmartTransactionsGas(unsignedTx, approveTxParams),
+ );
+ }, swapsRefreshRates.stxGetTransactionsRefreshTime);
+ dispatch(estimateSwapsSmartTransactionsGas(unsignedTx, approveTxParams));
+ } else if (intervalId) {
+ clearInterval(intervalId);
+ }
+ return () => clearInterval(intervalId);
+ // eslint-disable-next-line
+ }, [
+ dispatch,
+ currentSmartTransactionsEnabled,
+ smartTransactionsOptInStatus,
+ unsignedTransaction.data,
+ unsignedTransaction.from,
+ unsignedTransaction.value,
+ unsignedTransaction.gas,
+ unsignedTransaction.to,
+ chainId,
+ swapsRefreshRates.stxGetTransactionsRefreshTime,
+ ]);
let gasFeeInputs;
if (networkAndAccountSupports1559) {
@@ -196,12 +268,13 @@ export default function ViewQuote() {
const nonCustomMaxGasLimit = usedQuote?.gasEstimate
? usedGasLimitWithMultiplier
: `0x${decimalToHex(usedQuote?.maxGas || 0)}`;
- const maxGasLimit = customMaxGas || nonCustomMaxGasLimit;
+ let maxGasLimit = customMaxGas || nonCustomMaxGasLimit;
let maxFeePerGas;
let maxPriorityFeePerGas;
let baseAndPriorityFeePerGas;
+ // EIP-1559 gas fees.
if (networkAndAccountSupports1559) {
const {
maxFeePerGas: suggestedMaxFeePerGas,
@@ -218,10 +291,18 @@ export default function ViewQuote() {
);
}
- const gasTotalInWeiHex = calcGasTotal(
- maxGasLimit,
- networkAndAccountSupports1559 ? maxFeePerGas : gasPrice,
- );
+ // Smart Transactions gas fees.
+ if (
+ currentSmartTransactionsEnabled &&
+ smartTransactionsOptInStatus &&
+ smartTransactionEstimatedGas?.txData
+ ) {
+ maxGasLimit = `0x${decimalToHex(
+ smartTransactionEstimatedGas?.txData.gasLimit || 0,
+ )}`;
+ }
+
+ const gasTotalInWeiHex = calcGasTotal(maxGasLimit, maxFeePerGas || gasPrice);
const { tokensWithBalances } = useTokenTracker(swapsTokens, true);
const balanceToken =
@@ -258,6 +339,10 @@ export default function ViewQuote() {
approveGas,
memoizedTokenConversionRates,
chainId,
+ smartTransactionsEnabled &&
+ smartTransactionsOptInStatus &&
+ smartTransactionEstimatedGas?.txData,
+ nativeCurrencySymbol,
);
}, [
quotes,
@@ -269,6 +354,10 @@ export default function ViewQuote() {
approveGas,
memoizedTokenConversionRates,
chainId,
+ smartTransactionEstimatedGas?.txData,
+ nativeCurrencySymbol,
+ smartTransactionsEnabled,
+ smartTransactionsOptInStatus,
]);
const renderableDataForUsedQuote = renderablePopoverData.find(
@@ -287,7 +376,7 @@ export default function ViewQuote() {
sourceTokenIconUrl,
} = renderableDataForUsedQuote;
- const { feeInFiat, feeInEth } = getRenderableNetworkFeesForQuote({
+ let { feeInFiat, feeInEth } = getRenderableNetworkFeesForQuote({
tradeGas: usedGasLimit,
approveGas,
gasPrice: networkAndAccountSupports1559
@@ -302,14 +391,10 @@ export default function ViewQuote() {
nativeCurrencySymbol,
});
- const {
- feeInFiat: maxFeeInFiat,
- feeInEth: maxFeeInEth,
- nonGasFee,
- } = getRenderableNetworkFeesForQuote({
+ const renderableMaxFees = getRenderableNetworkFeesForQuote({
tradeGas: maxGasLimit,
approveGas,
- gasPrice: networkAndAccountSupports1559 ? maxFeePerGas : gasPrice,
+ gasPrice: maxFeePerGas || gasPrice,
currentCurrency,
conversionRate,
tradeValue,
@@ -318,6 +403,36 @@ export default function ViewQuote() {
chainId,
nativeCurrencySymbol,
});
+ let { feeInFiat: maxFeeInFiat, feeInEth: maxFeeInEth } = renderableMaxFees;
+ const { nonGasFee } = renderableMaxFees;
+
+ if (
+ currentSmartTransactionsEnabled &&
+ smartTransactionsOptInStatus &&
+ smartTransactionEstimatedGas?.txData
+ ) {
+ const stxEstimatedFeeInWeiDec =
+ smartTransactionEstimatedGas.txData.feeEstimate +
+ (smartTransactionEstimatedGas.approvalTxData?.feeEstimate || 0);
+ const stxMaxFeeInWeiDec = stxEstimatedFeeInWeiDec * 2;
+ ({ feeInFiat, feeInEth } = getFeeForSmartTransaction({
+ chainId,
+ currentCurrency,
+ conversionRate,
+ nativeCurrencySymbol,
+ feeInWeiDec: stxEstimatedFeeInWeiDec,
+ }));
+ ({
+ feeInFiat: maxFeeInFiat,
+ feeInEth: maxFeeInEth,
+ } = getFeeForSmartTransaction({
+ chainId,
+ currentCurrency,
+ conversionRate,
+ nativeCurrencySymbol,
+ feeInWeiDec: stxMaxFeeInWeiDec,
+ }));
+ }
const tokenCost = new BigNumber(usedQuote.sourceAmount);
const ethCost = new BigNumber(usedQuote.trade.value || 0, 10).plus(
@@ -407,6 +522,9 @@ export default function ViewQuote() {
available_quotes: numberOfQuotes,
is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType,
+ stx_enabled: currentSmartTransactionsEnabled,
+ current_stx_enabled: currentSmartTransactionsEnabled,
+ stx_user_opt_in: smartTransactionsOptInStatus,
};
const allAvailableQuotesOpened = useNewMetricEvent({
@@ -678,6 +796,21 @@ export default function ViewQuote() {
}
}, [dispatch, viewQuotePageLoadedEvent, reviewSwapClickedTimestamp]);
+ useEffect(() => {
+ // if smart transaction error is turned off, reset submit clicked boolean
+ if (
+ !currentSmartTransactionsEnabled &&
+ currentSmartTransactionsError &&
+ submitClicked
+ ) {
+ setSubmitClicked(false);
+ }
+ }, [
+ currentSmartTransactionsEnabled,
+ currentSmartTransactionsError,
+ submitClicked,
+ ]);
+
const transaction = {
userFeeLevel: swapsUserFeeLevel || GAS_RECOMMENDATIONS.HIGH,
txParams: {
@@ -710,6 +843,9 @@ export default function ViewQuote() {
swapToSymbol={destinationTokenSymbol}
initialAggId={usedQuote.aggregator}
onQuoteDetailsIsOpened={quoteDetailsOpened}
+ hideEstimatedGasFee={
+ smartTransactionsEnabled && smartTransactionsOptInStatus
+ }
/>
)}
@@ -768,51 +904,89 @@ export default function ViewQuote() {
sourceIconUrl={sourceTokenIconUrl}
destinationIconUrl={destinationIconUrl}
/>
-
- {
- allAvailableQuotesOpened();
- setSelectQuotePopoverShown(true);
- }}
- chainId={chainId}
- isBestQuote={isBestQuote}
- supportsEIP1559V2={supportsEIP1559V2}
- />
-
+ {currentSmartTransactionsEnabled &&
+ smartTransactionsOptInStatus &&
+ !smartTransactionEstimatedGas?.txData && (
+
+
+
+ )}
+ {(!currentSmartTransactionsEnabled ||
+ !smartTransactionsOptInStatus ||
+ smartTransactionEstimatedGas?.txData) && (
+
+ {
+ allAvailableQuotesOpened();
+ setSelectQuotePopoverShown(true);
+ }}
+ chainId={chainId}
+ isBestQuote={isBestQuote}
+ supportsEIP1559V2={supportsEIP1559V2}
+ networkAndAccountSupports1559={networkAndAccountSupports1559}
+ maxPriorityFeePerGasDecGWEI={hexWEIToDecGWEI(
+ maxPriorityFeePerGas,
+ )}
+ maxFeePerGasDecGWEI={hexWEIToDecGWEI(maxFeePerGas)}
+ smartTransactionsEnabled={currentSmartTransactionsEnabled}
+ smartTransactionsOptInStatus={smartTransactionsOptInStatus}
+ />
+
+ )}
{
setSubmitClicked(true);
if (!balanceError) {
- dispatch(signAndSendTransactions(history, metaMetricsEvent));
+ if (
+ currentSmartTransactionsEnabled &&
+ smartTransactionsOptInStatus &&
+ smartTransactionEstimatedGas?.txData
+ ) {
+ dispatch(
+ signAndSendSwapsSmartTransaction({
+ unsignedTransaction,
+ metaMetricsEvent,
+ history,
+ }),
+ );
+ } else {
+ dispatch(signAndSendTransactions(history, metaMetricsEvent));
+ }
} else if (destinationToken.symbol === defaultSwapsToken.symbol) {
history.push(DEFAULT_ROUTE);
} else {
history.push(`${ASSET_ROUTE}/${destinationToken.address}`);
}
}}
- submitText={t('swap')}
+ submitText={
+ currentSmartTransactionsEnabled &&
+ smartTransactionsOptInStatus &&
+ swapsSTXLoading
+ ? t('preparingSwap')
+ : t('swap')
+ }
hideCancel
disabled={
submitClicked ||
@@ -822,18 +996,11 @@ export default function ViewQuote() {
(networkAndAccountSupports1559 &&
baseAndPriorityFeePerGas === undefined) ||
(!networkAndAccountSupports1559 &&
- (gasPrice === null || gasPrice === undefined))
+ (gasPrice === null || gasPrice === undefined)) ||
+ (currentSmartTransactionsEnabled && currentSmartTransactionsError)
}
- tokenApprovalSourceTokenSymbol={sourceTokenSymbol}
- onTokenApprovalClick={onFeeCardTokenApprovalClick}
- metaMaskFee={String(metaMaskFee)}
- numberOfQuotes={Object.values(quotes).length}
- onQuotesClick={() => {
- allAvailableQuotesOpened();
- setSelectQuotePopoverShown(true);
- }}
- chainId={chainId}
- isBestQuote={isBestQuote}
+ className={isShowingWarning && 'view-quote__thin-swaps-footer'}
+ showTopBorder
/>
diff --git a/ui/pages/swaps/view-quote/view-quote.test.js b/ui/pages/swaps/view-quote/view-quote.test.js
index 5145f303e..6ad976117 100644
--- a/ui/pages/swaps/view-quote/view-quote.test.js
+++ b/ui/pages/swaps/view-quote/view-quote.test.js
@@ -65,7 +65,6 @@ describe('ViewQuote', () => {
).toMatchSnapshot();
expect(getByText('Estimated gas fee')).toBeInTheDocument();
expect(getByText('Max fee')).toBeInTheDocument();
- expect(getByText('Edit')).toBeInTheDocument();
expect(getByText('Swap')).toBeInTheDocument();
});
@@ -88,9 +87,8 @@ describe('ViewQuote', () => {
getByTestId('main-quote-summary__exchange-rate-container'),
).toMatchSnapshot();
expect(getByText('Estimated gas fee')).toBeInTheDocument();
- expect(getByText('0.01044 ETH')).toBeInTheDocument();
+ expect(getByText('0.00544 ETH')).toBeInTheDocument();
expect(getByText('Max fee')).toBeInTheDocument();
- expect(getByText('Edit')).toBeInTheDocument();
expect(getByText('Swap')).toBeInTheDocument();
});
});
diff --git a/ui/selectors/transactions.js b/ui/selectors/transactions.js
index 77980087b..ca2bb8798 100644
--- a/ui/selectors/transactions.js
+++ b/ui/selectors/transactions.js
@@ -8,6 +8,7 @@ import txHelper from '../helpers/utils/tx-helper';
import {
TRANSACTION_STATUSES,
TRANSACTION_TYPES,
+ SMART_TRANSACTION_STATUSES,
} from '../../shared/constants/transaction';
import { transactionMatchesNetwork } from '../../shared/modules/transaction.utils';
import {
@@ -45,13 +46,27 @@ export const unapprovedEncryptionPublicKeyMsgsSelector = (state) =>
export const unapprovedTypedMessagesSelector = (state) =>
state.metamask.unapprovedTypedMessages;
+export const smartTransactionsListSelector = (state) =>
+ state.metamask.smartTransactionsState?.smartTransactions?.[
+ getCurrentChainId(state)
+ ]
+ ?.filter((stx) => !stx.confirmed)
+ .map((stx) => ({
+ ...stx,
+ transactionType: TRANSACTION_TYPES.SMART,
+ status: stx.status?.startsWith('cancelled')
+ ? SMART_TRANSACTION_STATUSES.CANCELLED
+ : stx.status,
+ }));
+
export const selectedAddressTxListSelector = createSelector(
getSelectedAddress,
currentNetworkTxListSelector,
- (selectedAddress, transactions = []) => {
- return transactions.filter(
- ({ txParams }) => txParams.from === selectedAddress,
- );
+ smartTransactionsListSelector,
+ (selectedAddress, transactions = [], smTransactions = []) => {
+ return transactions
+ .filter(({ txParams }) => txParams.from === selectedAddress)
+ .concat(smTransactions);
},
);
diff --git a/ui/store/actionConstants.js b/ui/store/actionConstants.js
index 6ad3bef76..0bc5b5c80 100644
--- a/ui/store/actionConstants.js
+++ b/ui/store/actionConstants.js
@@ -108,4 +108,9 @@ export const HIDE_WHATS_NEW_POPUP = 'HIDE_WHATS_NEW_POPUP';
export const TOGGLE_GAS_LOADING_ANIMATION = 'TOGGLE_GAS_LOADING_ANIMATION';
+// Smart Transactions
+export const SET_SMART_TRANSACTIONS_ERROR = 'SET_SMART_TRANSACTIONS_ERROR';
+export const DISMISS_SMART_TRANSACTIONS_ERROR_MESSAGE =
+ 'DISMISS_SMART_TRANSACTIONS_ERROR_MESSAGE';
+
export const SET_CURRENCY_INPUT_SWITCH = 'SET_CURRENCY_INPUT_SWITCH';
diff --git a/ui/store/actions.js b/ui/store/actions.js
index 0717f9ab5..7842b4b5d 100644
--- a/ui/store/actions.js
+++ b/ui/store/actions.js
@@ -18,6 +18,7 @@ import {
import { hasUnconfirmedTransactions } from '../helpers/utils/confirm-tx.util';
import txHelper from '../helpers/utils/tx-helper';
import { getEnvironmentType, addHexPrefix } from '../../app/scripts/lib/util';
+import { decimalToHex } from '../helpers/utils/conversions.util';
import {
getMetaMaskAccounts,
getPermittedAccountsForCurrentTab,
@@ -33,6 +34,7 @@ import {
LEDGER_TRANSPORT_TYPES,
LEDGER_USB_VENDOR_ID,
} from '../../shared/constants/hardware-wallets';
+import { parseSmartTransactionsError } from '../pages/swaps/swaps.util';
import * as actionConstants from './actionConstants';
let background = null;
@@ -2409,6 +2411,13 @@ export function setSwapsLiveness(swapsLiveness) {
};
}
+export function setSwapsFeatureFlags(featureFlags) {
+ return async (dispatch) => {
+ await promisifiedBackground.setSwapsFeatureFlags(featureFlags);
+ await forceUpdateMetamaskState(dispatch);
+ };
+}
+
export function fetchAndSetQuotes(fetchParams, fetchParamsMetaData) {
return async (dispatch) => {
const [
@@ -3185,6 +3194,194 @@ export async function setWeb3ShimUsageAlertDismissed(origin) {
await promisifiedBackground.setWeb3ShimUsageAlertDismissed(origin);
}
+// Smart Transactions Controller
+export async function setSmartTransactionsOptInStatus(optInState) {
+ trackMetaMetricsEvent({
+ event: 'STX OptIn',
+ category: 'swaps',
+ sensitiveProperties: {
+ stx_enabled: true,
+ current_stx_enabled: true,
+ stx_user_opt_in: optInState,
+ },
+ });
+ await promisifiedBackground.setSmartTransactionsOptInStatus(optInState);
+}
+
+export function fetchSmartTransactionFees(unsignedTransaction) {
+ return async (dispatch) => {
+ try {
+ return await promisifiedBackground.fetchSmartTransactionFees(
+ unsignedTransaction,
+ );
+ } catch (e) {
+ log.error(e);
+ if (e.message.startsWith('Fetch error:')) {
+ const errorObj = parseSmartTransactionsError(e.message);
+ dispatch({
+ type: actionConstants.SET_SMART_TRANSACTIONS_ERROR,
+ payload: errorObj.type,
+ });
+ }
+ throw e;
+ }
+ };
+}
+
+export function estimateSmartTransactionsGas(
+ unsignedTransaction,
+ approveTxParams,
+) {
+ if (approveTxParams) {
+ approveTxParams.value = '0x0';
+ }
+ return async (dispatch) => {
+ try {
+ await promisifiedBackground.estimateSmartTransactionsGas(
+ unsignedTransaction,
+ approveTxParams,
+ );
+ } catch (e) {
+ log.error(e);
+ if (e.message.startsWith('Fetch error:')) {
+ const errorObj = parseSmartTransactionsError(e.message);
+ dispatch({
+ type: actionConstants.SET_SMART_TRANSACTIONS_ERROR,
+ payload: errorObj.type,
+ });
+ }
+ throw e;
+ }
+ };
+}
+
+const createSignedTransactions = async (
+ unsignedTransaction,
+ fees,
+ areCancelTransactions,
+) => {
+ const unsignedTransactionsWithFees = fees.map((fee) => {
+ const unsignedTransactionWithFees = {
+ ...unsignedTransaction,
+ maxFeePerGas: decimalToHex(fee.maxFeePerGas),
+ maxPriorityFeePerGas: decimalToHex(fee.maxPriorityFeePerGas),
+ gas: areCancelTransactions
+ ? decimalToHex(21000) // It has to be 21000 for cancel transactions, otherwise the API would reject it.
+ : unsignedTransaction.gas,
+ value: unsignedTransaction.value,
+ };
+ if (areCancelTransactions) {
+ unsignedTransactionWithFees.to = unsignedTransactionWithFees.from;
+ unsignedTransactionWithFees.data = '0x';
+ }
+ return unsignedTransactionWithFees;
+ });
+ const signedTransactions = await promisifiedBackground.approveTransactionsWithSameNonce(
+ unsignedTransactionsWithFees,
+ );
+ return signedTransactions;
+};
+
+export function signAndSendSmartTransaction({
+ unsignedTransaction,
+ smartTransactionFees,
+}) {
+ return async (dispatch) => {
+ const signedTransactions = await createSignedTransactions(
+ unsignedTransaction,
+ smartTransactionFees.fees,
+ );
+ const signedCanceledTransactions = await createSignedTransactions(
+ unsignedTransaction,
+ smartTransactionFees.cancelFees,
+ true,
+ );
+ try {
+ const response = await promisifiedBackground.submitSignedTransactions({
+ signedTransactions,
+ signedCanceledTransactions,
+ txParams: unsignedTransaction,
+ }); // Returns e.g.: { uuid: 'dP23W7c2kt4FK9TmXOkz1UM2F20' }
+ return response.uuid;
+ } catch (e) {
+ log.error(e);
+ if (e.message.startsWith('Fetch error:')) {
+ const errorObj = parseSmartTransactionsError(e.message);
+ dispatch({
+ type: actionConstants.SET_SMART_TRANSACTIONS_ERROR,
+ payload: errorObj.type,
+ });
+ }
+ throw e;
+ }
+ };
+}
+
+export function updateSmartTransaction(uuid, txData) {
+ return async (dispatch) => {
+ try {
+ await promisifiedBackground.updateSmartTransaction({
+ uuid,
+ ...txData,
+ });
+ } catch (e) {
+ log.error(e);
+ if (e.message.startsWith('Fetch error:')) {
+ const errorObj = parseSmartTransactionsError(e.message);
+ dispatch({
+ type: actionConstants.SET_SMART_TRANSACTIONS_ERROR,
+ payload: errorObj.type,
+ });
+ }
+ throw e;
+ }
+ };
+}
+
+export function setSmartTransactionsRefreshInterval(refreshInterval) {
+ return async () => {
+ try {
+ await promisifiedBackground.setStatusRefreshInterval(refreshInterval);
+ } catch (e) {
+ log.error(e);
+ }
+ };
+}
+
+export function cancelSmartTransaction(uuid) {
+ return async (dispatch) => {
+ try {
+ await promisifiedBackground.cancelSmartTransaction(uuid);
+ } catch (e) {
+ log.error(e);
+ if (e.message.startsWith('Fetch error:')) {
+ const errorObj = parseSmartTransactionsError(e.message);
+ dispatch({
+ type: actionConstants.SET_SMART_TRANSACTIONS_ERROR,
+ payload: errorObj.type,
+ });
+ }
+ throw e;
+ }
+ };
+}
+
+export function fetchSmartTransactionsLiveness() {
+ return async () => {
+ try {
+ await promisifiedBackground.fetchSmartTransactionsLiveness();
+ } catch (e) {
+ log.error(e);
+ }
+ };
+}
+
+export function dismissSmartTransactionsErrorMessage() {
+ return {
+ type: actionConstants.DISMISS_SMART_TRANSACTIONS_ERROR_MESSAGE,
+ };
+}
+
// DetectTokenController
export async function detectNewTokens() {
return promisifiedBackground.detectNewTokens();
diff --git a/yarn.lock b/yarn.lock
index e2211fb58..e2c668b34 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1627,10 +1627,10 @@
resolved "https://registry.yarnpkg.com/@ethersproject/logger/-/logger-5.5.0.tgz#0c2caebeff98e10aefa5aef27d7441c7fd18cf5d"
integrity sha512-rIY/6WPm7T8n3qS2vuHTUBPdXHl+rGxWxW5okDfo9J4Z0+gRRZT0msvUdIJkE4/HS29GUMziwGaaKO2bWONBrg==
-"@ethersproject/networks@5.5.0", "@ethersproject/networks@^5.5.0":
- version "5.5.0"
- resolved "https://registry.yarnpkg.com/@ethersproject/networks/-/networks-5.5.0.tgz#babec47cab892c51f8dd652ce7f2e3e14283981a"
- integrity sha512-KWfP3xOnJeF89Uf/FCJdV1a2aDJe5XTN2N52p4fcQ34QhDqQFkgQKZ39VGtiqUgHcLI8DfT0l9azC3KFTunqtA==
+"@ethersproject/networks@5.5.2", "@ethersproject/networks@^5.5.0":
+ version "5.5.2"
+ resolved "https://registry.yarnpkg.com/@ethersproject/networks/-/networks-5.5.2.tgz#784c8b1283cd2a931114ab428dae1bd00c07630b"
+ integrity sha512-NEqPxbGBfy6O3x4ZTISb90SjEDkWYDUbEeIFhJly0F7sZjoQMnj5KYzMSkMkLKZ+1fGpx00EDpHQCy6PrDupkQ==
dependencies:
"@ethersproject/logger" "^5.5.0"
@@ -1649,10 +1649,10 @@
dependencies:
"@ethersproject/logger" "^5.5.0"
-"@ethersproject/providers@5.5.0", "@ethersproject/providers@^5.4.5":
- version "5.5.0"
- resolved "https://registry.yarnpkg.com/@ethersproject/providers/-/providers-5.5.0.tgz#bc2876a8fe5e0053ed9828b1f3767ae46e43758b"
- integrity sha512-xqMbDnS/FPy+J/9mBLKddzyLLAQFjrVff5g00efqxPzcAwXiR+SiCGVy6eJ5iAIirBOATjx7QLhDNPGV+AEQsw==
+"@ethersproject/providers@5.5.3", "@ethersproject/providers@^5.4.5":
+ version "5.5.3"
+ resolved "https://registry.yarnpkg.com/@ethersproject/providers/-/providers-5.5.3.tgz#56c2b070542ac44eb5de2ed3cf6784acd60a3130"
+ integrity sha512-ZHXxXXXWHuwCQKrgdpIkbzMNJMvs+9YWemanwp1fA7XZEv7QlilseysPvQe0D7Q7DlkJX/w/bGA1MdgK2TbGvA==
dependencies:
"@ethersproject/abstract-provider" "^5.5.0"
"@ethersproject/abstract-signer" "^5.5.0"
@@ -1674,10 +1674,10 @@
bech32 "1.1.4"
ws "7.4.6"
-"@ethersproject/random@5.5.0", "@ethersproject/random@^5.5.0":
- version "5.5.0"
- resolved "https://registry.yarnpkg.com/@ethersproject/random/-/random-5.5.0.tgz#305ed9e033ca537735365ac12eed88580b0f81f9"
- integrity sha512-egGYZwZ/YIFKMHcoBUo8t3a8Hb/TKYX8BCBoLjudVCZh892welR3jOxgOmb48xznc9bTcMm7Tpwc1gHC1PFNFQ==
+"@ethersproject/random@5.5.1", "@ethersproject/random@^5.5.0":
+ version "5.5.1"
+ resolved "https://registry.yarnpkg.com/@ethersproject/random/-/random-5.5.1.tgz#7cdf38ea93dc0b1ed1d8e480ccdaf3535c555415"
+ integrity sha512-YaU2dQ7DuhL5Au7KbcQLHxcRHfgyNgvFV4sQOo0HrtW3Zkrc9ctWNz8wXQ4uCSfSDsqX2vcjhroxU5RQRV0nqA==
dependencies:
"@ethersproject/bytes" "^5.5.0"
"@ethersproject/logger" "^5.5.0"
@@ -1777,10 +1777,10 @@
"@ethersproject/transactions" "^5.5.0"
"@ethersproject/wordlists" "^5.5.0"
-"@ethersproject/web@5.5.0", "@ethersproject/web@^5.5.0":
- version "5.5.0"
- resolved "https://registry.yarnpkg.com/@ethersproject/web/-/web-5.5.0.tgz#0e5bb21a2b58fb4960a705bfc6522a6acf461e28"
- integrity sha512-BEgY0eL5oH4mAo37TNYVrFeHsIXLRxggCRG/ksRIxI2X5uj5IsjGmcNiRN/VirQOlBxcUhCgHhaDLG4m6XAVoA==
+"@ethersproject/web@5.5.1", "@ethersproject/web@^5.5.0":
+ version "5.5.1"
+ resolved "https://registry.yarnpkg.com/@ethersproject/web/-/web-5.5.1.tgz#cfcc4a074a6936c657878ac58917a61341681316"
+ integrity sha512-olvLvc1CB12sREc1ROPSHTdFCdvMh0J5GSJYiQg2D0hdD4QmJDy8QYDb1CvoqD/bF1c++aeKv2sR5uduuG9dQg==
dependencies:
"@ethersproject/base64" "^5.5.0"
"@ethersproject/bytes" "^5.5.0"
@@ -2878,6 +2878,19 @@
resolved "https://registry.yarnpkg.com/@metamask/slip44/-/slip44-2.0.0.tgz#1b646a1418af341d5ea979c28015a817ff23af33"
integrity sha512-eRomm783ti/1b/TlNnlTCUkYRuTaMYkeTAG0z2rt/WyT8UzxY+8+v/kbl9vk5qhDHeclzBrd9gbqLnLU1kh+Ow==
+"@metamask/smart-transactions-controller@^1.9.1":
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/@metamask/smart-transactions-controller/-/smart-transactions-controller-1.9.1.tgz#f9fa168b33cc23c2238c23eed29475f16afafdd0"
+ integrity sha512-Vq6HU+l6WSXTCTWazsFwSDNm5DtX6SWuqf3qkMWvollnSduExu2q1XrCIrtsDg7W69NO0XNYL3R13w+ZaNhjzA==
+ dependencies:
+ "@metamask/controllers" "^25.1.0"
+ "@types/lodash" "^4.14.176"
+ bignumber.js "^9.0.1"
+ ethers "^5.5.1"
+ fast-json-patch "^3.1.0"
+ isomorphic-fetch "^3.0.0"
+ lodash "^4.17.21"
+
"@metamask/snap-controllers@^0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@metamask/snap-controllers/-/snap-controllers-0.9.0.tgz#e0006fc9991e995dd86dff792106990aae2aeda0"
@@ -4390,10 +4403,10 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
-"@types/lodash@^4.14.107", "@types/lodash@^4.14.136":
- version "4.14.168"
- resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.168.tgz#fe24632e79b7ade3f132891afff86caa5e5ce008"
- integrity sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q==
+"@types/lodash@^4.14.107", "@types/lodash@^4.14.136", "@types/lodash@^4.14.176":
+ version "4.14.178"
+ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8"
+ integrity sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==
"@types/long@^4.0.1":
version "4.0.1"
@@ -11357,10 +11370,10 @@ ethers@^4.0.20, ethers@^4.0.28:
uuid "2.0.1"
xmlhttprequest "1.8.0"
-ethers@^5.0.8, ethers@^5.4.0, ethers@^5.4.1, ethers@^5.4.5:
- version "5.5.1"
- resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.5.1.tgz#d3259a95a42557844aa543906c537106c0406fbf"
- integrity sha512-RodEvUFZI+EmFcE6bwkuJqpCYHazdzeR1nMzg+YWQSmQEsNtfl1KHGfp/FWZYl48bI/g7cgBeP2IlPthjiVngw==
+ethers@^5.0.8, ethers@^5.4.0, ethers@^5.4.1, ethers@^5.4.5, ethers@^5.5.1:
+ version "5.5.4"
+ resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.5.4.tgz#e1155b73376a2f5da448e4a33351b57a885f4352"
+ integrity sha512-N9IAXsF8iKhgHIC6pquzRgPBJEzc9auw3JoRkaKe+y4Wl/LFBtDDunNe7YmdomontECAcC5APaAgWZBiu1kirw==
dependencies:
"@ethersproject/abi" "5.5.0"
"@ethersproject/abstract-provider" "5.5.1"
@@ -11377,11 +11390,11 @@ ethers@^5.0.8, ethers@^5.4.0, ethers@^5.4.1, ethers@^5.4.5:
"@ethersproject/json-wallets" "5.5.0"
"@ethersproject/keccak256" "5.5.0"
"@ethersproject/logger" "5.5.0"
- "@ethersproject/networks" "5.5.0"
+ "@ethersproject/networks" "5.5.2"
"@ethersproject/pbkdf2" "5.5.0"
"@ethersproject/properties" "5.5.0"
- "@ethersproject/providers" "5.5.0"
- "@ethersproject/random" "5.5.0"
+ "@ethersproject/providers" "5.5.3"
+ "@ethersproject/random" "5.5.1"
"@ethersproject/rlp" "5.5.0"
"@ethersproject/sha2" "5.5.0"
"@ethersproject/signing-key" "5.5.0"
@@ -11390,7 +11403,7 @@ ethers@^5.0.8, ethers@^5.4.0, ethers@^5.4.1, ethers@^5.4.5:
"@ethersproject/transactions" "5.5.0"
"@ethersproject/units" "5.5.0"
"@ethersproject/wallet" "5.5.0"
- "@ethersproject/web" "5.5.0"
+ "@ethersproject/web" "5.5.1"
"@ethersproject/wordlists" "5.5.0"
ethjs-abi@0.2.0:
@@ -12031,6 +12044,11 @@ fast-json-patch@^2.0.6, fast-json-patch@^2.2.1:
dependencies:
fast-deep-equal "^2.0.1"
+fast-json-patch@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/fast-json-patch/-/fast-json-patch-3.1.0.tgz#ec8cd9b9c4c564250ec8b9140ef7a55f70acaee6"
+ integrity sha512-IhpytlsVTRndz0hU5t0/MGzS/etxLlfrpG5V5M9mVbuj9TrJLWaMfsox9REM5rkuGX0T+5qjpe8XA1o0gZ42nA==
+
fast-json-stable-stringify@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2"