Add support for Smart Transactions (#12676)

feature/default_network_editable
Daniel 3 years ago committed by GitHub
parent d2c292bead
commit 2585f45bde
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 106
      app/_locales/en/messages.json
  2. BIN
      app/images/logo/metamask-smart-transactions@4x.png
  3. 39
      app/images/transaction-background-bottom.svg
  4. 34
      app/images/transaction-background-top.svg
  5. 29
      app/scripts/controllers/swaps.js
  6. 12
      app/scripts/controllers/swaps.test.js
  7. 180
      app/scripts/controllers/transactions/index.js
  8. 50
      app/scripts/controllers/transactions/lib/util.js
  9. 19
      app/scripts/controllers/transactions/tx-state-manager.js
  10. 64
      app/scripts/metamask-controller.js
  11. 23
      lavamoat/browserify/beta/policy.json
  12. 23
      lavamoat/browserify/flask/policy.json
  13. 23
      lavamoat/browserify/main/policy.json
  14. 201
      lavamoat/build-system/policy.json
  15. 1
      package.json
  16. 13
      patches/@metamask+smart-transactions-controller++fast-json-patch+3.1.0.patch
  17. 5
      shared/constants/swaps.js
  18. 19
      shared/constants/transaction.js
  19. 99
      test/jest/mock-store.js
  20. 4
      ui/components/app/transaction-detail/transaction-detail.component.js
  21. 90
      ui/components/app/transaction-list-item/smart-transaction-list-item.component.js
  22. 50
      ui/components/app/transaction-list/transaction-list.component.js
  23. 15
      ui/ducks/app/app.js
  24. 8
      ui/ducks/app/app.test.js
  25. 357
      ui/ducks/swaps/swaps.js
  26. 126
      ui/ducks/swaps/swaps.test.js
  27. 2
      ui/helpers/constants/routes.js
  28. 1
      ui/helpers/constants/transactions.js
  29. 8
      ui/pages/swaps/awaiting-signatures/awaiting-signatures.js
  30. 8
      ui/pages/swaps/awaiting-swap/awaiting-swap.js
  31. 138
      ui/pages/swaps/build-quote/build-quote.js
  32. 38
      ui/pages/swaps/build-quote/index.scss
  33. 10
      ui/pages/swaps/dropdown-search-list/dropdown-search-list.js
  34. 28
      ui/pages/swaps/fee-card/fee-card.js
  35. 20
      ui/pages/swaps/fee-card/fee-card.test.js
  36. 132
      ui/pages/swaps/index.js
  37. 11
      ui/pages/swaps/index.scss
  38. 8
      ui/pages/swaps/loading-swaps-quotes/loading-swaps-quotes.js
  39. 28
      ui/pages/swaps/select-quote-popover/quote-details/quote-details.js
  40. 8
      ui/pages/swaps/select-quote-popover/select-quote-popover.js
  41. 20
      ui/pages/swaps/select-quote-popover/sort-list/sort-list.js
  42. 60
      ui/pages/swaps/slippage-buttons/__snapshots__/slippage-buttons.test.js.snap
  43. 7
      ui/pages/swaps/slippage-buttons/index.scss
  44. 237
      ui/pages/swaps/slippage-buttons/slippage-buttons.js
  45. 23
      ui/pages/swaps/slippage-buttons/slippage-buttons.test.js
  46. 18
      ui/pages/swaps/smart-transaction-status/__snapshots__/arrow-icon.test.js.snap
  47. 24
      ui/pages/swaps/smart-transaction-status/__snapshots__/canceled-icon.test.js.snap
  48. 22
      ui/pages/swaps/smart-transaction-status/__snapshots__/reverted-icon.test.js.snap
  49. 18
      ui/pages/swaps/smart-transaction-status/__snapshots__/success-icon.test.js.snap
  50. 18
      ui/pages/swaps/smart-transaction-status/__snapshots__/timer-icon.test.js.snap
  51. 25
      ui/pages/swaps/smart-transaction-status/__snapshots__/unknown-icon.test.js.snap
  52. 18
      ui/pages/swaps/smart-transaction-status/arrow-icon.js
  53. 11
      ui/pages/swaps/smart-transaction-status/arrow-icon.test.js
  54. 24
      ui/pages/swaps/smart-transaction-status/canceled-icon.js
  55. 11
      ui/pages/swaps/smart-transaction-status/canceled-icon.test.js
  56. 1
      ui/pages/swaps/smart-transaction-status/index.js
  57. 84
      ui/pages/swaps/smart-transaction-status/index.scss
  58. 22
      ui/pages/swaps/smart-transaction-status/reverted-icon.js
  59. 11
      ui/pages/swaps/smart-transaction-status/reverted-icon.test.js
  60. 409
      ui/pages/swaps/smart-transaction-status/smart-transaction-status.js
  61. 10
      ui/pages/swaps/smart-transaction-status/smart-transaction-status.stories.js
  62. 25
      ui/pages/swaps/smart-transaction-status/smart-transaction-status.test.js
  63. 18
      ui/pages/swaps/smart-transaction-status/success-icon.js
  64. 11
      ui/pages/swaps/smart-transaction-status/success-icon.test.js
  65. 18
      ui/pages/swaps/smart-transaction-status/timer-icon.js
  66. 11
      ui/pages/swaps/smart-transaction-status/timer-icon.test.js
  67. 25
      ui/pages/swaps/smart-transaction-status/unknown-icon.js
  68. 11
      ui/pages/swaps/smart-transaction-status/unknown-icon.test.js
  69. 4
      ui/pages/swaps/swaps-footer/swaps-footer.js
  70. 84
      ui/pages/swaps/swaps.util.js
  71. 22
      ui/pages/swaps/swaps.util.test.js
  72. 9
      ui/pages/swaps/view-quote/index.scss
  73. 281
      ui/pages/swaps/view-quote/view-quote.js
  74. 4
      ui/pages/swaps/view-quote/view-quote.test.js
  75. 23
      ui/selectors/transactions.js
  76. 5
      ui/store/actionConstants.js
  77. 197
      ui/store/actions.js
  78. 74
      yarn.lock

@ -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.", "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'" "description": "$1 is string 'cancel' or 'speed up'"
}, },
"cancelSwap": {
"message": "Cancel swap"
},
"cancellationGasFee": { "cancellationGasFee": {
"message": "Cancellation Gas Fee" "message": "Cancellation Gas Fee"
}, },
@ -694,6 +697,9 @@
"customToken": { "customToken": {
"message": "Custom Token" "message": "Custom Token"
}, },
"customerSupport": {
"message": "customer support"
},
"dappSuggested": { "dappSuggested": {
"message": "Site suggested" "message": "Site suggested"
}, },
@ -982,6 +988,9 @@
"enableOpenSeaAPIDescription": { "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." "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": { "enableToken": {
"message": "enable $1", "message": "enable $1",
"description": "$1 is a token symbol, e.g. ETH" "description": "$1 is a token symbol, e.g. ETH"
@ -1993,6 +2002,9 @@
"noThanks": { "noThanks": {
"message": "No Thanks" "message": "No Thanks"
}, },
"noThanksVariant2": {
"message": "No, thanks."
},
"noTransactions": { "noTransactions": {
"message": "You have no transactions" "message": "You have no transactions"
}, },
@ -2295,6 +2307,9 @@
"message": "Preferred Ledger Connection Type", "message": "Preferred Ledger Connection Type",
"description": "A header for a dropdown in the advanced section of settings. Appears above the ledgerConnectionPreferenceDescription message" "description": "A header for a dropdown in the advanced section of settings. Appears above the ledgerConnectionPreferenceDescription message"
}, },
"preparingSwap": {
"message": "Preparing swap..."
},
"prev": { "prev": {
"message": "Prev" "message": "Prev"
}, },
@ -2737,6 +2752,9 @@
"slow": { "slow": {
"message": "Slow" "message": "Slow"
}, },
"smartTransaction": {
"message": "Smart transaction"
},
"snapAccess": { "snapAccess": {
"message": "$1 snap has access to:", "message": "$1 snap has access to:",
"description": "$1 represents the name of the snap" "description": "$1 represents the name of the snap"
@ -2871,6 +2889,86 @@
"storePhrase": { "storePhrase": {
"message": "Store this phrase in a password manager like 1Password." "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": { "submit": {
"message": "Submit" "message": "Submit"
}, },
@ -2910,6 +3008,10 @@
"message": "You need $1 more $2 to complete this swap", "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." "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": { "swapBestOfNQuotes": {
"message": "Best of $1 quotes.", "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" "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", "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" "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": { "swapConfirmWithHwWallet": {
"message": "Confirm with your hardware wallet" "message": "Confirm with your hardware wallet"
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

@ -0,0 +1,39 @@
<svg width="800" height="62" viewBox="0 0 800 62" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.6" d="M771.428 58.4105C771.515 58.4105 771.602 58.4105 771.69 58.4105C773.279 58.3407 774.746 57.6603 775.829 56.4914C778.047 54.0837 777.89 50.2978 775.48 48.0821C773.069 45.8663 769.279 46.0234 767.061 48.431C765.978 49.5999 765.419 51.1178 765.489 52.7054C765.559 54.2931 766.24 55.7586 767.41 56.8403C768.511 57.8697 769.925 58.4105 771.428 58.4105ZM771.445 48.1693C772.493 48.1693 773.523 48.5357 774.362 49.3033C776.108 50.9084 776.213 53.6301 774.606 55.3748C773.82 56.2122 772.772 56.7182 771.62 56.7531C770.467 56.8054 769.384 56.4042 768.528 55.6191C767.69 54.834 767.183 53.7871 767.148 52.6357C767.096 51.4842 767.498 50.4025 768.284 49.5476C769.122 48.6404 770.275 48.1693 771.445 48.1693Z" fill="#FFB0EB"/>
<path opacity="0.6" d="M540.023 29.9695C538.904 29.9695 537.997 29.0634 537.997 27.9457C537.997 26.828 538.904 25.9219 540.023 25.9219C541.142 25.9219 542.05 26.828 542.05 27.9457C542.05 29.0634 541.142 29.9695 540.023 29.9695Z" fill="url(#paint0_linear_482_3319)"/>
<path opacity="0.6" d="M722.945 18.7508C721.826 18.7508 720.919 17.8447 720.919 16.727C720.919 15.6092 721.826 14.7031 722.945 14.7031C724.064 14.7031 724.971 15.6092 724.971 16.727C724.971 17.8447 724.064 18.7508 722.945 18.7508Z" fill="url(#paint1_linear_482_3319)"/>
<path opacity="0.6" d="M629.995 38.9795C630.31 38.9795 630.624 38.8748 630.886 38.6829L644.44 28.3893C644.877 28.0578 645.104 27.4995 644.999 26.9587C644.894 26.4178 644.51 25.9642 643.986 25.8072L625.821 20.0149C625.297 19.8579 624.738 19.98 624.336 20.3638C623.952 20.7476 623.795 21.3059 623.952 21.8293L628.563 37.9152C628.703 38.3863 629.052 38.7527 629.524 38.9097C629.681 38.962 629.838 38.9795 629.995 38.9795ZM640.388 27.7612L630.816 35.0365L627.568 23.6787L640.388 27.7612Z" fill="url(#paint2_linear_482_3319)"/>
<path opacity="0.6" d="M474.407 30.6766C474.46 30.6766 474.495 30.6766 474.547 30.6766C480.014 30.6068 484.415 26.0881 484.346 20.6273C484.276 15.1665 479.769 10.7699 474.285 10.8397C468.818 10.9095 464.417 15.4282 464.487 20.889C464.556 26.3149 468.993 30.6766 474.407 30.6766ZM474.407 13.4218C478.407 13.4218 481.708 16.6494 481.761 20.6622C481.813 24.7098 478.564 28.0421 474.512 28.0945C470.46 28.1643 467.124 24.9017 467.072 20.8541C467.019 16.8065 470.268 13.4741 474.32 13.4218C474.337 13.4218 474.372 13.4218 474.407 13.4218Z" fill="#86E29B"/>
<path opacity="0.6" d="M295.91 44.6648C295.949 43.932 295.379 43.2899 294.645 43.2517C291.69 43.0863 290.043 44.4307 288.722 45.5071C287.522 46.4968 286.574 47.2748 284.661 47.1552C282.753 47.0523 281.899 46.1612 280.809 45.0625C279.616 43.847 278.142 42.3274 275.186 42.1621C272.231 41.9967 270.584 43.3411 269.263 44.4175C268.063 45.4072 267.114 46.1852 265.202 46.0656C263.294 45.9627 262.44 45.0716 261.35 43.9729C260.156 42.7574 258.682 41.2378 255.727 41.0725C252.771 40.9071 251.124 42.2515 249.804 43.3279C248.604 44.3176 247.655 45.0956 245.743 44.976C245.009 44.9377 244.366 45.5074 244.327 46.2402C244.289 46.9731 244.859 47.6151 245.592 47.6534C248.548 47.8187 250.195 46.4743 251.515 45.3979C252.715 44.4082 253.664 43.6302 255.576 43.7499C257.484 43.8527 258.338 44.7439 259.428 45.8426C260.622 47.058 262.096 48.5776 265.051 48.743C268.007 48.9083 269.654 47.5639 270.975 46.4875C272.174 45.4978 273.123 44.7198 275.036 44.8395C276.944 44.9423 277.797 45.8334 278.887 46.9321C280.081 48.1476 281.555 49.6672 284.51 49.8326C287.466 49.9979 289.113 48.6535 290.434 47.5771C291.633 46.5874 292.582 45.8094 294.495 45.9291C295.228 45.9673 295.871 45.3977 295.91 44.6648Z" fill="#FFB0EB"/>
<path opacity="0.6" d="M361.428 58.4105C361.515 58.4105 361.603 58.4105 361.69 58.4105C363.279 58.3407 364.747 57.6603 365.83 56.4914C368.048 54.0837 367.891 50.2978 365.48 48.0821C363.07 45.8663 359.28 46.0234 357.061 48.431C355.979 49.5999 355.42 51.1178 355.49 52.7054C355.559 54.2931 356.241 55.7586 357.411 56.8403C358.511 57.8697 359.926 58.4105 361.428 58.4105ZM361.445 48.1693C362.493 48.1693 363.524 48.5357 364.362 49.3033C366.109 50.9084 366.214 53.6301 364.607 55.3748C363.821 56.2122 362.773 56.7182 361.62 56.7531C360.467 56.8054 359.385 56.4042 358.529 55.6191C357.69 54.834 357.184 53.7871 357.149 52.6357C357.096 51.4842 357.498 50.4025 358.284 49.5476C359.123 48.6404 360.275 48.1693 361.445 48.1693Z" fill="#FFB0EB"/>
<path opacity="0.6" d="M130.024 29.9695C128.905 29.9695 127.998 29.0634 127.998 27.9457C127.998 26.828 128.905 25.9219 130.024 25.9219C131.143 25.9219 132.05 26.828 132.05 27.9457C132.05 29.0634 131.143 29.9695 130.024 29.9695Z" fill="url(#paint3_linear_482_3319)"/>
<path opacity="0.6" d="M322.946 4.75077C321.827 4.75077 320.92 3.84468 320.92 2.72695C320.92 1.60922 321.827 0.703125 322.946 0.703125C324.065 0.703125 324.972 1.60922 324.972 2.72695C324.972 3.84468 324.065 4.75077 322.946 4.75077Z" fill="url(#paint4_linear_482_3319)"/>
<path opacity="0.6" d="M179.996 61.9795C180.31 61.9795 180.625 61.8748 180.887 61.6829L194.44 51.3893C194.877 51.0578 195.104 50.4995 194.999 49.9587C194.895 49.4178 194.51 48.9642 193.986 48.8072L175.821 43.0149C175.297 42.8579 174.739 42.98 174.337 43.3638C173.953 43.7476 173.795 44.3059 173.953 44.8293L178.564 60.9152C178.703 61.3863 179.053 61.7527 179.524 61.9097C179.681 61.962 179.839 61.9795 179.996 61.9795ZM190.388 50.7612L180.817 58.0365L177.568 46.6787L190.388 50.7612Z" fill="url(#paint5_linear_482_3319)"/>
<path opacity="0.6" d="M34.4077 30.6766C34.4601 30.6766 34.495 30.6766 34.5474 30.6766C40.0144 30.6068 44.4159 26.0881 44.346 20.6273C44.2761 15.1665 39.7698 10.7699 34.2854 10.8397C28.8185 10.9095 24.417 15.4282 24.4869 20.889C24.5568 26.3149 28.9932 30.6766 34.4077 30.6766ZM34.4077 13.4218C38.4075 13.4218 41.7086 16.6494 41.761 20.6622C41.8134 24.7098 38.5647 28.0421 34.5125 28.0945C30.4604 28.1643 27.1243 24.9017 27.0719 20.8541C27.0195 16.8065 30.2682 13.4741 34.3204 13.4218C34.3379 13.4218 34.3728 13.4218 34.4077 13.4218Z" fill="#86E29B"/>
<defs>
<linearGradient id="paint0_linear_482_3319" x1="542.049" y1="27.9529" x2="537.996" y2="27.9529" gradientUnits="userSpaceOnUse">
<stop stop-color="#75C3FC"/>
<stop offset="1" stop-color="#75C3FC"/>
</linearGradient>
<linearGradient id="paint1_linear_482_3319" x1="724.971" y1="16.7328" x2="720.918" y2="16.7328" gradientUnits="userSpaceOnUse">
<stop stop-color="#75C3FC"/>
<stop offset="1" stop-color="#75C3FC"/>
</linearGradient>
<linearGradient id="paint2_linear_482_3319" x1="645.028" y1="29.4641" x2="623.903" y2="29.4641" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFE466"/>
<stop offset="1" stop-color="#FFAFEA"/>
</linearGradient>
<linearGradient id="paint3_linear_482_3319" x1="132.05" y1="27.9529" x2="127.996" y2="27.9529" gradientUnits="userSpaceOnUse">
<stop stop-color="#75C3FC"/>
<stop offset="1" stop-color="#75C3FC"/>
</linearGradient>
<linearGradient id="paint4_linear_482_3319" x1="324.971" y1="2.73276" x2="320.918" y2="2.73276" gradientUnits="userSpaceOnUse">
<stop stop-color="#75C3FC"/>
<stop offset="1" stop-color="#75C3FC"/>
</linearGradient>
<linearGradient id="paint5_linear_482_3319" x1="195.028" y1="52.4641" x2="173.904" y2="52.4641" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFE466"/>
<stop offset="1" stop-color="#FFAFEA"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

@ -0,0 +1,34 @@
<svg width="817" height="54" viewBox="0 0 817 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.75">
<path opacity="0.6" d="M720.209 36.4667C720.262 35.734 719.72 35.0884 718.987 35.0187C716.035 34.7919 714.358 36.1004 713.031 37.1472C711.808 38.1067 710.83 38.8569 708.926 38.7174C707.022 38.5603 706.184 37.6706 705.118 36.5365C703.948 35.2978 702.516 33.7451 699.564 33.5008C696.612 33.274 694.936 34.5825 693.608 35.6293C692.386 36.5889 691.408 37.3391 689.504 37.1995C687.6 37.0425 686.762 36.1527 685.696 35.0187C684.526 33.7799 683.094 32.2272 680.142 31.9829C677.19 31.7561 675.531 33.0646 674.186 34.1114C672.963 35.071 671.985 35.8212 670.081 35.6816C669.348 35.6293 668.701 36.1701 668.632 36.9029C668.562 37.6357 669.121 38.2812 669.854 38.351C672.806 38.5778 674.465 37.2693 675.81 36.2225C677.033 35.2629 678.011 34.5127 679.915 34.6523C681.819 34.8093 682.657 35.6991 683.722 36.8331C684.893 38.0718 686.325 39.6246 689.277 39.8688C692.228 40.0957 693.888 38.7872 695.233 37.7403C696.455 36.7808 697.433 36.0306 699.337 36.1701C701.241 36.3272 702.079 37.2169 703.145 38.351C704.315 39.5897 705.747 41.1425 708.699 41.3867C711.651 41.6135 713.328 40.305 714.655 39.2582C715.878 38.2986 716.856 37.5484 718.76 37.688C719.493 37.7578 720.139 37.2169 720.209 36.4667Z" fill="#FBC49D"/>
<path opacity="0.6" d="M488.556 23.1411C488.742 23.1411 488.928 23.0915 489.09 22.9923L503.392 14.5222C503.74 14.3114 503.938 13.9146 503.901 13.5177C503.851 13.1085 503.578 12.7736 503.193 12.6496L482.572 5.94052C482.187 5.81651 481.765 5.92812 481.492 6.22575C481.219 6.52339 481.144 6.95743 481.293 7.32947L487.612 22.5087C487.724 22.7815 487.96 23.0047 488.246 23.0915C488.345 23.1163 488.444 23.1411 488.556 23.1411ZM500.363 13.9022L489.065 20.5989L484.074 8.6068L500.363 13.9022Z" fill="#75C4FD"/>
<path opacity="0.6" d="M599.843 19.6242C600.07 19.6242 600.315 19.6068 600.542 19.5719C603.476 19.188 605.537 16.5013 605.17 13.5702C604.978 12.157 604.262 10.8834 603.127 10.0111C601.992 9.13874 600.577 8.75491 599.162 8.94683C597.747 9.13874 596.472 9.85406 595.599 10.9881C594.726 12.1221 594.341 13.5353 594.534 14.9485C594.9 17.6702 597.206 19.6242 599.843 19.6242ZM599.861 10.5694C600.682 10.5694 601.468 10.8311 602.114 11.337C602.9 11.9477 603.406 12.82 603.529 13.797C603.791 15.8208 602.358 17.6876 600.332 17.9668C598.306 18.2285 596.437 16.7978 596.158 14.774C596.036 13.797 596.298 12.82 596.891 12.0349C597.503 11.2498 598.376 10.7438 599.354 10.6217C599.529 10.5694 599.704 10.5694 599.861 10.5694Z" fill="#86E29B"/>
<path opacity="0.6" d="M789.426 33.0604C789.479 33.0604 789.514 33.0604 789.566 33.0604C795.033 32.9906 799.435 28.4719 799.365 23.0111C799.295 17.5503 794.789 13.1537 789.304 13.2235C783.837 13.2933 779.436 17.812 779.506 23.2728C779.576 28.6987 784.012 33.0604 789.426 33.0604ZM789.426 15.8056C793.426 15.8056 796.727 19.0332 796.78 23.046C796.832 27.0936 793.583 30.4259 789.531 30.4783C785.479 30.5481 782.143 27.2855 782.091 23.2379C782.038 19.1902 785.287 15.8579 789.339 15.8056C789.357 15.8056 789.392 15.8056 789.426 15.8056Z" fill="#86E29B"/>
</g>
<g opacity="0.75">
<path opacity="0.6" d="M169.843 29.6242C170.071 29.6242 170.315 29.6068 170.542 29.5719C173.476 29.188 175.537 26.5013 175.171 23.5702C174.979 22.157 174.262 20.8834 173.127 20.0111C171.992 19.1387 170.577 18.7549 169.162 18.9468C167.748 19.1387 166.473 19.8541 165.599 20.9881C164.726 22.1221 164.342 23.5353 164.534 24.9485C164.901 27.6702 167.206 29.6242 169.843 29.6242ZM169.861 20.5694C170.682 20.5694 171.468 20.8311 172.114 21.337C172.9 21.9477 173.407 22.82 173.529 23.797C173.791 25.8208 172.359 27.6876 170.333 27.9668C168.306 28.2285 166.438 26.7978 166.158 24.774C166.036 23.797 166.298 22.82 166.892 22.0349C167.503 21.2498 168.376 20.7438 169.354 20.6217C169.529 20.5694 169.704 20.5694 169.861 20.5694Z" fill="#86E29B"/>
<path opacity="0.6" d="M306.157 32.0974C305.038 32.0974 304.131 31.1913 304.131 30.0736C304.131 28.9559 305.038 28.0498 306.157 28.0498C307.276 28.0498 308.183 28.9559 308.183 30.0736C308.183 31.1913 307.276 32.0974 306.157 32.0974Z" fill="url(#paint0_linear_482_3306)"/>
<path opacity="0.6" d="M88.0759 4.28984C86.9569 4.28984 86.0498 3.38374 86.0498 2.26601C86.0498 1.14829 86.9569 0.242188 88.0759 0.242188C89.1948 0.242188 90.102 1.14829 90.102 2.26601C90.102 3.38374 89.1948 4.28984 88.0759 4.28984Z" fill="url(#paint1_linear_482_3306)"/>
<path opacity="0.6" d="M123.285 47.9139C122.166 47.9139 121.259 47.0078 121.259 45.89C121.259 44.7723 122.166 43.8662 123.285 43.8662C124.404 43.8662 125.311 44.7723 125.311 45.89C125.311 47.0078 124.404 47.9139 123.285 47.9139Z" fill="url(#paint2_linear_482_3306)"/>
<path opacity="0.75" d="M21.0915 29.9623C19.278 29.9623 17.8079 28.4938 17.8079 26.6823C17.8079 24.8708 19.278 23.4023 21.0915 23.4023C22.905 23.4023 24.3752 24.8708 24.3752 26.6823C24.3752 28.4938 22.905 29.9623 21.0915 29.9623Z" fill="url(#paint3_linear_482_3306)"/>
<path opacity="0.6" d="M379.426 53.0594C379.479 53.0594 379.514 53.0594 379.566 53.0594C385.033 52.9896 389.435 48.4709 389.365 43.0101C389.295 37.5493 384.789 33.1527 379.304 33.2225C373.837 33.2923 369.436 37.811 369.506 43.2718C369.575 48.6977 374.012 53.0594 379.426 53.0594ZM379.426 35.8046C383.426 35.8046 386.727 39.0323 386.78 43.045C386.832 47.0926 383.583 50.4249 379.531 50.4773C375.479 50.5471 372.143 47.2845 372.091 43.2369C372.038 39.1893 375.287 35.857 379.339 35.8046C379.357 35.8046 379.392 35.8046 379.426 35.8046Z" fill="#86E29B"/>
</g>
<defs>
<linearGradient id="paint0_linear_482_3306" x1="308.177" y1="30.0684" x2="304.124" y2="30.0684" gradientUnits="userSpaceOnUse">
<stop stop-color="#75C3FC"/>
<stop offset="1" stop-color="#75C3FC"/>
</linearGradient>
<linearGradient id="paint1_linear_482_3306" x1="90.1014" y1="2.27182" x2="86.0481" y2="2.27182" gradientUnits="userSpaceOnUse">
<stop stop-color="#75C3FC"/>
<stop offset="1" stop-color="#75C3FC"/>
</linearGradient>
<linearGradient id="paint2_linear_482_3306" x1="125.31" y1="45.8958" x2="121.257" y2="45.8958" gradientUnits="userSpaceOnUse">
<stop stop-color="#75C3FC"/>
<stop offset="1" stop-color="#75C3FC"/>
</linearGradient>
<linearGradient id="paint3_linear_482_3306" x1="24.3728" y1="26.8017" x2="17.7968" y2="26.5677" gradientUnits="userSpaceOnUse">
<stop stop-color="#75C3FC"/>
<stop offset="1" stop-color="#75C3FC"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

@ -41,6 +41,8 @@ const POLL_COUNT_LIMIT = 3;
// If for any reason the MetaSwap API fails to provide a refresh time, // If for any reason the MetaSwap API fails to provide a refresh time,
// provide a reasonable fallback to avoid further errors // provide a reasonable fallback to avoid further errors
const FALLBACK_QUOTE_REFRESH_TIME = MINUTE; const FALLBACK_QUOTE_REFRESH_TIME = MINUTE;
const FALLBACK_SMART_TRANSACTION_REFRESH_TIME = SECOND * 10;
const FALLBACK_SMART_TRANSACTIONS_DEADLINE = 180;
function calculateGasEstimateWithRefund( function calculateGasEstimateWithRefund(
maxGas = MAX_GAS_LIMIT, maxGas = MAX_GAS_LIMIT,
@ -84,6 +86,9 @@ const initialState = {
saveFetchedQuotes: false, saveFetchedQuotes: false,
swapsQuoteRefreshTime: FALLBACK_QUOTE_REFRESH_TIME, swapsQuoteRefreshTime: FALLBACK_QUOTE_REFRESH_TIME,
swapsQuotePrefetchingRefreshTime: 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 ( if (
!refreshRates || !refreshRates ||
typeof refreshRates.quotes !== 'number' || typeof refreshRates.quotes !== 'number' ||
typeof refreshRates.quotesPrefetching !== 'number' typeof refreshRates.quotesPrefetching !== 'number' ||
typeof refreshRates.stxGetTransactions !== 'number' ||
typeof refreshRates.stxBatchStatus !== 'number'
) { ) {
throw new Error( throw new Error(
`MetaMask - invalid response for refreshRates: ${response}`, `MetaMask - invalid response for refreshRates: ${response}`,
@ -144,6 +151,9 @@ export default class SwapsController {
return { return {
quotes: refreshRates.quotes * 1000, quotes: refreshRates.quotes * 1000,
quotesPrefetching: refreshRates.quotesPrefetching * 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, swapsRefreshRates?.quotes || FALLBACK_QUOTE_REFRESH_TIME,
swapsQuotePrefetchingRefreshTime: swapsQuotePrefetchingRefreshTime:
swapsRefreshRates?.quotesPrefetching || FALLBACK_QUOTE_REFRESH_TIME, 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() { resetPostFetchState() {
const { swapsState } = this.store.getState(); const { swapsState } = this.store.getState();
this.store.updateState({ this.store.updateState({
@ -583,6 +609,7 @@ export default class SwapsController {
swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime, swapsQuoteRefreshTime: swapsState.swapsQuoteRefreshTime,
swapsQuotePrefetchingRefreshTime: swapsQuotePrefetchingRefreshTime:
swapsState.swapsQuotePrefetchingRefreshTime, swapsState.swapsQuotePrefetchingRefreshTime,
swapsFeatureFlags: swapsState.swapsFeatureFlags,
}, },
}); });
clearTimeout(this.pollingTimeout); clearTimeout(this.pollingTimeout);

@ -131,8 +131,11 @@ const EMPTY_INIT_STATE = {
topAggId: null, topAggId: null,
routeState: '', routeState: '',
swapsFeatureIsLive: true, swapsFeatureIsLive: true,
swapsFeatureFlags: {},
swapsQuoteRefreshTime: 60000, swapsQuoteRefreshTime: 60000,
swapsQuotePrefetchingRefreshTime: 60000, swapsQuotePrefetchingRefreshTime: 60000,
swapsStxBatchStatusRefreshTime: 10000,
swapsStxGetTransactionsRefreshTime: 10000,
swapsUserFeeLevel: '', swapsUserFeeLevel: '',
saveFetchedQuotes: false, saveFetchedQuotes: false,
}, },
@ -840,6 +843,9 @@ describe('SwapsController', function () {
swapsQuoteRefreshTime: old.swapsQuoteRefreshTime, swapsQuoteRefreshTime: old.swapsQuoteRefreshTime,
swapsQuotePrefetchingRefreshTime: swapsQuotePrefetchingRefreshTime:
old.swapsQuotePrefetchingRefreshTime, old.swapsQuotePrefetchingRefreshTime,
swapsStxGetTransactionsRefreshTime:
old.swapsStxGetTransactionsRefreshTime,
swapsStxBatchStatusRefreshTime: old.swapsStxBatchStatusRefreshTime,
}); });
}); });
@ -885,15 +891,21 @@ describe('SwapsController', function () {
const tokens = 'test'; const tokens = 'test';
const fetchParams = 'test'; const fetchParams = 'test';
const swapsFeatureIsLive = false; const swapsFeatureIsLive = false;
const swapsFeatureFlags = {};
const swapsQuoteRefreshTime = 0; const swapsQuoteRefreshTime = 0;
const swapsQuotePrefetchingRefreshTime = 0; const swapsQuotePrefetchingRefreshTime = 0;
const swapsStxBatchStatusRefreshTime = 0;
const swapsStxGetTransactionsRefreshTime = 0;
swapsController.store.updateState({ swapsController.store.updateState({
swapsState: { swapsState: {
tokens, tokens,
fetchParams, fetchParams,
swapsFeatureIsLive, swapsFeatureIsLive,
swapsFeatureFlags,
swapsQuoteRefreshTime, swapsQuoteRefreshTime,
swapsQuotePrefetchingRefreshTime, swapsQuotePrefetchingRefreshTime,
swapsStxBatchStatusRefreshTime,
swapsStxGetTransactionsRefreshTime,
}, },
}); });

@ -20,7 +20,10 @@ import {
} from '../../lib/util'; } from '../../lib/util';
import { TRANSACTION_NO_CONTRACT_ERROR_KEY } from '../../../../ui/helpers/constants/error-keys'; import { TRANSACTION_NO_CONTRACT_ERROR_KEY } from '../../../../ui/helpers/constants/error-keys';
import { getSwapsTokensReceivedFromTxMeta } from '../../../../ui/pages/swaps/swaps.util'; 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 { import {
TRANSACTION_STATUSES, TRANSACTION_STATUSES,
TRANSACTION_TYPES, TRANSACTION_TYPES,
@ -65,6 +68,8 @@ const SWAP_TRANSACTION_TYPES = [
* @typedef {import('../../../../shared/constants/transaction').TransactionMetaMetricsEventString} TransactionMetaMetricsEventString * @typedef {import('../../../../shared/constants/transaction').TransactionMetaMetricsEventString} TransactionMetaMetricsEventString
*/ */
const METRICS_STATUS_FAILED = 'failed on-chain';
/** /**
* @typedef {Object} CustomGasSettings * @typedef {Object} CustomGasSettings
* @property {string} [gas] - The gas limit to use for the transaction * @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({ this.nonceTracker = new NonceTracker({
provider: this.provider, provider: this.provider,
blockTracker: this.blockTracker, blockTracker: this.blockTracker,
getPendingTransactions: this.txStateManager.getPendingTransactions.bind( getPendingTransactions: (...args) => {
this.txStateManager, const pendingTransactions = this.txStateManager.getPendingTransactions(
), ...args,
);
const externalPendingTransactions = opts.getExternalPendingTransactions(
...args,
);
return [...pendingTransactions, ...externalPendingTransactions];
},
getConfirmedTransactions: this.txStateManager.getConfirmedTransactions.bind( getConfirmedTransactions: this.txStateManager.getConfirmedTransactions.bind(
this.txStateManager, 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 * 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 { try {
// It seems that sometimes the numerical values being returned from const gasUsed = txUtils.normalizeTxReceiptGasUsed(txReceipt.gasUsed);
// this.query.getTransactionReceipt are BN instances and not strings.
const gasUsed =
typeof txReceipt.gasUsed === 'string'
? txReceipt.gasUsed
: txReceipt.gasUsed.toString(16);
txMeta.txReceipt = { txMeta.txReceipt = {
...txReceipt, ...txReceipt,
@ -1086,7 +1158,79 @@ export default class TransactionController extends EventEmitter {
} }
if (txReceipt.status === '0x0') { 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 // 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)}%` .round(2)}%`
: null; : null;
const estimatedVsUsedGasRatio = `${new BigNumber( const estimatedVsUsedGasRatio =
txMeta.txReceipt.gasUsed, txMeta.txReceipt.gasUsed && txMeta.swapMetaData.estimated_gas
16, ? `${new BigNumber(txMeta.txReceipt.gasUsed, 16)
) .div(txMeta.swapMetaData.estimated_gas, 10)
.div(txMeta.swapMetaData.estimated_gas, 10) .times(100)
.times(100) .round(2)}%`
.round(2)}%`; : null;
this._trackMetaMetricsEvent({ this._trackMetaMetricsEvent({
event: 'Swap Completed', event: 'Swap Completed',

@ -264,6 +264,44 @@ export function validateRecipient(txParams) {
return 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 * Returns a list of final states
* *
@ -277,3 +315,15 @@ export function getFinalStates() {
TRANSACTION_STATUSES.DROPPED, // the tx nonce was already used 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);
}

@ -11,7 +11,11 @@ import {
replayHistory, replayHistory,
snapshotFromTxMeta, snapshotFromTxMeta,
} from './lib/tx-state-history-helpers'; } 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 * TransactionStatuses reimported from the shared transaction constants file
@ -266,6 +270,19 @@ export default class TransactionStateManager extends EventEmitter {
return txMeta; 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 * @param {number} txId
* @returns {TransactionMeta} the txMeta who matches the given id if none found * @returns {TransactionMeta} the txMeta who matches the given id if none found

@ -35,6 +35,7 @@ import {
AssetsContractController, AssetsContractController,
CollectibleDetectionController, CollectibleDetectionController,
} from '@metamask/controllers'; } from '@metamask/controllers';
import SmartTransactionsController from '@metamask/smart-transactions-controller';
import { import {
PermissionController, PermissionController,
SubjectMetadataController, SubjectMetadataController,
@ -696,6 +697,9 @@ export default class MetamaskController extends EventEmitter {
getEIP1559GasFeeEstimates: this.gasFeeController.fetchGasFeeEstimates.bind( getEIP1559GasFeeEstimates: this.gasFeeController.fetchGasFeeEstimates.bind(
this.gasFeeController, this.gasFeeController,
), ),
getExternalPendingTransactions: this.getExternalPendingTransactions.bind(
this,
),
}); });
this.txController.on('newUnapprovedTx', () => opts.showUserConfirmation()); this.txController.on('newUnapprovedTx', () => opts.showUserConfirmation());
@ -831,6 +835,24 @@ export default class MetamaskController extends EventEmitter {
this.gasFeeController, 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 // ensure accountTracker updates balances after network change
this.networkController.on(NETWORK_EVENTS.NETWORK_DID_CHANGE, () => { this.networkController.on(NETWORK_EVENTS.NETWORK_DID_CHANGE, () => {
@ -871,6 +893,7 @@ export default class MetamaskController extends EventEmitter {
GasFeeController: this.gasFeeController, GasFeeController: this.gasFeeController,
TokenListController: this.tokenListController, TokenListController: this.tokenListController,
TokensController: this.tokensController, TokensController: this.tokensController,
SmartTransactionsController: this.smartTransactionsController,
CollectiblesController: this.collectiblesController, CollectiblesController: this.collectiblesController,
///: BEGIN:ONLY_INCLUDE_IN(flask) ///: BEGIN:ONLY_INCLUDE_IN(flask)
SnapController: this.snapController, SnapController: this.snapController,
@ -910,6 +933,7 @@ export default class MetamaskController extends EventEmitter {
GasFeeController: this.gasFeeController, GasFeeController: this.gasFeeController,
TokenListController: this.tokenListController, TokenListController: this.tokenListController,
TokensController: this.tokensController, TokensController: this.tokensController,
SmartTransactionsController: this.smartTransactionsController,
CollectiblesController: this.collectiblesController, CollectiblesController: this.collectiblesController,
///: BEGIN:ONLY_INCLUDE_IN(flask) ///: BEGIN:ONLY_INCLUDE_IN(flask)
SnapController: this.snapController, SnapController: this.snapController,
@ -1257,6 +1281,7 @@ export default class MetamaskController extends EventEmitter {
swapsController, swapsController,
threeBoxController, threeBoxController,
tokensController, tokensController,
smartTransactionsController,
txController, txController,
} = this; } = this;
@ -1479,6 +1504,9 @@ export default class MetamaskController extends EventEmitter {
updateAndApproveTransaction: txController.updateAndApproveTransaction.bind( updateAndApproveTransaction: txController.updateAndApproveTransaction.bind(
txController, txController,
), ),
approveTransactionsWithSameNonce: txController.approveTransactionsWithSameNonce.bind(
txController,
),
createCancelTransaction: this.createCancelTransaction.bind(this), createCancelTransaction: this.createCancelTransaction.bind(this),
createSpeedUpTransaction: this.createSpeedUpTransaction.bind(this), createSpeedUpTransaction: this.createSpeedUpTransaction.bind(this),
estimateGas: this.estimateGas.bind(this), estimateGas: this.estimateGas.bind(this),
@ -1618,6 +1646,9 @@ export default class MetamaskController extends EventEmitter {
swapsController, swapsController,
), ),
setSwapsLiveness: swapsController.setSwapsLiveness.bind(swapsController), setSwapsLiveness: swapsController.setSwapsLiveness.bind(swapsController),
setSwapsFeatureFlags: swapsController.setSwapsFeatureFlags.bind(
swapsController,
),
setSwapsUserFeeLevel: swapsController.setSwapsUserFeeLevel.bind( setSwapsUserFeeLevel: swapsController.setSwapsUserFeeLevel.bind(
swapsController, swapsController,
), ),
@ -1625,6 +1656,32 @@ export default class MetamaskController extends EventEmitter {
swapsController, 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 // MetaMetrics
trackMetaMetricsEvent: metaMetricsController.trackEvent.bind( trackMetaMetricsEvent: metaMetricsController.trackEvent.bind(
metaMetricsController, metaMetricsController,
@ -3519,6 +3576,13 @@ export default class MetamaskController extends EventEmitter {
// MISCELLANEOUS // MISCELLANEOUS
//============================================================================= //=============================================================================
getExternalPendingTransactions(address) {
return this.smartTransactionsController.getTransactions({
addressFrom: address,
status: 'pending',
});
}
/** /**
* Returns the nonce that will be associated with a transaction once approved * Returns the nonce that will be associated with a transaction once approved
* *

@ -563,10 +563,7 @@
"ethereumjs-wallet": true, "ethereumjs-wallet": true,
"ethers": true, "ethers": true,
"ethjs-unit": true, "ethjs-unit": true,
"ethjs-util": true,
"events": true, "events": true,
"human-standard-collectible-abi": true,
"human-standard-token-abi": true,
"immer": true, "immer": true,
"isomorphic-fetch": true, "isomorphic-fetch": true,
"jsonschema": true, "jsonschema": true,
@ -698,6 +695,25 @@
"events": true "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": { "@metamask/snap-controllers": {
"globals": { "globals": {
"URL": true, "URL": true,
@ -1935,7 +1951,6 @@
"addEventListener": true, "addEventListener": true,
"browser": true, "browser": true,
"clearInterval": true, "clearInterval": true,
"console.warn": true,
"open": true, "open": true,
"setInterval": true "setInterval": true
}, },

@ -563,10 +563,7 @@
"ethereumjs-wallet": true, "ethereumjs-wallet": true,
"ethers": true, "ethers": true,
"ethjs-unit": true, "ethjs-unit": true,
"ethjs-util": true,
"events": true, "events": true,
"human-standard-collectible-abi": true,
"human-standard-token-abi": true,
"immer": true, "immer": true,
"isomorphic-fetch": true, "isomorphic-fetch": true,
"jsonschema": true, "jsonschema": true,
@ -717,6 +714,25 @@
"events": true "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": { "@metamask/snap-controllers": {
"globals": { "globals": {
"URL": true, "URL": true,
@ -1954,7 +1970,6 @@
"addEventListener": true, "addEventListener": true,
"browser": true, "browser": true,
"clearInterval": true, "clearInterval": true,
"console.warn": true,
"open": true, "open": true,
"setInterval": true "setInterval": true
}, },

@ -563,10 +563,7 @@
"ethereumjs-wallet": true, "ethereumjs-wallet": true,
"ethers": true, "ethers": true,
"ethjs-unit": true, "ethjs-unit": true,
"ethjs-util": true,
"events": true, "events": true,
"human-standard-collectible-abi": true,
"human-standard-token-abi": true,
"immer": true, "immer": true,
"isomorphic-fetch": true, "isomorphic-fetch": true,
"jsonschema": true, "jsonschema": true,
@ -698,6 +695,25 @@
"events": true "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": { "@metamask/snap-controllers": {
"globals": { "globals": {
"URL": true, "URL": true,
@ -1935,7 +1951,6 @@
"addEventListener": true, "addEventListener": true,
"browser": true, "browser": true,
"clearInterval": true, "clearInterval": true,
"console.warn": true,
"open": true, "open": true,
"setInterval": true "setInterval": true
}, },

@ -1052,6 +1052,16 @@
"buffer-equal": true "buffer-equal": true
} }
}, },
"are-we-there-yet": {
"builtin": {
"events.EventEmitter": true,
"util.inherits": true
},
"packages": {
"delegates": true,
"readable-stream": true
}
},
"arr-diff": { "arr-diff": {
"packages": { "packages": {
"arr-flatten": true, "arr-flatten": true,
@ -1460,6 +1470,7 @@
"anymatch": true, "anymatch": true,
"async-each": true, "async-each": true,
"braces": true, "braces": true,
"fsevents": true,
"glob-parent": true, "glob-parent": true,
"inherits": true, "inherits": true,
"is-binary-path": true, "is-binary-path": true,
@ -1726,6 +1737,16 @@
"through2": true "through2": true
} }
}, },
"detect-libc": {
"builtin": {
"child_process.spawnSync": true,
"fs.readdirSync": true,
"os.platform": true
},
"globals": {
"process.env": true
}
},
"detective": { "detective": {
"packages": { "packages": {
"acorn-node": true, "acorn-node": true,
@ -2429,6 +2450,45 @@
"process.version": true "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": { "get-assigned-identifiers": {
"builtin": { "builtin": {
"assert.equal": true "assert.equal": true
@ -2807,6 +2867,16 @@
"process.argv": true "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": { "has-value": {
"packages": { "packages": {
"get-value": true, "get-value": true,
@ -2978,6 +3048,11 @@
"is-plain-object": true "is-plain-object": true
} }
}, },
"is-fullwidth-code-point": {
"packages": {
"number-is-nan": true
}
},
"is-glob": { "is-glob": {
"packages": { "packages": {
"is-extglob": true "is-extglob": true
@ -3508,6 +3583,56 @@
"setTimeout": true "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": { "normalize-package-data": {
"builtin": { "builtin": {
"url.parse": true, "url.parse": true,
@ -3535,6 +3660,22 @@
"once": true "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": { "object-copy": {
"packages": { "packages": {
"copy-descriptor": true, "copy-descriptor": true,
@ -3616,6 +3757,54 @@
"readable-stream": true "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": { "p-limit": {
"packages": { "packages": {
"p-try": true "p-try": true
@ -4325,6 +4514,12 @@
"lru-cache": true "lru-cache": true
} }
}, },
"set-blocking": {
"globals": {
"process.stderr": true,
"process.stdout": true
}
},
"set-value": { "set-value": {
"packages": { "packages": {
"extend-shallow": true, "extend-shallow": true,
@ -4588,6 +4783,7 @@
}, },
"string-width": { "string-width": {
"packages": { "packages": {
"code-point-at": true,
"emoji-regex": true, "emoji-regex": true,
"is-fullwidth-code-point": true, "is-fullwidth-code-point": true,
"strip-ansi": true "strip-ansi": true
@ -5240,6 +5436,11 @@
"isexe": true "isexe": true
} }
}, },
"wide-align": {
"packages": {
"string-width": true
}
},
"write": { "write": {
"builtin": { "builtin": {
"fs.createWriteStream": true, "fs.createWriteStream": true,

@ -121,6 +121,7 @@
"@metamask/providers": "^8.1.1", "@metamask/providers": "^8.1.1",
"@metamask/rpc-methods": "^0.9.0", "@metamask/rpc-methods": "^0.9.0",
"@metamask/slip44": "^2.0.0", "@metamask/slip44": "^2.0.0",
"@metamask/smart-transactions-controller": "^1.9.1",
"@metamask/snap-controllers": "^0.9.0", "@metamask/snap-controllers": "^0.9.0",
"@ngraveio/bc-ur": "^1.1.6", "@ngraveio/bc-ur": "^1.1.6",
"@popperjs/core": "^2.4.0", "@popperjs/core": "^2.4.0",

@ -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);

@ -123,6 +123,11 @@ export const ALLOWED_SWAPS_CHAIN_IDS = {
[AVALANCHE_CHAIN_ID]: true, [AVALANCHE_CHAIN_ID]: true,
}; };
export const ALLOWED_SMART_TRANSACTIONS_CHAIN_IDS = [
MAINNET_CHAIN_ID,
RINKEBY_CHAIN_ID,
];
export const SWAPS_CHAINID_CONTRACT_ADDRESS_MAP = { export const SWAPS_CHAINID_CONTRACT_ADDRESS_MAP = {
[MAINNET_CHAIN_ID]: MAINNET_CONTRACT_ADDRESS, [MAINNET_CHAIN_ID]: MAINNET_CONTRACT_ADDRESS,
[SWAPS_TESTNET_CHAIN_ID]: TESTNET_CONTRACT_ADDRESS, [SWAPS_TESTNET_CHAIN_ID]: TESTNET_CONTRACT_ADDRESS,

@ -55,6 +55,7 @@ export const TRANSACTION_TYPES = {
DEPLOY_CONTRACT: 'contractDeployment', DEPLOY_CONTRACT: 'contractDeployment',
SWAP: 'swap', SWAP: 'swap',
SWAP_APPROVAL: 'swapApproval', SWAP_APPROVAL: 'swapApproval',
SMART: 'smart',
SIGN: MESSAGE_TYPE.ETH_SIGN, SIGN: MESSAGE_TYPE.ETH_SIGN,
SIGN_TYPED_DATA: MESSAGE_TYPE.ETH_SIGN_TYPED_DATA, SIGN_TYPED_DATA: MESSAGE_TYPE.ETH_SIGN_TYPED_DATA,
PERSONAL_SIGN: MESSAGE_TYPE.PERSONAL_SIGN, PERSONAL_SIGN: MESSAGE_TYPE.PERSONAL_SIGN,
@ -128,6 +129,7 @@ export const TRANSACTION_STATUSES = {
FAILED: 'failed', FAILED: 'failed',
DROPPED: 'dropped', DROPPED: 'dropped',
CONFIRMED: 'confirmed', CONFIRMED: 'confirmed',
PENDING: 'pending',
}; };
/** /**
@ -150,6 +152,23 @@ export const TRANSACTION_GROUP_STATUSES = {
PENDING: 'pending', 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 * Transaction Group Category is a MetaMask construct to categorize the intent
* of a group of transactions for purposes of displaying in the UI * of a group of transactions for purposes of displaying in the UI

@ -1,5 +1,55 @@
import { MAINNET_CHAIN_ID } from '../../shared/constants/network'; 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 = () => { export const createSwapsMockStore = () => {
return { return {
swaps: { swaps: {
@ -21,6 +71,11 @@ export const createSwapsMockStore = () => {
fromToken: 'ETH', fromToken: 'ETH',
}, },
metamask: { metamask: {
networkDetails: {
EIPS: {
1559: false,
},
},
provider: { provider: {
chainId: MAINNET_CHAIN_ID, chainId: MAINNET_CHAIN_ID,
}, },
@ -96,6 +151,12 @@ export const createSwapsMockStore = () => {
}, },
], ],
swapsState: { swapsState: {
swapsFeatureFlags: {
smartTransactions: {
mobileActive: true,
extensionActive: true,
},
},
quotes: { quotes: {
TEST_AGG_1: { TEST_AGG_1: {
trade: { trade: {
@ -290,6 +351,44 @@ export const createSwapsMockStore = () => {
occurrences: 11, 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: { appState: {
modal: { modal: {

@ -12,13 +12,14 @@ export default function TransactionDetail({
rows = [], rows = [],
onEdit, onEdit,
userAcknowledgedGasMissing = false, userAcknowledgedGasMissing = false,
disableEditGasFeeButton = false,
}) { }) {
const t = useI18nContext(); const t = useI18nContext();
const { supportsEIP1559V2 } = useGasFeeContext(); const { supportsEIP1559V2 } = useGasFeeContext();
return ( return (
<div className="transaction-detail"> <div className="transaction-detail">
{supportsEIP1559V2 && ( {supportsEIP1559V2 && !disableEditGasFeeButton && (
<Box display="flex" justifyContent="flex-end" paddingTop={5}> <Box display="flex" justifyContent="flex-end" paddingTop={5}>
<EditGasFeeButton <EditGasFeeButton
userAcknowledgedGasMissing={userAcknowledgedGasMissing} userAcknowledgedGasMissing={userAcknowledgedGasMissing}
@ -45,4 +46,5 @@ TransactionDetail.propTypes = {
*/ */
onEdit: PropTypes.func, onEdit: PropTypes.func,
userAcknowledgedGasMissing: PropTypes.bool, userAcknowledgedGasMissing: PropTypes.bool,
disableEditGasFeeButton: PropTypes.bool,
}; };

@ -0,0 +1,90 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import ListItem from '../../ui/list-item';
import TransactionStatus from '../transaction-status/transaction-status.component';
import TransactionIcon from '../transaction-icon';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { formatDateWithYearContext } from '../../../helpers/utils/util';
import {
TRANSACTION_GROUP_CATEGORIES,
TRANSACTION_GROUP_STATUSES,
SMART_TRANSACTION_STATUSES,
} from '../../../../shared/constants/transaction';
import CancelButton from '../cancel-button';
import { cancelSwapsSmartTransaction } from '../../../ducks/swaps/swaps';
export default function SmartTransactionListItem({
smartTransaction,
isEarliestNonce = false,
}) {
const dispatch = useDispatch();
const t = useI18nContext();
const [cancelSwapLinkClicked, setCancelSwapLinkClicked] = useState(false);
const {
sourceTokenSymbol,
destinationTokenSymbol,
time,
status,
} = smartTransaction;
const category = TRANSACTION_GROUP_CATEGORIES.SWAP;
const title = t('swapTokenToToken', [
sourceTokenSymbol,
destinationTokenSymbol,
]);
const subtitle = 'metamask';
const date = formatDateWithYearContext(time);
let displayedStatusKey;
if (status === SMART_TRANSACTION_STATUSES.PENDING) {
displayedStatusKey = TRANSACTION_GROUP_STATUSES.PENDING;
} else if (status?.startsWith(SMART_TRANSACTION_STATUSES.CANCELLED)) {
displayedStatusKey = TRANSACTION_GROUP_STATUSES.CANCELLED;
}
const showCancelSwapLink =
smartTransaction.cancellable && !cancelSwapLinkClicked;
const className = 'transaction-list-item transaction-list-item--unconfirmed';
return (
<>
<ListItem
className={className}
title={title}
icon={
<TransactionIcon category={category} status={displayedStatusKey} />
}
subtitle={
<h3>
<TransactionStatus
isPending
isEarliestNonce={isEarliestNonce}
date={date}
status={displayedStatusKey}
/>
<span className="transaction-list-item__origin" title={subtitle}>
{subtitle}
</span>
</h3>
}
>
{displayedStatusKey === TRANSACTION_GROUP_STATUSES.PENDING &&
showCancelSwapLink && (
<div className="transaction-list-item__pending-actions">
<CancelButton
transaction={smartTransaction.uuid}
cancelTransaction={(e) => {
e?.preventDefault();
dispatch(cancelSwapsSmartTransaction(smartTransaction.uuid));
setCancelSwapLinkClicked(true);
}}
/>
</div>
)}
</ListItem>
</>
);
}
SmartTransactionListItem.propTypes = {
smartTransaction: PropTypes.object.isRequired,
isEarliestNonce: PropTypes.bool,
};

@ -8,6 +8,7 @@ import {
import { getCurrentChainId } from '../../../selectors'; import { getCurrentChainId } from '../../../selectors';
import { useI18nContext } from '../../../hooks/useI18nContext'; import { useI18nContext } from '../../../hooks/useI18nContext';
import TransactionListItem from '../transaction-list-item'; import TransactionListItem from '../transaction-list-item';
import SmartTransactionListItem from '../transaction-list-item/smart-transaction-list-item.component';
import Button from '../../ui/button'; import Button from '../../ui/button';
import { TOKEN_CATEGORY_HASH } from '../../../helpers/constants/transactions'; import { TOKEN_CATEGORY_HASH } from '../../../helpers/constants/transactions';
import { SWAPS_CHAINID_CONTRACT_ADDRESS_MAP } from '../../../../shared/constants/swaps'; import { SWAPS_CHAINID_CONTRACT_ADDRESS_MAP } from '../../../../shared/constants/swaps';
@ -114,38 +115,53 @@ export default function TransactionList({
[], [],
); );
const pendingLength = pendingTransactions.length;
return ( return (
<div className="transaction-list"> <div className="transaction-list">
<div className="transaction-list__transactions"> <div className="transaction-list__transactions">
{pendingLength > 0 && ( {pendingTransactions.length > 0 && (
<div className="transaction-list__pending-transactions"> <div className="transaction-list__pending-transactions">
<div className="transaction-list__header"> <div className="transaction-list__header">
{`${t('queue')} (${pendingTransactions.length})`} {`${t('queue')} (${pendingTransactions.length})`}
</div> </div>
{pendingTransactions.map((transactionGroup, index) => ( {pendingTransactions.map((transactionGroup, index) =>
<TransactionListItem transactionGroup.initialTransaction.transactionType ===
isEarliestNonce={index === 0} TRANSACTION_TYPES.SMART ? (
transactionGroup={transactionGroup} <SmartTransactionListItem
key={`${transactionGroup.nonce}:${index}`} isEarliestNonce={index === 0}
/> smartTransaction={transactionGroup.initialTransaction}
))} key={`${transactionGroup.nonce}:${index}`}
/>
) : (
<TransactionListItem
isEarliestNonce={index === 0}
transactionGroup={transactionGroup}
key={`${transactionGroup.nonce}:${index}`}
/>
),
)}
</div> </div>
)} )}
<div className="transaction-list__completed-transactions"> <div className="transaction-list__completed-transactions">
{pendingLength > 0 ? ( {pendingTransactions.length > 0 ? (
<div className="transaction-list__header">{t('history')}</div> <div className="transaction-list__header">{t('history')}</div>
) : null} ) : null}
{completedTransactions.length > 0 ? ( {completedTransactions.length > 0 ? (
completedTransactions completedTransactions
.slice(0, limit) .slice(0, limit)
.map((transactionGroup, index) => ( .map((transactionGroup, index) =>
<TransactionListItem transactionGroup.initialTransaction?.transactionType ===
transactionGroup={transactionGroup} 'smart' ? (
key={`${transactionGroup.nonce}:${limit + index - 10}`} <SmartTransactionListItem
/> smartTransaction={transactionGroup.initialTransaction}
)) key={`${transactionGroup.nonce}:${index}`}
/>
) : (
<TransactionListItem
transactionGroup={transactionGroup}
key={`${transactionGroup.nonce}:${limit + index - 10}`}
/>
),
)
) : ( ) : (
<div className="transaction-list__empty"> <div className="transaction-list__empty">
<div className="transaction-list__empty-text"> <div className="transaction-list__empty-text">

@ -52,6 +52,8 @@ export default function reduceApp(state = {}, action) {
testKey: null, testKey: null,
}, },
gasLoadingAnimationIsShowing: false, gasLoadingAnimationIsShowing: false,
smartTransactionsError: null,
smartTransactionsErrorMessageDismissed: false,
ledgerWebHidConnectedStatus: WEBHID_CONNECTED_STATUSES.UNKNOWN, ledgerWebHidConnectedStatus: WEBHID_CONNECTED_STATUSES.UNKNOWN,
ledgerTransportStatus: TRANSPORT_STATES.NONE, ledgerTransportStatus: TRANSPORT_STATES.NONE,
newNetworkAdded: '', newNetworkAdded: '',
@ -96,6 +98,19 @@ export default function reduceApp(state = {}, action) {
qrCodeData: action.value, 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: // modal methods:
case actionConstants.MODAL_OPEN: { case actionConstants.MODAL_OPEN: {
const { name, ...modalProps } = action.payload; const { name, ...modalProps } = action.payload;

@ -329,4 +329,12 @@ describe('App State', () => {
expect(state.isMouseUser).toStrictEqual(true); 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');
});
}); });

@ -21,9 +21,17 @@ import {
updateTransaction, updateTransaction,
resetBackgroundSwapsState, resetBackgroundSwapsState,
setSwapsLiveness, setSwapsLiveness,
setSwapsFeatureFlags,
setSelectedQuoteAggId, setSelectedQuoteAggId,
setSwapsTxGasLimit, setSwapsTxGasLimit,
cancelTx, cancelTx,
fetchSmartTransactionsLiveness,
signAndSendSmartTransaction,
updateSmartTransaction,
setSmartTransactionsRefreshInterval,
fetchSmartTransactionFees,
estimateSmartTransactionsGas,
cancelSmartTransaction,
} from '../../store/actions'; } from '../../store/actions';
import { import {
AWAITING_SIGNATURES_ROUTE, AWAITING_SIGNATURES_ROUTE,
@ -32,12 +40,15 @@ import {
LOADING_QUOTES_ROUTE, LOADING_QUOTES_ROUTE,
SWAPS_ERROR_ROUTE, SWAPS_ERROR_ROUTE,
SWAPS_MAINTENANCE_ROUTE, SWAPS_MAINTENANCE_ROUTE,
SMART_TRANSACTION_STATUS_ROUTE,
} from '../../helpers/constants/routes'; } from '../../helpers/constants/routes';
import { import {
fetchSwapsFeatureFlags, fetchSwapsFeatureFlags,
fetchSwapsGasPrices, fetchSwapsGasPrices,
isContractAddressValid, isContractAddressValid,
getSwapsLivenessForNetwork, getSwapsLivenessForNetwork,
parseSmartTransactionsError,
stxErrorTypes,
} from '../../pages/swaps/swaps.util'; } from '../../pages/swaps/swaps.util';
import { calcGasTotal } from '../../pages/send/send.utils'; import { calcGasTotal } from '../../pages/send/send.utils';
import { import {
@ -65,8 +76,12 @@ import {
CONTRACT_DATA_DISABLED_ERROR, CONTRACT_DATA_DISABLED_ERROR,
SWAP_FAILED_ERROR, SWAP_FAILED_ERROR,
SWAPS_FETCH_ORDER_CONFLICT, SWAPS_FETCH_ORDER_CONFLICT,
ALLOWED_SMART_TRANSACTIONS_CHAIN_IDS,
} from '../../../shared/constants/swaps'; } 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'; import { getGasFeeEstimates } from '../metamask/metamask';
const GAS_PRICES_LOADING_STATES = { const GAS_PRICES_LOADING_STATES = {
@ -100,6 +115,9 @@ const initialState = {
priceEstimates: {}, priceEstimates: {},
fallBackPrice: null, fallBackPrice: null,
}, },
currentSmartTransactionsError: '',
currentSmartTransactionsErrorMessageDismissed: false,
swapsSTXLoading: false,
}; };
const slice = createSlice({ const slice = createSlice({
@ -179,6 +197,18 @@ const slice = createSlice({
retrievedFallbackSwapsGasPrice: (state, action) => { retrievedFallbackSwapsGasPrice: (state, action) => {
state.customGas.fallBackPrice = action.payload; 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) => export const getIsFeatureFlagLoaded = (state) =>
state.swaps.isFeatureFlagLoaded; state.swaps.isFeatureFlagLoaded;
export const getSwapsSTXLoading = (state) => state.swaps.swapsSTXLoading;
export const getMaxSlippage = (state) => state.swaps.maxSlippage; export const getMaxSlippage = (state) => state.swaps.maxSlippage;
export const getTopAssets = (state) => state.swaps.topAssets; export const getTopAssets = (state) => state.swaps.topAssets;
@ -234,6 +266,12 @@ export const getSwapGasPriceEstimateData = (state) =>
export const getSwapsFallbackGasPrice = (state) => export const getSwapsFallbackGasPrice = (state) =>
state.swaps.customGas.fallBackPrice; state.swaps.customGas.fallBackPrice;
export const getCurrentSmartTransactionsError = (state) =>
state.swaps.currentSmartTransactionsError;
export const getCurrentSmartTransactionsErrorMessageDismissed = (state) =>
state.swaps.currentSmartTransactionsErrorMessageDismissed;
export function shouldShowCustomPriceTooLowWarning(state) { export function shouldShowCustomPriceTooLowWarning(state) {
const { average } = getSwapGasPriceEstimateData(state); const { average } = getSwapGasPriceEstimateData(state);
@ -263,6 +301,37 @@ const getSwapsState = (state) => state.metamask.swapsState;
export const getSwapsFeatureIsLive = (state) => export const getSwapsFeatureIsLive = (state) =>
state.metamask.swapsState.swapsFeatureIsLive; 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) => export const getSwapsQuoteRefreshTime = (state) =>
state.metamask.swapsState.swapsQuoteRefreshTime; state.metamask.swapsState.swapsQuoteRefreshTime;
@ -342,6 +411,51 @@ export const getApproveTxParams = (state) => {
return { ...approvalNeeded, gasPrice, data }; 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 // Actions / action-creators
const { const {
@ -367,10 +481,14 @@ const {
swapCustomGasModalLimitEdited, swapCustomGasModalLimitEdited,
retrievedFallbackSwapsGasPrice, retrievedFallbackSwapsGasPrice,
swapCustomGasModalClosed, swapCustomGasModalClosed,
setCurrentSmartTransactionsError,
dismissCurrentSmartTransactionsErrorMessage,
setSwapsSTXSubmitLoading,
} = actions; } = actions;
export { export {
clearSwapsState, clearSwapsState,
dismissCurrentSmartTransactionsErrorMessage,
setAggregatorMetadata, setAggregatorMetadata,
setBalanceError, setBalanceError,
setFetchingQuotes, setFetchingQuotes,
@ -430,19 +548,27 @@ export const fetchAndSetSwapsGasPriceInfo = () => {
}; };
}; };
export const fetchSwapsLiveness = () => { export const fetchSwapsLivenessAndFeatureFlags = () => {
return async (dispatch, getState) => { return async (dispatch, getState) => {
let swapsLivenessForNetwork = { let swapsLivenessForNetwork = {
swapsFeatureIsLive: false, swapsFeatureIsLive: false,
}; };
const chainId = getCurrentChainId(getState());
try { try {
const swapsFeatureFlags = await fetchSwapsFeatureFlags(); const swapsFeatureFlags = await fetchSwapsFeatureFlags();
await dispatch(setSwapsFeatureFlags(swapsFeatureFlags));
if (ALLOWED_SMART_TRANSACTIONS_CHAIN_IDS.includes(chainId)) {
await dispatch(fetchSmartTransactionsLiveness());
}
swapsLivenessForNetwork = getSwapsLivenessForNetwork( swapsLivenessForNetwork = getSwapsLivenessForNetwork(
swapsFeatureFlags, swapsFeatureFlags,
getCurrentChainId(getState()), chainId,
); );
} catch (error) { } 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)); await dispatch(setSwapsLiveness(swapsLivenessForNetwork));
dispatch(setIsFeatureFlagLoaded(true)); dispatch(setIsFeatureFlagLoaded(true));
@ -565,6 +691,11 @@ export const fetchQuotesAndSetQuoteState = (
const networkAndAccountSupports1559 = checkNetworkAndAccountSupports1559( const networkAndAccountSupports1559 = checkNetworkAndAccountSupports1559(
state, state,
); );
const smartTransactionsOptInStatus = getSmartTransactionsOptInStatus(state);
const smartTransactionsEnabled = getSmartTransactionsEnabled(state);
const currentSmartTransactionsEnabled = getCurrentSmartTransactionsEnabled(
state,
);
metaMetricsEvent({ metaMetricsEvent({
event: 'Quotes Requested', event: 'Quotes Requested',
category: 'swaps', category: 'swaps',
@ -577,6 +708,9 @@ export const fetchQuotesAndSetQuoteState = (
custom_slippage: maxSlippage !== 2, custom_slippage: maxSlippage !== 2,
is_hardware_wallet: hardwareWalletUsed, is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType, hardware_wallet_type: hardwareWalletType,
stx_enabled: smartTransactionsEnabled,
current_stx_enabled: currentSmartTransactionsEnabled,
stx_user_opt_in: smartTransactionsOptInStatus,
anonymizedData: true, anonymizedData: true,
}, },
}); });
@ -628,6 +762,9 @@ export const fetchQuotesAndSetQuoteState = (
custom_slippage: maxSlippage !== 2, custom_slippage: maxSlippage !== 2,
is_hardware_wallet: hardwareWalletUsed, is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType, hardware_wallet_type: hardwareWalletType,
stx_enabled: smartTransactionsEnabled,
current_stx_enabled: currentSmartTransactionsEnabled,
stx_user_opt_in: smartTransactionsOptInStatus,
}, },
}); });
dispatch(setSwapsErrorKey(QUOTES_NOT_AVAILABLE_ERROR)); dispatch(setSwapsErrorKey(QUOTES_NOT_AVAILABLE_ERROR));
@ -653,6 +790,9 @@ export const fetchQuotesAndSetQuoteState = (
available_quotes: Object.values(fetchedQuotes)?.length, available_quotes: Object.values(fetchedQuotes)?.length,
is_hardware_wallet: hardwareWalletUsed, is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType, hardware_wallet_type: hardwareWalletType,
stx_enabled: smartTransactionsEnabled,
current_stx_enabled: currentSmartTransactionsEnabled,
stx_user_opt_in: smartTransactionsOptInStatus,
anonymizedData: true, 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) => { export const signAndSendTransactions = (history, metaMetricsEvent) => {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState(); const state = getState();
@ -786,7 +1073,8 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => {
conversionRate: usdConversionRate, conversionRate: usdConversionRate,
numberOfDecimals: 6, numberOfDecimals: 6,
}); });
const smartTransactionsOptInStatus = getSmartTransactionsOptInStatus(state);
const smartTransactionsEnabled = getSmartTransactionsEnabled(state);
const swapMetaData = { const swapMetaData = {
token_from: sourceTokenInfo.symbol, token_from: sourceTokenInfo.symbol,
token_from_amount: String(swapTokenValue), token_from_amount: String(swapTokenValue),
@ -812,6 +1100,8 @@ export const signAndSendTransactions = (history, metaMetricsEvent) => {
median_metamask_fee: usedQuote.savings?.medianMetaMaskFee, median_metamask_fee: usedQuote.savings?.medianMetaMaskFee,
is_hardware_wallet: hardwareWalletUsed, is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: getHardwareWalletType(state), hardware_wallet_type: getHardwareWalletType(state),
stx_enabled: smartTransactionsEnabled,
stx_user_opt_in: smartTransactionsOptInStatus,
}; };
if (networkAndAccountSupports1559) { if (networkAndAccountSupports1559) {
swapMetaData.max_fee_per_gas = maxFeePerGas; swapMetaData.max_fee_per_gas = maxFeePerGas;
@ -985,3 +1275,60 @@ export function fetchMetaSwapsGasPriceEstimates() {
return priceEstimates; 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));
}
}
};
}

@ -1,12 +1,20 @@
import nock from 'nock'; import nock from 'nock';
import { MOCKS, createSwapsMockStore } from '../../../test/jest'; 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 { 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'; import * as swaps from './swaps';
jest.mock('../../store/actions.js', () => ({ jest.mock('../../store/actions.js', () => ({
setSwapsLiveness: jest.fn(), setSwapsLiveness: jest.fn(),
setSwapsFeatureFlags: jest.fn(),
fetchSmartTransactionsLiveness: jest.fn(),
})); }));
const providerState = { const providerState = {
@ -23,7 +31,7 @@ describe('Ducks - Swaps', () => {
nock.cleanAll(); nock.cleanAll();
}); });
describe('fetchSwapsLiveness', () => { describe('fetchSwapsLivenessAndFeatureFlags', () => {
const cleanFeatureFlagApiCache = () => { const cleanFeatureFlagApiCache = () => {
setStorageItem( setStorageItem(
'cachedFetch:https://api2.metaswap.codefi.network/featureFlags', 'cachedFetch:https://api2.metaswap.codefi.network/featureFlags',
@ -66,13 +74,14 @@ describe('Ducks - Swaps', () => {
const featureFlagApiNock = mockFeatureFlagsApiResponse({ const featureFlagApiNock = mockFeatureFlagsApiResponse({
featureFlagsResponse, featureFlagsResponse,
}); });
const swapsLiveness = await swaps.fetchSwapsLiveness()( const swapsLiveness = await swaps.fetchSwapsLivenessAndFeatureFlags()(
mockDispatch, mockDispatch,
createGetState(), createGetState(),
); );
expect(featureFlagApiNock.isDone()).toBe(true); expect(featureFlagApiNock.isDone()).toBe(true);
expect(mockDispatch).toHaveBeenCalledTimes(2); expect(mockDispatch).toHaveBeenCalledTimes(4);
expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness); expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness);
expect(setSwapsFeatureFlags).toHaveBeenCalledWith(featureFlagsResponse);
expect(swapsLiveness).toMatchObject(expectedSwapsLiveness); expect(swapsLiveness).toMatchObject(expectedSwapsLiveness);
}); });
@ -86,13 +95,14 @@ describe('Ducks - Swaps', () => {
const featureFlagApiNock = mockFeatureFlagsApiResponse({ const featureFlagApiNock = mockFeatureFlagsApiResponse({
featureFlagsResponse, featureFlagsResponse,
}); });
const swapsLiveness = await swaps.fetchSwapsLiveness()( const swapsLiveness = await swaps.fetchSwapsLivenessAndFeatureFlags()(
mockDispatch, mockDispatch,
createGetState(), createGetState(),
); );
expect(featureFlagApiNock.isDone()).toBe(true); expect(featureFlagApiNock.isDone()).toBe(true);
expect(mockDispatch).toHaveBeenCalledTimes(2); expect(mockDispatch).toHaveBeenCalledTimes(4);
expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness); expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness);
expect(setSwapsFeatureFlags).toHaveBeenCalledWith(featureFlagsResponse);
expect(swapsLiveness).toMatchObject(expectedSwapsLiveness); expect(swapsLiveness).toMatchObject(expectedSwapsLiveness);
}); });
@ -107,13 +117,14 @@ describe('Ducks - Swaps', () => {
const featureFlagApiNock = mockFeatureFlagsApiResponse({ const featureFlagApiNock = mockFeatureFlagsApiResponse({
featureFlagsResponse, featureFlagsResponse,
}); });
const swapsLiveness = await swaps.fetchSwapsLiveness()( const swapsLiveness = await swaps.fetchSwapsLivenessAndFeatureFlags()(
mockDispatch, mockDispatch,
createGetState(), createGetState(),
); );
expect(featureFlagApiNock.isDone()).toBe(true); expect(featureFlagApiNock.isDone()).toBe(true);
expect(mockDispatch).toHaveBeenCalledTimes(2); expect(mockDispatch).toHaveBeenCalledTimes(4);
expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness); expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness);
expect(setSwapsFeatureFlags).toHaveBeenCalledWith(featureFlagsResponse);
expect(swapsLiveness).toMatchObject(expectedSwapsLiveness); expect(swapsLiveness).toMatchObject(expectedSwapsLiveness);
}); });
@ -125,7 +136,7 @@ describe('Ducks - Swaps', () => {
const featureFlagApiNock = mockFeatureFlagsApiResponse({ const featureFlagApiNock = mockFeatureFlagsApiResponse({
replyWithError: true, replyWithError: true,
}); });
const swapsLiveness = await swaps.fetchSwapsLiveness()( const swapsLiveness = await swaps.fetchSwapsLivenessAndFeatureFlags()(
mockDispatch, mockDispatch,
createGetState(), createGetState(),
); );
@ -144,18 +155,22 @@ describe('Ducks - Swaps', () => {
const featureFlagApiNock = mockFeatureFlagsApiResponse({ const featureFlagApiNock = mockFeatureFlagsApiResponse({
featureFlagsResponse, featureFlagsResponse,
}); });
await swaps.fetchSwapsLiveness()(mockDispatch, createGetState()); await swaps.fetchSwapsLivenessAndFeatureFlags()(
mockDispatch,
createGetState(),
);
expect(featureFlagApiNock.isDone()).toBe(true); expect(featureFlagApiNock.isDone()).toBe(true);
const featureFlagApiNock2 = mockFeatureFlagsApiResponse({ const featureFlagApiNock2 = mockFeatureFlagsApiResponse({
featureFlagsResponse, featureFlagsResponse,
}); });
const swapsLiveness = await swaps.fetchSwapsLiveness()( const swapsLiveness = await swaps.fetchSwapsLivenessAndFeatureFlags()(
mockDispatch, mockDispatch,
createGetState(), createGetState(),
); );
expect(featureFlagApiNock2.isDone()).toBe(false); // Second API call wasn't made, cache was used instead. 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(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness);
expect(setSwapsFeatureFlags).toHaveBeenCalledWith(featureFlagsResponse);
expect(swapsLiveness).toMatchObject(expectedSwapsLiveness); 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,
);
});
});
}); });

@ -41,6 +41,7 @@ const BUILD_QUOTE_ROUTE = '/swaps/build-quote';
const VIEW_QUOTE_ROUTE = '/swaps/view-quote'; const VIEW_QUOTE_ROUTE = '/swaps/view-quote';
const LOADING_QUOTES_ROUTE = '/swaps/loading-quotes'; const LOADING_QUOTES_ROUTE = '/swaps/loading-quotes';
const AWAITING_SIGNATURES_ROUTE = '/swaps/awaiting-signatures'; const AWAITING_SIGNATURES_ROUTE = '/swaps/awaiting-signatures';
const SMART_TRANSACTION_STATUS_ROUTE = '/swaps/smart-transaction-status';
const AWAITING_SWAP_ROUTE = '/swaps/awaiting-swap'; const AWAITING_SWAP_ROUTE = '/swaps/awaiting-swap';
const SWAPS_ERROR_ROUTE = '/swaps/swaps-error'; const SWAPS_ERROR_ROUTE = '/swaps/swaps-error';
const SWAPS_MAINTENANCE_ROUTE = '/swaps/maintenance'; const SWAPS_MAINTENANCE_ROUTE = '/swaps/maintenance';
@ -237,6 +238,7 @@ export {
AWAITING_SIGNATURES_ROUTE, AWAITING_SIGNATURES_ROUTE,
SWAPS_ERROR_ROUTE, SWAPS_ERROR_ROUTE,
SWAPS_MAINTENANCE_ROUTE, SWAPS_MAINTENANCE_ROUTE,
SMART_TRANSACTION_STATUS_ROUTE,
ADD_COLLECTIBLE_ROUTE, ADD_COLLECTIBLE_ROUTE,
ONBOARDING_ROUTE, ONBOARDING_ROUTE,
ONBOARDING_HELP_US_IMPROVE_ROUTE, ONBOARDING_HELP_US_IMPROVE_ROUTE,

@ -7,6 +7,7 @@ export const PENDING_STATUS_HASH = {
[TRANSACTION_STATUSES.UNAPPROVED]: true, [TRANSACTION_STATUSES.UNAPPROVED]: true,
[TRANSACTION_STATUSES.APPROVED]: true, [TRANSACTION_STATUSES.APPROVED]: true,
[TRANSACTION_STATUSES.SUBMITTED]: true, [TRANSACTION_STATUSES.SUBMITTED]: true,
[TRANSACTION_STATUSES.PENDING]: true,
}; };
export const PRIORITY_STATUS_HASH = { export const PRIORITY_STATUS_HASH = {

@ -9,6 +9,8 @@ import {
getFetchParams, getFetchParams,
getApproveTxParams, getApproveTxParams,
prepareToLeaveSwaps, prepareToLeaveSwaps,
getSmartTransactionsOptInStatus,
getSmartTransactionsEnabled,
} from '../../../ducks/swaps/swaps'; } from '../../../ducks/swaps/swaps';
import { import {
isHardwareWallet, isHardwareWallet,
@ -41,6 +43,10 @@ export default function AwaitingSignatures() {
const approveTxParams = useSelector(getApproveTxParams, shallowEqual); const approveTxParams = useSelector(getApproveTxParams, shallowEqual);
const hardwareWalletUsed = useSelector(isHardwareWallet); const hardwareWalletUsed = useSelector(isHardwareWallet);
const hardwareWalletType = useSelector(getHardwareWalletType); const hardwareWalletType = useSelector(getHardwareWalletType);
const smartTransactionsOptInStatus = useSelector(
getSmartTransactionsOptInStatus,
);
const smartTransactionsEnabled = useSelector(getSmartTransactionsEnabled);
const needsTwoConfirmations = Boolean(approveTxParams); const needsTwoConfirmations = Boolean(approveTxParams);
const awaitingSignaturesEvent = useNewMetricEvent({ const awaitingSignaturesEvent = useNewMetricEvent({
@ -55,6 +61,8 @@ export default function AwaitingSignatures() {
custom_slippage: fetchParams?.slippage === 2, custom_slippage: fetchParams?.slippage === 2,
is_hardware_wallet: hardwareWalletUsed, is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType, hardware_wallet_type: hardwareWalletType,
stx_enabled: smartTransactionsEnabled,
stx_user_opt_in: smartTransactionsOptInStatus,
}, },
category: 'swaps', category: 'swaps',
}); });

@ -28,6 +28,8 @@ import {
navigateBackToBuildQuote, navigateBackToBuildQuote,
prepareForRetryGetQuotes, prepareForRetryGetQuotes,
prepareToLeaveSwaps, prepareToLeaveSwaps,
getSmartTransactionsOptInStatus,
getSmartTransactionsEnabled,
getFromTokenInputValue, getFromTokenInputValue,
getMaxSlippage, getMaxSlippage,
} from '../../../ducks/swaps/swaps'; } from '../../../ducks/swaps/swaps';
@ -104,6 +106,10 @@ export default function AwaitingSwap({
const hardwareWalletUsed = useSelector(isHardwareWallet); const hardwareWalletUsed = useSelector(isHardwareWallet);
const hardwareWalletType = useSelector(getHardwareWalletType); const hardwareWalletType = useSelector(getHardwareWalletType);
const smartTransactionsOptInStatus = useSelector(
getSmartTransactionsOptInStatus,
);
const smartTransactionsEnabled = useSelector(getSmartTransactionsEnabled);
const sensitiveProperties = { const sensitiveProperties = {
token_from: sourceTokenInfo?.symbol, token_from: sourceTokenInfo?.symbol,
token_from_amount: fetchParams?.value, token_from_amount: fetchParams?.value,
@ -114,6 +120,8 @@ export default function AwaitingSwap({
gas_fees: feeinUnformattedFiat, gas_fees: feeinUnformattedFiat,
is_hardware_wallet: hardwareWalletUsed, is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType, hardware_wallet_type: hardwareWalletType,
stx_enabled: smartTransactionsEnabled,
stx_user_opt_in: smartTransactionsOptInStatus,
}; };
const quotesExpiredEvent = useNewMetricEvent({ const quotesExpiredEvent = useNewMetricEvent({
event: 'Quotes Timed Out', event: 'Quotes Timed Out',

@ -19,7 +19,18 @@ import DropdownSearchList from '../dropdown-search-list';
import SlippageButtons from '../slippage-buttons'; import SlippageButtons from '../slippage-buttons';
import { getTokens, getConversionRate } from '../../../ducks/metamask/metamask'; import { getTokens, getConversionRate } from '../../../ducks/metamask/metamask';
import InfoTooltip from '../../../components/ui/info-tooltip'; 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 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 { import {
VIEW_QUOTE_ROUTE, VIEW_QUOTE_ROUTE,
LOADING_QUOTES_ROUTE, LOADING_QUOTES_ROUTE,
@ -40,10 +51,14 @@ import {
setFromTokenError, setFromTokenError,
setMaxSlippage, setMaxSlippage,
setReviewSwapClickedTimestamp, setReviewSwapClickedTimestamp,
getSmartTransactionsOptInStatus,
getSmartTransactionsEnabled,
getCurrentSmartTransactionsEnabled,
getFromTokenInputValue, getFromTokenInputValue,
getFromTokenError, getFromTokenError,
getMaxSlippage, getMaxSlippage,
getIsFeatureFlagLoaded, getIsFeatureFlagLoaded,
getCurrentSmartTransactionsError,
} from '../../../ducks/swaps/swaps'; } from '../../../ducks/swaps/swaps';
import { import {
getSwapsDefaultToken, getSwapsDefaultToken,
@ -53,6 +68,8 @@ import {
getRpcPrefsForCurrentProvider, getRpcPrefsForCurrentProvider,
getUseTokenDetection, getUseTokenDetection,
getTokenList, getTokenList,
isHardwareWallet,
getHardwareWalletType,
} from '../../../selectors'; } from '../../../selectors';
import { import {
@ -84,6 +101,7 @@ import {
setBackgroundSwapRouteState, setBackgroundSwapRouteState,
clearSwapsQuotes, clearSwapsQuotes,
stopPollingForQuotes, stopPollingForQuotes,
setSmartTransactionsOptInStatus,
} from '../../../store/actions'; } from '../../../store/actions';
import { import {
countDecimals, countDecimals,
@ -140,8 +158,33 @@ export default function BuildQuote({
const tokenConversionRates = useSelector(getTokenExchangeRates, isEqual); const tokenConversionRates = useSelector(getTokenExchangeRates, isEqual);
const conversionRate = useSelector(getConversionRate); 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 currentCurrency = useSelector(getCurrentCurrency);
const showSmartTransactionsOptInPopover =
smartTransactionsEnabled && !smartTransactionsOptInPopoverDisplayed;
const onCloseSmartTransactionsOptInPopover = (e) => {
e?.preventDefault();
setSmartTransactionsOptInStatus(false);
};
const onEnableSmartTransactionsClick = () =>
setSmartTransactionsOptInStatus(true);
const fetchParamsFromToken = isSwapsDefaultTokenSymbol( const fetchParamsFromToken = isSwapsDefaultTokenSymbol(
sourceTokenInfo?.symbol, sourceTokenInfo?.symbol,
chainId, chainId,
@ -402,10 +445,23 @@ export default function BuildQuote({
fromTokenBalance, 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(() => { useEffect(() => {
dispatch(resetSwapsPostFetchState()); dispatch(resetSwapsPostFetchState());
dispatch(setReviewSwapClickedTimestamp()); dispatch(setReviewSwapClickedTimestamp());
}, [dispatch]); buildQuotePageLoadedEvent();
}, [dispatch, buildQuotePageLoadedEvent]);
const BlockExplorerLink = () => { const BlockExplorerLink = () => {
return ( return (
@ -493,11 +549,87 @@ export default function BuildQuote({
fromTokenInputValue, fromTokenInputValue,
fromTokenAddress, fromTokenAddress,
toTokenAddress, toTokenAddress,
smartTransactionsOptInStatus,
]); ]);
return ( return (
<div className="build-quote"> <div className="build-quote">
<div className="build-quote__content"> <div className="build-quote__content">
{showSmartTransactionsOptInPopover && (
<Popover
title={t('stxAreHere')}
footer={
<>
<Button type="primary" onClick={onEnableSmartTransactionsClick}>
{t('enableSmartTransactions')}
</Button>
<Box marginTop={1}>
<Typography variant={TYPOGRAPHY.H6}>
<Button
type="link"
onClick={onCloseSmartTransactionsOptInPopover}
className="smart-transactions-popover__no-thanks-link"
>
{t('noThanksVariant2')}
</Button>
</Typography>
</Box>
</>
}
footerClassName="smart-transactions-popover__footer"
className="smart-transactions-popover"
>
<Box
paddingRight={6}
paddingLeft={6}
paddingTop={0}
paddingBottom={0}
display={DISPLAY.FLEX}
className="smart-transactions-popover__content"
>
<Box
marginTop={0}
marginBottom={4}
display={DISPLAY.FLEX}
flexDirection={FLEX_DIRECTION.COLUMN}
>
<img
src="./images/logo/metamask-smart-transactions@4x.png"
alt={t('swapSwapSwitch')}
/>
</Box>
<Typography variant={TYPOGRAPHY.H6} marginTop={0}>
{t('stxDescription')}
</Typography>
<Typography
tag="ul"
variant={TYPOGRAPHY.H6}
fontWeight={FONT_WEIGHT.BOLD}
marginTop={3}
>
<li>{t('stxBenefit1')}</li>
<li>{t('stxBenefit2')}</li>
<li>{t('stxBenefit3')}</li>
<li>{t('stxBenefit4')}</li>
</Typography>
<Typography
variant={TYPOGRAPHY.H8}
color={COLORS.UI4}
boxProps={{ marginTop: 3 }}
>
{t('stxSubDescription')}&nbsp;
<Typography
tag="span"
fontWeight={FONT_WEIGHT.BOLD}
variant={TYPOGRAPHY.H8}
color={COLORS.UI4}
>
{t('stxYouCanOptOut')}&nbsp;
</Typography>
</Typography>
</Box>
</Popover>
)}
<div className="build-quote__dropdown-input-pair-header"> <div className="build-quote__dropdown-input-pair-header">
<div className="build-quote__input-label">{t('swapSwapFrom')}</div> <div className="build-quote__input-label">{t('swapSwapFrom')}</div>
{!isSwapsDefaultTokenSymbol(fromTokenSymbol, chainId) && ( {!isSwapsDefaultTokenSymbol(fromTokenSymbol, chainId) && (
@ -683,6 +815,10 @@ export default function BuildQuote({
}} }}
maxAllowedSlippage={MAX_ALLOWED_SLIPPAGE} maxAllowedSlippage={MAX_ALLOWED_SLIPPAGE}
currentSlippage={maxSlippage} currentSlippage={maxSlippage}
smartTransactionsEnabled={smartTransactionsEnabled}
smartTransactionsOptInStatus={smartTransactionsOptInStatus}
setSmartTransactionsOptInStatus={setSmartTransactionsOptInStatus}
currentSmartTransactionsError={currentSmartTransactionsError}
/> />
</div> </div>
)} )}

@ -173,3 +173,41 @@
width: 100%; 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;
}
}
}

@ -24,6 +24,10 @@ import {
} from '../../../selectors/selectors'; } from '../../../selectors/selectors';
import { SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../shared/constants/swaps'; import { SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../shared/constants/swaps';
import { getURLHostName } from '../../../helpers/utils/util'; import { getURLHostName } from '../../../helpers/utils/util';
import {
getSmartTransactionsOptInStatus,
getSmartTransactionsEnabled,
} from '../../../ducks/swaps/swaps';
export default function DropdownSearchList({ export default function DropdownSearchList({
searchListClassName, searchListClassName,
@ -55,6 +59,10 @@ export default function DropdownSearchList({
const hardwareWalletType = useSelector(getHardwareWalletType); const hardwareWalletType = useSelector(getHardwareWalletType);
const chainId = useSelector(getCurrentChainId); const chainId = useSelector(getCurrentChainId);
const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider); const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider);
const smartTransactionsOptInStatus = useSelector(
getSmartTransactionsOptInStatus,
);
const smartTransactionsEnabled = useSelector(getSmartTransactionsEnabled);
const tokenImportedEvent = useNewMetricEvent({ const tokenImportedEvent = useNewMetricEvent({
event: 'Token Imported', event: 'Token Imported',
@ -64,6 +72,8 @@ export default function DropdownSearchList({
chain_id: chainId, chain_id: chainId,
is_hardware_wallet: hardwareWalletUsed, is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType, hardware_wallet_type: hardwareWalletType,
stx_enabled: smartTransactionsEnabled,
stx_user_opt_in: smartTransactionsOptInStatus,
}, },
category: 'swaps', category: 'swaps',
}); });

@ -35,6 +35,8 @@ export default function FeeCard({
numberOfQuotes, numberOfQuotes,
onQuotesClick, onQuotesClick,
chainId, chainId,
smartTransactionsOptInStatus,
smartTransactionsEnabled,
isBestQuote, isBestQuote,
supportsEIP1559V2 = false, supportsEIP1559V2 = false,
}) { }) {
@ -74,11 +76,15 @@ export default function FeeCard({
<div className="fee-card"> <div className="fee-card">
<div className="fee-card__main"> <div className="fee-card__main">
<TransactionDetail <TransactionDetail
disableEditGasFeeButton={
smartTransactionsEnabled && smartTransactionsOptInStatus
}
rows={[ rows={[
<TransactionDetailItem <TransactionDetailItem
key="gas-item" key="gas-item"
detailTitle={ detailTitle={
supportsEIP1559V2 ? ( supportsEIP1559V2 &&
(!smartTransactionsEnabled || !smartTransactionsOptInStatus) ? (
<GasDetailsItemTitle /> <GasDetailsItemTitle />
) : ( ) : (
<> <>
@ -133,14 +139,16 @@ export default function FeeCard({
{t('maxFee')} {t('maxFee')}
</Typography> </Typography>
{`: ${secondaryFee.maxFee}`} {`: ${secondaryFee.maxFee}`}
{!supportsEIP1559V2 && ( {!supportsEIP1559V2 &&
<span (!smartTransactionsEnabled ||
className="fee-card__edit-link" !smartTransactionsOptInStatus) && (
onClick={() => onFeeCardMaxRowClick()} <span
> className="fee-card__edit-link"
{t('edit')} onClick={() => onFeeCardMaxRowClick()}
</span> >
)} {t('edit')}
</span>
)}
</> </>
) )
} }
@ -213,6 +221,8 @@ FeeCard.propTypes = {
onQuotesClick: PropTypes.func.isRequired, onQuotesClick: PropTypes.func.isRequired,
numberOfQuotes: PropTypes.number.isRequired, numberOfQuotes: PropTypes.number.isRequired,
chainId: PropTypes.string.isRequired, chainId: PropTypes.string.isRequired,
smartTransactionsOptInStatus: PropTypes.bool,
smartTransactionsEnabled: PropTypes.bool,
isBestQuote: PropTypes.bool.isRequired, isBestQuote: PropTypes.bool.isRequired,
supportsEIP1559V2: PropTypes.bool, supportsEIP1559V2: PropTypes.bool,
}; };

@ -134,6 +134,26 @@ describe('FeeCard', () => {
).toMatchSnapshot(); ).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(
<FeeCard {...props} />,
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', () => { it('renders the component with EIP-1559 V2 enabled', () => {
useGasFeeEstimates.mockImplementation(() => ({ gasFeeEstimates: {} })); useGasFeeEstimates.mockImplementation(() => ({ gasFeeEstimates: {} }));
useSelector.mockImplementation((selector) => { useSelector.mockImplementation((selector) => {

@ -32,17 +32,25 @@ import {
getSwapsFeatureIsLive, getSwapsFeatureIsLive,
prepareToLeaveSwaps, prepareToLeaveSwaps,
fetchAndSetSwapsGasPriceInfo, fetchAndSetSwapsGasPriceInfo,
fetchSwapsLiveness, fetchSwapsLivenessAndFeatureFlags,
getReviewSwapClickedTimestamp, getReviewSwapClickedTimestamp,
getPendingSmartTransactions,
getSmartTransactionsOptInStatus,
getSmartTransactionsEnabled,
getCurrentSmartTransactionsError,
dismissCurrentSmartTransactionsErrorMessage,
getCurrentSmartTransactionsErrorMessageDismissed,
navigateBackToBuildQuote, navigateBackToBuildQuote,
} from '../../ducks/swaps/swaps'; } from '../../ducks/swaps/swaps';
import { import {
checkNetworkAndAccountSupports1559, checkNetworkAndAccountSupports1559,
currentNetworkTxListSelector, currentNetworkTxListSelector,
getSwapsDefaultToken,
} from '../../selectors'; } from '../../selectors';
import { import {
AWAITING_SIGNATURES_ROUTE, AWAITING_SIGNATURES_ROUTE,
AWAITING_SWAP_ROUTE, AWAITING_SWAP_ROUTE,
SMART_TRANSACTION_STATUS_ROUTE,
BUILD_QUOTE_ROUTE, BUILD_QUOTE_ROUTE,
VIEW_QUOTE_ROUTE, VIEW_QUOTE_ROUTE,
LOADING_QUOTES_ROUTE, LOADING_QUOTES_ROUTE,
@ -70,6 +78,7 @@ import { useNewMetricEvent } from '../../hooks/useMetricEvent';
import { useGasFeeEstimates } from '../../hooks/useGasFeeEstimates'; import { useGasFeeEstimates } from '../../hooks/useGasFeeEstimates';
import FeatureToggledRoute from '../../helpers/higher-order-components/feature-toggled-route'; import FeatureToggledRoute from '../../helpers/higher-order-components/feature-toggled-route';
import { TRANSACTION_STATUSES } from '../../../shared/constants/transaction'; import { TRANSACTION_STATUSES } from '../../../shared/constants/transaction';
import ActionableMessage from '../../components/ui/actionable-message';
import { import {
fetchTokens, fetchTokens,
fetchTopAssets, fetchTopAssets,
@ -77,6 +86,7 @@ import {
fetchAggregatorMetadata, fetchAggregatorMetadata,
} from './swaps.util'; } from './swaps.util';
import AwaitingSignatures from './awaiting-signatures'; import AwaitingSignatures from './awaiting-signatures';
import SmartTransactionStatus from './smart-transaction-status';
import AwaitingSwap from './awaiting-swap'; import AwaitingSwap from './awaiting-swap';
import LoadingQuote from './loading-swaps-quotes'; import LoadingQuote from './loading-swaps-quotes';
import BuildQuote from './build-quote'; import BuildQuote from './build-quote';
@ -92,6 +102,8 @@ export default function Swap() {
const isAwaitingSignaturesRoute = pathname === AWAITING_SIGNATURES_ROUTE; const isAwaitingSignaturesRoute = pathname === AWAITING_SIGNATURES_ROUTE;
const isSwapsErrorRoute = pathname === SWAPS_ERROR_ROUTE; const isSwapsErrorRoute = pathname === SWAPS_ERROR_ROUTE;
const isLoadingQuotesRoute = pathname === LOADING_QUOTES_ROUTE; const isLoadingQuotesRoute = pathname === LOADING_QUOTES_ROUTE;
const isSmartTransactionStatusRoute =
pathname === SMART_TRANSACTION_STATUS_ROUTE;
const isViewQuoteRoute = pathname === VIEW_QUOTE_ROUTE; const isViewQuoteRoute = pathname === VIEW_QUOTE_ROUTE;
const fetchParams = useSelector(getFetchParams, isEqual); const fetchParams = useSelector(getFetchParams, isEqual);
@ -112,10 +124,24 @@ export default function Swap() {
const networkAndAccountSupports1559 = useSelector( const networkAndAccountSupports1559 = useSelector(
checkNetworkAndAccountSupports1559, checkNetworkAndAccountSupports1559,
); );
const defaultSwapsToken = useSelector(getSwapsDefaultToken, isEqual);
const tokenList = useSelector(getTokenList, isEqual); const tokenList = useSelector(getTokenList, isEqual);
const listTokenValues = shuffle(Object.values(tokenList)); const listTokenValues = shuffle(Object.values(tokenList));
const reviewSwapClickedTimestamp = useSelector(getReviewSwapClickedTimestamp); const reviewSwapClickedTimestamp = useSelector(getReviewSwapClickedTimestamp);
const pendingSmartTransactions = useSelector(getPendingSmartTransactions);
const reviewSwapClicked = Boolean(reviewSwapClickedTimestamp); 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) { if (networkAndAccountSupports1559) {
// This will pre-load gas fees before going to the View Quote page. // 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], current_screen: pathname.match(/\/swaps\/(.+)/u)[1],
is_hardware_wallet: hardwareWalletUsed, is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType, hardware_wallet_type: hardwareWalletType,
stx_enabled: smartTransactionsEnabled,
stx_user_opt_in: smartTransactionsOptInStatus,
}, },
}); });
const exitEventRef = useRef(); const exitEventRef = useRef();
@ -227,10 +255,10 @@ export default function Swap() {
}); });
useEffect(() => { useEffect(() => {
const fetchSwapsLivenessWrapper = async () => { const fetchSwapsLivenessAndFeatureFlagsWrapper = async () => {
await dispatch(fetchSwapsLiveness()); await dispatch(fetchSwapsLivenessAndFeatureFlags());
}; };
fetchSwapsLivenessWrapper(); fetchSwapsLivenessAndFeatureFlagsWrapper();
return () => { return () => {
exitEventRef.current(); exitEventRef.current();
}; };
@ -260,10 +288,37 @@ export default function Swap() {
return () => window.removeEventListener('beforeunload', fn); return () => window.removeEventListener('beforeunload', fn);
}, [dispatch, isLoadingQuotesRoute]); }, [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) { if (!isSwapsChain) {
return <Redirect to={{ pathname: DEFAULT_ROUTE }} />; return <Redirect to={{ pathname: DEFAULT_ROUTE }} />;
} }
const isStxNotEnoughFundsError =
currentSmartTransactionsError === 'not_enough_funds';
return ( return (
<div className="swaps"> <div className="swaps">
<div className="swaps__container"> <div className="swaps__container">
@ -286,10 +341,60 @@ export default function Swap() {
history.push(DEFAULT_ROUTE); history.push(DEFAULT_ROUTE);
}} }}
> >
{!isAwaitingSwapRoute && !isAwaitingSignaturesRoute && t('cancel')} {!isAwaitingSwapRoute &&
!isAwaitingSignaturesRoute &&
!isSmartTransactionStatusRoute &&
t('cancel')}
</div> </div>
</div> </div>
<div className="swaps__content"> <div className="swaps__content">
{showSmartTransactionsErrorMessage && (
<ActionableMessage
type={isStxNotEnoughFundsError ? 'default' : 'warning'}
message={
isStxNotEnoughFundsError ? (
<div>
{t('swapApproveNeedMoreTokensSmartTransactions', [
defaultSwapsToken.symbol,
])}{' '}
<span
onClick={() =>
dispatch(dismissCurrentSmartTransactionsErrorMessage())
}
style={{
textDecoration: 'underline',
cursor: 'pointer',
}}
>
{t('stxTryRegular')}
</span>
</div>
) : (
<div className="build-quote__token-verification-warning-message">
<div className="build-quote__bold">
{t('stxUnavailable')}
</div>
<div>{t('stxFallbackToNormal')}</div>
</div>
)
}
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
/>
)}
<Switch> <Switch>
<FeatureToggledRoute <FeatureToggledRoute
redirectRoute={SWAPS_MAINTENANCE_ROUTE} redirectRoute={SWAPS_MAINTENANCE_ROUTE}
@ -320,6 +425,16 @@ export default function Swap() {
path={VIEW_QUOTE_ROUTE} path={VIEW_QUOTE_ROUTE}
exact exact
render={() => { render={() => {
if (
pendingSmartTransactions.length > 0 &&
routeState === 'smartTransactionStatus'
) {
return (
<Redirect
to={{ pathname: SMART_TRANSACTION_STATUS_ROUTE }}
/>
);
}
if (Object.values(quotes).length) { if (Object.values(quotes).length) {
return ( return (
<ViewQuote numberOfQuotes={Object.values(quotes).length} /> <ViewQuote numberOfQuotes={Object.values(quotes).length} />
@ -395,6 +510,13 @@ export default function Swap() {
return <AwaitingSignatures />; return <AwaitingSignatures />;
}} }}
/> />
<Route
path={SMART_TRANSACTION_STATUS_ROUTE}
exact
render={() => {
return <SmartTransactionStatus />;
}}
/>
<Route <Route
path={AWAITING_SWAP_ROUTE} path={AWAITING_SWAP_ROUTE}
exact exact

@ -1,5 +1,6 @@
@import 'awaiting-swap/index'; @import 'awaiting-swap/index';
@import 'awaiting-signatures/index'; @import 'awaiting-signatures/index';
@import 'smart-transaction-status/index';
@import 'build-quote/index'; @import 'build-quote/index';
@import 'countdown-timer/index'; @import 'countdown-timer/index';
@import 'dropdown-input-pair/index'; @import 'dropdown-input-pair/index';
@ -24,6 +25,16 @@
z-index: 12; z-index: 12;
} }
&__error-message {
margin-left: 24px;
margin-right: 24px;
@media screen and (min-width: $break-large) {
margin-left: 20px;
margin-right: 20px;
}
}
&__container { &__container {
display: flex; display: flex;
flex-flow: column; flex-flow: column;

@ -9,6 +9,8 @@ import {
navigateBackToBuildQuote, navigateBackToBuildQuote,
getFetchParams, getFetchParams,
getQuotesFetchStartTime, getQuotesFetchStartTime,
getSmartTransactionsOptInStatus,
getSmartTransactionsEnabled,
} from '../../../ducks/swaps/swaps'; } from '../../../ducks/swaps/swaps';
import { import {
isHardwareWallet, isHardwareWallet,
@ -35,6 +37,10 @@ export default function LoadingSwapsQuotes({
const quotesFetchStartTime = useSelector(getQuotesFetchStartTime); const quotesFetchStartTime = useSelector(getQuotesFetchStartTime);
const hardwareWalletUsed = useSelector(isHardwareWallet); const hardwareWalletUsed = useSelector(isHardwareWallet);
const hardwareWalletType = useSelector(getHardwareWalletType); const hardwareWalletType = useSelector(getHardwareWalletType);
const smartTransactionsOptInStatus = useSelector(
getSmartTransactionsOptInStatus,
);
const smartTransactionsEnabled = useSelector(getSmartTransactionsEnabled);
const quotesRequestCancelledEventConfig = { const quotesRequestCancelledEventConfig = {
event: 'Quotes Request Cancelled', event: 'Quotes Request Cancelled',
category: 'swaps', category: 'swaps',
@ -48,6 +54,8 @@ export default function LoadingSwapsQuotes({
response_time: Date.now() - quotesFetchStartTime, response_time: Date.now() - quotesFetchStartTime,
is_hardware_wallet: hardwareWalletUsed, is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType, hardware_wallet_type: hardwareWalletType,
stx_enabled: smartTransactionsEnabled,
stx_user_opt_in: smartTransactionsOptInStatus,
}, },
}; };

@ -15,6 +15,7 @@ const QuoteDetails = ({
feeInEth, feeInEth,
networkFees, networkFees,
metaMaskFee, metaMaskFee,
hideEstimatedGasFee,
}) => { }) => {
const t = useContext(I18nContext); const t = useContext(I18nContext);
return ( return (
@ -55,19 +56,21 @@ const QuoteDetails = ({
<span className="quote-details__bold">{` ${destinationTokenSymbol}`}</span> <span className="quote-details__bold">{` ${destinationTokenSymbol}`}</span>
</div> </div>
</div> </div>
<div className="quote-details__row"> {!hideEstimatedGasFee && (
<div className="quote-details__detail-header"> <div className="quote-details__row">
{t('swapEstimatedNetworkFees')} <div className="quote-details__detail-header">
<InfoTooltip {t('swapEstimatedNetworkFees')}
position="bottom" <InfoTooltip
contentText={t('swapEstimatedNetworkFeesInfo')} position="bottom"
/> contentText={t('swapEstimatedNetworkFeesInfo')}
</div> />
<div className="quote-details__detail-content"> </div>
<span>{feeInEth}</span> <div className="quote-details__detail-content">
<span className="quote-details__light-grey">{` (${networkFees})`}</span> <span>{feeInEth}</span>
<span className="quote-details__light-grey">{` (${networkFees})`}</span>
</div>
</div> </div>
</div> )}
<div className="quote-details__row"> <div className="quote-details__row">
<div className="quote-details__detail-header"> <div className="quote-details__detail-header">
{t('swapSource')} {t('swapSource')}
@ -105,6 +108,7 @@ QuoteDetails.propTypes = {
feeInEth: PropTypes.string.isRequired, feeInEth: PropTypes.string.isRequired,
networkFees: PropTypes.string.isRequired, networkFees: PropTypes.string.isRequired,
metaMaskFee: PropTypes.number.isRequired, metaMaskFee: PropTypes.number.isRequired,
hideEstimatedGasFee: PropTypes.bool,
}; };
export default QuoteDetails; export default QuoteDetails;

@ -14,6 +14,7 @@ const SelectQuotePopover = ({
swapToSymbol, swapToSymbol,
initialAggId, initialAggId,
onQuoteDetailsIsOpened, onQuoteDetailsIsOpened,
hideEstimatedGasFee,
}) => { }) => {
const t = useContext(I18nContext); const t = useContext(I18nContext);
@ -105,10 +106,14 @@ const SelectQuotePopover = ({
setSortDirection={setSortDirection} setSortDirection={setSortDirection}
sortColumn={sortColumn} sortColumn={sortColumn}
setSortColumn={setSortColumn} setSortColumn={setSortColumn}
hideEstimatedGasFee={hideEstimatedGasFee}
/> />
)} )}
{contentView === 'quoteDetails' && viewingAgg && ( {contentView === 'quoteDetails' && viewingAgg && (
<QuoteDetails {...viewingAgg} /> <QuoteDetails
{...viewingAgg}
hideEstimatedGasFee={hideEstimatedGasFee}
/>
)} )}
</Popover> </Popover>
</div> </div>
@ -123,6 +128,7 @@ SelectQuotePopover.propTypes = {
quoteDataRows: PropTypes.arrayOf(QUOTE_DATA_ROWS_PROPTYPES_SHAPE), quoteDataRows: PropTypes.arrayOf(QUOTE_DATA_ROWS_PROPTYPES_SHAPE),
initialAggId: PropTypes.string, initialAggId: PropTypes.string,
onQuoteDetailsIsOpened: PropTypes.func, onQuoteDetailsIsOpened: PropTypes.func,
hideEstimatedGasFee: PropTypes.bool.isRequired,
}; };
export default SelectQuotePopover; export default SelectQuotePopover;

@ -32,6 +32,7 @@ export default function SortList({
setSortDirection, setSortDirection,
sortColumn = null, sortColumn = null,
setSortColumn, setSortColumn,
hideEstimatedGasFee,
}) { }) {
const t = useContext(I18nContext); const t = useContext(I18nContext);
const [noRowHover, setRowNowHover] = useState(false); 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" className="select-quote-popover__column-header select-quote-popover__network-fees select-quote-popover__network-fees-header"
onClick={() => onColumnHeaderClick('rawNetworkFees')} onClick={() => onColumnHeaderClick('rawNetworkFees')}
> >
<span>{t('swapEstimatedNetworkFees')}</span> {!hideEstimatedGasFee && (
<InfoTooltip <>
position="bottom" <span>{t('swapEstimatedNetworkFees')}</span>
contentText={t('swapEstimatedNetworkFeesInfo')} <InfoTooltip
/> position="bottom"
<ToggleArrows /> contentText={t('swapEstimatedNetworkFeesInfo')}
/>
<ToggleArrows />
</>
)}
</div> </div>
<div <div
className="select-quote-popover__column-header select-quote-popover__quote-source" className="select-quote-popover__column-header select-quote-popover__quote-source"
@ -152,7 +157,7 @@ export default function SortList({
)} )}
</div> </div>
<div className="select-quote-popover__network-fees"> <div className="select-quote-popover__network-fees">
{networkFees} {!hideEstimatedGasFee && networkFees}
</div> </div>
<div className="select-quote-popover__quote-source"> <div className="select-quote-popover__quote-source">
<div <div
@ -199,4 +204,5 @@ SortList.propTypes = {
setSortDirection: PropTypes.func.isRequired, setSortDirection: PropTypes.func.isRequired,
sortColumn: PropTypes.string, sortColumn: PropTypes.string,
setSortColumn: PropTypes.func.isRequired, setSortColumn: PropTypes.func.isRequired,
hideEstimatedGasFee: PropTypes.bool.isRequired,
}; };

@ -1,15 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SlippageButtons renders the component with initial props 1`] = ` exports[`SlippageButtons renders the component with initial props 1`] = `
<div <button
class="slippage-buttons__header" class="slippage-buttons__header slippage-buttons__header--open"
> >
<div <div
class="slippage-buttons__header-text" class="slippage-buttons__header-text"
> >
Advanced Options Advanced Options
</div> </div>
</div> <i
class="fa fa-angle-up"
/>
</button>
`; `;
exports[`SlippageButtons renders the component with initial props 2`] = ` exports[`SlippageButtons renders the component with initial props 2`] = `
@ -18,16 +21,63 @@ exports[`SlippageButtons renders the component with initial props 2`] = `
role="radiogroup" role="radiogroup"
> >
<button <button
aria-checked="false" aria-checked="true"
class="button-group__button radio-button" class="button-group__button radio-button button-group__button--active radio-button--active"
data-testid="button-group__button0" data-testid="button-group__button0"
role="radio" role="radio"
> >
2% 2%
</button> </button>
<button
aria-checked="false"
class="button-group__button radio-button"
data-testid="button-group__button1"
role="radio"
>
3%
</button>
<button
aria-checked="false"
class="button-group__button slippage-buttons__button-group-custom-button radio-button"
data-testid="button-group__button2"
role="radio"
>
custom
</button>
</div>
`;
exports[`SlippageButtons renders the component with the Smart Transaction opt-in button available 1`] = `
<button
class="slippage-buttons__header slippage-buttons__header--open"
>
<div
class="slippage-buttons__header-text"
>
Advanced Options
</div>
<i
class="fa fa-angle-up"
/>
</button>
`;
exports[`SlippageButtons renders the component with the Smart Transaction opt-in button available 2`] = `
<div
class="button-group slippage-buttons__button-group radio-button-group"
role="radiogroup"
>
<button <button
aria-checked="true" aria-checked="true"
class="button-group__button radio-button button-group__button--active radio-button--active" class="button-group__button radio-button button-group__button--active radio-button--active"
data-testid="button-group__button0"
role="radio"
>
2%
</button>
<button
aria-checked="false"
class="button-group__button radio-button"
data-testid="button-group__button1" data-testid="button-group__button1"
role="radio" role="radio"
> >

@ -7,17 +7,22 @@
&__header { &__header {
display: flex; display: flex;
align-items: center; align-items: center;
color: var(--Blue-500);
margin-bottom: 0; margin-bottom: 0;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
background: unset; background: unset;
margin-bottom: 8px;
&--open {
margin-bottom: 8px;
}
} }
&__header-text { &__header-text {
@include H6; @include H6;
margin-right: 6px; margin-right: 6px;
color: var(--Blue-500);
font-weight: 900; font-weight: 900;
} }

@ -5,11 +5,25 @@ import { I18nContext } from '../../../contexts/i18n';
import ButtonGroup from '../../../components/ui/button-group'; import ButtonGroup from '../../../components/ui/button-group';
import Button from '../../../components/ui/button'; import Button from '../../../components/ui/button';
import InfoTooltip from '../../../components/ui/info-tooltip'; import InfoTooltip from '../../../components/ui/info-tooltip';
import ToggleButton from '../../../components/ui/toggle-button';
import Box from '../../../components/ui/box';
import Typography from '../../../components/ui/typography';
import {
TYPOGRAPHY,
FONT_WEIGHT,
ALIGN_ITEMS,
DISPLAY,
} from '../../../helpers/constants/design-system';
import { smartTransactionsErrorMessages } from '../swaps.util';
export default function SlippageButtons({ export default function SlippageButtons({
onSelect, onSelect,
maxAllowedSlippage, maxAllowedSlippage,
currentSlippage, currentSlippage,
smartTransactionsEnabled,
smartTransactionsOptInStatus,
setSmartTransactionsOptInStatus,
currentSmartTransactionsError,
}) { }) {
const t = useContext(I18nContext); const t = useContext(I18nContext);
const [customValue, setCustomValue] = useState(() => { const [customValue, setCustomValue] = useState(() => {
@ -33,6 +47,9 @@ export default function SlippageButtons({
} }
return 1; // Choose activeButtonIndex = 1 for 3% slippage by default. return 1; // Choose activeButtonIndex = 1 for 3% slippage by default.
}); });
const [open, setOpen] = useState(() => {
return currentSlippage !== 3; // Only open Advanced Options by default if it's not default 3% slippage.
});
const [inputRef, setInputRef] = useState(null); const [inputRef, setInputRef] = useState(null);
let errorText = ''; let errorText = '';
@ -68,94 +85,146 @@ export default function SlippageButtons({
return ( return (
<div className="slippage-buttons"> <div className="slippage-buttons">
<div className="slippage-buttons__header"> <button
onClick={() => setOpen(!open)}
className={classnames('slippage-buttons__header', {
'slippage-buttons__header--open': open,
})}
>
<div className="slippage-buttons__header-text"> <div className="slippage-buttons__header-text">
{t('swapsAdvancedOptions')} {t('swapsAdvancedOptions')}
</div> </div>
</div> {open ? (
<i className="fa fa-angle-up" />
) : (
<i className="fa fa-angle-down" />
)}
</button>
<div className="slippage-buttons__content"> <div className="slippage-buttons__content">
<div className="slippage-buttons__dropdown-content"> {open && (
<div className="slippage-buttons__buttons-prefix"> <>
<div className="slippage-buttons__prefix-text"> <div className="slippage-buttons__dropdown-content">
{t('swapsMaxSlippage')} <div className="slippage-buttons__buttons-prefix">
<div className="slippage-buttons__prefix-text">
{t('swapsMaxSlippage')}
</div>
<InfoTooltip
position="top"
contentText={t('swapAdvancedSlippageInfo')}
/>
</div>
<ButtonGroup
defaultActiveButtonIndex={
activeButtonIndex === 2 && !customValue
? 1
: activeButtonIndex
}
variant="radiogroup"
newActiveButtonIndex={activeButtonIndex}
className={classnames(
'button-group',
'slippage-buttons__button-group',
)}
>
<Button
onClick={() => {
setCustomValue('');
setEnteringCustomValue(false);
setActiveButtonIndex(0);
onSelect(2);
}}
>
2%
</Button>
<Button
onClick={() => {
setCustomValue('');
setEnteringCustomValue(false);
setActiveButtonIndex(1);
onSelect(3);
}}
>
3%
</Button>
<Button
className={classnames(
'slippage-buttons__button-group-custom-button',
{
'radio-button--danger': errorText,
},
)}
onClick={() => {
setActiveButtonIndex(2);
setEnteringCustomValue(true);
}}
>
{enteringCustomValue ? (
<div
className={classnames('slippage-buttons__custom-input', {
'slippage-buttons__custom-input--danger': errorText,
})}
>
<input
onChange={(event) => {
setCustomValue(event.target.value);
onSelect(Number(event.target.value));
}}
type="number"
step="0.1"
ref={setInputRef}
onBlur={() => {
setEnteringCustomValue(false);
}}
value={customValue || ''}
/>
</div>
) : (
customValueText
)}
{(customValue || enteringCustomValue) && (
<div className="slippage-buttons__percentage-suffix">%</div>
)}
</Button>
</ButtonGroup>
</div> </div>
<InfoTooltip {smartTransactionsEnabled && (
position="top" <Box marginTop={2} display={DISPLAY.FLEX}>
contentText={t('swapAdvancedSlippageInfo')} <Box
/> display={DISPLAY.FLEX}
</div> alignItems={ALIGN_ITEMS.CENTER}
<ButtonGroup paddingRight={3}
defaultActiveButtonIndex={
activeButtonIndex === 2 && !customValue ? 1 : activeButtonIndex
}
variant="radiogroup"
newActiveButtonIndex={activeButtonIndex}
className={classnames(
'button-group',
'slippage-buttons__button-group',
)}
>
<Button
onClick={() => {
setCustomValue('');
setEnteringCustomValue(false);
setActiveButtonIndex(0);
onSelect(2);
}}
>
2%
</Button>
<Button
onClick={() => {
setCustomValue('');
setEnteringCustomValue(false);
setActiveButtonIndex(1);
onSelect(3);
}}
>
3%
</Button>
<Button
className={classnames(
'slippage-buttons__button-group-custom-button',
{
'radio-button--danger': errorText,
},
)}
onClick={() => {
setActiveButtonIndex(2);
setEnteringCustomValue(true);
}}
>
{enteringCustomValue ? (
<div
className={classnames('slippage-buttons__custom-input', {
'slippage-buttons__custom-input--danger': errorText,
})}
> >
<input <Typography
onChange={(event) => { variant={TYPOGRAPHY.H6}
setCustomValue(event.target.value); boxProps={{ paddingRight: 2 }}
onSelect(Number(event.target.value)); fontWeight={FONT_WEIGHT.BOLD}
}} >
type="number" {t('smartTransaction')}
step="0.1" </Typography>
ref={setInputRef} {currentSmartTransactionsError ? (
onBlur={() => { <InfoTooltip
setEnteringCustomValue(false); position="top"
}} contentText={smartTransactionsErrorMessages(
value={customValue || ''} currentSmartTransactionsError,
/> )}
</div> />
) : ( ) : (
customValueText <InfoTooltip position="top" contentText={t('stxTooltip')} />
)} )}
{(customValue || enteringCustomValue) && ( </Box>
<div className="slippage-buttons__percentage-suffix">%</div> <ToggleButton
)} value={smartTransactionsOptInStatus}
</Button> onToggle={(value) => {
</ButtonGroup> setSmartTransactionsOptInStatus(!value);
</div> }}
offLabel={t('off')}
onLabel={t('on')}
disabled={Boolean(currentSmartTransactionsError)}
/>
</Box>
)}
</>
)}
{errorText && ( {errorText && (
<div className="slippage-buttons__error-text">{errorText}</div> <div className="slippage-buttons__error-text">{errorText}</div>
)} )}
@ -168,4 +237,8 @@ SlippageButtons.propTypes = {
onSelect: PropTypes.func.isRequired, onSelect: PropTypes.func.isRequired,
maxAllowedSlippage: PropTypes.number.isRequired, maxAllowedSlippage: PropTypes.number.isRequired,
currentSlippage: PropTypes.number, currentSlippage: PropTypes.number,
smartTransactionsEnabled: PropTypes.bool.isRequired,
smartTransactionsOptInStatus: PropTypes.object,
setSmartTransactionsOptInStatus: PropTypes.func,
currentSmartTransactionsError: PropTypes.string,
}; };

@ -7,14 +7,15 @@ const createProps = (customProps = {}) => {
return { return {
onSelect: jest.fn(), onSelect: jest.fn(),
maxAllowedSlippage: 15, maxAllowedSlippage: 15,
currentSlippage: 3, currentSlippage: 2,
smartTransactionsEnabled: false,
...customProps, ...customProps,
}; };
}; };
describe('SlippageButtons', () => { describe('SlippageButtons', () => {
it('renders the component with initial props', () => { it('renders the component with initial props', () => {
const { getByText } = renderWithProvider( const { getByText, queryByText } = renderWithProvider(
<SlippageButtons {...createProps()} />, <SlippageButtons {...createProps()} />,
); );
expect(getByText('2%')).toBeInTheDocument(); expect(getByText('2%')).toBeInTheDocument();
@ -27,5 +28,23 @@ describe('SlippageButtons', () => {
expect( expect(
document.querySelector('.slippage-buttons__button-group'), document.querySelector('.slippage-buttons__button-group'),
).toMatchSnapshot(); ).toMatchSnapshot();
expect(queryByText('Smart transaction')).not.toBeInTheDocument();
});
it('renders the component with the Smart Transaction opt-in button available', () => {
const { getByText } = renderWithProvider(
<SlippageButtons {...createProps({ smartTransactionsEnabled: true })} />,
);
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();
}); });
}); });

@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ArrowIcon renders the ArrowIcon component 1`] = `
<div>
<svg
fill="none"
height="13"
viewBox="0 0 15 13"
width="15"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.4946 6.14779C14.4863 5.93594 14.3991 5.69108 14.2539 5.53666L9.51334 0.49958C9.1921 0.21091 8.57613 0.0917437 8.21709 0.425509C7.86344 0.754237 7.87396 1.39178 8.22627 1.72181L11.5595 5.25889L1.1618 5.25889C0.670919 5.25889 0.272949 5.65687 0.272949 6.14779C0.272949 6.6387 0.670919 7.03668 1.1618 7.03668L11.5595 7.03668L8.22627 10.5738C7.92297 10.8776 7.86691 11.5376 8.21709 11.8701C8.56718 12.2025 9.20529 12.0963 9.51334 11.796L14.2539 6.75891C14.4161 6.58653 14.4952 6.38428 14.4946 6.14779Z"
fill="#D6D9DC"
/>
</svg>
</div>
`;

@ -0,0 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CanceledIcon renders the CanceledIcon component 1`] = `
<div>
<svg
fill="none"
height="39"
viewBox="0 0 41 39"
width="41"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M34.5805 6.55143C36.1394 8.11033 37.3994 9.91222 38.3237 11.8681C38.7066 12.6782 39.0318 13.5148 39.2968 14.3714L39.3242 14.4606C39.4816 14.9785 39.6171 15.5037 39.73 16.0347C40.6511 20.3654 40.0335 24.9029 37.9511 28.8509C35.6001 33.308 31.5749 36.6487 26.7609 38.138C24.3773 38.8754 21.8717 39.1361 19.3873 38.9052C16.9029 38.6743 14.4883 37.9563 12.2813 36.7922C10.2608 35.7264 8.52045 34.3588 7.08333 32.7636C5.61245 31.1448 4.43054 29.2824 3.59178 27.2574C3.32329 26.6092 2.89685 25.2767 2.89685 25.2767H6.89583C7.68667 27.4068 8.96033 29.362 10.6288 30.9609L10.6318 30.96C11.5096 31.8005 12.501 32.5516 13.595 33.1786C15.8742 34.483 18.4736 35.1864 21.1455 35.1864C25.1768 35.1864 29.0429 33.585 31.8935 30.7345C34.744 27.8839 36.3455 24.0177 36.3455 19.9865C36.3455 18.7267 36.1891 17.483 35.8871 16.2814C35.8206 16.017 35.7471 15.7546 35.6666 15.4945C34.4752 11.6432 31.8027 8.42306 28.237 6.54231C24.6713 4.66156 20.5045 4.27429 16.6533 5.46571C12.8837 6.63189 9.71868 9.21706 7.82253 12.6699L12.8961 15.307L3.28331 18.3447L0.245605 8.73191L4.45005 10.9171C6.81537 6.56278 10.7905 3.30177 15.5303 1.83546C17.3658 1.26761 19.2587 0.986653 21.1455 0.986694C26.1846 0.986694 31.0173 2.98824 34.5805 6.55143Z"
fill="#037DD6"
/>
<path
clip-rule="evenodd"
d="M18.1252 22.1036L14.7107 18.689L12.0926 21.3257L18.1252 27.3584L30.2322 15.2515L27.614 12.6148L18.1252 22.1036Z"
fill="#037DD6"
fill-rule="evenodd"
/>
</svg>
</div>
`;

@ -0,0 +1,22 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RevertedIcon renders the RevertedIcon component 1`] = `
<div>
<svg
fill="none"
height="39"
viewBox="0 0 41 39"
width="41"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M34.6806 6.55143C36.2395 8.11033 37.4995 9.91222 38.4238 11.8681C38.8067 12.6782 39.1319 13.5148 39.3969 14.3714L39.4243 14.4606C39.5817 14.9785 39.7172 15.5037 39.8301 16.0347C40.7512 20.3654 40.1336 24.9029 38.0512 28.8509C35.7002 33.308 31.675 36.6487 26.861 38.138C24.4773 38.8754 21.9718 39.1361 19.4874 38.9052C17.003 38.6743 14.5884 37.9563 12.3814 36.7922C10.3609 35.7264 8.62055 34.3588 7.18343 32.7636C5.71254 31.1448 4.53064 29.2824 3.69188 27.2574C3.42339 26.6092 2.99695 25.2767 2.99695 25.2767H6.99592C7.78677 27.4068 9.06042 29.362 10.7289 30.9609L10.7319 30.96C11.6097 31.8005 12.6011 32.5516 13.6951 33.1786C15.9743 34.483 18.5737 35.1864 21.2456 35.1864C25.2769 35.1864 29.143 33.585 31.9936 30.7345C34.8441 27.8839 36.4456 24.0177 36.4456 19.9865C36.4456 18.7267 36.2892 17.483 35.9872 16.2814C35.9207 16.017 35.8471 15.7546 35.7667 15.4945C34.5753 11.6432 31.9028 8.42306 28.3371 6.54231C24.7714 4.66156 20.6046 4.27429 16.7534 5.46571C12.9838 6.63189 9.81878 9.21706 7.92263 12.6699L12.9962 15.307L3.3834 18.3447L0.345703 8.73191L4.55015 10.9171C6.91547 6.56278 10.8906 3.30177 15.6304 1.83546C17.4659 1.26761 19.3588 0.986653 21.2456 0.986694C26.2847 0.986694 31.1174 2.98824 34.6806 6.55143Z"
fill="#D73A49"
/>
<path
d="M18.5849 19.9869L15.1454 23.4264L17.9845 26.2655L21.424 22.826L24.8635 26.2655L27.7026 23.4264L24.2631 19.9869L27.7026 16.5473L24.8635 13.7082L21.424 17.1478L17.9845 13.7082L15.1454 16.5473L18.5849 19.9869Z"
fill="#D73A49"
/>
</svg>
</div>
`;

@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SuccessIcon renders the SuccessIcon component 1`] = `
<div>
<svg
fill="none"
height="39"
viewBox="0 0 39 39"
width="39"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M34.5457 19.8983C34.5457 23.9296 32.9443 27.7958 30.0937 30.6463C27.2432 33.4969 23.377 35.0983 19.3457 35.0983C15.3144 35.0983 11.4482 33.4969 8.59768 30.6463C5.74713 27.7958 4.1457 23.9296 4.1457 19.8983C4.1457 15.867 5.74713 12.0008 8.59768 9.15029C11.4482 6.29974 15.3144 4.69832 19.3457 4.69832C20.7897 4.69832 22.1957 4.90732 23.5257 5.28732L26.5087 2.30432C24.3047 1.39232 21.8917 0.898315 19.3457 0.898315C16.8506 0.898315 14.3799 1.38977 12.0747 2.3446C9.76953 3.29944 7.67499 4.69897 5.91067 6.46329C2.34748 10.0265 0.345703 14.8592 0.345703 19.8983C0.345703 24.9374 2.34748 29.7702 5.91067 33.3333C7.67499 35.0977 9.76953 36.4972 12.0747 37.452C14.3799 38.4069 16.8506 38.8983 19.3457 38.8983C24.3848 38.8983 29.2175 36.8965 32.7807 33.3333C36.3439 29.7702 38.3457 24.9374 38.3457 19.8983H34.5457ZM11.5747 16.2503L8.8957 18.9483L17.4457 27.4983L36.4457 8.49832L33.7667 5.80032L17.4457 22.1213L11.5747 16.2503Z"
fill="#28A745"
/>
</svg>
</div>
`;

@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TimerIcon renders the TimerIcon component 1`] = `
<div>
<svg
fill="none"
height="14"
viewBox="0 0 15 14"
width="15"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.36621 0.0810547C3.62012 0.0810547 0.584961 3.11621 0.584961 6.8623C0.584961 10.6084 3.62012 13.6436 7.36621 13.6436C11.1123 13.6436 14.1475 10.6084 14.1475 6.8623C14.1475 3.11621 11.1123 0.0810547 7.36621 0.0810547ZM7.36621 12.3311C4.33105 12.3311 1.89746 9.89746 1.89746 6.8623C1.89746 3.85449 4.33105 1.39355 7.36621 1.39355C10.374 1.39355 12.835 3.85449 12.835 6.8623C12.835 9.89746 10.374 12.3311 7.36621 12.3311ZM9.03418 9.4873C9.19824 9.59668 9.38965 9.56934 9.49902 9.40527L10.0186 8.72168C10.1279 8.55762 10.1006 8.36621 9.93652 8.25684L8.13184 6.91699V3.03418C8.13184 2.87012 7.96777 2.70605 7.80371 2.70605H6.92871C6.7373 2.70605 6.60059 2.87012 6.60059 3.03418V7.5459C6.60059 7.62793 6.62793 7.7373 6.70996 7.79199L9.03418 9.4873Z"
fill="#037DD6"
/>
</svg>
</div>
`;

@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UnknownIcon renders the UnknownIcon component 1`] = `
<div>
<svg
fill="none"
height="39"
viewBox="0 0 39 39"
width="39"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="19.1533"
cy="19.2715"
r="17.1"
stroke="#037DD6"
stroke-width="3.8"
/>
<path
d="M16.712 21.5034C16.712 20.7834 16.88 20.1834 17.216 19.7034C17.568 19.2074 18.08 18.7274 18.752 18.2634C19.008 18.0874 19.264 17.9274 19.52 17.7834C19.776 17.6394 20 17.4874 20.192 17.3274C20.4 17.1514 20.56 16.9674 20.672 16.7754C20.8 16.5674 20.864 16.3194 20.864 16.0314C20.864 15.5354 20.688 15.1594 20.336 14.9034C20 14.6474 19.592 14.5194 19.112 14.5194C18.616 14.5194 18.16 14.6794 17.744 14.9994C17.344 15.3034 17.144 15.7674 17.144 16.3914H13.52C13.52 15.6394 13.664 14.9354 13.952 14.2794C14.24 13.6074 14.632 13.0314 15.128 12.5514C15.64 12.0554 16.232 11.6634 16.904 11.3754C17.592 11.0874 18.328 10.9434 19.112 10.9434C19.896 10.9434 20.616 11.0794 21.272 11.3514C21.944 11.6234 22.512 11.9834 22.976 12.4314C23.456 12.8794 23.824 13.3994 24.08 13.9914C24.352 14.5674 24.488 15.1754 24.488 15.8154C24.488 16.2314 24.456 16.6234 24.392 16.9914C24.344 17.3594 24.24 17.7114 24.08 18.0474C23.936 18.3834 23.728 18.7114 23.456 19.0314C23.184 19.3514 22.84 19.6634 22.424 19.9674C21.784 20.4474 21.272 20.8234 20.888 21.0954C20.52 21.3674 20.336 21.6474 20.336 21.9354V21.9594H16.712V21.5034ZM18.512 28.4634C18.16 28.4634 17.824 28.3994 17.504 28.2714C17.2 28.1274 16.928 27.9434 16.688 27.7194C16.464 27.4794 16.28 27.2074 16.136 26.9034C16.008 26.5834 15.944 26.2474 15.944 25.8954C15.944 25.5434 16.008 25.2154 16.136 24.9114C16.28 24.5914 16.464 24.3194 16.688 24.0954C16.928 23.8554 17.2 23.6714 17.504 23.5434C17.824 23.3994 18.16 23.3274 18.512 23.3274C18.864 23.3274 19.192 23.3994 19.496 23.5434C19.816 23.6714 20.088 23.8554 20.312 24.0954C20.552 24.3194 20.736 24.5914 20.864 24.9114C21.008 25.2154 21.08 25.5434 21.08 25.8954C21.08 26.2474 21.008 26.5834 20.864 26.9034C20.736 27.2074 20.552 27.4794 20.312 27.7194C20.088 27.9434 19.816 28.1274 19.496 28.2714C19.192 28.3994 18.864 28.4634 18.512 28.4634Z"
fill="#037DD6"
/>
</svg>
</div>
`;

@ -0,0 +1,18 @@
import React from 'react';
export default function ArrowIcon() {
return (
<svg
width="15"
height="13"
viewBox="0 0 15 13"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.4946 6.14779C14.4863 5.93594 14.3991 5.69108 14.2539 5.53666L9.51334 0.49958C9.1921 0.21091 8.57613 0.0917437 8.21709 0.425509C7.86344 0.754237 7.87396 1.39178 8.22627 1.72181L11.5595 5.25889L1.1618 5.25889C0.670919 5.25889 0.272949 5.65687 0.272949 6.14779C0.272949 6.6387 0.670919 7.03668 1.1618 7.03668L11.5595 7.03668L8.22627 10.5738C7.92297 10.8776 7.86691 11.5376 8.21709 11.8701C8.56718 12.2025 9.20529 12.0963 9.51334 11.796L14.2539 6.75891C14.4161 6.58653 14.4952 6.38428 14.4946 6.14779Z"
fill="#D6D9DC"
/>
</svg>
);
}

@ -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(<ArrowIcon />);
expect(container).toMatchSnapshot();
});
});

@ -0,0 +1,24 @@
import React from 'react';
export default function CanceledIcon() {
return (
<svg
width="41"
height="39"
viewBox="0 0 41 39"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M34.5805 6.55143C36.1394 8.11033 37.3994 9.91222 38.3237 11.8681C38.7066 12.6782 39.0318 13.5148 39.2968 14.3714L39.3242 14.4606C39.4816 14.9785 39.6171 15.5037 39.73 16.0347C40.6511 20.3654 40.0335 24.9029 37.9511 28.8509C35.6001 33.308 31.5749 36.6487 26.7609 38.138C24.3773 38.8754 21.8717 39.1361 19.3873 38.9052C16.9029 38.6743 14.4883 37.9563 12.2813 36.7922C10.2608 35.7264 8.52045 34.3588 7.08333 32.7636C5.61245 31.1448 4.43054 29.2824 3.59178 27.2574C3.32329 26.6092 2.89685 25.2767 2.89685 25.2767H6.89583C7.68667 27.4068 8.96033 29.362 10.6288 30.9609L10.6318 30.96C11.5096 31.8005 12.501 32.5516 13.595 33.1786C15.8742 34.483 18.4736 35.1864 21.1455 35.1864C25.1768 35.1864 29.0429 33.585 31.8935 30.7345C34.744 27.8839 36.3455 24.0177 36.3455 19.9865C36.3455 18.7267 36.1891 17.483 35.8871 16.2814C35.8206 16.017 35.7471 15.7546 35.6666 15.4945C34.4752 11.6432 31.8027 8.42306 28.237 6.54231C24.6713 4.66156 20.5045 4.27429 16.6533 5.46571C12.8837 6.63189 9.71868 9.21706 7.82253 12.6699L12.8961 15.307L3.28331 18.3447L0.245605 8.73191L4.45005 10.9171C6.81537 6.56278 10.7905 3.30177 15.5303 1.83546C17.3658 1.26761 19.2587 0.986653 21.1455 0.986694C26.1846 0.986694 31.0173 2.98824 34.5805 6.55143Z"
fill="#037DD6"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18.1252 22.1036L14.7107 18.689L12.0926 21.3257L18.1252 27.3584L30.2322 15.2515L27.614 12.6148L18.1252 22.1036Z"
fill="#037DD6"
/>
</svg>
);
}

@ -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(<CanceledIcon />);
expect(container).toMatchSnapshot();
});
});

@ -0,0 +1 @@
export { default } from './smart-transaction-status';

@ -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;
}
}

@ -0,0 +1,22 @@
import React from 'react';
export default function RevertedIcon() {
return (
<svg
width="41"
height="39"
viewBox="0 0 41 39"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M34.6806 6.55143C36.2395 8.11033 37.4995 9.91222 38.4238 11.8681C38.8067 12.6782 39.1319 13.5148 39.3969 14.3714L39.4243 14.4606C39.5817 14.9785 39.7172 15.5037 39.8301 16.0347C40.7512 20.3654 40.1336 24.9029 38.0512 28.8509C35.7002 33.308 31.675 36.6487 26.861 38.138C24.4773 38.8754 21.9718 39.1361 19.4874 38.9052C17.003 38.6743 14.5884 37.9563 12.3814 36.7922C10.3609 35.7264 8.62055 34.3588 7.18343 32.7636C5.71254 31.1448 4.53064 29.2824 3.69188 27.2574C3.42339 26.6092 2.99695 25.2767 2.99695 25.2767H6.99592C7.78677 27.4068 9.06042 29.362 10.7289 30.9609L10.7319 30.96C11.6097 31.8005 12.6011 32.5516 13.6951 33.1786C15.9743 34.483 18.5737 35.1864 21.2456 35.1864C25.2769 35.1864 29.143 33.585 31.9936 30.7345C34.8441 27.8839 36.4456 24.0177 36.4456 19.9865C36.4456 18.7267 36.2892 17.483 35.9872 16.2814C35.9207 16.017 35.8471 15.7546 35.7667 15.4945C34.5753 11.6432 31.9028 8.42306 28.3371 6.54231C24.7714 4.66156 20.6046 4.27429 16.7534 5.46571C12.9838 6.63189 9.81878 9.21706 7.92263 12.6699L12.9962 15.307L3.3834 18.3447L0.345703 8.73191L4.55015 10.9171C6.91547 6.56278 10.8906 3.30177 15.6304 1.83546C17.4659 1.26761 19.3588 0.986653 21.2456 0.986694C26.2847 0.986694 31.1174 2.98824 34.6806 6.55143Z"
fill="#D73A49"
/>
<path
d="M18.5849 19.9869L15.1454 23.4264L17.9845 26.2655L21.424 22.826L24.8635 26.2655L27.7026 23.4264L24.2631 19.9869L27.7026 16.5473L24.8635 13.7082L21.424 17.1478L17.9845 13.7082L15.1454 16.5473L18.5849 19.9869Z"
fill="#D73A49"
/>
</svg>
);
}

@ -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(<RevertedIcon />);
expect(container).toMatchSnapshot();
});
});

@ -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 = <SuccessIcon />;
} else if (smartTransactionStatus === 'cancelled_user_cancelled') {
headerText = t('stxUserCancelled');
description = t('stxUserCancelledDescription');
icon = <CanceledIcon />;
} else if (
smartTransactionStatus.startsWith('cancelled') ||
smartTransactionStatus.includes('deadline_missed')
) {
headerText = t('stxCancelled');
description = t('stxCancelledDescription');
subDescription = t('stxCancelledSubDescription');
icon = <CanceledIcon />;
} else if (smartTransactionStatus === 'unknown') {
headerText = t('stxUnknown');
description = t('stxUnknownDescription');
icon = <UnknownIcon />;
} else if (smartTransactionStatus === 'reverted') {
headerText = t('stxFailure');
description = t('stxFailureDescription', [
<a
className="smart-transaction-status__support-link"
key="smart-transaction-status-support-link"
href="https://support.metamask.io"
target="_blank"
rel="noopener noreferrer"
>
{t('customerSupport')}
</a>,
]);
icon = <RevertedIcon />;
}
const showCancelSwapLink =
latestSmartTransaction.cancellable && !cancelSwapLinkClicked;
const CancelSwap = () => {
return (
<Box marginBottom={0}>
<a
className="smart-transaction-status__cancel-swap-link"
href="#"
onClick={(e) => {
e?.preventDefault();
setCancelSwapLinkClicked(true); // We want to hide it after a user clicks on it.
cancelSmartTransactionEvent();
dispatch(cancelSwapsSmartTransaction(latestSmartTransactionUuid));
}}
>
{t('cancelSwap')}
</a>
</Box>
);
};
return (
<div className="smart-transaction-status">
<Box
paddingLeft={8}
paddingRight={8}
height={BLOCK_SIZES.FULL}
justifyContent={JUSTIFY_CONTENT.START}
display={DISPLAY.FLEX}
className="smart-transaction-status__content"
>
<Box
marginTop={10}
marginBottom={0}
display={DISPLAY.FLEX}
justifyContent={JUSTIFY_CONTENT.CENTER}
alignItems={ALIGN_ITEMS.CENTER}
>
<Typography color={COLORS.UI4} variant={TYPOGRAPHY.H6}>
{`${fetchParams?.value && Number(fetchParams.value).toFixed(5)} `}
</Typography>
<Typography
color={COLORS.UI4}
variant={TYPOGRAPHY.H6}
fontWeight={FONT_WEIGHT.BOLD}
boxProps={{ marginLeft: 1, marginRight: 2 }}
>
{sourceTokenInfo?.symbol}
</Typography>
<UrlIcon
url={sourceTokenInfo.iconUrl}
className="main-quote-summary__icon"
name={sourceTokenInfo.symbol}
fallbackClassName="main-quote-summary__icon-fallback"
/>
<Box display={DISPLAY.BLOCK} marginLeft={2} marginRight={2}>
<ArrowIcon />
</Box>
<UrlIcon
url={destinationTokenInfo.iconUrl}
className="main-quote-summary__icon"
name={destinationTokenInfo.symbol}
fallbackClassName="main-quote-summary__icon-fallback"
/>
<Typography
color={COLORS.UI4}
variant={TYPOGRAPHY.H6}
boxProps={{ marginLeft: 2 }}
>
{`~${destinationValue && Number(destinationValue).toFixed(5)} `}
</Typography>
<Typography
color={COLORS.UI4}
variant={TYPOGRAPHY.H6}
fontWeight={FONT_WEIGHT.BOLD}
boxProps={{ marginLeft: 1 }}
>
{destinationTokenInfo?.symbol}
</Typography>
</Box>
<Box
marginTop={3}
className="smart-transaction-status__background-animation smart-transaction-status__background-animation--top"
></Box>
{icon && (
<Box marginTop={3} marginBottom={2}>
{icon}
</Box>
)}
{isSmartTransactionPending && (
<Box
marginTop={7}
marginBottom={1}
display={DISPLAY.FLEX}
justifyContent={JUSTIFY_CONTENT.CENTER}
alignItems={ALIGN_ITEMS.CENTER}
>
<TimerIcon />
<Typography
color={COLORS.UI4}
variant={TYPOGRAPHY.H6}
boxProps={{ marginLeft: 1 }}
>
{`${t('swapCompleteIn')} `}
</Typography>
<Typography
color={COLORS.UI4}
variant={TYPOGRAPHY.H6}
fontWeight={FONT_WEIGHT.BOLD}
boxProps={{ marginLeft: 1 }}
className="smart-transaction-status__remaining-time"
>
{showRemainingTimeInMinAndSec(timeLeftForPendingStxInSec)}
</Typography>
</Box>
)}
<Typography
color={COLORS.BLACK}
variant={TYPOGRAPHY.H4}
fontWeight={FONT_WEIGHT.BOLD}
>
{headerText}
</Typography>
{isSmartTransactionPending && (
<div className="smart-transaction-status__loading-bar-container">
<div
className="smart-transaction-status__loading-bar"
style={{
width: `${
(100 / swapsRefreshRates.stxStatusDeadline) *
(swapsRefreshRates.stxStatusDeadline -
timeLeftForPendingStxInSec)
}%`,
}}
/>
</div>
)}
{description && (
<Typography
variant={TYPOGRAPHY.H6}
boxProps={{ marginTop: 0 }}
color={COLORS.UI4}
>
{description}
</Typography>
)}
<Box
marginTop={3}
className="smart-transaction-status__background-animation smart-transaction-status__background-animation--bottom"
></Box>
{subDescription && (
<Typography
variant={TYPOGRAPHY.H7}
boxProps={{ marginTop: 8 }}
color={COLORS.UI4}
>
{subDescription}
</Typography>
)}
</Box>
{showCancelSwapLink &&
latestSmartTransactionUuid &&
isSmartTransactionPending && <CancelSwap />}
<SwapsFooter
onSubmit={async () => {
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"
/>
</div>
);
}

@ -0,0 +1,10 @@
import React from 'react';
import SmartTransactionStatus from './smart-transaction-status';
export default {
title: 'SmartTransactionStatus',
};
export const SmartTransactionStatusComponent = () => {
return <SmartTransactionStatus />;
};

@ -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(<SmartTransactionStatus />, store);
expect(getByText('Optimizing gas...')).toBeInTheDocument();
expect(getByText('Close')).toBeInTheDocument();
});
});

@ -0,0 +1,18 @@
import React from 'react';
export default function SuccessIcon() {
return (
<svg
width="39"
height="39"
viewBox="0 0 39 39"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M34.5457 19.8983C34.5457 23.9296 32.9443 27.7958 30.0937 30.6463C27.2432 33.4969 23.377 35.0983 19.3457 35.0983C15.3144 35.0983 11.4482 33.4969 8.59768 30.6463C5.74713 27.7958 4.1457 23.9296 4.1457 19.8983C4.1457 15.867 5.74713 12.0008 8.59768 9.15029C11.4482 6.29974 15.3144 4.69832 19.3457 4.69832C20.7897 4.69832 22.1957 4.90732 23.5257 5.28732L26.5087 2.30432C24.3047 1.39232 21.8917 0.898315 19.3457 0.898315C16.8506 0.898315 14.3799 1.38977 12.0747 2.3446C9.76953 3.29944 7.67499 4.69897 5.91067 6.46329C2.34748 10.0265 0.345703 14.8592 0.345703 19.8983C0.345703 24.9374 2.34748 29.7702 5.91067 33.3333C7.67499 35.0977 9.76953 36.4972 12.0747 37.452C14.3799 38.4069 16.8506 38.8983 19.3457 38.8983C24.3848 38.8983 29.2175 36.8965 32.7807 33.3333C36.3439 29.7702 38.3457 24.9374 38.3457 19.8983H34.5457ZM11.5747 16.2503L8.8957 18.9483L17.4457 27.4983L36.4457 8.49832L33.7667 5.80032L17.4457 22.1213L11.5747 16.2503Z"
fill="#28A745"
/>
</svg>
);
}

@ -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(<SuccessIcon />);
expect(container).toMatchSnapshot();
});
});

@ -0,0 +1,18 @@
import React from 'react';
export default function TimerIcon() {
return (
<svg
width="15"
height="14"
viewBox="0 0 15 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.36621 0.0810547C3.62012 0.0810547 0.584961 3.11621 0.584961 6.8623C0.584961 10.6084 3.62012 13.6436 7.36621 13.6436C11.1123 13.6436 14.1475 10.6084 14.1475 6.8623C14.1475 3.11621 11.1123 0.0810547 7.36621 0.0810547ZM7.36621 12.3311C4.33105 12.3311 1.89746 9.89746 1.89746 6.8623C1.89746 3.85449 4.33105 1.39355 7.36621 1.39355C10.374 1.39355 12.835 3.85449 12.835 6.8623C12.835 9.89746 10.374 12.3311 7.36621 12.3311ZM9.03418 9.4873C9.19824 9.59668 9.38965 9.56934 9.49902 9.40527L10.0186 8.72168C10.1279 8.55762 10.1006 8.36621 9.93652 8.25684L8.13184 6.91699V3.03418C8.13184 2.87012 7.96777 2.70605 7.80371 2.70605H6.92871C6.7373 2.70605 6.60059 2.87012 6.60059 3.03418V7.5459C6.60059 7.62793 6.62793 7.7373 6.70996 7.79199L9.03418 9.4873Z"
fill="#037DD6"
/>
</svg>
);
}

@ -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(<TimerIcon />);
expect(container).toMatchSnapshot();
});
});

@ -0,0 +1,25 @@
import React from 'react';
export default function UnknownIcon() {
return (
<svg
width="39"
height="39"
viewBox="0 0 39 39"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="19.1533"
cy="19.2715"
r="17.1"
stroke="#037DD6"
strokeWidth="3.8"
/>
<path
d="M16.712 21.5034C16.712 20.7834 16.88 20.1834 17.216 19.7034C17.568 19.2074 18.08 18.7274 18.752 18.2634C19.008 18.0874 19.264 17.9274 19.52 17.7834C19.776 17.6394 20 17.4874 20.192 17.3274C20.4 17.1514 20.56 16.9674 20.672 16.7754C20.8 16.5674 20.864 16.3194 20.864 16.0314C20.864 15.5354 20.688 15.1594 20.336 14.9034C20 14.6474 19.592 14.5194 19.112 14.5194C18.616 14.5194 18.16 14.6794 17.744 14.9994C17.344 15.3034 17.144 15.7674 17.144 16.3914H13.52C13.52 15.6394 13.664 14.9354 13.952 14.2794C14.24 13.6074 14.632 13.0314 15.128 12.5514C15.64 12.0554 16.232 11.6634 16.904 11.3754C17.592 11.0874 18.328 10.9434 19.112 10.9434C19.896 10.9434 20.616 11.0794 21.272 11.3514C21.944 11.6234 22.512 11.9834 22.976 12.4314C23.456 12.8794 23.824 13.3994 24.08 13.9914C24.352 14.5674 24.488 15.1754 24.488 15.8154C24.488 16.2314 24.456 16.6234 24.392 16.9914C24.344 17.3594 24.24 17.7114 24.08 18.0474C23.936 18.3834 23.728 18.7114 23.456 19.0314C23.184 19.3514 22.84 19.6634 22.424 19.9674C21.784 20.4474 21.272 20.8234 20.888 21.0954C20.52 21.3674 20.336 21.6474 20.336 21.9354V21.9594H16.712V21.5034ZM18.512 28.4634C18.16 28.4634 17.824 28.3994 17.504 28.2714C17.2 28.1274 16.928 27.9434 16.688 27.7194C16.464 27.4794 16.28 27.2074 16.136 26.9034C16.008 26.5834 15.944 26.2474 15.944 25.8954C15.944 25.5434 16.008 25.2154 16.136 24.9114C16.28 24.5914 16.464 24.3194 16.688 24.0954C16.928 23.8554 17.2 23.6714 17.504 23.5434C17.824 23.3994 18.16 23.3274 18.512 23.3274C18.864 23.3274 19.192 23.3994 19.496 23.5434C19.816 23.6714 20.088 23.8554 20.312 24.0954C20.552 24.3194 20.736 24.5914 20.864 24.9114C21.008 25.2154 21.08 25.5434 21.08 25.8954C21.08 26.2474 21.008 26.5834 20.864 26.9034C20.736 27.2074 20.552 27.4794 20.312 27.7194C20.088 27.9434 19.816 28.1274 19.496 28.2714C19.192 28.3994 18.864 28.4634 18.512 28.4634Z"
fill="#037DD6"
/>
</svg>
);
}

@ -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(<UnknownIcon />);
expect(container).toMatchSnapshot();
});
});

@ -14,6 +14,7 @@ export default function SwapsFooter({
showTermsOfService, showTermsOfService,
showTopBorder, showTopBorder,
className = '', className = '',
cancelText,
}) { }) {
const t = useContext(I18nContext); const t = useContext(I18nContext);
@ -27,7 +28,7 @@ export default function SwapsFooter({
<PageContainerFooter <PageContainerFooter
onCancel={onCancel} onCancel={onCancel}
hideCancel={hideCancel} hideCancel={hideCancel}
cancelText={t('back')} cancelText={cancelText || t('back')}
onSubmit={onSubmit} onSubmit={onSubmit}
submitText={submitText} submitText={submitText}
footerClassName={classnames( footerClassName={classnames(
@ -66,4 +67,5 @@ SwapsFooter.propTypes = {
showTermsOfService: PropTypes.bool, showTermsOfService: PropTypes.bool,
showTopBorder: PropTypes.bool, showTopBorder: PropTypes.bool,
className: PropTypes.string, className: PropTypes.string,
cancelText: PropTypes.string,
}; };

@ -27,6 +27,7 @@ import {
POLYGON_CHAIN_ID, POLYGON_CHAIN_ID,
LOCALHOST_CHAIN_ID, LOCALHOST_CHAIN_ID,
RINKEBY_CHAIN_ID, RINKEBY_CHAIN_ID,
ETH_SYMBOL,
AVALANCHE_CHAIN_ID, AVALANCHE_CHAIN_ID,
} from '../../../shared/constants/network'; } from '../../../shared/constants/network';
import { SECOND } from '../../../shared/constants/time'; import { SECOND } from '../../../shared/constants/time';
@ -80,9 +81,11 @@ const getBaseUrlForNewSwapsApi = (type, chainId) => {
return `${v2ApiBaseUrl}/networks/${chainIdDecimal}`; return `${v2ApiBaseUrl}/networks/${chainIdDecimal}`;
}; };
const TEST_CHAIN_IDS = [RINKEBY_CHAIN_ID, LOCALHOST_CHAIN_ID];
export const getBaseApi = function (type, chainId = MAINNET_CHAIN_ID) { export const getBaseApi = function (type, chainId = MAINNET_CHAIN_ID) {
// eslint-disable-next-line no-param-reassign // 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 baseUrl = getBaseUrlForNewSwapsApi(type, chainId);
const chainIdDecimal = chainId && parseInt(chainId, 16); const chainIdDecimal = chainId && parseInt(chainId, 16);
if (!baseUrl) { 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({ export function getRenderableNetworkFeesForQuote({
tradeGas, tradeGas,
approveGas, approveGas,
@ -553,6 +584,8 @@ export function quotesToRenderableData(
approveGas, approveGas,
tokenConversionRates, tokenConversionRates,
chainId, chainId,
smartTransactionEstimatedGas,
nativeCurrencySymbol,
) { ) {
return Object.values(quotes).map((quote) => { return Object.values(quotes).map((quote) => {
const { const {
@ -577,11 +610,16 @@ export function quotesToRenderableData(
destinationTokenInfo.decimals, destinationTokenInfo.decimals,
).toPrecision(8); ).toPrecision(8);
const { let feeInFiat = null;
let feeInEth = null;
let rawNetworkFees = null;
let rawEthFee = null;
({
feeInFiat, feeInFiat,
feeInEth,
rawNetworkFees, rawNetworkFees,
rawEthFee, rawEthFee,
feeInEth,
} = getRenderableNetworkFeesForQuote({ } = getRenderableNetworkFeesForQuote({
tradeGas: gasEstimateWithRefund || decimalToHex(averageGas || 800000), tradeGas: gasEstimateWithRefund || decimalToHex(averageGas || 800000),
approveGas, approveGas,
@ -592,7 +630,17 @@ export function quotesToRenderableData(
sourceSymbol: sourceTokenInfo.symbol, sourceSymbol: sourceTokenInfo.symbol,
sourceAmount, sourceAmount,
chainId, chainId,
}); }));
if (smartTransactionEstimatedGas) {
({ feeInFiat, feeInEth } = getFeeForSmartTransaction({
chainId,
currentCurrency,
conversionRate,
nativeCurrencySymbol,
estimatedFeeInWeiDec: smartTransactionEstimatedGas.feeEstimate,
}));
}
const slippageMultiplier = new BigNumber(100 - slippage).div(100); const slippageMultiplier = new BigNumber(100 - slippage).div(100);
const minimumAmountReceived = new BigNumber(destinationValue) const minimumAmountReceived = new BigNumber(destinationValue)
@ -845,3 +893,31 @@ export const countDecimals = (value) => {
} }
return value.toString().split('.')[1]?.length || 0; 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());
};

@ -38,6 +38,7 @@ import {
getSwapsLivenessForNetwork, getSwapsLivenessForNetwork,
countDecimals, countDecimals,
shouldEnableDirectWrapping, shouldEnableDirectWrapping,
showRemainingTimeInMinAndSec,
} from './swaps.util'; } from './swaps.util';
jest.mock('../../helpers/utils/storage-helpers.js', () => ({ jest.mock('../../helpers/utils/storage-helpers.js', () => ({
@ -545,4 +546,25 @@ describe('Swaps Util', () => {
).toBe(false); ).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);
});
});
}); });

@ -5,6 +5,15 @@
flex: 1; flex: 1;
width: 100%; 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 { &__content {
display: flex; display: flex;
flex-flow: column; flex-flow: column;

@ -35,6 +35,15 @@ import {
swapsQuoteSelected, swapsQuoteSelected,
getSwapsQuoteRefreshTime, getSwapsQuoteRefreshTime,
getReviewSwapClickedTimestamp, getReviewSwapClickedTimestamp,
getSmartTransactionsOptInStatus,
signAndSendSwapsSmartTransaction,
getSwapsRefreshStates,
getSmartTransactionsEnabled,
getCurrentSmartTransactionsError,
getCurrentSmartTransactionsErrorMessageDismissed,
getSwapsSTXLoading,
estimateSwapsSmartTransactionsGas,
getSmartTransactionEstimatedGas,
} from '../../../ducks/swaps/swaps'; } from '../../../ducks/swaps/swaps';
import { import {
conversionRateSelector, conversionRateSelector,
@ -80,6 +89,7 @@ import {
hexToDecimal, hexToDecimal,
getValueFromWeiHex, getValueFromWeiHex,
decGWEIToHexWEI, decGWEIToHexWEI,
hexWEIToDecGWEI,
addHexes, addHexes,
} from '../../../helpers/utils/conversions.util'; } from '../../../helpers/utils/conversions.util';
import { GasFeeContextProvider } from '../../../contexts/gasFee'; import { GasFeeContextProvider } from '../../../contexts/gasFee';
@ -93,6 +103,7 @@ import ActionableMessage from '../../../components/ui/actionable-message/actiona
import { import {
quotesToRenderableData, quotesToRenderableData,
getRenderableNetworkFeesForQuote, getRenderableNetworkFeesForQuote,
getFeeForSmartTransaction,
} from '../swaps.util'; } from '../swaps.util';
import { useTokenTracker } from '../../../hooks/useTokenTracker'; import { useTokenTracker } from '../../../hooks/useTokenTracker';
import { QUOTES_EXPIRED_ERROR } from '../../../../shared/constants/swaps'; import { QUOTES_EXPIRED_ERROR } from '../../../../shared/constants/swaps';
@ -102,8 +113,12 @@ import {
} from '../../../../shared/constants/gas'; } from '../../../../shared/constants/gas';
import CountdownTimer from '../countdown-timer'; import CountdownTimer from '../countdown-timer';
import SwapsFooter from '../swaps-footer'; 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'; import ViewQuotePriceDifference from './view-quote-price-difference';
let intervalId;
export default function ViewQuote() { export default function ViewQuote() {
const history = useHistory(); const history = useHistory();
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -168,6 +183,63 @@ export default function ViewQuote() {
const chainId = useSelector(getCurrentChainId); const chainId = useSelector(getCurrentChainId);
const nativeCurrencySymbol = useSelector(getNativeCurrency); const nativeCurrencySymbol = useSelector(getNativeCurrency);
const reviewSwapClickedTimestamp = useSelector(getReviewSwapClickedTimestamp); 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; let gasFeeInputs;
if (networkAndAccountSupports1559) { if (networkAndAccountSupports1559) {
@ -196,12 +268,13 @@ export default function ViewQuote() {
const nonCustomMaxGasLimit = usedQuote?.gasEstimate const nonCustomMaxGasLimit = usedQuote?.gasEstimate
? usedGasLimitWithMultiplier ? usedGasLimitWithMultiplier
: `0x${decimalToHex(usedQuote?.maxGas || 0)}`; : `0x${decimalToHex(usedQuote?.maxGas || 0)}`;
const maxGasLimit = customMaxGas || nonCustomMaxGasLimit; let maxGasLimit = customMaxGas || nonCustomMaxGasLimit;
let maxFeePerGas; let maxFeePerGas;
let maxPriorityFeePerGas; let maxPriorityFeePerGas;
let baseAndPriorityFeePerGas; let baseAndPriorityFeePerGas;
// EIP-1559 gas fees.
if (networkAndAccountSupports1559) { if (networkAndAccountSupports1559) {
const { const {
maxFeePerGas: suggestedMaxFeePerGas, maxFeePerGas: suggestedMaxFeePerGas,
@ -218,10 +291,18 @@ export default function ViewQuote() {
); );
} }
const gasTotalInWeiHex = calcGasTotal( // Smart Transactions gas fees.
maxGasLimit, if (
networkAndAccountSupports1559 ? maxFeePerGas : gasPrice, currentSmartTransactionsEnabled &&
); smartTransactionsOptInStatus &&
smartTransactionEstimatedGas?.txData
) {
maxGasLimit = `0x${decimalToHex(
smartTransactionEstimatedGas?.txData.gasLimit || 0,
)}`;
}
const gasTotalInWeiHex = calcGasTotal(maxGasLimit, maxFeePerGas || gasPrice);
const { tokensWithBalances } = useTokenTracker(swapsTokens, true); const { tokensWithBalances } = useTokenTracker(swapsTokens, true);
const balanceToken = const balanceToken =
@ -258,6 +339,10 @@ export default function ViewQuote() {
approveGas, approveGas,
memoizedTokenConversionRates, memoizedTokenConversionRates,
chainId, chainId,
smartTransactionsEnabled &&
smartTransactionsOptInStatus &&
smartTransactionEstimatedGas?.txData,
nativeCurrencySymbol,
); );
}, [ }, [
quotes, quotes,
@ -269,6 +354,10 @@ export default function ViewQuote() {
approveGas, approveGas,
memoizedTokenConversionRates, memoizedTokenConversionRates,
chainId, chainId,
smartTransactionEstimatedGas?.txData,
nativeCurrencySymbol,
smartTransactionsEnabled,
smartTransactionsOptInStatus,
]); ]);
const renderableDataForUsedQuote = renderablePopoverData.find( const renderableDataForUsedQuote = renderablePopoverData.find(
@ -287,7 +376,7 @@ export default function ViewQuote() {
sourceTokenIconUrl, sourceTokenIconUrl,
} = renderableDataForUsedQuote; } = renderableDataForUsedQuote;
const { feeInFiat, feeInEth } = getRenderableNetworkFeesForQuote({ let { feeInFiat, feeInEth } = getRenderableNetworkFeesForQuote({
tradeGas: usedGasLimit, tradeGas: usedGasLimit,
approveGas, approveGas,
gasPrice: networkAndAccountSupports1559 gasPrice: networkAndAccountSupports1559
@ -302,14 +391,10 @@ export default function ViewQuote() {
nativeCurrencySymbol, nativeCurrencySymbol,
}); });
const { const renderableMaxFees = getRenderableNetworkFeesForQuote({
feeInFiat: maxFeeInFiat,
feeInEth: maxFeeInEth,
nonGasFee,
} = getRenderableNetworkFeesForQuote({
tradeGas: maxGasLimit, tradeGas: maxGasLimit,
approveGas, approveGas,
gasPrice: networkAndAccountSupports1559 ? maxFeePerGas : gasPrice, gasPrice: maxFeePerGas || gasPrice,
currentCurrency, currentCurrency,
conversionRate, conversionRate,
tradeValue, tradeValue,
@ -318,6 +403,36 @@ export default function ViewQuote() {
chainId, chainId,
nativeCurrencySymbol, 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 tokenCost = new BigNumber(usedQuote.sourceAmount);
const ethCost = new BigNumber(usedQuote.trade.value || 0, 10).plus( const ethCost = new BigNumber(usedQuote.trade.value || 0, 10).plus(
@ -407,6 +522,9 @@ export default function ViewQuote() {
available_quotes: numberOfQuotes, available_quotes: numberOfQuotes,
is_hardware_wallet: hardwareWalletUsed, is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType, hardware_wallet_type: hardwareWalletType,
stx_enabled: currentSmartTransactionsEnabled,
current_stx_enabled: currentSmartTransactionsEnabled,
stx_user_opt_in: smartTransactionsOptInStatus,
}; };
const allAvailableQuotesOpened = useNewMetricEvent({ const allAvailableQuotesOpened = useNewMetricEvent({
@ -678,6 +796,21 @@ export default function ViewQuote() {
} }
}, [dispatch, viewQuotePageLoadedEvent, reviewSwapClickedTimestamp]); }, [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 = { const transaction = {
userFeeLevel: swapsUserFeeLevel || GAS_RECOMMENDATIONS.HIGH, userFeeLevel: swapsUserFeeLevel || GAS_RECOMMENDATIONS.HIGH,
txParams: { txParams: {
@ -710,6 +843,9 @@ export default function ViewQuote() {
swapToSymbol={destinationTokenSymbol} swapToSymbol={destinationTokenSymbol}
initialAggId={usedQuote.aggregator} initialAggId={usedQuote.aggregator}
onQuoteDetailsIsOpened={quoteDetailsOpened} onQuoteDetailsIsOpened={quoteDetailsOpened}
hideEstimatedGasFee={
smartTransactionsEnabled && smartTransactionsOptInStatus
}
/> />
)} )}
@ -768,51 +904,89 @@ export default function ViewQuote() {
sourceIconUrl={sourceTokenIconUrl} sourceIconUrl={sourceTokenIconUrl}
destinationIconUrl={destinationIconUrl} destinationIconUrl={destinationIconUrl}
/> />
<div {currentSmartTransactionsEnabled &&
className={classnames('view-quote__fee-card-container', { smartTransactionsOptInStatus &&
'view-quote__fee-card-container--three-rows': !smartTransactionEstimatedGas?.txData && (
approveTxParams && (!balanceError || warningHidden), <Box marginTop={0} marginBottom={10}>
})} <PulseLoader />
> </Box>
<FeeCard )}
primaryFee={{ {(!currentSmartTransactionsEnabled ||
fee: feeInEth, !smartTransactionsOptInStatus ||
maxFee: maxFeeInEth, smartTransactionEstimatedGas?.txData) && (
}} <div
secondaryFee={{ className={classnames('view-quote__fee-card-container', {
fee: feeInFiat, 'view-quote__fee-card-container--three-rows':
maxFee: maxFeeInFiat, approveTxParams && (!balanceError || warningHidden),
}} })}
onFeeCardMaxRowClick={onFeeCardMaxRowClick} >
hideTokenApprovalRow={ <FeeCard
!approveTxParams || (balanceError && !warningHidden) primaryFee={{
} fee: feeInEth,
tokenApprovalSourceTokenSymbol={sourceTokenSymbol} maxFee: maxFeeInEth,
onTokenApprovalClick={onFeeCardTokenApprovalClick} }}
metaMaskFee={String(metaMaskFee)} secondaryFee={{
numberOfQuotes={Object.values(quotes).length} fee: feeInFiat,
onQuotesClick={() => { maxFee: maxFeeInFiat,
allAvailableQuotesOpened(); }}
setSelectQuotePopoverShown(true); onFeeCardMaxRowClick={onFeeCardMaxRowClick}
}} hideTokenApprovalRow={
chainId={chainId} !approveTxParams || (balanceError && !warningHidden)
isBestQuote={isBestQuote} }
supportsEIP1559V2={supportsEIP1559V2} tokenApprovalSourceTokenSymbol={sourceTokenSymbol}
/> onTokenApprovalClick={onFeeCardTokenApprovalClick}
</div> metaMaskFee={String(metaMaskFee)}
numberOfQuotes={Object.values(quotes).length}
onQuotesClick={() => {
allAvailableQuotesOpened();
setSelectQuotePopoverShown(true);
}}
chainId={chainId}
isBestQuote={isBestQuote}
supportsEIP1559V2={supportsEIP1559V2}
networkAndAccountSupports1559={networkAndAccountSupports1559}
maxPriorityFeePerGasDecGWEI={hexWEIToDecGWEI(
maxPriorityFeePerGas,
)}
maxFeePerGasDecGWEI={hexWEIToDecGWEI(maxFeePerGas)}
smartTransactionsEnabled={currentSmartTransactionsEnabled}
smartTransactionsOptInStatus={smartTransactionsOptInStatus}
/>
</div>
)}
</div> </div>
<SwapsFooter <SwapsFooter
onSubmit={() => { onSubmit={() => {
setSubmitClicked(true); setSubmitClicked(true);
if (!balanceError) { 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) { } else if (destinationToken.symbol === defaultSwapsToken.symbol) {
history.push(DEFAULT_ROUTE); history.push(DEFAULT_ROUTE);
} else { } else {
history.push(`${ASSET_ROUTE}/${destinationToken.address}`); history.push(`${ASSET_ROUTE}/${destinationToken.address}`);
} }
}} }}
submitText={t('swap')} submitText={
currentSmartTransactionsEnabled &&
smartTransactionsOptInStatus &&
swapsSTXLoading
? t('preparingSwap')
: t('swap')
}
hideCancel hideCancel
disabled={ disabled={
submitClicked || submitClicked ||
@ -822,18 +996,11 @@ export default function ViewQuote() {
(networkAndAccountSupports1559 && (networkAndAccountSupports1559 &&
baseAndPriorityFeePerGas === undefined) || baseAndPriorityFeePerGas === undefined) ||
(!networkAndAccountSupports1559 && (!networkAndAccountSupports1559 &&
(gasPrice === null || gasPrice === undefined)) (gasPrice === null || gasPrice === undefined)) ||
(currentSmartTransactionsEnabled && currentSmartTransactionsError)
} }
tokenApprovalSourceTokenSymbol={sourceTokenSymbol} className={isShowingWarning && 'view-quote__thin-swaps-footer'}
onTokenApprovalClick={onFeeCardTokenApprovalClick} showTopBorder
metaMaskFee={String(metaMaskFee)}
numberOfQuotes={Object.values(quotes).length}
onQuotesClick={() => {
allAvailableQuotesOpened();
setSelectQuotePopoverShown(true);
}}
chainId={chainId}
isBestQuote={isBestQuote}
/> />
</div> </div>
</TransactionModalContextProvider> </TransactionModalContextProvider>

@ -65,7 +65,6 @@ describe('ViewQuote', () => {
).toMatchSnapshot(); ).toMatchSnapshot();
expect(getByText('Estimated gas fee')).toBeInTheDocument(); expect(getByText('Estimated gas fee')).toBeInTheDocument();
expect(getByText('Max fee')).toBeInTheDocument(); expect(getByText('Max fee')).toBeInTheDocument();
expect(getByText('Edit')).toBeInTheDocument();
expect(getByText('Swap')).toBeInTheDocument(); expect(getByText('Swap')).toBeInTheDocument();
}); });
@ -88,9 +87,8 @@ describe('ViewQuote', () => {
getByTestId('main-quote-summary__exchange-rate-container'), getByTestId('main-quote-summary__exchange-rate-container'),
).toMatchSnapshot(); ).toMatchSnapshot();
expect(getByText('Estimated gas fee')).toBeInTheDocument(); 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('Max fee')).toBeInTheDocument();
expect(getByText('Edit')).toBeInTheDocument();
expect(getByText('Swap')).toBeInTheDocument(); expect(getByText('Swap')).toBeInTheDocument();
}); });
}); });

@ -8,6 +8,7 @@ import txHelper from '../helpers/utils/tx-helper';
import { import {
TRANSACTION_STATUSES, TRANSACTION_STATUSES,
TRANSACTION_TYPES, TRANSACTION_TYPES,
SMART_TRANSACTION_STATUSES,
} from '../../shared/constants/transaction'; } from '../../shared/constants/transaction';
import { transactionMatchesNetwork } from '../../shared/modules/transaction.utils'; import { transactionMatchesNetwork } from '../../shared/modules/transaction.utils';
import { import {
@ -45,13 +46,27 @@ export const unapprovedEncryptionPublicKeyMsgsSelector = (state) =>
export const unapprovedTypedMessagesSelector = (state) => export const unapprovedTypedMessagesSelector = (state) =>
state.metamask.unapprovedTypedMessages; 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( export const selectedAddressTxListSelector = createSelector(
getSelectedAddress, getSelectedAddress,
currentNetworkTxListSelector, currentNetworkTxListSelector,
(selectedAddress, transactions = []) => { smartTransactionsListSelector,
return transactions.filter( (selectedAddress, transactions = [], smTransactions = []) => {
({ txParams }) => txParams.from === selectedAddress, return transactions
); .filter(({ txParams }) => txParams.from === selectedAddress)
.concat(smTransactions);
}, },
); );

@ -108,4 +108,9 @@ export const HIDE_WHATS_NEW_POPUP = 'HIDE_WHATS_NEW_POPUP';
export const TOGGLE_GAS_LOADING_ANIMATION = 'TOGGLE_GAS_LOADING_ANIMATION'; 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'; export const SET_CURRENCY_INPUT_SWITCH = 'SET_CURRENCY_INPUT_SWITCH';

@ -18,6 +18,7 @@ import {
import { hasUnconfirmedTransactions } from '../helpers/utils/confirm-tx.util'; import { hasUnconfirmedTransactions } from '../helpers/utils/confirm-tx.util';
import txHelper from '../helpers/utils/tx-helper'; import txHelper from '../helpers/utils/tx-helper';
import { getEnvironmentType, addHexPrefix } from '../../app/scripts/lib/util'; import { getEnvironmentType, addHexPrefix } from '../../app/scripts/lib/util';
import { decimalToHex } from '../helpers/utils/conversions.util';
import { import {
getMetaMaskAccounts, getMetaMaskAccounts,
getPermittedAccountsForCurrentTab, getPermittedAccountsForCurrentTab,
@ -33,6 +34,7 @@ import {
LEDGER_TRANSPORT_TYPES, LEDGER_TRANSPORT_TYPES,
LEDGER_USB_VENDOR_ID, LEDGER_USB_VENDOR_ID,
} from '../../shared/constants/hardware-wallets'; } from '../../shared/constants/hardware-wallets';
import { parseSmartTransactionsError } from '../pages/swaps/swaps.util';
import * as actionConstants from './actionConstants'; import * as actionConstants from './actionConstants';
let background = null; 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) { export function fetchAndSetQuotes(fetchParams, fetchParamsMetaData) {
return async (dispatch) => { return async (dispatch) => {
const [ const [
@ -3185,6 +3194,194 @@ export async function setWeb3ShimUsageAlertDismissed(origin) {
await promisifiedBackground.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 // DetectTokenController
export async function detectNewTokens() { export async function detectNewTokens() {
return promisifiedBackground.detectNewTokens(); return promisifiedBackground.detectNewTokens();

@ -1627,10 +1627,10 @@
resolved "https://registry.yarnpkg.com/@ethersproject/logger/-/logger-5.5.0.tgz#0c2caebeff98e10aefa5aef27d7441c7fd18cf5d" resolved "https://registry.yarnpkg.com/@ethersproject/logger/-/logger-5.5.0.tgz#0c2caebeff98e10aefa5aef27d7441c7fd18cf5d"
integrity sha512-rIY/6WPm7T8n3qS2vuHTUBPdXHl+rGxWxW5okDfo9J4Z0+gRRZT0msvUdIJkE4/HS29GUMziwGaaKO2bWONBrg== integrity sha512-rIY/6WPm7T8n3qS2vuHTUBPdXHl+rGxWxW5okDfo9J4Z0+gRRZT0msvUdIJkE4/HS29GUMziwGaaKO2bWONBrg==
"@ethersproject/networks@5.5.0", "@ethersproject/networks@^5.5.0": "@ethersproject/networks@5.5.2", "@ethersproject/networks@^5.5.0":
version "5.5.0" version "5.5.2"
resolved "https://registry.yarnpkg.com/@ethersproject/networks/-/networks-5.5.0.tgz#babec47cab892c51f8dd652ce7f2e3e14283981a" resolved "https://registry.yarnpkg.com/@ethersproject/networks/-/networks-5.5.2.tgz#784c8b1283cd2a931114ab428dae1bd00c07630b"
integrity sha512-KWfP3xOnJeF89Uf/FCJdV1a2aDJe5XTN2N52p4fcQ34QhDqQFkgQKZ39VGtiqUgHcLI8DfT0l9azC3KFTunqtA== integrity sha512-NEqPxbGBfy6O3x4ZTISb90SjEDkWYDUbEeIFhJly0F7sZjoQMnj5KYzMSkMkLKZ+1fGpx00EDpHQCy6PrDupkQ==
dependencies: dependencies:
"@ethersproject/logger" "^5.5.0" "@ethersproject/logger" "^5.5.0"
@ -1649,10 +1649,10 @@
dependencies: dependencies:
"@ethersproject/logger" "^5.5.0" "@ethersproject/logger" "^5.5.0"
"@ethersproject/providers@5.5.0", "@ethersproject/providers@^5.4.5": "@ethersproject/providers@5.5.3", "@ethersproject/providers@^5.4.5":
version "5.5.0" version "5.5.3"
resolved "https://registry.yarnpkg.com/@ethersproject/providers/-/providers-5.5.0.tgz#bc2876a8fe5e0053ed9828b1f3767ae46e43758b" resolved "https://registry.yarnpkg.com/@ethersproject/providers/-/providers-5.5.3.tgz#56c2b070542ac44eb5de2ed3cf6784acd60a3130"
integrity sha512-xqMbDnS/FPy+J/9mBLKddzyLLAQFjrVff5g00efqxPzcAwXiR+SiCGVy6eJ5iAIirBOATjx7QLhDNPGV+AEQsw== integrity sha512-ZHXxXXXWHuwCQKrgdpIkbzMNJMvs+9YWemanwp1fA7XZEv7QlilseysPvQe0D7Q7DlkJX/w/bGA1MdgK2TbGvA==
dependencies: dependencies:
"@ethersproject/abstract-provider" "^5.5.0" "@ethersproject/abstract-provider" "^5.5.0"
"@ethersproject/abstract-signer" "^5.5.0" "@ethersproject/abstract-signer" "^5.5.0"
@ -1674,10 +1674,10 @@
bech32 "1.1.4" bech32 "1.1.4"
ws "7.4.6" ws "7.4.6"
"@ethersproject/random@5.5.0", "@ethersproject/random@^5.5.0": "@ethersproject/random@5.5.1", "@ethersproject/random@^5.5.0":
version "5.5.0" version "5.5.1"
resolved "https://registry.yarnpkg.com/@ethersproject/random/-/random-5.5.0.tgz#305ed9e033ca537735365ac12eed88580b0f81f9" resolved "https://registry.yarnpkg.com/@ethersproject/random/-/random-5.5.1.tgz#7cdf38ea93dc0b1ed1d8e480ccdaf3535c555415"
integrity sha512-egGYZwZ/YIFKMHcoBUo8t3a8Hb/TKYX8BCBoLjudVCZh892welR3jOxgOmb48xznc9bTcMm7Tpwc1gHC1PFNFQ== integrity sha512-YaU2dQ7DuhL5Au7KbcQLHxcRHfgyNgvFV4sQOo0HrtW3Zkrc9ctWNz8wXQ4uCSfSDsqX2vcjhroxU5RQRV0nqA==
dependencies: dependencies:
"@ethersproject/bytes" "^5.5.0" "@ethersproject/bytes" "^5.5.0"
"@ethersproject/logger" "^5.5.0" "@ethersproject/logger" "^5.5.0"
@ -1777,10 +1777,10 @@
"@ethersproject/transactions" "^5.5.0" "@ethersproject/transactions" "^5.5.0"
"@ethersproject/wordlists" "^5.5.0" "@ethersproject/wordlists" "^5.5.0"
"@ethersproject/web@5.5.0", "@ethersproject/web@^5.5.0": "@ethersproject/web@5.5.1", "@ethersproject/web@^5.5.0":
version "5.5.0" version "5.5.1"
resolved "https://registry.yarnpkg.com/@ethersproject/web/-/web-5.5.0.tgz#0e5bb21a2b58fb4960a705bfc6522a6acf461e28" resolved "https://registry.yarnpkg.com/@ethersproject/web/-/web-5.5.1.tgz#cfcc4a074a6936c657878ac58917a61341681316"
integrity sha512-BEgY0eL5oH4mAo37TNYVrFeHsIXLRxggCRG/ksRIxI2X5uj5IsjGmcNiRN/VirQOlBxcUhCgHhaDLG4m6XAVoA== integrity sha512-olvLvc1CB12sREc1ROPSHTdFCdvMh0J5GSJYiQg2D0hdD4QmJDy8QYDb1CvoqD/bF1c++aeKv2sR5uduuG9dQg==
dependencies: dependencies:
"@ethersproject/base64" "^5.5.0" "@ethersproject/base64" "^5.5.0"
"@ethersproject/bytes" "^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" resolved "https://registry.yarnpkg.com/@metamask/slip44/-/slip44-2.0.0.tgz#1b646a1418af341d5ea979c28015a817ff23af33"
integrity sha512-eRomm783ti/1b/TlNnlTCUkYRuTaMYkeTAG0z2rt/WyT8UzxY+8+v/kbl9vk5qhDHeclzBrd9gbqLnLU1kh+Ow== 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": "@metamask/snap-controllers@^0.9.0":
version "0.9.0" version "0.9.0"
resolved "https://registry.yarnpkg.com/@metamask/snap-controllers/-/snap-controllers-0.9.0.tgz#e0006fc9991e995dd86dff792106990aae2aeda0" 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" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
"@types/lodash@^4.14.107", "@types/lodash@^4.14.136": "@types/lodash@^4.14.107", "@types/lodash@^4.14.136", "@types/lodash@^4.14.176":
version "4.14.168" version "4.14.178"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.168.tgz#fe24632e79b7ade3f132891afff86caa5e5ce008" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8"
integrity sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q== integrity sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==
"@types/long@^4.0.1": "@types/long@^4.0.1":
version "4.0.1" version "4.0.1"
@ -11357,10 +11370,10 @@ ethers@^4.0.20, ethers@^4.0.28:
uuid "2.0.1" uuid "2.0.1"
xmlhttprequest "1.8.0" xmlhttprequest "1.8.0"
ethers@^5.0.8, ethers@^5.4.0, ethers@^5.4.1, ethers@^5.4.5: ethers@^5.0.8, ethers@^5.4.0, ethers@^5.4.1, ethers@^5.4.5, ethers@^5.5.1:
version "5.5.1" version "5.5.4"
resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.5.1.tgz#d3259a95a42557844aa543906c537106c0406fbf" resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.5.4.tgz#e1155b73376a2f5da448e4a33351b57a885f4352"
integrity sha512-RodEvUFZI+EmFcE6bwkuJqpCYHazdzeR1nMzg+YWQSmQEsNtfl1KHGfp/FWZYl48bI/g7cgBeP2IlPthjiVngw== integrity sha512-N9IAXsF8iKhgHIC6pquzRgPBJEzc9auw3JoRkaKe+y4Wl/LFBtDDunNe7YmdomontECAcC5APaAgWZBiu1kirw==
dependencies: dependencies:
"@ethersproject/abi" "5.5.0" "@ethersproject/abi" "5.5.0"
"@ethersproject/abstract-provider" "5.5.1" "@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/json-wallets" "5.5.0"
"@ethersproject/keccak256" "5.5.0" "@ethersproject/keccak256" "5.5.0"
"@ethersproject/logger" "5.5.0" "@ethersproject/logger" "5.5.0"
"@ethersproject/networks" "5.5.0" "@ethersproject/networks" "5.5.2"
"@ethersproject/pbkdf2" "5.5.0" "@ethersproject/pbkdf2" "5.5.0"
"@ethersproject/properties" "5.5.0" "@ethersproject/properties" "5.5.0"
"@ethersproject/providers" "5.5.0" "@ethersproject/providers" "5.5.3"
"@ethersproject/random" "5.5.0" "@ethersproject/random" "5.5.1"
"@ethersproject/rlp" "5.5.0" "@ethersproject/rlp" "5.5.0"
"@ethersproject/sha2" "5.5.0" "@ethersproject/sha2" "5.5.0"
"@ethersproject/signing-key" "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/transactions" "5.5.0"
"@ethersproject/units" "5.5.0" "@ethersproject/units" "5.5.0"
"@ethersproject/wallet" "5.5.0" "@ethersproject/wallet" "5.5.0"
"@ethersproject/web" "5.5.0" "@ethersproject/web" "5.5.1"
"@ethersproject/wordlists" "5.5.0" "@ethersproject/wordlists" "5.5.0"
ethjs-abi@0.2.0: ethjs-abi@0.2.0:
@ -12031,6 +12044,11 @@ fast-json-patch@^2.0.6, fast-json-patch@^2.2.1:
dependencies: dependencies:
fast-deep-equal "^2.0.1" 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: fast-json-stable-stringify@^2.0.0:
version "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" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2"

Loading…
Cancel
Save