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 && ( + + + + + + + + + } + footerClassName="smart-transactions-popover__footer" + className="smart-transactions-popover" + > + + + {t('swapSwapSwitch')} + + + {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}
    Advanced Options
    -
    + + `; exports[`SlippageButtons renders the component with initial props 2`] = ` @@ -18,16 +21,63 @@ exports[`SlippageButtons renders the component with initial props 2`] = ` role="radiogroup" > + + +
    +`; + +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`] = ` +
    +
    + {open ? ( + + ) : ( + + )} +
    -
    -
    -
    - {t('swapsMaxSlippage')} + {open && ( + <> +
    +
    +
    + {t('swapsMaxSlippage')} +
    + +
    + + + + +
    - -
    - - - - - -
    + + {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"