From 2585f45bde6fa4ad4dc3fa17f78ef10306c1e4da Mon Sep 17 00:00:00 2001 From: Daniel <80175477+dan437@users.noreply.github.com> Date: Fri, 18 Feb 2022 17:48:38 +0100 Subject: [PATCH] Add support for Smart Transactions (#12676) --- app/_locales/en/messages.json | 106 +++++ .../logo/metamask-smart-transactions@4x.png | Bin 0 -> 121766 bytes app/images/transaction-background-bottom.svg | 39 ++ app/images/transaction-background-top.svg | 34 ++ app/scripts/controllers/swaps.js | 29 +- app/scripts/controllers/swaps.test.js | 12 + app/scripts/controllers/transactions/index.js | 180 +++++++- .../controllers/transactions/lib/util.js | 50 +++ .../transactions/tx-state-manager.js | 19 +- app/scripts/metamask-controller.js | 64 +++ lavamoat/browserify/beta/policy.json | 23 +- lavamoat/browserify/flask/policy.json | 23 +- lavamoat/browserify/main/policy.json | 23 +- lavamoat/build-system/policy.json | 201 +++++++++ package.json | 1 + ...ns-controller++fast-json-patch+3.1.0.patch | 13 + shared/constants/swaps.js | 5 + shared/constants/transaction.js | 19 + test/jest/mock-store.js | 99 +++++ .../transaction-detail.component.js | 4 +- .../smart-transaction-list-item.component.js | 90 ++++ .../transaction-list.component.js | 50 ++- ui/ducks/app/app.js | 15 + ui/ducks/app/app.test.js | 8 + ui/ducks/swaps/swaps.js | 357 ++++++++++++++- ui/ducks/swaps/swaps.test.js | 126 +++++- ui/helpers/constants/routes.js | 2 + ui/helpers/constants/transactions.js | 1 + .../awaiting-signatures.js | 8 + ui/pages/swaps/awaiting-swap/awaiting-swap.js | 8 + ui/pages/swaps/build-quote/build-quote.js | 138 +++++- ui/pages/swaps/build-quote/index.scss | 38 ++ .../dropdown-search-list.js | 10 + ui/pages/swaps/fee-card/fee-card.js | 28 +- ui/pages/swaps/fee-card/fee-card.test.js | 20 + ui/pages/swaps/index.js | 132 +++++- ui/pages/swaps/index.scss | 11 + .../loading-swaps-quotes.js | 8 + .../quote-details/quote-details.js | 28 +- .../select-quote-popover.js | 8 +- .../sort-list/sort-list.js | 20 +- .../slippage-buttons.test.js.snap | 64 ++- ui/pages/swaps/slippage-buttons/index.scss | 7 +- .../slippage-buttons/slippage-buttons.js | 239 ++++++---- .../slippage-buttons/slippage-buttons.test.js | 23 +- .../__snapshots__/arrow-icon.test.js.snap | 18 + .../__snapshots__/canceled-icon.test.js.snap | 24 + .../__snapshots__/reverted-icon.test.js.snap | 22 + .../__snapshots__/success-icon.test.js.snap | 18 + .../__snapshots__/timer-icon.test.js.snap | 18 + .../__snapshots__/unknown-icon.test.js.snap | 25 ++ .../smart-transaction-status/arrow-icon.js | 18 + .../arrow-icon.test.js | 11 + .../smart-transaction-status/canceled-icon.js | 24 + .../canceled-icon.test.js | 11 + .../swaps/smart-transaction-status/index.js | 1 + .../swaps/smart-transaction-status/index.scss | 84 ++++ .../smart-transaction-status/reverted-icon.js | 22 + .../reverted-icon.test.js | 11 + .../smart-transaction-status.js | 409 ++++++++++++++++++ .../smart-transaction-status.stories.js | 10 + .../smart-transaction-status.test.js | 25 ++ .../smart-transaction-status/success-icon.js | 18 + .../success-icon.test.js | 11 + .../smart-transaction-status/timer-icon.js | 18 + .../timer-icon.test.js | 11 + .../smart-transaction-status/unknown-icon.js | 25 ++ .../unknown-icon.test.js | 11 + ui/pages/swaps/swaps-footer/swaps-footer.js | 4 +- ui/pages/swaps/swaps.util.js | 84 +++- ui/pages/swaps/swaps.util.test.js | 22 + ui/pages/swaps/view-quote/index.scss | 9 + ui/pages/swaps/view-quote/view-quote.js | 281 +++++++++--- ui/pages/swaps/view-quote/view-quote.test.js | 4 +- ui/selectors/transactions.js | 23 +- ui/store/actionConstants.js | 5 + ui/store/actions.js | 197 +++++++++ yarn.lock | 74 ++-- 78 files changed, 3639 insertions(+), 292 deletions(-) create mode 100644 app/images/logo/metamask-smart-transactions@4x.png create mode 100644 app/images/transaction-background-bottom.svg create mode 100644 app/images/transaction-background-top.svg create mode 100644 patches/@metamask+smart-transactions-controller++fast-json-patch+3.1.0.patch create mode 100644 ui/components/app/transaction-list-item/smart-transaction-list-item.component.js create mode 100644 ui/pages/swaps/smart-transaction-status/__snapshots__/arrow-icon.test.js.snap create mode 100644 ui/pages/swaps/smart-transaction-status/__snapshots__/canceled-icon.test.js.snap create mode 100644 ui/pages/swaps/smart-transaction-status/__snapshots__/reverted-icon.test.js.snap create mode 100644 ui/pages/swaps/smart-transaction-status/__snapshots__/success-icon.test.js.snap create mode 100644 ui/pages/swaps/smart-transaction-status/__snapshots__/timer-icon.test.js.snap create mode 100644 ui/pages/swaps/smart-transaction-status/__snapshots__/unknown-icon.test.js.snap create mode 100644 ui/pages/swaps/smart-transaction-status/arrow-icon.js create mode 100644 ui/pages/swaps/smart-transaction-status/arrow-icon.test.js create mode 100644 ui/pages/swaps/smart-transaction-status/canceled-icon.js create mode 100644 ui/pages/swaps/smart-transaction-status/canceled-icon.test.js create mode 100644 ui/pages/swaps/smart-transaction-status/index.js create mode 100644 ui/pages/swaps/smart-transaction-status/index.scss create mode 100644 ui/pages/swaps/smart-transaction-status/reverted-icon.js create mode 100644 ui/pages/swaps/smart-transaction-status/reverted-icon.test.js create mode 100644 ui/pages/swaps/smart-transaction-status/smart-transaction-status.js create mode 100644 ui/pages/swaps/smart-transaction-status/smart-transaction-status.stories.js create mode 100644 ui/pages/swaps/smart-transaction-status/smart-transaction-status.test.js create mode 100644 ui/pages/swaps/smart-transaction-status/success-icon.js create mode 100644 ui/pages/swaps/smart-transaction-status/success-icon.test.js create mode 100644 ui/pages/swaps/smart-transaction-status/timer-icon.js create mode 100644 ui/pages/swaps/smart-transaction-status/timer-icon.test.js create mode 100644 ui/pages/swaps/smart-transaction-status/unknown-icon.js create mode 100644 ui/pages/swaps/smart-transaction-status/unknown-icon.test.js 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 0000000000000000000000000000000000000000..636576495eadbaa70dd9a5c4d101e95d86caf87e GIT binary patch literal 121766 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 DK;e!VzbC(xj8Kq14 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

Q$}(K>Eh_T`y!`TB4K(ph4-s1JT6~X0xzt{&0^a zS&Toz`9`>QTSFy;9Y*81ZzI~a7xC_+8*aNH!a^{<_LHoNn4z!GXpEb-rQ z!NX;|^i9ms=j#3?iK|agvB*Rl`Mm40eEXh#wtu=kE!|L2sNdb0Nq zK>fxtWIhH_9N`OJeGz~Cmp_$@6c?~8-;auxz3F!`h>(TqZgEmcSRAbU-)xvWVg^D7 z%2_PFBq%JLGS1=Em*uYH&iy9mGEDA?ah-Y+ub9NHYRD@bCcs#bb?gKe?=TsJ@(w2W zmej6G5(a{LSa!dltOgSV)PGaL@z<f~d;#8an4&j8K$Q(SO2q@EQo^QgBJgzRD$#vdSrCzr(p$QRJ)}W${2u8Z?X%Sb- zSayt@T&w7#VpF-ztU0vgaTRfN(${x_)i5f=s8};Ovz;Nl9|sj;)Y+m=m@}P?*V7PQ zU3c!*JB;)@IypUwZ~d)rq19~S&;RXzMZ4bd!PYTibyWHAR?8kLo@t5!@=)GfOo@W9 zXlw06mK0f8WJ%p|u`j>vaojgyHcjREiT*vNzH^VuJJC2==cLI?i~Q?^Wx)mR80b44 zg<&2Hj6wNP5Dc6zxx!MksdlA7?!!Rom^!#$94;}UWtVS^=-yc{v{E6=n69H@Nk2cn zx*w0A?5xSPBHx`m^L0u^SEtl{p5}Z>0-Nih$B8e_1(WB|=)ahTB_}$0r;TR6xEvT4 z#;XCmJr!Y+7FzwLh%l>ImFaK&-QU9_Pd_5ZjB6iz2_fWeha(oU<%l$>4gMl{KBm~< zT&y_d8@+IT%?m6URxV!MlnA5jduSh)=U1$o$k1rUZ!5p*)NS|h?i(NC#1jWW?rz?0 zelT?3xmJ$hrXz#Y^z0i*KYT;Oh^!$qL6%VySD?CrQjPGnh3TmWzJnEboXRo(nHqQb>9 z&pnIQY73{{ejgva`M&G&B_d2!=1Er7gqR|fIf$Y`84$dt5rhNb(Tg%laU?NUi(77@ zpByavp`83z`>v!-MTR6aaXQN)f1yStgv8}Pc>yITwXM#T3a7OU0(XZ+nqXK=&jp$w z5Aq!xYTch*P&j3-^|xFcHG|=?P4Rd(STA?qWIq4f94%4#80%V9C$?XibMIJyaApk$ z%s-gn^ANHjznMu4T2gtxjE=c@Eal9^5i4PsDm#T@Svr9zZK~Lz!N!nZRUKSX-GD5`wOQ}QZW4JQq`Hl=toVDk zaLU>k8qa*)oG*<>zkr~AYfCW*xnrmpqtl-2i(j{cYORU~AAS(eKL074K6whQb_WWcyJ1X2PL#0n9aOAg54RQiZR4$J~QEC*Fp6}c>DD7BT;29am1S9iN&D?$~N{

@jD0dpe6Y=%8YcjL;rtLU_QSg%ZC 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 U + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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')} - + {!hideEstimatedGasFee && ( +
    +
    + {t('swapEstimatedNetworkFees')} + +
    +
    + {feeInEth} + {` (${networkFees})`} +
    -
    - {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')} -
    - -
    - - - - - -
    + +
    + + + + + +
    + {smartTransactionsEnabled && ( + + + + {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"