From 30d13422b59ed3bbe5ab85d0a0f7690d50dd5724 Mon Sep 17 00:00:00 2001 From: Dan J Miller Date: Tue, 6 Oct 2020 15:58:38 -0230 Subject: [PATCH] Add MetaMask Swaps (#9482) --- .storybook/images/0x.svg | 1 + .storybook/images/AST.png | Bin 0 -> 12120 bytes .storybook/images/BAT_icon.svg | 1 + .storybook/images/CVL_token.svg | 1 + .storybook/images/gladius.svg | 9 + .storybook/images/gnosis.svg | 49 ++ .storybook/images/metamark.svg | 1 + .storybook/images/omg.jpg | Bin 0 -> 13649 bytes .storybook/images/sai.svg | 29 + .storybook/images/tether_usd.png | Bin 0 -> 913 bytes .storybook/images/wbtc.png | Bin 0 -> 45822 bytes .storybook/images/wed.png | Bin 0 -> 265229 bytes app/_locales/en/messages.json | 285 +++++++ app/images/black-eth-logo.svg | 9 + app/images/icons/swap2.svg | 3 + app/images/source-logos-all.svg | 132 ++++ app/scripts/background.js | 1 + app/scripts/controllers/app-state.js | 10 + app/scripts/controllers/swaps.js | 620 +++++++++++++++ app/scripts/controllers/transactions/index.js | 66 +- .../controllers/transactions/tx-gas-utils.js | 13 +- app/scripts/lib/segment.js | 27 + app/scripts/metamask-controller.js | 45 +- package.json | 5 +- test/data/fetch-mocks.json | 7 + test/data/transaction-data.json | 41 + test/e2e/address-book.spec.js | 10 +- test/e2e/ethereum-on.spec.js | 4 + test/e2e/fixtures/imported-account/state.json | 3 +- test/e2e/fixtures/localization/state.json | 3 +- test/e2e/fixtures/personal-sign/state.json | 3 +- test/e2e/from-import-ui.spec.js | 8 +- test/e2e/incremental-security.spec.js | 4 + test/e2e/metamask-responsive-ui.spec.js | 8 +- test/e2e/metamask-ui.spec.js | 14 +- test/e2e/permissions.spec.js | 4 + test/e2e/send-edit.spec.js | 8 +- test/e2e/threebox.spec.js | 12 + test/e2e/webdriver/index.js | 4 + .../controllers/metamask-controller-test.js | 5 +- test/unit/app/controllers/swaps-test.js | 745 ++++++++++++++++++ test/unit/ui/app/actions.spec.js | 8 + .../app/app-header/app-header.component.js | 13 +- .../advanced-gas-inputs.component.js | 15 +- .../advanced-gas-inputs/index.scss | 4 + .../advanced-tab-content.component.js | 3 + .../gas-modal-page-container.component.js | 51 +- .../gas-modal-page-container.container.js | 89 ++- ...gas-modal-page-container-component.test.js | 5 +- ...gas-modal-page-container-container.test.js | 7 + .../edit-approval-permission.component.js | 19 +- .../edit-approval-permission/index.scss | 12 +- .../app/transaction-icon/transaction-icon.js | 3 + .../transaction-list.component.js | 34 +- .../app/wallet-overview/eth-overview.js | 71 +- .../components/app/wallet-overview/index.scss | 31 +- .../app/wallet-overview/token-overview.js | 71 +- ui/app/components/ui/button-group/index.scss | 5 +- .../components/ui/icon-button/icon-button.js | 37 + .../ui/icon-button/icon-button.scss | 30 + ui/app/components/ui/icon-button/index.js | 1 + .../icon-with-fallback.component.js | 12 +- .../ui/icon/overview-buy-icon.component.js | 25 + .../ui/icon/overview-send-icon.component.js | 23 + .../ui/icon/sun-check-icon.component.js | 23 + .../ui/icon/swap-icon-for-list.component.js | 25 + .../components/ui/icon/swap-icon.component.js | 25 + ui/app/components/ui/info-tooltip/index.scss | 6 +- .../page-container-footer.component.js | 11 +- ui/app/components/ui/pulse-loader/index.scss | 4 +- ui/app/components/ui/tooltip/index.scss | 30 + ui/app/components/ui/ui-components.scss | 3 + ui/app/components/ui/url-icon/index.js | 1 + ui/app/components/ui/url-icon/index.scss | 38 + ui/app/components/ui/url-icon/url-icon.js | 25 + ui/app/ducks/index.js | 2 + ui/app/ducks/swaps/swaps.js | 570 ++++++++++++++ ui/app/helpers/constants/routes.js | 19 + ui/app/helpers/constants/swaps.js | 21 + ui/app/helpers/constants/transactions.js | 4 + .../feature-toggled-route.js | 16 + ui/app/helpers/utils/conversions.util.js | 44 ++ ui/app/helpers/utils/token-util.js | 25 +- ui/app/helpers/utils/util.js | 38 + ui/app/helpers/utils/util.test.js | 17 + .../tests/useTransactionDisplayData.test.js | 44 +- ui/app/hooks/useCurrentAsset.js | 24 + ui/app/hooks/useEthFiatAmount.js | 34 + ui/app/hooks/usePrevious.js | 9 + ui/app/hooks/useSwappedTokenValue.js | 56 ++ ui/app/hooks/useTokenFiatAmount.js | 22 +- ui/app/hooks/useTokensToSearch.js | 115 +++ ui/app/hooks/useTransactionDisplayData.js | 74 +- ui/app/hooks/useTransactionTimeRemaining.js | 13 +- .../end-of-flow/end-of-flow.component.js | 5 +- .../end-of-flow/end-of-flow.container.js | 13 +- .../end-of-flow/tests/end-of-flow.test.js | 1 + ui/app/pages/home/home.component.js | 30 +- ui/app/pages/home/home.container.js | 10 + ui/app/pages/pages.scss | 2 +- ui/app/pages/routes/routes.component.js | 17 + ui/app/pages/routes/routes.container.js | 2 + .../send/send-footer/send-footer.utils.js | 7 +- .../tests/send-footer-utils.test.js | 18 - .../actionable-message/actionable-message.js | 0 .../actionable-message.stories.js | 0 .../actionable-message/index.js | 0 .../actionable-message/index.scss | 10 + .../swaps/awaiting-swap/awaiting-swap.js | 277 +++++++ .../awaiting-swap/awaiting-swap.stories.js | 76 ++ ui/app/pages/swaps/awaiting-swap/index.js | 1 + ui/app/pages/swaps/awaiting-swap/index.scss | 84 ++ .../awaiting-swap/quotes-timeout-icon.js | 9 + .../swaps/awaiting-swap/swap-failure-icon.js | 9 + .../swaps/awaiting-swap/swap-success-icon.js | 9 + .../view-on-ether-scan-link/index.js | 1 + .../view-on-ether-scan-link.js | 29 + ui/app/pages/swaps/build-quote/build-quote.js | 270 +++++++ .../swaps/build-quote/build-quote.stories.js | 82 ++ ui/app/pages/swaps/build-quote/index.js | 1 + ui/app/pages/swaps/build-quote/index.scss | 121 +++ .../swaps/countdown-timer/countdown-timer.js | 112 +++ .../countdown-timer.stories.js | 47 ++ ui/app/pages/swaps/countdown-timer/index.js | 1 + ui/app/pages/swaps/countdown-timer/index.scss | 39 + .../dropdown-input-pair.js | 126 +++ .../dropdown-input-pair.stories.js | 46 ++ .../pages/swaps/dropdown-input-pair/index.js | 1 + .../swaps/dropdown-input-pair/index.scss | 76 ++ .../dropdown-search-list.js | 152 ++++ .../dropdown-search-list.stories.js | 44 ++ .../pages/swaps/dropdown-search-list/index.js | 1 + .../swaps/dropdown-search-list/index.scss | 153 ++++ .../exchange-rate-display.js | 41 +- .../exchange-rate-display.stories.js | 0 .../exchange-rate-display/index.js | 0 .../swaps/exchange-rate-display/index.scss | 53 ++ ui/app/pages/swaps/fee-card/fee-card.js | 108 +++ .../pages/swaps/fee-card/fee-card.stories.js | 94 +++ .../pages/{token => swaps}/fee-card/index.js | 0 .../{token => swaps}/fee-card/index.scss | 54 +- ui/app/pages/swaps/index.js | 387 +++++++++ ui/app/pages/swaps/index.scss | 103 +++ ui/app/pages/swaps/intro-popup/index.js | 1 + ui/app/pages/swaps/intro-popup/index.scss | 71 ++ ui/app/pages/swaps/intro-popup/intro-popup.js | 93 +++ .../loading-swaps-quotes/aggregator-logo.js | 24 + .../background-animation.js | 77 ++ .../pages/swaps/loading-swaps-quotes/index.js | 1 + .../swaps/loading-swaps-quotes/index.scss | 134 ++++ .../loading-swaps-quotes-stories-metadata.js | 26 + .../loading-swaps-quotes.js | 236 ++++++ .../loading-swaps-quotes.stories.js | 91 +++ .../pages/swaps/main-quote-summary/index.js | 1 + .../pages/swaps/main-quote-summary/index.scss | 109 +++ .../main-quote-summary/main-quote-summary.js | 138 ++++ .../main-quote-summary.stories.js | 35 + .../main-quote-summary/quote-backdrop.js | 39 + .../pages/swaps/searchable-item-list/index.js | 1 + .../swaps/searchable-item-list/index.scss | 178 +++++ .../searchable-item-list/item-list/index.js | 1 + .../item-list/item-list.component.js | 107 +++ .../list-item-search/index.js | 1 + .../list-item-search.component.js | 84 ++ .../searchable-item-list.js | 72 ++ .../pages/swaps/select-quote-popover/index.js | 1 + .../swaps/select-quote-popover/index.scss | 282 +++++++ .../select-quote-popover/mock-quote-data.js | 106 +++ .../quote-details/index.js | 1 + .../quote-details/index.scss | 102 +++ .../quote-details/quote-details.js | 110 +++ .../select-quote-popover-constants.js | 19 + .../select-quote-popover.js | 112 +++ .../select-quote-popover.stories.js | 29 + .../select-quote-popover/sort-list/index.js | 1 + .../sort-list/sort-list.js | 176 +++++ ui/app/pages/swaps/slippage-buttons/index.js | 1 + .../pages/swaps/slippage-buttons/index.scss | 132 ++++ .../slippage-buttons/slippage-buttons.js | 142 ++++ .../slippage-buttons.stories.js | 15 + ui/app/pages/swaps/swaps-footer/index.js | 1 + ui/app/pages/swaps/swaps-footer/index.scss | 61 ++ .../pages/swaps/swaps-footer/swaps-footer.js | 60 ++ .../pages/swaps/swaps-util-test-constants.js | 118 +++ ui/app/pages/swaps/swaps.util.js | 398 ++++++++++ ui/app/pages/swaps/swaps.util.test.js | 151 ++++ ui/app/pages/swaps/view-quote/index.js | 1 + ui/app/pages/swaps/view-quote/index.scss | 161 ++++ ui/app/pages/swaps/view-quote/view-quote.js | 559 +++++++++++++ .../token/exchange-rate-display/index.scss | 26 - ui/app/pages/token/fee-card/fee-card.js | 69 -- .../pages/token/fee-card/fee-card.stories.js | 47 -- ui/app/pages/token/index.scss | 3 - ui/app/selectors/selectors.js | 4 + ui/app/store/actions.js | 201 ++++- 195 files changed, 11058 insertions(+), 370 deletions(-) create mode 100644 .storybook/images/0x.svg create mode 100644 .storybook/images/AST.png create mode 100644 .storybook/images/BAT_icon.svg create mode 100644 .storybook/images/CVL_token.svg create mode 100644 .storybook/images/gladius.svg create mode 100644 .storybook/images/gnosis.svg create mode 100644 .storybook/images/metamark.svg create mode 100644 .storybook/images/omg.jpg create mode 100644 .storybook/images/sai.svg create mode 100644 .storybook/images/tether_usd.png create mode 100644 .storybook/images/wbtc.png create mode 100644 .storybook/images/wed.png create mode 100644 app/images/black-eth-logo.svg create mode 100644 app/images/icons/swap2.svg create mode 100644 app/images/source-logos-all.svg create mode 100644 app/scripts/controllers/swaps.js create mode 100644 app/scripts/lib/segment.js create mode 100644 test/unit/app/controllers/swaps-test.js create mode 100644 ui/app/components/ui/icon-button/icon-button.js create mode 100644 ui/app/components/ui/icon-button/icon-button.scss create mode 100644 ui/app/components/ui/icon-button/index.js create mode 100644 ui/app/components/ui/icon/overview-buy-icon.component.js create mode 100644 ui/app/components/ui/icon/overview-send-icon.component.js create mode 100644 ui/app/components/ui/icon/sun-check-icon.component.js create mode 100644 ui/app/components/ui/icon/swap-icon-for-list.component.js create mode 100644 ui/app/components/ui/icon/swap-icon.component.js create mode 100644 ui/app/components/ui/tooltip/index.scss create mode 100644 ui/app/components/ui/url-icon/index.js create mode 100644 ui/app/components/ui/url-icon/index.scss create mode 100644 ui/app/components/ui/url-icon/url-icon.js create mode 100644 ui/app/ducks/swaps/swaps.js create mode 100644 ui/app/helpers/constants/swaps.js create mode 100644 ui/app/helpers/higher-order-components/feature-toggled-route.js create mode 100644 ui/app/hooks/useCurrentAsset.js create mode 100644 ui/app/hooks/useEthFiatAmount.js create mode 100644 ui/app/hooks/usePrevious.js create mode 100644 ui/app/hooks/useSwappedTokenValue.js create mode 100644 ui/app/hooks/useTokensToSearch.js rename ui/app/pages/{token => swaps}/actionable-message/actionable-message.js (100%) rename ui/app/pages/{token => swaps}/actionable-message/actionable-message.stories.js (100%) rename ui/app/pages/{token => swaps}/actionable-message/index.js (100%) rename ui/app/pages/{token => swaps}/actionable-message/index.scss (84%) create mode 100644 ui/app/pages/swaps/awaiting-swap/awaiting-swap.js create mode 100644 ui/app/pages/swaps/awaiting-swap/awaiting-swap.stories.js create mode 100644 ui/app/pages/swaps/awaiting-swap/index.js create mode 100644 ui/app/pages/swaps/awaiting-swap/index.scss create mode 100644 ui/app/pages/swaps/awaiting-swap/quotes-timeout-icon.js create mode 100644 ui/app/pages/swaps/awaiting-swap/swap-failure-icon.js create mode 100644 ui/app/pages/swaps/awaiting-swap/swap-success-icon.js create mode 100644 ui/app/pages/swaps/awaiting-swap/view-on-ether-scan-link/index.js create mode 100644 ui/app/pages/swaps/awaiting-swap/view-on-ether-scan-link/view-on-ether-scan-link.js create mode 100644 ui/app/pages/swaps/build-quote/build-quote.js create mode 100644 ui/app/pages/swaps/build-quote/build-quote.stories.js create mode 100644 ui/app/pages/swaps/build-quote/index.js create mode 100644 ui/app/pages/swaps/build-quote/index.scss create mode 100644 ui/app/pages/swaps/countdown-timer/countdown-timer.js create mode 100644 ui/app/pages/swaps/countdown-timer/countdown-timer.stories.js create mode 100644 ui/app/pages/swaps/countdown-timer/index.js create mode 100644 ui/app/pages/swaps/countdown-timer/index.scss create mode 100644 ui/app/pages/swaps/dropdown-input-pair/dropdown-input-pair.js create mode 100644 ui/app/pages/swaps/dropdown-input-pair/dropdown-input-pair.stories.js create mode 100644 ui/app/pages/swaps/dropdown-input-pair/index.js create mode 100644 ui/app/pages/swaps/dropdown-input-pair/index.scss create mode 100644 ui/app/pages/swaps/dropdown-search-list/dropdown-search-list.js create mode 100644 ui/app/pages/swaps/dropdown-search-list/dropdown-search-list.stories.js create mode 100644 ui/app/pages/swaps/dropdown-search-list/index.js create mode 100644 ui/app/pages/swaps/dropdown-search-list/index.scss rename ui/app/pages/{token => swaps}/exchange-rate-display/exchange-rate-display.js (79%) rename ui/app/pages/{token => swaps}/exchange-rate-display/exchange-rate-display.stories.js (100%) rename ui/app/pages/{token => swaps}/exchange-rate-display/index.js (100%) create mode 100644 ui/app/pages/swaps/exchange-rate-display/index.scss create mode 100644 ui/app/pages/swaps/fee-card/fee-card.js create mode 100644 ui/app/pages/swaps/fee-card/fee-card.stories.js rename ui/app/pages/{token => swaps}/fee-card/index.js (100%) rename ui/app/pages/{token => swaps}/fee-card/index.scss (68%) create mode 100644 ui/app/pages/swaps/index.js create mode 100644 ui/app/pages/swaps/index.scss create mode 100644 ui/app/pages/swaps/intro-popup/index.js create mode 100644 ui/app/pages/swaps/intro-popup/index.scss create mode 100644 ui/app/pages/swaps/intro-popup/intro-popup.js create mode 100644 ui/app/pages/swaps/loading-swaps-quotes/aggregator-logo.js create mode 100644 ui/app/pages/swaps/loading-swaps-quotes/background-animation.js create mode 100644 ui/app/pages/swaps/loading-swaps-quotes/index.js create mode 100644 ui/app/pages/swaps/loading-swaps-quotes/index.scss create mode 100644 ui/app/pages/swaps/loading-swaps-quotes/loading-swaps-quotes-stories-metadata.js create mode 100644 ui/app/pages/swaps/loading-swaps-quotes/loading-swaps-quotes.js create mode 100644 ui/app/pages/swaps/loading-swaps-quotes/loading-swaps-quotes.stories.js create mode 100644 ui/app/pages/swaps/main-quote-summary/index.js create mode 100644 ui/app/pages/swaps/main-quote-summary/index.scss create mode 100644 ui/app/pages/swaps/main-quote-summary/main-quote-summary.js create mode 100644 ui/app/pages/swaps/main-quote-summary/main-quote-summary.stories.js create mode 100644 ui/app/pages/swaps/main-quote-summary/quote-backdrop.js create mode 100644 ui/app/pages/swaps/searchable-item-list/index.js create mode 100644 ui/app/pages/swaps/searchable-item-list/index.scss create mode 100644 ui/app/pages/swaps/searchable-item-list/item-list/index.js create mode 100644 ui/app/pages/swaps/searchable-item-list/item-list/item-list.component.js create mode 100644 ui/app/pages/swaps/searchable-item-list/list-item-search/index.js create mode 100644 ui/app/pages/swaps/searchable-item-list/list-item-search/list-item-search.component.js create mode 100644 ui/app/pages/swaps/searchable-item-list/searchable-item-list.js create mode 100644 ui/app/pages/swaps/select-quote-popover/index.js create mode 100644 ui/app/pages/swaps/select-quote-popover/index.scss create mode 100644 ui/app/pages/swaps/select-quote-popover/mock-quote-data.js create mode 100644 ui/app/pages/swaps/select-quote-popover/quote-details/index.js create mode 100644 ui/app/pages/swaps/select-quote-popover/quote-details/index.scss create mode 100644 ui/app/pages/swaps/select-quote-popover/quote-details/quote-details.js create mode 100644 ui/app/pages/swaps/select-quote-popover/select-quote-popover-constants.js create mode 100644 ui/app/pages/swaps/select-quote-popover/select-quote-popover.js create mode 100644 ui/app/pages/swaps/select-quote-popover/select-quote-popover.stories.js create mode 100644 ui/app/pages/swaps/select-quote-popover/sort-list/index.js create mode 100644 ui/app/pages/swaps/select-quote-popover/sort-list/sort-list.js create mode 100644 ui/app/pages/swaps/slippage-buttons/index.js create mode 100644 ui/app/pages/swaps/slippage-buttons/index.scss create mode 100644 ui/app/pages/swaps/slippage-buttons/slippage-buttons.js create mode 100644 ui/app/pages/swaps/slippage-buttons/slippage-buttons.stories.js create mode 100644 ui/app/pages/swaps/swaps-footer/index.js create mode 100644 ui/app/pages/swaps/swaps-footer/index.scss create mode 100644 ui/app/pages/swaps/swaps-footer/swaps-footer.js create mode 100644 ui/app/pages/swaps/swaps-util-test-constants.js create mode 100644 ui/app/pages/swaps/swaps.util.js create mode 100644 ui/app/pages/swaps/swaps.util.test.js create mode 100644 ui/app/pages/swaps/view-quote/index.js create mode 100644 ui/app/pages/swaps/view-quote/index.scss create mode 100644 ui/app/pages/swaps/view-quote/view-quote.js delete mode 100644 ui/app/pages/token/exchange-rate-display/index.scss delete mode 100644 ui/app/pages/token/fee-card/fee-card.js delete mode 100644 ui/app/pages/token/fee-card/fee-card.stories.js delete mode 100644 ui/app/pages/token/index.scss diff --git a/.storybook/images/0x.svg b/.storybook/images/0x.svg new file mode 100644 index 000000000..529d6c3d6 --- /dev/null +++ b/.storybook/images/0x.svg @@ -0,0 +1 @@ +Asset 1 \ No newline at end of file diff --git a/.storybook/images/AST.png b/.storybook/images/AST.png new file mode 100644 index 0000000000000000000000000000000000000000..df29cd143fa42891968d6a3eaaa4ee9cd0c45510 GIT binary patch literal 12120 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 Gz58E|;={fG literal 0 HcmV?d00001 diff --git a/.storybook/images/BAT_icon.svg b/.storybook/images/BAT_icon.svg new file mode 100644 index 000000000..da2eecc5b --- /dev/null +++ b/.storybook/images/BAT_icon.svg @@ -0,0 +1 @@ +BAT_icon \ No newline at end of file diff --git a/.storybook/images/CVL_token.svg b/.storybook/images/CVL_token.svg new file mode 100644 index 000000000..a853a3dc0 --- /dev/null +++ b/.storybook/images/CVL_token.svg @@ -0,0 +1 @@ +CVL_token \ No newline at end of file diff --git a/.storybook/images/gladius.svg b/.storybook/images/gladius.svg new file mode 100644 index 000000000..7b9b7c806 --- /dev/null +++ b/.storybook/images/gladius.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/.storybook/images/gnosis.svg b/.storybook/images/gnosis.svg new file mode 100644 index 000000000..e3a80267b --- /dev/null +++ b/.storybook/images/gnosis.svg @@ -0,0 +1,49 @@ + + + +image/svg+xml \ No newline at end of file diff --git a/.storybook/images/metamark.svg b/.storybook/images/metamark.svg new file mode 100644 index 000000000..027d5823c --- /dev/null +++ b/.storybook/images/metamark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.storybook/images/omg.jpg b/.storybook/images/omg.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9efc3bfba783e45475531212f8ccf5eac96f2d9f GIT binary patch literal 13649 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 literal 0 HcmV?d00001 diff --git a/.storybook/images/sai.svg b/.storybook/images/sai.svg new file mode 100644 index 000000000..b4ec43f60 --- /dev/null +++ b/.storybook/images/sai.svg @@ -0,0 +1,29 @@ + + + + icon + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.storybook/images/tether_usd.png b/.storybook/images/tether_usd.png new file mode 100644 index 0000000000000000000000000000000000000000..bfd3f0c441338d565be23e0ac3871c4802bf2e23 GIT binary patch literal 913 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 aN}l}YH=Zag<-(8b|LDW}{_^0Lp8r3o-K-D* literal 0 HcmV?d00001 diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 75be31074..a2fc5173c 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -114,6 +114,10 @@ "amount": { "message": "Amount" }, + "amountInEth": { + "message": "$1 ETH", + "description": "Displays an eth amount to the user. $1 is a decimal number" + }, "amountWithColon": { "message": "Amount:" }, @@ -125,6 +129,9 @@ "message": "MetaMask", "description": "The name of the application" }, + "approvalTxGasCost": { + "message": "Approval Tx Gas Cost" + }, "approve": { "message": "Approve spend limit" }, @@ -639,6 +646,10 @@ "externalExtension": { "message": "External Extension" }, + "extraApprovalGas": { + "message": "+$1 approval gas", + "description": "Expresses an additional gas amount the user will have to pay, on top of some other displayed amount. $1 is a decimal amount of gas" + }, "failed": { "message": "Failed" }, @@ -943,6 +954,9 @@ "metamaskDescription": { "message": "Connecting you to Ethereum and the Decentralized Web." }, + "metamaskSwapsOfflineDescription": { + "message": "MetaMask Swaps is undergoing maintenance. Please check back later." + }, "metamaskVersion": { "message": "MetaMask Version" }, @@ -1069,6 +1083,9 @@ "off": { "message": "Off" }, + "offlineForMaintenance": { + "message": "Offline for maintenance" + }, "ok": { "message": "Ok" }, @@ -1082,6 +1099,9 @@ "onlyAddTrustedNetworks": { "message": "A malicious Ethereum network provider can lie about the state of the blockchain and record your network activity. Only add custom networks you trust." }, + "onlyAvailableOnMainnet": { + "message": "Only available on mainnet" + }, "onlyConnectTrust": { "message": "Only connect with sites you trust." }, @@ -1471,6 +1491,9 @@ "speedUpTransaction": { "message": "Speed up this transaction" }, + "spendLimitInsufficient": { + "message": "Spend limit insufficient" + }, "spendLimitInvalid": { "message": "Spend limit invalid; must be a positive number" }, @@ -1532,6 +1555,265 @@ "supportCenter": { "message": "Visit our Support Center" }, + "swap": { + "message": "Swap" + }, + "swapAdvancedSlippageInfo": { + "message": "If the price changes between the time your order is placed and confirmed it’s called “slippage”. Your swap will automatically cancel if slippage exceeds your “max slippage” setting." + }, + "swapAggregator": { + "message": "Aggregator" + }, + "swapAmountReceived": { + "message": "Guaranteed amount" + }, + "swapAmountReceivedInfo": { + "message": "This is the minimum amount you will receive. You may receive more depending on slippage." + }, + "swapApproval": { + "message": "Approve $1 for swaps", + "description": "Used in the transaction display list to describe a transaction that is an approve call on a token that is to be swapped.. $1 is the symbol of a token that has been approved." + }, + "swapApproveNeedMoreTokens": { + "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." + }, + "swapBuildQuotePlaceHolderText": { + "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" + }, + "swapCheckingQuote": { + "message": "Checking $1", + "description": "Shown to the user during quote loading. $1 is the name of an aggregator. The message indicates that metamask is currently checking if that aggregator has a trade/quote for their requested swap." + }, + "swapCustom": { + "message": "custom" + }, + "swapDecentralizedExchange": { + "message": "Decentralized exchange" + }, + "swapEditLimit": { + "message": "Edit limit" + }, + "swapEnableDescription": { + "message": "This is required and gives MetaMask permission to swap your $1.", + "description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps." + }, + "swapEstimatedNetworkFee": { + "message": "Estimated network fee" + }, + "swapEstimatedNetworkFees": { + "message": "Estimated network fees" + }, + "swapEstimatedNetworkFeesInfo": { + "message": "This is an estimate of the network fee that will be used to complete your swap. The actual amount may change according to network conditions." + }, + "swapEstimatedTime": { + "message": "Estimated time:" + }, + "swapEstimatedTimeCalculating": { + "message": "Calculating..." + }, + "swapEstimatedTimeFull": { + "message": "$1 $2", + "description": "This message shows bolded swapEstimatedTime message, which is substited for $1, followed by either the estimated remaining transaction time in mm:ss, or the swapEstimatedTimeCalculating message, which are substituted for $2." + }, + "swapFailedErrorDescription": { + "message": "Your funds are safe and still available in your wallet." + }, + "swapFailedErrorTitle": { + "message": "Swap failed" + }, + "swapFetchingQuotesErrorDescription": { + "message": "Hmmm... something went wrong. Try again, or if errors persist, contact customer support." + }, + "swapFetchingQuotesErrorTitle": { + "message": "Error fetching quotes" + }, + "swapFetchingTokens": { + "message": "Fetching tokens..." + }, + "swapFinalizing": { + "message": "Finalizing..." + }, + "swapGetQuotes": { + "message": "Get quotes" + }, + "swapHighSlippageWarning": { + "message": "Slippage amount is very high. Make sure you know what you are doing!" + }, + "swapIntroLearnMoreHeader": { + "message": "Want to learn more?" + }, + "swapIntroLearnMoreLink": { + "message": "Learn more about MetaMask Swaps" + }, + "swapIntroLiquiditySourcesLabel": { + "message": "Liquidity sources include:" + }, + "swapIntroPopupSubTitle": { + "message": "You can now swap tokens directly in your MetaMask wallet. MetaMask Swaps agglomerates multiple decentralized exchange aggregators, professional market makers, and individual DEXs to ensure MetaMask users always get the best price with the lowest network fees." + }, + "swapIntroPopupTitle": { + "message": "Token swapping is here!" + }, + "swapLearnMoreContractsAuditReview": { + "message": "Review our official contracts audit" + }, + "swapLowSlippageError": { + "message": "Transaction may fail, max slippage too low." + }, + "swapMaxNetworkFeeInfo": { + "message": "The Max network fee is the most you’ll pay to complete your transaction. The max fee helps ensure your Swap has the best chance of succeeding. MetaMask does not profit from network fees." + }, + "swapMaxNetworkFees": { + "message": "Max network fee" + }, + "swapMaxSlippage": { + "message": "Max slippage" + }, + "swapMetaMaskFee": { + "message": "MetaMask fee" + }, + "swapMetaMaskFeeDescription": { + "message": "A service fee of $1% is automatically factored into each quote, which supports ongoing development to make MetaMask even better.", + "description": "Provides information about the fee that metamask takes for swaps. $1 is a decimal number." + }, + "swapMinimumAmountReceivingInfoTooltip": { + "message": "This is the minimum amount you will receive. You may receive more depending on slippage." + }, + "swapNQuotesAvailable": { + "message": "$1 quotes available", + "description": "$1 is the number of quotes that the user can select from when opening the list of quotes on the 'view quote' screen" + }, + "swapNewQuoteIn": { + "message": "New quotes in $1", + "description": "Tells the user the amount of time until the currently displayed quotes are update. $1 is a time that is counting down from 1:00 to 0:00" + }, + "swapOnceTransactionHasProcess": { + "message": "Your $1 will be added to your account once this transaction has processed.", + "description": "This message communicates the token that is being transferred. It is shown on the awaiting swap screen. The $1 will be a token symbol." + }, + "swapProcessing": { + "message": "Processing" + }, + "swapQuoteDetails": { + "message": "Quote details" + }, + "swapQuoteDetailsSlippageInfo": { + "message": "If the price changes between the time your order is placed and confirmed it’s called \"slippage\". Your Swap will automatically cancel if slippage exceeds your \"max slippage\" setting." + }, + "swapQuoteNofN": { + "message": "Quote $1 of $2", + "description": "A count of loaded quotes shown to the user while they are waiting for quotes to be fetched. $1 is the number of quotes already loaded, and $2 is the total number of quotes to load." + }, + "swapQuoteSource": { + "message": "Quote source" + }, + "swapQuotesAreRefreshed": { + "message": "Quotes are refreshed often to reflect current market conditions." + }, + "swapQuotesExpiredErrorDescription": { + "message": "Please request new quotes to get the latest rates." + }, + "swapQuotesExpiredErrorTitle": { + "message": "Quotes timeout" + }, + "swapQuotesNotAvailableErrorDescription": { + "message": "Try adjusting the amount or slippage settings and try again." + }, + "swapQuotesNotAvailableErrorTitle": { + "message": "No quotes available" + }, + "swapRate": { + "message": "Rate" + }, + "swapReceiving": { + "message": "Receiving" + }, + "swapRequestForQuotation": { + "message": "Request for quotation" + }, + "swapSearchForAToken": { + "message": "Search for a token" + }, + "swapSelect": { + "message": "Select" + }, + "swapSelectAQuote": { + "message": "Select a quote" + }, + "swapSelectQuotePopoverDescription": { + "message": "Below are all the quotes gathered from multiple liquidity sources." + }, + "swapSlippageTooLow": { + "message": "Slippage must be greater than zero" + }, + "swapSource": { + "message": "Liquidity source" + }, + "swapSourceInfo": { + "message": "We search multiple liquidity sources (exchanges, aggregators and professional market makers) to find the best rates and lowest network fees." + }, + "swapStartSwapping": { + "message": "Start swapping" + }, + "swapSwapFrom": { + "message": "Swap from" + }, + "swapSwapTo": { + "message": "Swap to" + }, + "swapThisWillAllowApprove": { + "message": "This will allow $1 to be swapped." + }, + "swapTokenAvailable": { + "message": "Your $1 has been added to your account.", + "description": "This message is shown after a swap is successful and communicates the exact amount of tokens the user has received for a swap. The $1 is a decimal number of tokens followed by the token symbol." + }, + "swapTokenToToken": { + "message": "Swap $1 to $2", + "description": "Used in the transaction display list to describe a swap. $1 and $2 are the symbols of tokens in involved in a swap." + }, + "swapTransactionComplete": { + "message": "Transaction complete" + }, + "swapUnknown": { + "message": "Unknown" + }, + "swapViewToken": { + "message": "View $1" + }, + "swapYourTokenBalance": { + "message": "$1 $2 available to swap", + "description": "Tells the user how much of a token they have in their balance. $1 is a decimal number amount of tokens, and $2 is a token symbol" + }, + "swapZeroSlippage": { + "message": "0% Slippage" + }, + "swapsAdvancedOptions": { + "message": "Advanced Options" + }, + "swapsAlmostDone": { + "message": "Almost done..." + }, + "swapsBestQuote": { + "message": "Best quote" + }, + "swapsConvertToAbout": { + "message": "Convert $1 to about", + "description": "This message is part of a quote for a swap. The $1 is the amount being converted, and the amount it is being swapped for is below this message" + }, + "swapsMaxSlippage": { + "message": "Max slippage" + }, + "swapsNotEnoughForTx": { + "message": "Not enough $1 to complete this transaction", + "description": "Tells the user that they don't have enough of a token for a proposed swap. $1 is a token symbol" + }, + "swapsViewInActivity": { + "message": "View in activity" + }, "switchNetworks": { "message": "Switch Networks" }, @@ -1577,6 +1859,9 @@ "terms": { "message": "Terms of Use" }, + "termsOfService": { + "message": "Terms of Service" + }, "testFaucet": { "message": "Test Faucet" }, diff --git a/app/images/black-eth-logo.svg b/app/images/black-eth-logo.svg new file mode 100644 index 000000000..dab7473eb --- /dev/null +++ b/app/images/black-eth-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/images/icons/swap2.svg b/app/images/icons/swap2.svg new file mode 100644 index 000000000..8d280af0d --- /dev/null +++ b/app/images/icons/swap2.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/images/source-logos-all.svg b/app/images/source-logos-all.svg new file mode 100644 index 000000000..22006c179 --- /dev/null +++ b/app/images/source-logos-all.svg @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/scripts/background.js b/app/scripts/background.js index 3f3a076cd..b6fdfaf5d 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -239,6 +239,7 @@ function setupController (initState, initLangCode) { initLangCode, // platform specific api platform, + extension, getRequestAccountTabIds: () => { return requestAccountTabIds }, diff --git a/app/scripts/controllers/app-state.js b/app/scripts/controllers/app-state.js index c29b76a40..edfe1c5b6 100644 --- a/app/scripts/controllers/app-state.js +++ b/app/scripts/controllers/app-state.js @@ -22,6 +22,7 @@ export default class AppStateController extends EventEmitter { this.store = new ObservableStore({ timeoutMinutes: 0, connectedStatusPopoverHasBeenShown: true, + swapsWelcomeMessageHasBeenShown: false, defaultHomeActiveTabName: null, ...initState, }) this.timer = null @@ -110,6 +111,15 @@ export default class AppStateController extends EventEmitter { }) } + /** + * Record that the user has seen the swap screen welcome message + */ + setSwapsWelcomeMessageHasBeenShown () { + this.store.updateState({ + swapsWelcomeMessageHasBeenShown: true, + }) + } + /** * Sets the last active time to the current time * @returns {void} diff --git a/app/scripts/controllers/swaps.js b/app/scripts/controllers/swaps.js new file mode 100644 index 000000000..31e669c8b --- /dev/null +++ b/app/scripts/controllers/swaps.js @@ -0,0 +1,620 @@ +import { ethers } from 'ethers' +import log from 'loglevel' +import BigNumber from 'bignumber.js' +import ObservableStore from 'obs-store' +import { mapValues } from 'lodash' +import abi from 'human-standard-token-abi' +import { calcTokenAmount } from '../../../ui/app/helpers/utils/token-util' +import { calcGasTotal } from '../../../ui/app/pages/send/send.utils' +import { conversionUtil } from '../../../ui/app/helpers/utils/conversion-util' +import { + ETH_SWAPS_TOKEN_ADDRESS, + DEFAULT_ERC20_APPROVE_GAS, + QUOTES_EXPIRED_ERROR, + QUOTES_NOT_AVAILABLE_ERROR, +} from '../../../ui/app/helpers/constants/swaps' +import { + fetchTradesInfo as defaultFetchTradesInfo, + fetchSwapsFeatureLiveness as defaultFetchSwapsFeatureLiveness, +} from '../../../ui/app/pages/swaps/swaps.util' + +const METASWAP_ADDRESS = '0x016B4bf68d421147c06f1b8680602c5bf0Df91A8' + +// The MAX_GAS_LIMIT is a number that is higher than the maximum gas costs we have observed on any aggregator +const MAX_GAS_LIMIT = 2500000 + +// To ensure that our serves are not spammed if MetaMask is left idle, we limit the number of fetches for quotes that are made on timed intervals. +// 3 seems to be an appropriate balance of giving users the time they need when MetaMask is not left idle, and turning polling off when it is. +const POLL_COUNT_LIMIT = 3 + +function calculateGasEstimateWithRefund (maxGas = MAX_GAS_LIMIT, estimatedRefund = 0, estimatedGas = 0) { + const maxGasMinusRefund = new BigNumber( + maxGas, + 10, + ) + .minus(estimatedRefund, 10) + + const gasEstimateWithRefund = maxGasMinusRefund.lt( + estimatedGas, + 16, + ) + ? maxGasMinusRefund.toString(16) + : estimatedGas + + return gasEstimateWithRefund +} + +// This is the amount of time to wait, after successfully fetching quotes and their gas estimates, before fetching for new quotes +const QUOTE_POLLING_INTERVAL = 50 * 1000 + +const initialState = { + swapsState: { + quotes: {}, + fetchParams: null, + tokens: null, + tradeTxId: null, + approveTxId: null, + maxMode: false, + quotesLastFetched: null, + customMaxGas: '', + customGasPrice: null, + selectedAggId: null, + customApproveTxData: '', + errorKey: '', + topAggId: null, + routeState: '', + swapsFeatureIsLive: false, + }, +} + +export default class SwapsController { + constructor ({ + getBufferedGasLimit, + provider, + getProviderConfig, + tokenRatesStore, + fetchTradesInfo = defaultFetchTradesInfo, + fetchSwapsFeatureLiveness = defaultFetchSwapsFeatureLiveness, + }) { + this.store = new ObservableStore({ + swapsState: { ...initialState.swapsState }, + }) + + this._fetchTradesInfo = fetchTradesInfo + this._fetchSwapsFeatureLiveness = fetchSwapsFeatureLiveness + + this.getBufferedGasLimit = getBufferedGasLimit + this.tokenRatesStore = tokenRatesStore + + this.pollCount = 0 + this.getProviderConfig = getProviderConfig + + this.ethersProvider = new ethers.providers.Web3Provider(provider) + + this._setupSwapsLivenessFetching() + } + + // Once quotes are fetched, we poll for new ones to keep the quotes up to date. Market and aggregator contract conditions can change fast enough + // that quotes will no longer be available after 1 or 2 minutes. When fetchAndSetQuotes is first called it, receives fetch that parameters are stored in + // state. These stored parameters are used on subsequent calls made during polling. + // Note: we stop polling after 3 requests, until new quotes are explicitly asked for. The logic that enforces that maximum is in the body of fetchAndSetQuotes + pollForNewQuotes () { + this.pollingTimeout = setTimeout(() => { + const { swapsState } = this.store.getState() + this.fetchAndSetQuotes(swapsState.fetchParams, swapsState.fetchParams.metaData, true) + }, QUOTE_POLLING_INTERVAL) + } + + stopPollingForQuotes () { + clearTimeout(this.pollingTimeout) + } + + async fetchAndSetQuotes (fetchParams, fetchParamsMetaData = {}, isPolledRequest) { + if (!fetchParams) { + return null + } + + // Every time we get a new request that is not from the polling, we reset the poll count so we can poll for up to three more sets of quotes with these new params. + if (!isPolledRequest) { + this.pollCount = 0 + } + + // If there are any pending poll requests, clear them so that they don't get call while this new fetch is in process + clearTimeout(this.pollingTimeout) + + if (!isPolledRequest) { + this.setSwapsErrorKey('') + } + let newQuotes = await this._fetchTradesInfo(fetchParams) + + newQuotes = mapValues(newQuotes, (quote) => ({ + ...quote, + sourceTokenInfo: fetchParamsMetaData.sourceTokenInfo, + destinationTokenInfo: fetchParamsMetaData.destinationTokenInfo, + })) + + const quotesLastFetched = Date.now() + + let approvalRequired = false + if (fetchParams.sourceToken !== ETH_SWAPS_TOKEN_ADDRESS && Object.values(newQuotes).length) { + const allowance = await this._getERC20Allowance( + fetchParams.sourceToken, + fetchParams.fromAddress, + ) + + // For a user to be able to swap a token, they need to have approved the MetaSwap contract to withdraw that token. + // _getERC20Allowance() returns the amount of the token they have approved for withdrawal. If that amount is greater + // than 0, it means that approval has already occured and is not needed. Otherwise, for tokens to be swapped, a new + // call of the ERC-20 approve method is required. + approvalRequired = allowance.eq(0) + if (!approvalRequired) { + newQuotes = mapValues(newQuotes, (quote) => ({ + ...quote, + approvalNeeded: null, + })) + } else if (!isPolledRequest) { + const { gasLimit: approvalGas } = await this.timedoutGasReturn({ + ...Object.values(newQuotes)[0].approvalNeeded, + gas: DEFAULT_ERC20_APPROVE_GAS, + }) + + newQuotes = mapValues(newQuotes, (quote) => ({ + ...quote, + approvalNeeded: { + ...quote.approvalNeeded, + gas: approvalGas || DEFAULT_ERC20_APPROVE_GAS, + }, + })) + } + } + + const topAggIdData = await this._findTopQuoteAggId(newQuotes) + let { topAggId } = topAggIdData + const { isBest } = topAggIdData + + if (isBest) { + newQuotes[topAggId].isBestQuote = true + } + + // We can reduce time on the loading screen by only doing this after the + // loading screen and best quote have rendered. + if (!approvalRequired && !fetchParams?.balanceError) { + newQuotes = await this.getAllQuotesWithGasEstimates(newQuotes) + + if (fetchParamsMetaData.maxMode && fetchParams.sourceToken === ETH_SWAPS_TOKEN_ADDRESS) { + newQuotes = await this._modifyValuesForMaxEthMode(newQuotes, fetchParamsMetaData.accountBalance) + } + + if (Object.values(newQuotes).length === 0) { + this.setSwapsErrorKey(QUOTES_NOT_AVAILABLE_ERROR) + } else { + const { + topAggId: topAggIdAfterGasEstimates, + isBest: isBestAfterGasEstimates, + } = await this._findTopQuoteAggId(newQuotes) + topAggId = topAggIdAfterGasEstimates + if (isBestAfterGasEstimates) { + newQuotes = mapValues(newQuotes, (quote) => ({ ...quote, isBestQuote: false })) + newQuotes[topAggId].isBestQuote = true + } + } + } + + const { swapsState } = this.store.getState() + let { selectedAggId } = swapsState + if (!newQuotes[selectedAggId]) { + selectedAggId = null + } + this.store.updateState({ + swapsState: { + ...swapsState, + quotes: newQuotes, + fetchParams: { ...fetchParams, metaData: fetchParamsMetaData }, + quotesLastFetched, + selectedAggId, + topAggId, + }, + }) + + // We only want to do up to a maximum of three requests from polling. + this.pollCount += 1 + if (this.pollCount < POLL_COUNT_LIMIT + 1) { + this.pollForNewQuotes() + } else { + this.resetPostFetchState() + this.setSwapsErrorKey(QUOTES_EXPIRED_ERROR) + return null + } + + return [newQuotes, topAggId] + } + + safeRefetchQuotes () { + const { swapsState } = this.store.getState() + if (!this.pollingTimeout && swapsState.fetchParams) { + this.fetchAndSetQuotes(swapsState.fetchParams) + } + } + + setSelectedQuoteAggId (selectedAggId) { + const { swapsState } = this.store.getState() + this.store.updateState({ swapsState: { ...swapsState, selectedAggId } }) + } + + setSwapsTokens (tokens) { + const { swapsState } = this.store.getState() + this.store.updateState({ swapsState: { ...swapsState, tokens } }) + } + + setSwapsErrorKey (errorKey) { + const { swapsState } = this.store.getState() + this.store.updateState({ swapsState: { ...swapsState, errorKey } }) + } + + async getAllQuotesWithGasEstimates (quotes) { + const quoteGasData = await Promise.all( + Object.values(quotes).map(async (quote) => { + const { gasLimit, simulationFails } = await this.timedoutGasReturn({ + ...quote.trade, + gas: `0x${(quote.maxGas || MAX_GAS_LIMIT).toString(16)}`, + }) + return [gasLimit, simulationFails, quote.aggregator] + }), + ) + + const newQuotes = {} + quoteGasData.forEach(([gasLimit, simulationFails, aggId]) => { + if (gasLimit && !simulationFails) { + const gasEstimateWithRefund = calculateGasEstimateWithRefund(quotes[aggId].maxGas, quotes[aggId].estimatedRefund, gasLimit) + + newQuotes[aggId] = { + ...quotes[aggId], + gasEstimate: gasLimit, + gasEstimateWithRefund, + } + } else if (quotes[aggId].approvalNeeded) { + // If gas estimation fails, but an ERC-20 approve is needed, then we do not add any estimate property to the quote object + // Such quotes will rely on the maxGas and averageGas properties from the api + newQuotes[aggId] = quotes[aggId] + } + // If gas estimation fails and no approval is needed, then we filter that quote out, so that it is not shown to the user + }) + return newQuotes + } + + timedoutGasReturn (tradeTxParams) { + return new Promise((resolve) => { + let gasTimedOut = false + + const gasTimeout = setTimeout(() => { + gasTimedOut = true + resolve({ gasLimit: null, simulationFails: true }) + }, 5000) + + this.getBufferedGasLimit({ txParams: tradeTxParams }, 1) + .then(({ gasLimit, simulationFails }) => { + if (!gasTimedOut) { + clearTimeout(gasTimeout) + resolve({ gasLimit, simulationFails }) + } + }) + .catch((e) => { + log.error(e) + if (!gasTimedOut) { + clearTimeout(gasTimeout) + resolve({ gasLimit: null, simulationFails: true }) + } + }) + }) + } + + async setInitialGasEstimate (initialAggId, baseGasEstimate) { + const { swapsState } = this.store.getState() + + const quoteToUpdate = { ...swapsState.quotes[initialAggId] } + + const { + gasLimit: newGasEstimate, + simulationFails, + } = await this.timedoutGasReturn({ + ...quoteToUpdate.trade, + gas: baseGasEstimate, + }) + + if (newGasEstimate && !simulationFails) { + const gasEstimateWithRefund = calculateGasEstimateWithRefund(quoteToUpdate.maxGas, quoteToUpdate.estimatedRefund, newGasEstimate) + + quoteToUpdate.gasEstimate = newGasEstimate + quoteToUpdate.gasEstimateWithRefund = gasEstimateWithRefund + } + + this.store.updateState({ + swapsState: { ...swapsState, quotes: { ...swapsState.quotes, [initialAggId]: quoteToUpdate } }, + }) + } + + setApproveTxId (approveTxId) { + const { swapsState } = this.store.getState() + this.store.updateState({ swapsState: { ...swapsState, approveTxId } }) + } + + setTradeTxId (tradeTxId) { + const { swapsState } = this.store.getState() + this.store.updateState({ swapsState: { ...swapsState, tradeTxId } }) + } + + setMaxMode (maxMode) { + const { swapsState } = this.store.getState() + this.store.updateState({ swapsState: { ...swapsState, maxMode } }) + } + + setQuotesLastFetched (quotesLastFetched) { + const { swapsState } = this.store.getState() + this.store.updateState({ swapsState: { ...swapsState, quotesLastFetched } }) + } + + setSwapsTxGasPrice (gasPrice) { + const { swapsState } = this.store.getState() + this.store.updateState({ + swapsState: { ...swapsState, customGasPrice: gasPrice }, + }) + } + + setSwapsTxGasLimit (gasLimit) { + const { swapsState } = this.store.getState() + this.store.updateState({ + swapsState: { ...swapsState, customMaxGas: gasLimit }, + }) + } + + setCustomApproveTxData (data) { + const { swapsState } = this.store.getState() + this.store.updateState({ + swapsState: { ...swapsState, customApproveTxData: data }, + }) + } + + setBackgroundSwapRouteState (routeState) { + const { swapsState } = this.store.getState() + this.store.updateState({ swapsState: { ...swapsState, routeState } }) + } + + setSwapsLiveness (swapsFeatureIsLive) { + const { swapsState } = this.store.getState() + this.store.updateState({ swapsState: { ...swapsState, swapsFeatureIsLive } }) + } + + resetPostFetchState () { + const { swapsState } = this.store.getState() + + this.store.updateState({ + swapsState: { + ...initialState.swapsState, + tokens: swapsState.tokens, + fetchParams: swapsState.fetchParams, + swapsFeatureIsLive: swapsState.swapsFeatureIsLive, + }, + }) + clearTimeout(this.pollingTimeout) + } + + resetSwapsState () { + const { swapsState } = this.store.getState() + + this.store.updateState({ + swapsState: { ...initialState.swapsState, tokens: swapsState.tokens, swapsFeatureIsLive: swapsState.swapsFeatureIsLive }, + }) + clearTimeout(this.pollingTimeout) + } + + async _getEthersGasPrice () { + const ethersGasPrice = await this.ethersProvider.getGasPrice() + return ethersGasPrice.toHexString() + } + + async _findTopQuoteAggId (quotes) { + const tokenConversionRates = this.tokenRatesStore.getState() + .contractExchangeRates + const { + swapsState: { customGasPrice }, + } = this.store.getState() + + if (!Object.values(quotes).length) { + return {} + } + + const usedGasPrice = customGasPrice || await this._getEthersGasPrice() + + let topAggId = '' + let ethValueOfTradeForBestQuote = null + + Object.values(quotes).forEach((quote) => { + const { + destinationAmount = 0, + destinationToken, + destinationTokenInfo, + trade, + approvalNeeded, + averageGas, + gasEstimate, + aggregator, + } = quote + const tradeGasLimitForCalculation = gasEstimate + ? new BigNumber(gasEstimate, 16) + : new BigNumber(averageGas || MAX_GAS_LIMIT, 10) + const totalGasLimitForCalculation = tradeGasLimitForCalculation + .plus(approvalNeeded?.gas || '0x0', 16) + .toString(16) + const gasTotalInWeiHex = calcGasTotal( + totalGasLimitForCalculation, + usedGasPrice, + ) + const totalEthCost = new BigNumber(gasTotalInWeiHex, 16).plus( + trade.value, + 16, + ) + const ethFee = conversionUtil(totalEthCost, { + fromCurrency: 'ETH', + fromDenomination: 'WEI', + toDenomination: 'ETH', + fromNumericBase: 'BN', + numberOfDecimals: 6, + }) + + const tokenConversionRate = tokenConversionRates[destinationToken] + const ethValueOfTrade = + destinationTokenInfo.symbol === 'ETH' + ? calcTokenAmount(destinationAmount, 18).minus(ethFee, 10) + : new BigNumber(tokenConversionRate || 1, 10) + .times( + calcTokenAmount( + destinationAmount, + destinationTokenInfo.decimals, + ), + 10, + ) + .minus(tokenConversionRate ? ethFee.toString(10) : 0, 10) + + if ( + ethValueOfTradeForBestQuote === null || + ethValueOfTrade.gt(ethValueOfTradeForBestQuote) + ) { + topAggId = aggregator + ethValueOfTradeForBestQuote = ethValueOfTrade + } + }) + + const isBest = + quotes[topAggId]?.destinationTokenInfo?.symbol === 'ETH' || + Boolean(tokenConversionRates[quotes[topAggId]?.destinationToken]) + + return { topAggId, isBest } + } + + async _getERC20Allowance (contractAddress, walletAddress) { + const contract = new ethers.Contract( + contractAddress, abi, this.ethersProvider, + ) + return await contract.allowance(walletAddress, METASWAP_ADDRESS) + } + + /** + * Sets up the fetching of the swaps feature liveness flag from our API. + * Performs an initial fetch when called, then fetches on a 10-minute + * interval. + * + * If the browser goes offline, the interval is cleared and swaps are disabled + * until the value can be fetched again. + */ + _setupSwapsLivenessFetching () { + const TEN_MINUTES_MS = 10 * 60 * 1000 + let intervalId = null + + const fetchAndSetupInterval = () => { + if (window.navigator.onLine && intervalId === null) { + // Set the interval first to prevent race condition between listener and + // initial call to this function. + intervalId = setInterval(this._fetchAndSetSwapsLiveness, TEN_MINUTES_MS) + this._fetchAndSetSwapsLiveness() + } + } + + window.addEventListener('online', fetchAndSetupInterval) + window.addEventListener('offline', () => { + if (intervalId !== null) { + clearInterval(intervalId) + intervalId = null + + const { swapsState } = this.store.getState() + if (swapsState.swapsFeatureIsLive) { + this.setSwapsLiveness(false) + } + } + }) + + fetchAndSetupInterval() + } + + /** + * This function should only be called via _setupSwapsLivenessFetching. + * + * Attempts to fetch the swaps feature liveness flag from our API. Tries + * to fetch three times at 5-second intervals before giving up, in which + * case the value defaults to 'false'. + * + * Only updates state if the fetched/computed flag value differs from current + * state. + */ + async _fetchAndSetSwapsLiveness () { + const { swapsState } = this.store.getState() + const { swapsFeatureIsLive: oldSwapsFeatureIsLive } = swapsState + let swapsFeatureIsLive = false + let successfullyFetched = false + let numAttempts = 0 + + const fetchAndIncrementNumAttempts = async () => { + try { + swapsFeatureIsLive = Boolean(await this._fetchSwapsFeatureLiveness()) + successfullyFetched = true + } catch (err) { + log.error(err) + numAttempts += 1 + } + } + + await fetchAndIncrementNumAttempts() + + // The loop conditions are modified by fetchAndIncrementNumAttempts. + // eslint-disable-next-line no-unmodified-loop-condition + while (!successfullyFetched && numAttempts < 3) { + await new Promise((resolve) => { + setTimeout(resolve, 5000) // 5 seconds + }) + await fetchAndIncrementNumAttempts() + } + + if (!successfullyFetched) { + log.error('Failed to fetch swaps feature flag 3 times. Setting to false and trying again next interval.') + } + + if (swapsFeatureIsLive !== oldSwapsFeatureIsLive) { + this.setSwapsLiveness(swapsFeatureIsLive) + } + } + + async _modifyValuesForMaxEthMode (newQuotes, accountBalance) { + const { + swapsState: { customGasPrice }, + } = this.store.getState() + + const usedGasPrice = customGasPrice || await this._getEthersGasPrice() + + const mappedNewQuotes = mapValues(newQuotes, (quote) => { + const oldSourceAmount = quote.sourceAmount + + const gasTotalInWeiHex = calcGasTotal((new BigNumber(quote.maxGas, 10)).toString(16), usedGasPrice) + const newSourceAmount = (new BigNumber(accountBalance, 16)).minus(gasTotalInWeiHex, 16).toString(10) + + const newOldRatio = (new BigNumber(newSourceAmount, 10)).div(oldSourceAmount, 10) + const oldNewDifference = (new BigNumber(oldSourceAmount, 10)).minus(newSourceAmount, 10) + + const oldDestinationAmount = quote.destinationAmount + const newDestinationAmount = (new BigNumber(oldDestinationAmount, 10)).times(newOldRatio) + + const oldValue = quote.trade.value + const newValue = (new BigNumber(oldValue, 16)).minus(oldNewDifference, 10) + + return { + ...quote, + trade: { + ...quote.trade, + value: newValue, + }, + destinationAmount: newDestinationAmount, + sourceAmount: newSourceAmount, + } + }) + + return mappedNewQuotes + } +} diff --git a/app/scripts/controllers/transactions/index.js b/app/scripts/controllers/transactions/index.js index 63ebd8573..3de676f60 100644 --- a/app/scripts/controllers/transactions/index.js +++ b/app/scripts/controllers/transactions/index.js @@ -15,10 +15,13 @@ import { SEND_ETHER_ACTION_KEY, DEPLOY_CONTRACT_ACTION_KEY, CONTRACT_INTERACTION_KEY, + SWAP, } from '../../../../ui/app/helpers/constants/transactions' import cleanErrorStack from '../../lib/cleanErrorStack' import { hexToBn, bnToHex, BnMultiplyByFraction } from '../../lib/util' import { TRANSACTION_NO_CONTRACT_ERROR_KEY } from '../../../../ui/app/helpers/constants/error-keys' +import { getSwapsTokensReceivedFromTxMeta } from '../../../../ui/app/pages/swaps/swaps.util' +import { segment, METAMETRICS_ANONYMOUS_ID } from '../../lib/segment' import TransactionStateManager from './tx-state-manager' import TxGasUtil from './tx-gas-utils' import PendingTransactionTracker from './pending-tx-tracker' @@ -72,11 +75,12 @@ export default class TransactionController extends EventEmitter { this.blockTracker = opts.blockTracker this.signEthTx = opts.signTransaction this.inProcessOfSigning = new Set() + this.version = opts.version this.memStore = new ObservableStore({}) this.query = new EthQuery(this.provider) - this.txGasUtil = new TxGasUtil(this.provider) + this.txGasUtil = new TxGasUtil(this.provider) this._mapMethods() this.txStateManager = new TransactionStateManager({ initState: opts.initState, @@ -359,6 +363,11 @@ export default class TransactionController extends EventEmitter { type: TRANSACTION_TYPE_CANCEL, }) + if (originalTxMeta.transactionCategory === SWAP) { + newTxMeta.sourceTokenSymbol = originalTxMeta.sourceTokenSymbol + newTxMeta.destinationTokenSymbol = originalTxMeta.destinationTokenSymbol + } + this.addTx(newTxMeta) await this.approveTransaction(newTxMeta.id) return newTxMeta @@ -521,6 +530,10 @@ export default class TransactionController extends EventEmitter { async publishTransaction (txId, rawTx) { const txMeta = this.txStateManager.getTx(txId) txMeta.rawTx = rawTx + if (txMeta.transactionCategory === SWAP) { + const preTxBalance = await this.query.getBalance(txMeta.txParams.from) + txMeta.preTxBalance = preTxBalance.toString(16) + } this.txStateManager.updateTx(txMeta, 'transactions#publishTransaction') let txHash try { @@ -566,6 +579,57 @@ export default class TransactionController extends EventEmitter { gasUsed, } + if (txMeta.transactionCategory === SWAP) { + const postTxBalance = await this.query.getBalance(txMeta.txParams.from) + txMeta.postTxBalance = postTxBalance.toString(16) + } + + if (txMeta.swapMetaData) { + let { version } = this.platform + if (process.env.METAMASK_ENVIRONMENT !== 'production') { + version = `${version}-${process.env.METAMASK_ENVIRONMENT}` + } + const segmentContext = { + app: { + version, + name: 'MetaMask Extension', + }, + locale: this.preferencesStore.getState().currentLocale.replace('_', '-'), + page: { + path: '/background-process', + title: 'Background Process', + url: '/background-process', + }, + userAgent: window.navigator.userAgent, + } + + const metametricsId = this.preferencesStore.getState().metaMetricsId + if (metametricsId && txMeta.swapMetaData && txReceipt.status !== '0x0') { + segment.track({ event: 'Swap Completed', userId: metametricsId, context: segmentContext, category: 'swaps' }) + segment.track({ + event: 'Swap Completed', + properties: { + ...txMeta.swapMetaData, + token_to_amount_received: getSwapsTokensReceivedFromTxMeta(txMeta.destinationTokenSymbol, txMeta, txMeta.destinationTokenAddress, txMeta.txParams.from, txMeta.destinationTokenDecimals), + }, + context: segmentContext, + anonymousId: METAMETRICS_ANONYMOUS_ID, + excludeMetaMetricsId: true, + category: 'swaps', + }) + } else if (metametricsId && txMeta.swapMetaData) { + segment.track({ event: 'Swap Failed', userId: metametricsId, context: segmentContext, category: 'swaps' }) + segment.track({ + event: 'Swap Failed', + properties: { ...txMeta.swapMetaData }, + anonymousId: METAMETRICS_ANONYMOUS_ID, + excludeMetaMetricsId: true, + context: segmentContext, + category: 'swaps', + }) + } + } + this.txStateManager.updateTx(txMeta, 'transactions#confirmTransaction - add txReceipt') } catch (err) { log.error(err) diff --git a/app/scripts/controllers/transactions/tx-gas-utils.js b/app/scripts/controllers/transactions/tx-gas-utils.js index 4b897b968..fa89ef293 100644 --- a/app/scripts/controllers/transactions/tx-gas-utils.js +++ b/app/scripts/controllers/transactions/tx-gas-utils.js @@ -1,5 +1,6 @@ import EthQuery from 'ethjs-query' import log from 'loglevel' +import ethUtil from 'ethereumjs-util' import { hexToBn, BnMultiplyByFraction, bnToHex } from '../../lib/util' /** @@ -69,11 +70,11 @@ export default class TxGasUtil { @param {string} blockGasLimitHex - the block gas limit @returns {string} - the buffered gas limit as a hex string */ - addGasBuffer (initialGasLimitHex, blockGasLimitHex) { + addGasBuffer (initialGasLimitHex, blockGasLimitHex, multiplier = 1.5) { const initialGasLimitBn = hexToBn(initialGasLimitHex) const blockGasLimitBn = hexToBn(blockGasLimitHex) const upperGasLimitBn = blockGasLimitBn.muln(0.9) - const bufferedGasLimitBn = initialGasLimitBn.muln(1.5) + const bufferedGasLimitBn = initialGasLimitBn.muln(multiplier) // if initialGasLimit is above blockGasLimit, dont modify it if (initialGasLimitBn.gt(upperGasLimitBn)) { @@ -86,4 +87,12 @@ export default class TxGasUtil { // otherwise use blockGasLimit return bnToHex(upperGasLimitBn) } + + async getBufferedGasLimit (txMeta, multiplier) { + const { blockGasLimit, estimatedGasHex, simulationFails } = await this.analyzeGasUsage(txMeta) + + // add additional gas buffer to our estimation for safety + const gasLimit = this.addGasBuffer(ethUtil.addHexPrefix(estimatedGasHex), blockGasLimit, multiplier) + return { gasLimit, simulationFails } + } } diff --git a/app/scripts/lib/segment.js b/app/scripts/lib/segment.js new file mode 100644 index 000000000..7e59cdc2b --- /dev/null +++ b/app/scripts/lib/segment.js @@ -0,0 +1,27 @@ +import Analytics from 'analytics-node' + +const inDevelopment = process.env.METAMASK_DEBUG || process.env.IN_TEST + +const flushAt = inDevelopment ? 1 : undefined + +const segmentNoop = { + track () { + // noop + }, + page () { + // noop + }, + identify () { + // noop + }, +} + +// We do not want to track events on development builds unless specifically +// provided a SEGMENT_WRITE_KEY. This also holds true for test environments and +// E2E, which is handled in the build process by never providing the SEGMENT_WRITE_KEY +// which process.env.IN_TEST is true +export const segment = process.env.SEGMENT_WRITE_KEY + ? new Analytics(process.env.SEGMENT_WRITE_KEY, { flushAt }) + : segmentNoop + +export const METAMETRICS_ANONYMOUS_ID = '0x0000000000000000' diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 84533551d..0912a0b22 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -2,7 +2,6 @@ import EventEmitter from 'events' import pump from 'pump' import Dnode from 'dnode' -import extension from 'extensionizer' import ObservableStore from 'obs-store' import asStream from 'obs-store/lib/asStream' import RpcEngine from 'json-rpc-engine' @@ -50,6 +49,7 @@ import TypedMessageManager from './lib/typed-message-manager' import TransactionController from './controllers/transactions' import TokenRatesController from './controllers/token-rates' import DetectTokensController from './controllers/detect-tokens' +import SwapsController from './controllers/swaps' import { PermissionsController } from './controllers/permissions' import getRestrictedMethods from './controllers/permissions/restrictedMethods' import nodeify from './lib/nodeify' @@ -71,8 +71,10 @@ export default class MetamaskController extends EventEmitter { this.sendUpdate = debounce(this.privateSendUpdate.bind(this), 200) this.opts = opts + this.extension = opts.extension this.platform = opts.platform const initState = opts.initState || {} + const version = this.platform.getVersion() this.recordFirstTimeInfo(initState) // this keeps track of how many "controllerStream" connections are open @@ -92,6 +94,12 @@ export default class MetamaskController extends EventEmitter { // lock to ensure only one vault created at once this.createVaultMutex = new Mutex() + this.extension.runtime.onInstalled.addListener((details) => { + if (details.reason === 'update' && version === '8.1.0') { + this.platform.openExtensionInBrowser() + } + }) + // next, we will initialize the controllers // controller initialization order matters @@ -210,7 +218,6 @@ export default class MetamaskController extends EventEmitter { preferencesStore: this.preferencesController.store, }) - const version = this.platform.getVersion() this.threeBoxController = new ThreeBoxController({ preferencesController: this.preferencesController, addressBookController: this.addressBookController, @@ -230,6 +237,7 @@ export default class MetamaskController extends EventEmitter { signTransaction: this.keyringController.signTransaction.bind(this.keyringController), provider: this.provider, blockTracker: this.blockTracker, + version: this.platform.getVersion(), }) this.txController.on('newUnapprovedTx', () => opts.showUnapprovedTx()) @@ -267,6 +275,14 @@ export default class MetamaskController extends EventEmitter { this.encryptionPublicKeyManager = new EncryptionPublicKeyManager() this.typedMessageManager = new TypedMessageManager({ networkController: this.networkController }) + this.swapsController = new SwapsController({ + getBufferedGasLimit: this.txController.txGasUtil.getBufferedGasLimit.bind(this.txController.txGasUtil), + provider: this.provider, + getNetwork: this.networkController.getNetworkState.bind(this.networkController), + getProviderConfig: this.networkController.getProviderConfig.bind(this.networkController), + tokenRatesStore: this.tokenRatesController.store, + }) + this.store.updateStructure({ AppStateController: this.appStateController.store, TransactionController: this.txController.store, @@ -306,6 +322,7 @@ export default class MetamaskController extends EventEmitter { PermissionsController: this.permissionsController.permissions, PermissionsMetadata: this.permissionsController.store, ThreeBoxController: this.threeBoxController.store, + SwapsController: this.swapsController.store, // ENS Controller EnsController: this.ensController.store, }) @@ -426,6 +443,7 @@ export default class MetamaskController extends EventEmitter { preferencesController, threeBoxController, txController, + swapsController, } = this return { @@ -491,6 +509,7 @@ export default class MetamaskController extends EventEmitter { setLastActiveTime: nodeify(this.appStateController.setLastActiveTime, this.appStateController), setDefaultHomeActiveTabName: nodeify(this.appStateController.setDefaultHomeActiveTabName, this.appStateController), setConnectedStatusPopoverHasBeenShown: nodeify(this.appStateController.setConnectedStatusPopoverHasBeenShown, this.appStateController), + setSwapsWelcomeMessageHasBeenShown: nodeify(this.appStateController.setSwapsWelcomeMessageHasBeenShown, this.appStateController), // EnsController tryReverseResolveAddress: nodeify(this.ensController.reverseResolveAddress, this.ensController), @@ -512,6 +531,7 @@ export default class MetamaskController extends EventEmitter { estimateGas: nodeify(this.estimateGas, this), getPendingNonce: nodeify(this.getPendingNonce, this), getNextNonce: nodeify(this.getNextNonce, this), + addUnapprovedTransaction: nodeify(txController.addUnapprovedTransaction, txController), // messageManager signMessage: nodeify(this.signMessage, this), @@ -558,6 +578,25 @@ export default class MetamaskController extends EventEmitter { addPermittedAccount: nodeify(permissionsController.addPermittedAccount, permissionsController), removePermittedAccount: nodeify(permissionsController.removePermittedAccount, permissionsController), requestAccountsPermissionWithId: nodeify(permissionsController.requestAccountsPermissionWithId, permissionsController), + + // swaps + fetchAndSetQuotes: nodeify(swapsController.fetchAndSetQuotes, swapsController), + setSelectedQuoteAggId: nodeify(swapsController.setSelectedQuoteAggId, swapsController), + resetSwapsState: nodeify(swapsController.resetSwapsState, swapsController), + setSwapsTokens: nodeify(swapsController.setSwapsTokens, swapsController), + setApproveTxId: nodeify(swapsController.setApproveTxId, swapsController), + setTradeTxId: nodeify(swapsController.setTradeTxId, swapsController), + setMaxMode: nodeify(swapsController.setMaxMode, swapsController), + setSwapsTxGasPrice: nodeify(swapsController.setSwapsTxGasPrice, swapsController), + setSwapsTxGasLimit: nodeify(swapsController.setSwapsTxGasLimit, swapsController), + safeRefetchQuotes: nodeify(swapsController.safeRefetchQuotes, swapsController), + stopPollingForQuotes: nodeify(swapsController.stopPollingForQuotes, swapsController), + setBackgroundSwapRouteState: nodeify(swapsController.setBackgroundSwapRouteState, swapsController), + resetPostFetchState: nodeify(swapsController.resetPostFetchState, swapsController), + setSwapsErrorKey: nodeify(swapsController.setSwapsErrorKey, swapsController), + setInitialGasEstimate: nodeify(swapsController.setInitialGasEstimate, swapsController), + setCustomApproveTxData: nodeify(swapsController.setCustomApproveTxData, swapsController), + setSwapsLiveness: nodeify(swapsController.setSwapsLiveness, swapsController), } } @@ -1545,7 +1584,7 @@ export default class MetamaskController extends EventEmitter { ? 'metamask' : (new URL(sender.url)).origin let extensionId - if (sender.id !== extension.runtime.id) { + if (sender.id !== this.extension.runtime.id) { extensionId = sender.id } let tabId diff --git a/package.json b/package.json index 56d68a442..0bb950daf 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "test:e2e:firefox": "SELENIUM_BROWSER=firefox test/e2e/run-all.sh", "test:coverage": "nyc --silent --check-coverage yarn test:unit:strict && nyc --silent --no-clean yarn test:unit:lax && nyc report --reporter=text --reporter=html", "test:coverage:strict": "nyc --check-coverage yarn test:unit:strict", + "test:coverage:path": "nyc --check-coverage yarn test:unit:path", "test:coveralls-upload": "if [ \"$COVERALLS_REPO_TOKEN\" ]; then nyc report --reporter=text-lcov | coveralls; fi", "ganache:start": "./development/run-ganache", "sentry:publish": "node ./development/sentry-publish.js", @@ -44,8 +45,8 @@ "devtools:redux": "remotedev --hostname=localhost --port=8000", "start:dev": "concurrently -k -n build,react,redux yarn:start yarn:devtools:react yarn:devtools:redux", "announce": "node development/announcer.js", - "storybook": "start-storybook -p 6006 -c .storybook --static-dir ./app", - "storybook:build": "build-storybook -c .storybook -o .out --static-dir ./app", + "storybook": "start-storybook -p 6006 -c .storybook --static-dir ./app, ./storybook/images", + "storybook:build": "build-storybook -c .storybook -o .out --static-dir ./app, ./storybook/images", "storybook:deploy": "storybook-to-ghpages --existing-output-dir .out --remote storybook --branch master", "update-changelog": "./development/auto-changelog.sh", "generate:migration": "./development/generate-migration.sh" diff --git a/test/data/fetch-mocks.json b/test/data/fetch-mocks.json index 1be8290be..805d6e33a 100644 --- a/test/data/fetch-mocks.json +++ b/test/data/fetch-mocks.json @@ -5918,5 +5918,12 @@ ], "metametrics": { "mockMetaMetricsResponse": true + }, + "swaps": { + "featureFlag": { + "status": { + "active": true + } + } } } diff --git a/test/data/transaction-data.json b/test/data/transaction-data.json index dfbdb735a..552f447f4 100644 --- a/test/data/transaction-data.json +++ b/test/data/transaction-data.json @@ -495,5 +495,46 @@ }, "hasRetried": false, "hasCancelled": false + }, + { + "initialTransaction": { + "blockNumber": "6195527", + "id": 4243712234858467, + "metamaskNetworkId": "4", + "status": "confirmed", + "time": 1585088013000, + "txParams": { + "from": "0xee014609ef9e09776ac5fe00bdbfef57bcdefebb", + "gas": "0x5208", + "gasPrice": "0x77359400", + "nonce": "0x3", + "to": "0xabca64466f257793eaa52fcfff5066894b76a149", + "value": "0xde0b6b3a7640000" + }, + "hash": "0xbcb195f393f4468945b4045cd41bcdbc2f19ad75ae92a32cf153a3004e42009a", + "transactionCategory": "swap" + }, + "primaryTransaction": { + "blockNumber": "6195527", + "id": 4243712234858467, + "metamaskNetworkId": "4", + "status": "confirmed", + "time": 1585088013000, + "txParams": { + "from": "0xee014609ef9e09776ac5fe00bdbfef57bcdefebb", + "gas": "0x5208", + "gasPrice": "0x77359400", + "nonce": "0x3", + "to": "0xabca64466f257793eaa52fcfff5066894b76a149", + "value": "0xde0b6b3a7640000" + }, + "hash": "0xbcb195f393f4468945b4045cd41bcdbc2f19ad75ae92a32cf153a3004e42009a", + "transactionCategory": "swap", + "destinationTokenSymbol": "ABC", + "destinationTokenAddress": "0xabca64466f257793eaa52fcfff5066894b76a149", + "sourceTokenSymbol": "ETH" + }, + "hasRetried": false, + "hasCancelled": false } ] diff --git a/test/e2e/address-book.spec.js b/test/e2e/address-book.spec.js index 2cb46dad5..30b7c7203 100644 --- a/test/e2e/address-book.spec.js +++ b/test/e2e/address-book.spec.js @@ -118,6 +118,12 @@ describe('MetaMask', function () { await driver.clickElement(By.xpath(`//button[contains(text(), '${enLocaleMessages.endOfFlowMessage10.message}')]`)) await driver.delay(regularDelayMs) }) + + it('closes the swaps intro popup', async function () { + await driver.findElement(By.css(`.popover-header__subtitle`)) + await driver.clickElement(By.css('.popover-header__button')) + await driver.delay(regularDelayMs) + }) }) describe('Import seed phrase', function () { @@ -161,7 +167,7 @@ describe('MetaMask', function () { describe('Adds an entry to the address book and sends eth to that address', function () { it('starts a send transaction', async function () { - await driver.clickElement(By.xpath(`//button[contains(text(), 'Send')]`)) + await driver.clickElement(By.css('[data-testid="eth-overview-send"]')) await driver.delay(regularDelayMs) const inputAddress = await driver.findElement(By.css('input[placeholder="Search, public address (0x), or ENS"]')) @@ -210,7 +216,7 @@ describe('MetaMask', function () { describe('Sends to an address book entry', function () { it('starts a send transaction by clicking address book entry', async function () { - await driver.clickElement(By.xpath(`//button[contains(text(), 'Send')]`)) + await driver.clickElement(By.css('[data-testid="eth-overview-send"]')) await driver.delay(regularDelayMs) const recipientRowTitle = await driver.findElement(By.css('.send__select-recipient-wrapper__group-item__title')) diff --git a/test/e2e/ethereum-on.spec.js b/test/e2e/ethereum-on.spec.js index 5dd15d5fd..29720ec32 100644 --- a/test/e2e/ethereum-on.spec.js +++ b/test/e2e/ethereum-on.spec.js @@ -84,6 +84,10 @@ describe('MetaMask', function () { await driver.clickElement(By.xpath(`//button[contains(text(), '${enLocaleMessages.remindMeLater.message}')]`)) await driver.delay(regularDelayMs) + await driver.findElement(By.css(`.popover-header__subtitle`)) + await driver.clickElement(By.css('.popover-header__button')) + await driver.delay(regularDelayMs) + await driver.clickElement(By.css('[data-testid="account-options-menu-button"]')) await driver.clickElement(By.css('[data-testid="account-options-menu__account-details"]')) }) diff --git a/test/e2e/fixtures/imported-account/state.json b/test/e2e/fixtures/imported-account/state.json index 6f3d8d1cb..337875652 100644 --- a/test/e2e/fixtures/imported-account/state.json +++ b/test/e2e/fixtures/imported-account/state.json @@ -1,7 +1,8 @@ { "data": { "AppStateController": { - "mkrMigrationReminderTimestamp": null + "mkrMigrationReminderTimestamp": null, + "swapsWelcomeMessageHasBeenShown": true }, "CachedBalancesController": { "cachedBalances": { diff --git a/test/e2e/fixtures/localization/state.json b/test/e2e/fixtures/localization/state.json index 52978e003..3ecc52079 100644 --- a/test/e2e/fixtures/localization/state.json +++ b/test/e2e/fixtures/localization/state.json @@ -1,7 +1,8 @@ { "data": { "AppStateController": { - "mkrMigrationReminderTimestamp": null + "mkrMigrationReminderTimestamp": null, + "swapsWelcomeMessageHasBeenShown": true }, "CachedBalancesController": { "cachedBalances": { diff --git a/test/e2e/fixtures/personal-sign/state.json b/test/e2e/fixtures/personal-sign/state.json index c6ad1f27c..5ca886017 100644 --- a/test/e2e/fixtures/personal-sign/state.json +++ b/test/e2e/fixtures/personal-sign/state.json @@ -1,7 +1,8 @@ { "data": { "AppStateController": { - "connectedStatusPopoverHasBeenShown": false + "connectedStatusPopoverHasBeenShown": false, + "swapsWelcomeMessageHasBeenShown": true }, "CachedBalancesController": { "cachedBalances": { diff --git a/test/e2e/from-import-ui.spec.js b/test/e2e/from-import-ui.spec.js index d9b93bcad..2430d7adf 100644 --- a/test/e2e/from-import-ui.spec.js +++ b/test/e2e/from-import-ui.spec.js @@ -93,6 +93,12 @@ describe('Using MetaMask with an existing account', function () { await driver.clickElement(By.xpath(`//button[contains(text(), '${enLocaleMessages.endOfFlowMessage10.message}')]`)) await driver.delay(regularDelayMs) }) + + it('closes the swaps intro popup', async function () { + await driver.findElement(By.css(`.popover-header__subtitle`)) + await driver.clickElement(By.css('.popover-header__button')) + await driver.delay(regularDelayMs) + }) }) describe('Show account information', function () { @@ -186,7 +192,7 @@ describe('Using MetaMask with an existing account', function () { describe('Send ETH from inside MetaMask', function () { it('starts a send transaction', async function () { - await driver.clickElement(By.xpath(`//button[contains(text(), 'Send')]`)) + await driver.clickElement(By.css('[data-testid="eth-overview-send"]')) await driver.delay(regularDelayMs) const inputAddress = await driver.findElement(By.css('input[placeholder="Search, public address (0x), or ENS"]')) diff --git a/test/e2e/incremental-security.spec.js b/test/e2e/incremental-security.spec.js index 6b76bae12..8bf990696 100644 --- a/test/e2e/incremental-security.spec.js +++ b/test/e2e/incremental-security.spec.js @@ -90,6 +90,10 @@ describe('MetaMask', function () { await driver.clickElement(By.xpath(`//button[contains(text(), '${enLocaleMessages.remindMeLater.message}')]`)) await driver.delay(regularDelayMs) + await driver.findElement(By.css(`.popover-header__subtitle`)) + await driver.clickElement(By.css('.popover-header__button')) + await driver.delay(regularDelayMs) + await driver.clickElement(By.css('[data-testid="account-options-menu-button"]')) await driver.clickElement(By.css('[data-testid="account-options-menu__account-details"]')) }) diff --git a/test/e2e/metamask-responsive-ui.spec.js b/test/e2e/metamask-responsive-ui.spec.js index 72c70b965..9f1356900 100644 --- a/test/e2e/metamask-responsive-ui.spec.js +++ b/test/e2e/metamask-responsive-ui.spec.js @@ -113,6 +113,12 @@ describe('MetaMask', function () { await driver.clickElement(By.xpath(`//button[contains(text(), '${enLocaleMessages.endOfFlowMessage10.message}')]`)) await driver.delay(regularDelayMs) }) + + it('closes the swaps intro popup', async function () { + await driver.findElement(By.css(`.popover-header__subtitle`)) + await driver.clickElement(By.css('.popover-header__button')) + await driver.delay(regularDelayMs) + }) }) describe('Show account information', function () { @@ -176,7 +182,7 @@ describe('MetaMask', function () { describe('Send ETH from inside MetaMask', function () { it('starts to send a transaction', async function () { - await driver.clickElement(By.xpath(`//button[contains(text(), 'Send')]`)) + await driver.clickElement(By.css('[data-testid="eth-overview-send"]')) await driver.delay(regularDelayMs) const inputAddress = await driver.findElement(By.css('input[placeholder="Search, public address (0x), or ENS"]')) diff --git a/test/e2e/metamask-ui.spec.js b/test/e2e/metamask-ui.spec.js index 37e1ac556..12fcff05a 100644 --- a/test/e2e/metamask-ui.spec.js +++ b/test/e2e/metamask-ui.spec.js @@ -115,6 +115,12 @@ describe('MetaMask', function () { await driver.clickElement(By.xpath(`//button[contains(text(), '${enLocaleMessages.endOfFlowMessage10.message}')]`)) await driver.delay(regularDelayMs) }) + + it('closes the swaps intro popup', async function () { + await driver.findElement(By.css(`.popover-header__subtitle`)) + await driver.clickElement(By.css('.popover-header__button')) + await driver.delay(regularDelayMs) + }) }) describe('Show account information', function () { @@ -217,7 +223,7 @@ describe('MetaMask', function () { describe('Send ETH from inside MetaMask using default gas', function () { it('starts a send transaction', async function () { - await driver.clickElement(By.xpath(`//button[contains(text(), 'Send')]`)) + await driver.clickElement(By.css('[data-testid="eth-overview-send"]')) await driver.delay(regularDelayMs) const inputAddress = await driver.findElement(By.css('input[placeholder="Search, public address (0x), or ENS"]')) @@ -281,7 +287,7 @@ describe('MetaMask', function () { describe('Send ETH from inside MetaMask using fast gas option', function () { it('starts a send transaction', async function () { - await driver.clickElement(By.xpath(`//button[contains(text(), 'Send')]`)) + await driver.clickElement(By.css('[data-testid="eth-overview-send"]')) await driver.delay(regularDelayMs) const inputAddress = await driver.findElement(By.css('input[placeholder="Search, public address (0x), or ENS"]')) @@ -320,7 +326,7 @@ describe('MetaMask', function () { describe('Send ETH from inside MetaMask using advanced gas modal', function () { it('starts a send transaction', async function () { - await driver.clickElement(By.xpath(`//button[contains(text(), 'Send')]`)) + await driver.clickElement(By.css('[data-testid="eth-overview-send"]')) await driver.delay(regularDelayMs) const inputAddress = await driver.findElement(By.css('input[placeholder="Search, public address (0x), or ENS"]')) @@ -842,7 +848,7 @@ describe('MetaMask', function () { describe('Send token from inside MetaMask', function () { let gasModal it('starts to send a transaction', async function () { - await driver.clickElement(By.xpath(`//button[contains(text(), 'Send')]`)) + await driver.clickElement(By.css('[data-testid="eth-overview-send"]')) await driver.delay(regularDelayMs) const inputAddress = await driver.findElement(By.css('input[placeholder="Search, public address (0x), or ENS"]')) diff --git a/test/e2e/permissions.spec.js b/test/e2e/permissions.spec.js index 87c439c0c..2c66a1bc4 100644 --- a/test/e2e/permissions.spec.js +++ b/test/e2e/permissions.spec.js @@ -85,6 +85,10 @@ describe('MetaMask', function () { await driver.clickElement(By.xpath(`//button[contains(text(), '${enLocaleMessages.remindMeLater.message}')]`)) await driver.delay(regularDelayMs) + await driver.findElement(By.css(`.popover-header__subtitle`)) + await driver.clickElement(By.css('.popover-header__button')) + await driver.delay(regularDelayMs) + await driver.clickElement(By.css('[data-testid="account-options-menu-button"]')) await driver.clickElement(By.css('[data-testid="account-options-menu__account-details"]')) }) diff --git a/test/e2e/send-edit.spec.js b/test/e2e/send-edit.spec.js index aa0fc6645..566743f14 100644 --- a/test/e2e/send-edit.spec.js +++ b/test/e2e/send-edit.spec.js @@ -91,11 +91,17 @@ describe('Using MetaMask with an existing account', function () { await driver.clickElement(By.xpath(`//button[contains(text(), '${enLocaleMessages.endOfFlowMessage10.message}')]`)) await driver.delay(regularDelayMs) }) + + it('closes the swaps intro popup', async function () { + await driver.findElement(By.css(`.popover-header__subtitle`)) + await driver.clickElement(By.css('.popover-header__button')) + await driver.delay(regularDelayMs) + }) }) describe('Send ETH from inside MetaMask', function () { it('starts a send transaction', async function () { - await driver.clickElement(By.xpath(`//button[contains(text(), 'Send')]`)) + await driver.clickElement(By.css('[data-testid="eth-overview-send"]')) await driver.delay(regularDelayMs) const inputAddress = await driver.findElement(By.css('input[placeholder="Search, public address (0x), or ENS"]')) diff --git a/test/e2e/threebox.spec.js b/test/e2e/threebox.spec.js index 3254d8cb9..04da1c545 100644 --- a/test/e2e/threebox.spec.js +++ b/test/e2e/threebox.spec.js @@ -95,6 +95,12 @@ describe('MetaMask', function () { await driver.delay(regularDelayMs) }) + it('closes the swaps intro popup', async function () { + await driver.findElement(By.css(`.popover-header__subtitle`)) + await driver.clickElement(By.css('.popover-header__button')) + await driver.delay(regularDelayMs) + }) + it('balance renders', async function () { const balance = await driver.findElement(By.css('[data-testid="wallet-balance"] .list-item__heading')) await driver.wait(until.elementTextMatches(balance, /25\s*ETH/u)) @@ -201,6 +207,12 @@ describe('MetaMask', function () { await driver2.delay(regularDelayMs) }) + it('closes the swaps intro popup', async function () { + await driver2.findElement(By.css(`.popover-header__subtitle`)) + await driver2.clickElement(By.css('.popover-header__button')) + await driver2.delay(regularDelayMs) + }) + it('balance renders', async function () { const balance = await driver2.findElement(By.css('[data-testid="wallet-balance"] .list-item__heading')) await driver2.wait(until.elementTextMatches(balance, /25\s*ETH/u)) diff --git a/test/e2e/webdriver/index.js b/test/e2e/webdriver/index.js index 23e5acd5a..20be5d031 100644 --- a/test/e2e/webdriver/index.js +++ b/test/e2e/webdriver/index.js @@ -47,6 +47,10 @@ async function setupFetchMocking (driver) { return { json: async () => clone(mockResponses.ethGasPredictTable) } } else if (url.match(/chromeextensionmm/u)) { return { json: async () => clone(mockResponses.metametrics) } + } else if (url.match(/^https:\/\/(api\.metaswap|.*airswap-dev)/u)) { + if (url.match(/featureFlag$/u)) { + return { json: async () => clone(mockResponses.swaps.featureFlag) } + } } return window.origFetch(...args) } diff --git a/test/unit/app/controllers/metamask-controller-test.js b/test/unit/app/controllers/metamask-controller-test.js index c9f67af77..0cbd0bea9 100644 --- a/test/unit/app/controllers/metamask-controller-test.js +++ b/test/unit/app/controllers/metamask-controller-test.js @@ -32,6 +32,9 @@ class ThreeBoxControllerMock { const ExtensionizerMock = { runtime: { id: 'fake-extension-id', + onInstalled: { + addListener: () => undefined, + }, }, } @@ -60,7 +63,6 @@ const createLoggerMiddlewareMock = () => (req, res, next) => { const MetaMaskController = proxyquire('../../../../app/scripts/metamask-controller', { './controllers/threebox': { default: ThreeBoxControllerMock }, - 'extensionizer': ExtensionizerMock, './lib/createLoggerMiddleware': { default: createLoggerMiddlewareMock }, }).default @@ -101,6 +103,7 @@ describe('MetaMaskController', function () { }, initState: cloneDeep(firstTimeState), platform: { showTransactionNotification: () => undefined, getVersion: () => 'foo' }, + extension: ExtensionizerMock, infuraProjectId: 'foo', }) diff --git a/test/unit/app/controllers/swaps-test.js b/test/unit/app/controllers/swaps-test.js new file mode 100644 index 000000000..c926182f1 --- /dev/null +++ b/test/unit/app/controllers/swaps-test.js @@ -0,0 +1,745 @@ +import assert from 'assert' +import sinon from 'sinon' + +import { ethers } from 'ethers' +import BigNumber from 'bignumber.js' +import ObservableStore from 'obs-store' +import { createTestProviderTools } from '../../../stub/provider' +import { DEFAULT_ERC20_APPROVE_GAS } from '../../../../ui/app/helpers/constants/swaps' +import SwapsController from '../../../../app/scripts/controllers/swaps' + +const MOCK_FETCH_PARAMS = { + slippage: 3, + sourceToken: '0x6b175474e89094c44da98b954eedeac495271d0f', + sourceDecimals: 18, + destinationToken: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + value: '1000000000000000000', + fromAddress: '0x7F18BB4Dd92CF2404C54CBa1A9BE4A1153bdb078', + exchangeList: 'zeroExV1', +} + +const TEST_AGG_ID = 'zeroExV1' +const MOCK_QUOTES = { + [TEST_AGG_ID]: { + trade: { + data: '0x00', + from: '0x7F18BB4Dd92CF2404C54CBa1A9BE4A1153bdb078', + value: '0x17647444f166000', + gas: '0xe09c0', + gasPrice: undefined, + to: '0x016B4bf68d421147c06f1b8680602c5bf0Df91A8', + }, + sourceAmount: '1000000000000000000000000000000000000', + destinationAmount: '396493201125465', + error: null, + sourceToken: '0x6b175474e89094c44da98b954eedeac495271d0f', + destinationToken: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + approvalNeeded: null, + maxGas: 920000, + averageGas: 312510, + estimatedRefund: 343090, + fetchTime: 559, + aggregator: TEST_AGG_ID, + aggType: 'AGG', + slippage: 3, + }, +} +const MOCK_FETCH_METADATA = { + destinationTokenInfo: { + symbol: 'FOO', + decimals: 18, + }, +} + +const MOCK_TOKEN_RATES_STORE = new ObservableStore({ + contractExchangeRates: { '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': 2 }, +}) + +const MOCK_GET_PROVIDER_CONFIG = () => ({ type: 'FAKE_NETWORK' }) + +const MOCK_GET_BUFFERED_GAS_LIMIT = async () => ({ + gasLimit: 2000000, + simulationFails: undefined, +}) + +const EMPTY_INIT_STATE = { + swapsState: { + quotes: {}, + fetchParams: null, + tokens: null, + tradeTxId: null, + approveTxId: null, + maxMode: false, + quotesLastFetched: null, + customMaxGas: '', + customGasPrice: null, + selectedAggId: null, + customApproveTxData: '', + errorKey: '', + topAggId: null, + routeState: '', + swapsFeatureIsLive: false, + }, +} + +const sandbox = sinon.createSandbox() +const fetchTradesInfoStub = sandbox.stub() +const fetchSwapsFeatureLivenessStub = sandbox.stub() + +describe('SwapsController', function () { + let provider + + const getSwapsController = () => { + return new SwapsController({ + getBufferedGasLimit: MOCK_GET_BUFFERED_GAS_LIMIT, + provider, + getProviderConfig: MOCK_GET_PROVIDER_CONFIG, + tokenRatesStore: MOCK_TOKEN_RATES_STORE, + fetchTradesInfo: fetchTradesInfoStub, + fetchSwapsFeatureLiveness: fetchSwapsFeatureLivenessStub, + }) + } + + before(function () { + const providerResultStub = { + // 1 gwei + eth_gasPrice: '0x0de0b6b3a7640000', + // by default, all accounts are external accounts (not contracts) + eth_getCode: '0x', + } + provider = createTestProviderTools({ scaffold: providerResultStub }) + .provider + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('constructor', function () { + it('should setup correctly', function () { + const swapsController = getSwapsController() + assert.deepStrictEqual(swapsController.store.getState(), EMPTY_INIT_STATE) + assert.deepStrictEqual( + swapsController.getBufferedGasLimit, + MOCK_GET_BUFFERED_GAS_LIMIT, + ) + assert.strictEqual(swapsController.pollCount, 0) + assert.deepStrictEqual( + swapsController.getProviderConfig, + MOCK_GET_PROVIDER_CONFIG, + ) + }) + }) + + describe('API', function () { + let swapsController + beforeEach(function () { + swapsController = getSwapsController() + }) + + describe('setters', function () { + it('should set selected quote agg id', function () { + const selectedAggId = 'test' + swapsController.setSelectedQuoteAggId(selectedAggId) + assert.deepStrictEqual( + swapsController.store.getState().swapsState.selectedAggId, + selectedAggId, + ) + }) + + it('should set swaps tokens', function () { + const tokens = [] + swapsController.setSwapsTokens(tokens) + assert.deepStrictEqual( + swapsController.store.getState().swapsState.tokens, + tokens, + ) + }) + + it('should set trade tx id', function () { + const tradeTxId = 'test' + swapsController.setTradeTxId(tradeTxId) + assert.strictEqual( + swapsController.store.getState().swapsState.tradeTxId, + tradeTxId, + ) + }) + + it('should set max mode', function () { + const maxMode = true + swapsController.setMaxMode(maxMode) + assert.strictEqual( + swapsController.store.getState().swapsState.maxMode, + maxMode, + ) + }) + + it('should set swaps tx gas price', function () { + const gasPrice = 1 + swapsController.setSwapsTxGasPrice(gasPrice) + assert.deepStrictEqual( + swapsController.store.getState().swapsState.customGasPrice, + gasPrice, + ) + }) + + it('should set swaps tx gas limit', function () { + const gasLimit = '1' + swapsController.setSwapsTxGasLimit(gasLimit) + assert.deepStrictEqual( + swapsController.store.getState().swapsState.customMaxGas, + gasLimit, + ) + }) + + it('should set background swap route state', function () { + const routeState = 'test' + swapsController.setBackgroundSwapRouteState(routeState) + assert.deepStrictEqual( + swapsController.store.getState().swapsState.routeState, + routeState, + ) + }) + + it('should set swaps error key', function () { + const errorKey = 'test' + swapsController.setSwapsErrorKey(errorKey) + assert.deepStrictEqual( + swapsController.store.getState().swapsState.errorKey, + errorKey, + ) + }) + + it('should set initial gas estimate', async function () { + const initialAggId = TEST_AGG_ID + const baseGasEstimate = 10 + const { maxGas, estimatedRefund } = MOCK_QUOTES[TEST_AGG_ID] + + const { swapsState } = swapsController.store.getState() + // Set mock quotes in order to have data for the test agg + swapsController.store.updateState({ + swapsState: { ...swapsState, quotes: MOCK_QUOTES }, + }) + + await swapsController.setInitialGasEstimate( + initialAggId, + baseGasEstimate, + ) + + const { + gasLimit: bufferedGasLimit, + } = await swapsController.getBufferedGasLimit() + const { + gasEstimate, + gasEstimateWithRefund, + } = swapsController.store.getState().swapsState.quotes[initialAggId] + assert.strictEqual(gasEstimate, bufferedGasLimit) + assert.strictEqual( + gasEstimateWithRefund, + new BigNumber(maxGas, 10).minus(estimatedRefund, 10).toString(16), + ) + }) + + it('should set custom approve tx data', function () { + const data = 'test' + swapsController.setCustomApproveTxData(data) + assert.deepStrictEqual( + swapsController.store.getState().swapsState.customApproveTxData, + data, + ) + }) + }) + + describe('fetchAndSetQuotes', function () { + it('returns null if fetchParams is not provided', async function () { + const quotes = await swapsController.fetchAndSetQuotes(undefined) + assert.strictEqual(quotes, null) + }) + + it('calls fetchTradesInfo with the given fetchParams and returns the correct quotes', async function () { + fetchTradesInfoStub.resolves(MOCK_QUOTES) + + // Make it so approval is not required + sandbox + .stub(swapsController, '_getERC20Allowance') + .resolves(ethers.BigNumber.from(1)) + + const [newQuotes] = await swapsController.fetchAndSetQuotes( + MOCK_FETCH_PARAMS, + MOCK_FETCH_METADATA, + ) + + assert.deepStrictEqual(newQuotes[TEST_AGG_ID], { + ...MOCK_QUOTES[TEST_AGG_ID], + sourceTokenInfo: undefined, + destinationTokenInfo: { + symbol: 'FOO', + decimals: 18, + }, + isBestQuote: true, + // TODO: find a way to calculate these values dynamically + gasEstimate: 2000000, + gasEstimateWithRefund: '8cd8e', + }) + + assert.strictEqual( + fetchTradesInfoStub.calledOnceWithExactly(MOCK_FETCH_PARAMS), + true, + ) + }) + + it('performs the allowance check', async function () { + fetchTradesInfoStub.resolves(MOCK_QUOTES) + + // Make it so approval is not required + const allowanceStub = sandbox + .stub(swapsController, '_getERC20Allowance') + .resolves(ethers.BigNumber.from(1)) + + await swapsController.fetchAndSetQuotes( + MOCK_FETCH_PARAMS, + MOCK_FETCH_METADATA, + ) + + assert.strictEqual( + allowanceStub.calledOnceWithExactly( + MOCK_FETCH_PARAMS.sourceToken, + MOCK_FETCH_PARAMS.fromAddress, + ), + true, + ) + }) + + it('gets the gas limit if approval is required', async function () { + fetchTradesInfoStub.resolves(MOCK_QUOTES) + + // Ensure approval is required + sandbox + .stub(swapsController, '_getERC20Allowance') + .resolves(ethers.BigNumber.from(0)) + + const timedoutGasReturnResult = { gasLimit: 1000000 } + const timedoutGasReturnStub = sandbox + .stub(swapsController, 'timedoutGasReturn') + .resolves(timedoutGasReturnResult) + + await swapsController.fetchAndSetQuotes( + MOCK_FETCH_PARAMS, + MOCK_FETCH_METADATA, + ) + + // Mocked quotes approvalNeeded is null, so it will only be called with the gas + assert.strictEqual( + timedoutGasReturnStub.calledOnceWithExactly({ + gas: DEFAULT_ERC20_APPROVE_GAS, + }), + true, + ) + }) + + it('marks the best quote', async function () { + fetchTradesInfoStub.resolves(MOCK_QUOTES) + + // Make it so approval is not required + sandbox + .stub(swapsController, '_getERC20Allowance') + .resolves(ethers.BigNumber.from(1)) + + const [newQuotes, topAggId] = await swapsController.fetchAndSetQuotes( + MOCK_FETCH_PARAMS, + MOCK_FETCH_METADATA, + ) + + assert.strictEqual(topAggId, TEST_AGG_ID) + assert.strictEqual(newQuotes[topAggId].isBestQuote, true) + }) + + it('selects the best quote', async function () { + const bestAggId = 'bestAggId' + + // Clone the existing mock quote and increase destination amount + const bestQuote = { + ...MOCK_QUOTES[TEST_AGG_ID], + aggregator: bestAggId, + destinationAmount: ethers.BigNumber.from( + MOCK_QUOTES[TEST_AGG_ID].destinationAmount, + ) + .add(1) + .toString(), + } + const quotes = { ...MOCK_QUOTES, [bestAggId]: bestQuote } + fetchTradesInfoStub.resolves(quotes) + + // Make it so approval is not required + sandbox + .stub(swapsController, '_getERC20Allowance') + .resolves(ethers.BigNumber.from(1)) + + const [newQuotes, topAggId] = await swapsController.fetchAndSetQuotes( + MOCK_FETCH_PARAMS, + MOCK_FETCH_METADATA, + ) + + assert.strictEqual(topAggId, bestAggId) + assert.strictEqual(newQuotes[topAggId].isBestQuote, true) + }) + + it('does not set isBestQuote if no conversion rate exists for destination token', async function () { + fetchTradesInfoStub.resolves(MOCK_QUOTES) + + // Make it so approval is not required + sandbox + .stub(swapsController, '_getERC20Allowance') + .resolves(ethers.BigNumber.from(1)) + + swapsController.tokenRatesStore.updateState({ + contractExchangeRates: {}, + }) + const [newQuotes, topAggId] = await swapsController.fetchAndSetQuotes( + MOCK_FETCH_PARAMS, + MOCK_FETCH_METADATA, + ) + + assert.strictEqual(newQuotes[topAggId].isBestQuote, undefined) + }) + }) + + describe('resetSwapsState', function () { + it('resets the swaps state correctly', function () { + const { swapsState: old } = swapsController.store.getState() + swapsController.resetSwapsState() + const { swapsState } = swapsController.store.getState() + assert.deepStrictEqual(swapsState, { + ...EMPTY_INIT_STATE.swapsState, + tokens: old.tokens, + }) + }) + + it('clears polling timeout', function () { + swapsController.pollingTimeout = setTimeout( + () => assert.fail(), + 1000000, + ) + swapsController.resetSwapsState() + assert.strictEqual(swapsController.pollingTimeout._idleTimeout, -1) + }) + }) + + describe('stopPollingForQuotes', function () { + it('clears polling timeout', function () { + swapsController.pollingTimeout = setTimeout( + () => assert.fail(), + 1000000, + ) + swapsController.stopPollingForQuotes() + assert.strictEqual(swapsController.pollingTimeout._idleTimeout, -1) + }) + + it('resets quotes state correctly', function () { + swapsController.stopPollingForQuotes() + const { swapsState } = swapsController.store.getState() + assert.deepStrictEqual(swapsState.quotes, {}) + assert.strictEqual(swapsState.quotesLastFetched, null) + }) + }) + + describe('resetPostFetchState', function () { + it('clears polling timeout', function () { + swapsController.pollingTimeout = setTimeout( + () => assert.fail(), + 1000000, + ) + swapsController.resetPostFetchState() + assert.strictEqual(swapsController.pollingTimeout._idleTimeout, -1) + }) + + it('updates state correctly', function () { + const tokens = 'test' + const fetchParams = 'test' + const swapsFeatureIsLive = false + swapsController.store.updateState({ + swapsState: { tokens, fetchParams, swapsFeatureIsLive }, + }) + + swapsController.resetPostFetchState() + + const { swapsState } = swapsController.store.getState() + assert.deepStrictEqual(swapsState, { + ...EMPTY_INIT_STATE.swapsState, + tokens, + fetchParams, + swapsFeatureIsLive, + }) + }) + }) + + describe('_setupSwapsLivenessFetching ', function () { + + let clock + const EXPECTED_TIME = 600000 + + const getLivenessState = () => { + return swapsController.store.getState().swapsState.swapsFeatureIsLive + } + + // We have to do this to overwrite window.navigator.onLine + const stubWindow = () => { + sandbox.replace(global, 'window', { + addEventListener: window.addEventListener, + navigator: { onLine: true }, + dispatchEvent: window.dispatchEvent, + Event: window.Event, + }) + } + + beforeEach(function () { + stubWindow() + clock = sandbox.useFakeTimers() + sandbox.spy(clock, 'setInterval') + + sandbox.stub( + SwapsController.prototype, + '_fetchAndSetSwapsLiveness', + ).resolves(undefined) + + sandbox.spy( + SwapsController.prototype, + '_setupSwapsLivenessFetching', + ) + + sandbox.spy(window, 'addEventListener') + }) + + afterEach(function () { + sandbox.restore() + }) + + it('calls _setupSwapsLivenessFetching in constructor', function () { + swapsController = getSwapsController() + + assert.ok( + swapsController._setupSwapsLivenessFetching.calledOnce, + 'should have called _setupSwapsLivenessFetching once', + ) + assert.ok( + window.addEventListener.calledWith('online'), + ) + assert.ok( + window.addEventListener.calledWith('offline'), + ) + assert.ok( + clock.setInterval.calledOnceWithExactly( + sinon.match.func, + EXPECTED_TIME, + ), + 'should have set an interval', + ) + }) + + it('handles browser being offline on boot, then coming online', async function () { + window.navigator.onLine = false + + swapsController = getSwapsController() + assert.ok( + swapsController._setupSwapsLivenessFetching.calledOnce, + 'should have called _setupSwapsLivenessFetching once', + ) + assert.ok( + swapsController._fetchAndSetSwapsLiveness.notCalled, + 'should not have called _fetchAndSetSwapsLiveness', + ) + assert.ok( + clock.setInterval.notCalled, + 'should not have set an interval', + ) + assert.strictEqual( + getLivenessState(), false, + 'swaps feature should be disabled', + ) + + const fetchPromise = new Promise((resolve) => { + const originalFunction = swapsController._fetchAndSetSwapsLiveness + swapsController._fetchAndSetSwapsLiveness = () => { + originalFunction() + resolve() + swapsController._fetchAndSetSwapsLiveness = originalFunction + } + }) + + // browser comes online + window.navigator.onLine = true + window.dispatchEvent(new window.Event('online')) + await fetchPromise + + assert.ok( + swapsController._fetchAndSetSwapsLiveness.calledOnce, + 'should have called _fetchAndSetSwapsLiveness once', + ) + assert.ok( + clock.setInterval.calledOnceWithExactly( + sinon.match.func, + EXPECTED_TIME, + ), + 'should have set an interval', + ) + }) + + it('clears interval if browser goes offline', async function () { + swapsController = getSwapsController() + + // set feature to live + const { swapsState } = swapsController.store.getState() + swapsController.store.updateState({ + swapsState: { ...swapsState, swapsFeatureIsLive: true }, + }) + + sandbox.spy(swapsController.store, 'updateState') + + assert.ok( + clock.setInterval.calledOnceWithExactly( + sinon.match.func, + EXPECTED_TIME, + ), + 'should have set an interval', + ) + + const clearIntervalPromise = new Promise((resolve) => { + const originalFunction = clock.clearInterval + clock.clearInterval = (intervalId) => { + originalFunction(intervalId) + clock.clearInterval = originalFunction + resolve() + } + }) + + // browser goes offline + window.navigator.onLine = false + window.dispatchEvent(new window.Event('offline')) + + // if this resolves, clearInterval was called + await clearIntervalPromise + + assert.ok( + swapsController._fetchAndSetSwapsLiveness.calledOnce, + 'should have called _fetchAndSetSwapsLiveness once', + ) + assert.ok( + swapsController.store.updateState.calledOnce, + 'should have called updateState once', + ) + assert.strictEqual( + getLivenessState(), false, + 'swaps feature should be disabled', + ) + }) + }) + + describe('_fetchAndSetSwapsLiveness', function () { + + const getLivenessState = () => { + return swapsController.store.getState().swapsState.swapsFeatureIsLive + } + + beforeEach(function () { + fetchSwapsFeatureLivenessStub.reset() + sandbox.stub( + SwapsController.prototype, + '_setupSwapsLivenessFetching', + ) + swapsController = getSwapsController() + }) + + afterEach(function () { + sandbox.restore() + }) + + it('fetches feature liveness as expected when API is live', async function () { + fetchSwapsFeatureLivenessStub.resolves(true) + + assert.strictEqual( + getLivenessState(), false, 'liveness should be false on boot', + ) + + await swapsController._fetchAndSetSwapsLiveness() + + assert.ok( + fetchSwapsFeatureLivenessStub.calledOnce, + 'should have called fetch function once', + ) + assert.strictEqual( + getLivenessState(), true, 'liveness should be true after call', + ) + }) + + it('does not update state if fetched value is same as state value', async function () { + fetchSwapsFeatureLivenessStub.resolves(false) + sandbox.spy(swapsController.store, 'updateState') + + assert.strictEqual( + getLivenessState(), false, 'liveness should be false on boot', + ) + + await swapsController._fetchAndSetSwapsLiveness() + + assert.ok( + fetchSwapsFeatureLivenessStub.calledOnce, + 'should have called fetch function once', + ) + assert.ok( + swapsController.store.updateState.notCalled, + 'should not have called store.updateState', + ) + assert.strictEqual( + getLivenessState(), false, 'liveness should remain false after call', + ) + }) + + it('tries three times before giving up if fetching fails', async function () { + const clock = sandbox.useFakeTimers() + fetchSwapsFeatureLivenessStub.rejects(new Error('foo')) + sandbox.spy(swapsController.store, 'updateState') + + assert.strictEqual( + getLivenessState(), false, 'liveness should be false on boot', + ) + + swapsController._fetchAndSetSwapsLiveness() + await clock.runAllAsync() + + assert.ok( + fetchSwapsFeatureLivenessStub.calledThrice, + 'should have called fetch function three times', + ) + assert.ok( + swapsController.store.updateState.notCalled, + 'should not have called store.updateState', + ) + assert.strictEqual( + getLivenessState(), false, 'liveness should remain false after call', + ) + }) + + it('sets state after fetching on successful retry', async function () { + const clock = sandbox.useFakeTimers() + fetchSwapsFeatureLivenessStub.onCall(0).rejects(new Error('foo')) + fetchSwapsFeatureLivenessStub.onCall(1).rejects(new Error('foo')) + fetchSwapsFeatureLivenessStub.onCall(2).resolves(true) + + assert.strictEqual( + getLivenessState(), false, 'liveness should be false on boot', + ) + + swapsController._fetchAndSetSwapsLiveness() + await clock.runAllAsync() + + assert.strictEqual( + fetchSwapsFeatureLivenessStub.callCount, 3, + 'should have called fetch function three times', + ) + assert.strictEqual( + getLivenessState(), true, 'liveness should be true after call', + ) + }) + }) + }) +}) diff --git a/test/unit/ui/app/actions.spec.js b/test/unit/ui/app/actions.spec.js index 2dc949c84..d52fb5240 100644 --- a/test/unit/ui/app/actions.spec.js +++ b/test/unit/ui/app/actions.spec.js @@ -16,6 +16,13 @@ const { provider } = createTestProviderTools({ scaffold: {} }) const middleware = [thunk] const defaultState = { metamask: {} } const mockStore = (state = defaultState) => configureStore(middleware)(state) +const extensionMock = { + runtime: { + onInstalled: { + addListener: () => undefined, + }, + }, +} describe('Actions', function () { @@ -32,6 +39,7 @@ describe('Actions', function () { beforeEach(async function () { metamaskController = new MetaMaskController({ + extension: extensionMock, platform: { getVersion: () => 'foo' }, provider, keyringController: new KeyringController({}), diff --git a/ui/app/components/app/app-header/app-header.component.js b/ui/app/components/app/app-header/app-header.component.js index b4c4fa992..d3378aca9 100644 --- a/ui/app/components/app/app-header/app-header.component.js +++ b/ui/app/components/app/app-header/app-header.component.js @@ -19,7 +19,9 @@ export default class AppHeader extends PureComponent { isUnlocked: PropTypes.bool, hideNetworkIndicator: PropTypes.bool, disabled: PropTypes.bool, + disableNetworkIndicator: PropTypes.bool, isAccountMenuOpen: PropTypes.bool, + onClick: PropTypes.func, } static contextTypes = { @@ -84,7 +86,9 @@ export default class AppHeader extends PureComponent { provider, isUnlocked, hideNetworkIndicator, + disableNetworkIndicator, disabled, + onClick, } = this.props return ( @@ -94,7 +98,12 @@ export default class AppHeader extends PureComponent {
history.push(DEFAULT_ROUTE)} + onClick={async () => { + if (onClick) { + await onClick() + } + history.push(DEFAULT_ROUTE) + }} />
{ @@ -104,7 +113,7 @@ export default class AppHeader extends PureComponent { network={network} provider={provider} onClick={(event) => this.handleNetworkIndicatorClick(event)} - disabled={disabled} + disabled={disabled || disableNetworkIndicator} />
) diff --git a/ui/app/components/app/gas-customization/advanced-gas-inputs/advanced-gas-inputs.component.js b/ui/app/components/app/gas-customization/advanced-gas-inputs/advanced-gas-inputs.component.js index d3ba70630..75319090d 100644 --- a/ui/app/components/app/gas-customization/advanced-gas-inputs/advanced-gas-inputs.component.js +++ b/ui/app/components/app/gas-customization/advanced-gas-inputs/advanced-gas-inputs.component.js @@ -17,6 +17,7 @@ export default class AdvancedGasInputs extends Component { insufficientBalance: PropTypes.bool, customPriceIsSafe: PropTypes.bool, isSpeedUp: PropTypes.bool, + customGasLimitMessage: PropTypes.string, } constructor (props) { @@ -101,7 +102,7 @@ export default class AdvancedGasInputs extends Component { return {} } - renderGasInput ({ value, onChange, errorComponent, errorType, label, tooltipTitle }) { + renderGasInput ({ value, onChange, errorComponent, errorType, label, customMessageComponent, tooltipTitle }) { return (
@@ -140,7 +141,7 @@ export default class AdvancedGasInputs extends Component {
- { errorComponent } + { errorComponent || customMessageComponent }
) @@ -151,6 +152,7 @@ export default class AdvancedGasInputs extends Component { insufficientBalance, customPriceIsSafe, isSpeedUp, + customGasLimitMessage, } = this.props const { gasPrice, @@ -177,6 +179,14 @@ export default class AdvancedGasInputs extends Component { ) : null + const gasLimitCustomMessageComponent = customGasLimitMessage + ? ( +
+ { customGasLimitMessage } +
+ ) + : null + return (
{ this.renderGasInput({ @@ -193,6 +203,7 @@ export default class AdvancedGasInputs extends Component { value: this.state.gasLimit, onChange: this.onChangeGasLimit, errorComponent: gasLimitErrorComponent, + customMessageComponent: gasLimitCustomMessageComponent, errorType: gasLimitErrorType, }) }
diff --git a/ui/app/components/app/gas-customization/advanced-gas-inputs/index.scss b/ui/app/components/app/gas-customization/advanced-gas-inputs/index.scss index b30a6a3b9..ec4fb9aa7 100644 --- a/ui/app/components/app/gas-customization/advanced-gas-inputs/index.scss +++ b/ui/app/components/app/gas-customization/advanced-gas-inputs/index.scss @@ -132,5 +132,9 @@ right: 10px; color: $dusty-gray; } + + &__custom-text { + @include H7; + } } } diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/advanced-tab-content.component.js b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/advanced-tab-content.component.js index 91c36f0a4..255012392 100644 --- a/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/advanced-tab-content.component.js +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/advanced-tab-content/advanced-tab-content.component.js @@ -26,6 +26,7 @@ export default class AdvancedTabContent extends Component { customPriceIsSafe: PropTypes.bool, isSpeedUp: PropTypes.bool, isEthereumNetwork: PropTypes.bool, + customGasLimitMessage: PropTypes.string, } renderDataSummary (transactionFee, timeRemaining) { @@ -65,6 +66,7 @@ export default class AdvancedTabContent extends Component { isSpeedUp, transactionFee, isEthereumNetwork, + customGasLimitMessage, } = this.props return ( @@ -80,6 +82,7 @@ export default class AdvancedTabContent extends Component { insufficientBalance={insufficientBalance} customPriceIsSafe={customPriceIsSafe} isSpeedUp={isSpeedUp} + customGasLimitMessage={customGasLimitMessage} /> { isEthereumNetwork diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.component.js b/ui/app/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.component.js index 9199dc1d1..9c8969085 100644 --- a/ui/app/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.component.js +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.component.js @@ -2,6 +2,10 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import PageContainer from '../../../ui/page-container' import { Tabs, Tab } from '../../../ui/tabs' +import { calcGasTotal } from '../../../../pages/send/send.utils' +import { + sumHexWEIsToRenderableFiat, +} from '../../../../helpers/utils/conversions.util' import AdvancedTabContent from './advanced-tab-content' import BasicTabContent from './basic-tab-content' @@ -9,6 +13,7 @@ export default class GasModalPageContainer extends Component { static contextTypes = { t: PropTypes.func, metricsEvent: PropTypes.func, + trackEvent: PropTypes.func, } static propTypes = { @@ -29,6 +34,7 @@ export default class GasModalPageContainer extends Component { newTotalEth: PropTypes.string, sendAmount: PropTypes.string, transactionFee: PropTypes.string, + extraInfoRow: PropTypes.shape({ label: PropTypes.string, value: PropTypes.string }), }), onSubmit: PropTypes.func, customModalGasPriceInHex: PropTypes.string, @@ -43,9 +49,16 @@ export default class GasModalPageContainer extends Component { isRetry: PropTypes.bool, disableSave: PropTypes.bool, isEthereumNetwork: PropTypes.bool, + customGasLimitMessage: PropTypes.string, + customTotalSupplement: PropTypes.string, + isSwap: PropTypes.boolean, + value: PropTypes.string, + conversionRate: PropTypes.string, } - state = {} + state = { + selectedTab: 'Basic', + } componentDidMount () { const promise = this.props.hideBasic @@ -84,6 +97,7 @@ export default class GasModalPageContainer extends Component { transactionFee, }, isEthereumNetwork, + customGasLimitMessage, } = this.props return ( @@ -92,6 +106,7 @@ export default class GasModalPageContainer extends Component { updateCustomGasLimit={updateCustomGasLimit} customModalGasPriceInHex={customModalGasPriceInHex} customModalGasLimitInHex={customModalGasLimitInHex} + customGasLimitMessage={customGasLimitMessage} timeRemaining={currentTimeEstimate} transactionFee={transactionFee} gasChartProps={gasChartProps} @@ -105,7 +120,7 @@ export default class GasModalPageContainer extends Component { ) } - renderInfoRows (newTotalFiat, newTotalEth, sendAmount, transactionFee) { + renderInfoRows (newTotalFiat, newTotalEth, sendAmount, transactionFee, extraInfoRow) { return (
@@ -117,6 +132,12 @@ export default class GasModalPageContainer extends Component { {this.context.t('transactionFee')} {transactionFee}
+ {extraInfoRow && ( +
+ {extraInfoRow.label} + {extraInfoRow.value} +
+ )}
{this.context.t('newTotal')} {newTotalEth} @@ -138,6 +159,7 @@ export default class GasModalPageContainer extends Component { newTotalEth, sendAmount, transactionFee, + extraInfoRow, }, } = this.props @@ -157,12 +179,12 @@ export default class GasModalPageContainer extends Component { } return ( - + this.setState({ selectedTab: tabName })}> {tabsToRender.map(({ name, content }, i) => (
{ content } - { this.renderInfoRows(newTotalFiat, newTotalEth, sendAmount, transactionFee) } + { this.renderInfoRows(newTotalFiat, newTotalEth, sendAmount, transactionFee, extraInfoRow) }
))} @@ -199,7 +221,26 @@ export default class GasModalPageContainer extends Component { }, }) } - onSubmit(customModalGasLimitInHex, customModalGasPriceInHex) + if (this.props.isSwap) { + const newSwapGasTotal = calcGasTotal(customModalGasLimitInHex, customModalGasPriceInHex) + let speedSet = '' + if (this.state.selectedTab === 'Basic') { + const { gasButtonInfo } = this.props.gasPriceButtonGroupProps + const selectedGasButtonInfo = gasButtonInfo.find(({ priceInHexWei }) => priceInHexWei === customModalGasPriceInHex) + speedSet = selectedGasButtonInfo?.gasEstimateType || '' + } + + this.context.trackEvent({ + event: 'Gas Fees Changed', + category: 'swaps', + properties: { + speed_set: speedSet, + gas_mode: this.state.selectedTab, + gas_fees: sumHexWEIsToRenderableFiat([this.props.value, newSwapGasTotal, this.props.customTotalSupplement], 'usd', this.props.conversionRate)?.slice(1), + }, + }) + } + onSubmit(customModalGasLimitInHex, customModalGasPriceInHex, this.state.selectedTab, this.context.mixPanelTrack) }} submitText={this.context.t('save')} headerCloseText={this.context.t('close')} diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js b/ui/app/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js index c56f705b2..73407bf3c 100644 --- a/ui/app/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/gas-modal-page-container.container.js @@ -11,6 +11,7 @@ import { updateSendAmount, setGasTotal, updateTransaction, + setSwapsTxGasParams, } from '../../../../store/actions' import { setCustomGasPrice, @@ -47,13 +48,11 @@ import { } from '../../../../selectors' import { - formatCurrency, -} from '../../../../helpers/utils/confirm-tx.util' -import { - addHexWEIsToDec, + addHexes, subtractHexWEIsToDec, - decEthToConvertedCurrency as ethTotalToConvertedCurrency, hexWEIToDecGWEI, + getValueFromWeiHex, + sumHexWEIsToRenderableFiat, } from '../../../../helpers/utils/conversions.util' import { getRenderableTimeEstimate } from '../../../../helpers/utils/gas-time-estimates.util' import { @@ -69,10 +68,17 @@ import GasModalPageContainer from './gas-modal-page-container.component' const mapStateToProps = (state, ownProps) => { const { currentNetworkTxList, send } = state.metamask const { modalState: { props: modalProps } = {} } = state.appState.modal || {} - const { txData = {} } = modalProps || {} + const { + txData = {}, + isSwap = false, + customGasLimitMessage = '', + customTotalSupplement = '', + extraInfoRow = null, + } = modalProps || {} const { transaction = {} } = ownProps - const selectedTransaction = currentNetworkTxList.find(({ id }) => id === (transaction.id || txData.id)) - + const selectedTransaction = isSwap + ? txData + : currentNetworkTxList.find(({ id }) => id === (transaction.id || txData.id)) const buttonDataLoading = getBasicGasEstimateLoadingStatus(state) const gasEstimatesLoading = getGasEstimatesLoadingStatus(state) const sendToken = getSendToken(state) @@ -95,8 +101,11 @@ const mapStateToProps = (state, ownProps) => { const currentCurrency = getCurrentCurrency(state) const conversionRate = getConversionRate(state) - - const newTotalFiat = addHexWEIsToRenderableFiat(value, customGasTotal, currentCurrency, conversionRate) + const newTotalFiat = sumHexWEIsToRenderableFiat( + [value, customGasTotal, customTotalSupplement], + currentCurrency, + conversionRate, + ) const { hideBasic } = state.appState.modal.modalState.props @@ -114,9 +123,13 @@ const mapStateToProps = (state, ownProps) => { const isSendTokenSet = Boolean(sendToken) - const newTotalEth = maxModeOn && !isSendTokenSet ? addHexWEIsToRenderableEth(balance, '0x0') : addHexWEIsToRenderableEth(value, customGasTotal) + const newTotalEth = maxModeOn && !isSendTokenSet + ? sumHexWEIsToRenderableEth([balance, '0x0']) + : sumHexWEIsToRenderableEth([value, customGasTotal, customTotalSupplement]) - const sendAmount = maxModeOn && !isSendTokenSet ? subtractHexWEIsFromRenderableEth(balance, customGasTotal) : addHexWEIsToRenderableEth(value, '0x0') + const sendAmount = maxModeOn && !isSendTokenSet + ? subtractHexWEIsFromRenderableEth(balance, customGasTotal) + : sumHexWEIsToRenderableEth([value, '0x0']) const insufficientBalance = maxModeOn ? false : !isBalanceSufficient({ amount: value, @@ -135,6 +148,7 @@ const mapStateToProps = (state, ownProps) => { return { hideBasic, isConfirm: isConfirm(state), + isSwap, customModalGasPriceInHex, customModalGasLimitInHex, customGasPrice, @@ -158,12 +172,19 @@ const mapStateToProps = (state, ownProps) => { estimatedTimesMax: estimatedTimes[0], }, infoRowProps: { - originalTotalFiat: addHexWEIsToRenderableFiat(value, customGasTotal, currentCurrency, conversionRate), - originalTotalEth: addHexWEIsToRenderableEth(value, customGasTotal), + originalTotalFiat: sumHexWEIsToRenderableFiat( + [value, customGasTotal, customTotalSupplement], + currentCurrency, + conversionRate, + ), + originalTotalEth: sumHexWEIsToRenderableEth( + [value, customGasTotal, customTotalSupplement], + ), newTotalFiat: showFiat ? newTotalFiat : '', newTotalEth, - transactionFee: addHexWEIsToRenderableEth('0x0', customGasTotal), + transactionFee: sumHexWEIsToRenderableEth(['0x0', customGasTotal]), sendAmount, + extraInfoRow, }, transaction: txData || transaction, isSpeedUp: transaction.status === 'submitted', @@ -176,6 +197,10 @@ const mapStateToProps = (state, ownProps) => { sendToken, balance, tokenBalance: getTokenBalance(state), + customGasLimitMessage, + conversionRate, + value, + customTotalSupplement, } } @@ -214,6 +239,9 @@ const mapDispatchToProps = (dispatch) => { dispatch(updateSendErrors({ amount: null })) dispatch(updateSendAmount(calcMaxAmount(maxAmountDataObject))) }, + updateSwapTxGas: (gasLimit, gasPrice) => { + dispatch(setSwapsTxGasParams(gasLimit, gasPrice)) + }, } } @@ -222,6 +250,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { gasPriceButtonGroupProps, // eslint-disable-next-line no-shadow isConfirm, + isSwap, txId, isSpeedUp, isRetry, @@ -245,6 +274,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { cancelAndClose: dispatchCancelAndClose, hideModal: dispatchHideModal, setAmountToMax: dispatchSetAmountToMax, + updateSwapTxGas: dispatchUpdateSwapTxGas, ...otherDispatchProps } = dispatchProps @@ -253,7 +283,10 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { ...otherDispatchProps, ...ownProps, onSubmit: (gasLimit, gasPrice) => { - if (isConfirm) { + if (isSwap) { + dispatchUpdateSwapTxGas(gasLimit, gasPrice) + dispatchHideModal() + } else if (isConfirm) { const updatedTx = { ...transaction, txParams: { @@ -296,7 +329,11 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { dispatchHideSidebar() } }, - disableSave: insufficientBalance || (isSpeedUp && customGasPrice === 0) || customGasLimit < 21000, + disableSave: ( + insufficientBalance || + (isSpeedUp && customGasPrice === 0) || + customGasLimit < 21000 + ), } } @@ -314,19 +351,15 @@ function calcCustomGasLimit (customGasLimitInHex) { return parseInt(customGasLimitInHex, 16) } -function addHexWEIsToRenderableEth (aHexWEI, bHexWEI) { - return formatETHFee(addHexWEIsToDec(aHexWEI, bHexWEI)) +function sumHexWEIsToRenderableEth (hexWEIs) { + const hexWEIsSum = hexWEIs.filter((n) => n).reduce(addHexes) + return formatETHFee(getValueFromWeiHex({ + value: hexWEIsSum, + toCurrency: 'ETH', + numberOfDecimals: 6, + })) } function subtractHexWEIsFromRenderableEth (aHexWEI, bHexWEI) { return formatETHFee(subtractHexWEIsToDec(aHexWEI, bHexWEI)) } - -function addHexWEIsToRenderableFiat (aHexWEI, bHexWEI, convertedCurrency, conversionRate) { - const ethTotal = ethTotalToConvertedCurrency( - addHexWEIsToDec(aHexWEI, bHexWEI), - convertedCurrency, - conversionRate, - ) - return formatCurrency(ethTotal, convertedCurrency) -} diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-component.test.js b/ui/app/components/app/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-component.test.js index 9c8b399fe..ef1d1e311 100644 --- a/ui/app/components/app/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-component.test.js +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-component.test.js @@ -56,6 +56,7 @@ const mockInfoRowProps = { newTotalEth: 'mockNewTotalEth', sendAmount: 'mockSendAmount', transactionFee: 'mockTransactionFee', + extraInfoRow: { label: 'mockLabel', value: 'mockValue' }, } const GP = GasModalPageContainer.prototype @@ -183,8 +184,8 @@ describe('GasModalPageContainer Component', function () { assert.equal(GP.renderInfoRows.callCount, 2) - assert.deepEqual(GP.renderInfoRows.getCall(0).args, ['mockNewTotalFiat', 'mockNewTotalEth', 'mockSendAmount', 'mockTransactionFee']) - assert.deepEqual(GP.renderInfoRows.getCall(1).args, ['mockNewTotalFiat', 'mockNewTotalEth', 'mockSendAmount', 'mockTransactionFee']) + assert.deepEqual(GP.renderInfoRows.getCall(0).args, ['mockNewTotalFiat', 'mockNewTotalEth', 'mockSendAmount', 'mockTransactionFee', { label: 'mockLabel', value: 'mockValue' }]) + assert.deepEqual(GP.renderInfoRows.getCall(1).args, ['mockNewTotalFiat', 'mockNewTotalEth', 'mockSendAmount', 'mockTransactionFee', { label: 'mockLabel', value: 'mockValue' }]) }) it('should not render the basic tab if hideBasic is true', function () { diff --git a/ui/app/components/app/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-container.test.js b/ui/app/components/app/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-container.test.js index f27d0de52..57514e5fa 100644 --- a/ui/app/components/app/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-container.test.js +++ b/ui/app/components/app/gas-customization/gas-modal-page-container/tests/gas-modal-page-container-container.test.js @@ -62,6 +62,7 @@ describe('gas-modal-page-container container', function () { txData: { id: 34, }, + extraInfoRow: { label: 'mockLabel', value: 'mockValue' }, }, }, }, @@ -125,6 +126,9 @@ describe('gas-modal-page-container container', function () { currentTimeEstimate: '~1 min 11 sec', newTotalFiat: '637.41', blockTime: 12, + conversionRate: 50, + customGasLimitMessage: '', + customTotalSupplement: '', customModalGasLimitInHex: 'aaaaaaaa', customModalGasPriceInHex: 'ffffffff', customGasTotal: 'aaaaaaa955555556', @@ -144,6 +148,7 @@ describe('gas-modal-page-container container', function () { gasEstimatesLoading: false, hideBasic: true, infoRowProps: { + extraInfoRow: { label: 'mockLabel', value: 'mockValue' }, originalTotalFiat: '637.41', originalTotalEth: '12.748189 ETH', newTotalFiat: '637.41', @@ -154,6 +159,7 @@ describe('gas-modal-page-container container', function () { insufficientBalance: true, isSpeedUp: false, isRetry: false, + isSwap: false, txId: 34, isEthereumNetwork: true, isMainnet: true, @@ -163,6 +169,7 @@ describe('gas-modal-page-container container', function () { transaction: { id: 34, }, + value: '0x640000000000000', } const baseMockOwnProps = { transaction: { id: 34 } } const tests = [ diff --git a/ui/app/components/app/modals/edit-approval-permission/edit-approval-permission.component.js b/ui/app/components/app/modals/edit-approval-permission/edit-approval-permission.component.js index 9354dad2c..e91ff4717 100644 --- a/ui/app/components/app/modals/edit-approval-permission/edit-approval-permission.component.js +++ b/ui/app/components/app/modals/edit-approval-permission/edit-approval-permission.component.js @@ -21,6 +21,7 @@ export default class EditApprovalPermission extends PureComponent { tokenBalance: PropTypes.string, setCustomAmount: PropTypes.func, origin: PropTypes.string.isRequired, + requiredMinimum: PropTypes.instanceOf(BigNumber), } static contextTypes = { @@ -28,7 +29,8 @@ export default class EditApprovalPermission extends PureComponent { } state = { - customSpendLimit: this.props.customTokenAmount, + // This is used as a TextField value, which should be a string. + customSpendLimit: this.props.customTokenAmount || '', selectedOptionIsUnlimited: !this.props.customTokenAmount, } @@ -63,8 +65,10 @@ export default class EditApprovalPermission extends PureComponent { address={address} diameter={32} /> -
{ name }
-
{ t('balance') }
+
+
{ name }
+
{ t('balance') }
+
{`${Number(tokenBalance).toPrecision(9)} ${tokenSymbol}`} @@ -163,7 +167,7 @@ export default class EditApprovalPermission extends PureComponent { validateSpendLimit () { const { t } = this.context - const { decimals } = this.props + const { decimals, requiredMinimum } = this.props const { selectedOptionIsUnlimited, customSpendLimit } = this.state if (selectedOptionIsUnlimited || !customSpendLimit) { @@ -187,6 +191,13 @@ export default class EditApprovalPermission extends PureComponent { return t('spendLimitTooLarge') } + if ( + requiredMinimum !== undefined && + customSpendLimitNumber.lessThan(requiredMinimum) + ) { + return t('spendLimitInsufficient') + } + return undefined } diff --git a/ui/app/components/app/modals/edit-approval-permission/index.scss b/ui/app/components/app/modals/edit-approval-permission/index.scss index 0b80db408..ecf0705cc 100644 --- a/ui/app/components/app/modals/edit-approval-permission/index.scss +++ b/ui/app/components/app/modals/edit-approval-permission/index.scss @@ -46,12 +46,13 @@ } &__name { - margin-left: 8px; margin-right: 8px; + min-width: 64px; } &__balance { color: #6a737d; + margin-left: 8px; } } @@ -155,6 +156,15 @@ position: absolute; } } + + &__name-and-balance-container { + display: flex; + flex: 0 0 100%; + flex-flow: column; + align-items: flex-start; + justify-content: center; + margin-left: 8px; + } } .edit-approval-permission-modal-content { diff --git a/ui/app/components/app/transaction-icon/transaction-icon.js b/ui/app/components/app/transaction-icon/transaction-icon.js index d0c7ef455..688215e36 100644 --- a/ui/app/components/app/transaction-icon/transaction-icon.js +++ b/ui/app/components/app/transaction-icon/transaction-icon.js @@ -5,12 +5,14 @@ import Interaction from '../../ui/icon/interaction-icon.component' import Receive from '../../ui/icon/receive-icon.component' import Send from '../../ui/icon/send-icon.component' import Sign from '../../ui/icon/sign-icon.component' +import Swap from '../../ui/icon/swap-icon-for-list.component' import { TRANSACTION_CATEGORY_APPROVAL, TRANSACTION_CATEGORY_SIGNATURE_REQUEST, TRANSACTION_CATEGORY_INTERACTION, TRANSACTION_CATEGORY_SEND, TRANSACTION_CATEGORY_RECEIVE, + TRANSACTION_CATEGORY_SWAP, UNAPPROVED_STATUS, FAILED_STATUS, REJECTED_STATUS, @@ -26,6 +28,7 @@ const ICON_MAP = { [TRANSACTION_CATEGORY_SEND]: Send, [TRANSACTION_CATEGORY_SIGNATURE_REQUEST]: Sign, [TRANSACTION_CATEGORY_RECEIVE]: Receive, + [TRANSACTION_CATEGORY_SWAP]: Swap, } const FAIL_COLOR = '#D73A49' diff --git a/ui/app/components/app/transaction-list/transaction-list.component.js b/ui/app/components/app/transaction-list/transaction-list.component.js index a05cf06f9..7d5d0b280 100644 --- a/ui/app/components/app/transaction-list/transaction-list.component.js +++ b/ui/app/components/app/transaction-list/transaction-list.component.js @@ -12,19 +12,36 @@ import * as actions from '../../../ducks/gas/gas.duck' import { useI18nContext } from '../../../hooks/useI18nContext' import TransactionListItem from '../transaction-list-item' import Button from '../../ui/button' -import { TOKEN_CATEGORY_HASH } from '../../../helpers/constants/transactions' +import { TOKEN_CATEGORY_HASH, TRANSACTION_CATEGORY_SWAP } from '../../../helpers/constants/transactions' +import { SWAPS_CONTRACT_ADDRESS } from '../../../helpers/constants/swaps' const PAGE_INCREMENT = 10 const getTransactionGroupRecipientAddressFilter = (recipientAddress) => { - return ({ initialTransaction: { txParams } }) => txParams && txParams.to === recipientAddress + return ({ initialTransaction: { txParams } }) => { + return txParams?.to === recipientAddress || ( + txParams?.to === SWAPS_CONTRACT_ADDRESS && + txParams.data.match(recipientAddress.slice(2)) + ) + } } const tokenTransactionFilter = ({ initialTransaction: { transactionCategory, }, -}) => !TOKEN_CATEGORY_HASH[transactionCategory] + primaryTransaction: { + destinationTokenSymbol, + sourceTokenSymbol, + }, +}) => { + if (TOKEN_CATEGORY_HASH[transactionCategory]) { + return false + } else if (transactionCategory === TRANSACTION_CATEGORY_SWAP) { + return destinationTokenSymbol === 'ETH' || sourceTokenSymbol === 'ETH' + } + return true +} const getFilteredTransactionGroups = (transactionGroups, hideTokenTransactions, tokenAddress) => { if (hideTokenTransactions) { @@ -88,7 +105,11 @@ export default function TransactionList ({ hideTokenTransactions, tokenAddress }
{ pendingTransactions.map((transactionGroup, index) => ( - + )) }
@@ -107,7 +128,10 @@ export default function TransactionList ({ hideTokenTransactions, tokenAddress } { completedTransactions.length > 0 ? completedTransactions.slice(0, limit).map((transactionGroup, index) => ( - + )) : (
diff --git a/ui/app/components/app/wallet-overview/eth-overview.js b/ui/app/components/app/wallet-overview/eth-overview.js index a98904f24..240a7be27 100644 --- a/ui/app/components/app/wallet-overview/eth-overview.js +++ b/ui/app/components/app/wallet-overview/eth-overview.js @@ -4,17 +4,22 @@ import { useDispatch, useSelector } from 'react-redux' import classnames from 'classnames' import { useHistory } from 'react-router-dom' -import Button from '../../ui/button' import Identicon from '../../ui/identicon' import { I18nContext } from '../../../contexts/i18n' -import { SEND_ROUTE } from '../../../helpers/constants/routes' -import { useMetricEvent } from '../../../hooks/useMetricEvent' +import { SEND_ROUTE, BUILD_QUOTE_ROUTE } from '../../../helpers/constants/routes' +import { useMetricEvent, useNewMetricEvent } from '../../../hooks/useMetricEvent' import Tooltip from '../../ui/tooltip' import UserPreferencedCurrencyDisplay from '../user-preferenced-currency-display' import { PRIMARY, SECONDARY } from '../../../helpers/constants/common' import { showModal } from '../../../store/actions' -import { isBalanceCached, getSelectedAccount, getShouldShowFiat } from '../../../selectors/selectors' -import PaperAirplane from '../../ui/icon/paper-airplane-icon' +import { isBalanceCached, getSelectedAccount, getShouldShowFiat, getCurrentNetworkId, getCurrentKeyring } from '../../../selectors/selectors' +import { getValueFromWeiHex } from '../../../helpers/utils/conversions.util' +import SwapIcon from '../../ui/icon/swap-icon.component' +import BuyIcon from '../../ui/icon/overview-buy-icon.component' +import SendIcon from '../../ui/icon/overview-send-icon.component' +import { getSwapsFeatureLiveness, setSwapsFromToken } from '../../../ducks/swaps/swaps' +import { ETH_SWAPS_TOKEN_OBJECT } from '../../../helpers/constants/swaps' +import IconButton from '../../ui/icon-button' import WalletOverview from './wallet-overview' const EthOverview = ({ className }) => { @@ -35,10 +40,15 @@ const EthOverview = ({ className }) => { }, }) const history = useHistory() + const keyring = useSelector(getCurrentKeyring) + const usingHardwareWallet = keyring.type.search('Hardware') !== -1 const balanceIsCached = useSelector(isBalanceCached) const showFiat = useSelector(getShouldShowFiat) const selectedAccount = useSelector(getSelectedAccount) const { balance } = selectedAccount + const networkId = useSelector(getCurrentNetworkId) + const enteredSwapsEvent = useNewMetricEvent({ event: 'Swaps Opened', properties: { source: 'Main View', active_currency: 'ETH' }, category: 'swaps' }) + const swapsEnabled = useSelector(getSwapsFeatureLiveness) return ( { )} buttons={( <> - - + /> + {swapsEnabled ? ( + { + if (networkId === '1') { + enteredSwapsEvent() + dispatch(setSwapsFromToken({ + ...ETH_SWAPS_TOKEN_OBJECT, + balance, + string: getValueFromWeiHex({ value: balance, numberOfDecimals: 4, toDenomination: 'ETH' }), + })) + if (usingHardwareWallet) { + global.platform.openExtensionInBrowser(BUILD_QUOTE_ROUTE) + } else { + history.push(BUILD_QUOTE_ROUTE) + } + } + }} + label={ t('swap') } + tooltipRender={(contents) => ( + + {contents} + + )} + /> + ) : null} )} className={className} diff --git a/ui/app/components/app/wallet-overview/index.scss b/ui/app/components/app/wallet-overview/index.scss index 2deaa5d55..b86f12dd6 100644 --- a/ui/app/components/app/wallet-overview/index.scss +++ b/ui/app/components/app/wallet-overview/index.scss @@ -3,7 +3,7 @@ justify-content: space-between; align-items: center; flex: 1; - height: 209px; + min-height: 209px; min-width: 0; padding-top: 10px; flex-direction: column; @@ -20,7 +20,7 @@ &__buttons { display: flex; flex-direction: row; - height: 44px; + height: 68px; margin-bottom: 24px; } } @@ -83,8 +83,23 @@ color: $Grey-400; } - .wallet-overview &__button { - @extend %asset-buttons; + &__button { + margin-right: 24px; + } + + &__button:last-of-type { + margin-right: 0; + } + + &__circle { + display: flex; + justify-content: center; + align-items: center; + height: 36px; + width: 36px; + background: #037dd6; + border-radius: 18px; + margin-top: 6px; } } @@ -115,7 +130,11 @@ color: $Grey-400; } - .wallet-overview &__button { - @extend %asset-buttons; + &__button { + margin-right: 24px; + } + + &__button:last-of-type { + margin-right: 0; } } diff --git a/ui/app/components/app/wallet-overview/token-overview.js b/ui/app/components/app/wallet-overview/token-overview.js index ea7e870f9..8952fd42e 100644 --- a/ui/app/components/app/wallet-overview/token-overview.js +++ b/ui/app/components/app/wallet-overview/token-overview.js @@ -3,17 +3,22 @@ import PropTypes from 'prop-types' import { useDispatch, useSelector } from 'react-redux' import { useHistory } from 'react-router-dom' -import Button from '../../ui/button' import Identicon from '../../ui/identicon' +import Tooltip from '../../ui/tooltip' import CurrencyDisplay from '../../ui/currency-display' import { I18nContext } from '../../../contexts/i18n' -import { SEND_ROUTE } from '../../../helpers/constants/routes' -import { useMetricEvent } from '../../../hooks/useMetricEvent' +import { SEND_ROUTE, BUILD_QUOTE_ROUTE } from '../../../helpers/constants/routes' +import { useMetricEvent, useNewMetricEvent } from '../../../hooks/useMetricEvent' import { useTokenTracker } from '../../../hooks/useTokenTracker' import { useTokenFiatAmount } from '../../../hooks/useTokenFiatAmount' -import { getAssetImages } from '../../../selectors/selectors' import { updateSendToken } from '../../../store/actions' -import PaperAirplane from '../../ui/icon/paper-airplane-icon' +import { getSwapsFeatureLiveness, setSwapsFromToken } from '../../../ducks/swaps/swaps' +import { getAssetImages, getCurrentKeyring, getCurrentNetworkId } from '../../../selectors/selectors' + +import SwapIcon from '../../ui/icon/swap-icon.component' +import SendIcon from '../../ui/icon/overview-send-icon.component' + +import IconButton from '../../ui/icon-button' import WalletOverview from './wallet-overview' const TokenOverview = ({ className, token }) => { @@ -28,9 +33,15 @@ const TokenOverview = ({ className, token }) => { }) const history = useHistory() const assetImages = useSelector(getAssetImages) + + const keyring = useSelector(getCurrentKeyring) + const usingHardwareWallet = keyring.type.search('Hardware') !== -1 const { tokensWithBalances } = useTokenTracker([token]) const balance = tokensWithBalances[0]?.string const formattedFiatBalance = useTokenFiatAmount(token.address, balance, token.symbol) + const networkId = useSelector(getCurrentNetworkId) + const enteredSwapsEvent = useNewMetricEvent({ event: 'Swaps Opened', properties: { source: 'Token View', active_currency: token.symbol }, category: 'swaps' }) + const swapsEnabled = useSelector(getSwapsFeatureLiveness) return ( {
)} buttons={( - + <> + { + sendTokenEvent() + dispatch(updateSendToken(token)) + history.push(SEND_ROUTE) + }} + Icon={SendIcon} + label={t('send')} + data-testid="eth-overview-send" + /> + {swapsEnabled ? ( + { + if (networkId === '1') { + enteredSwapsEvent() + dispatch(setSwapsFromToken({ ...token, iconUrl: assetImages[token.address] })) + if (usingHardwareWallet) { + global.platform.openExtensionInBrowser(BUILD_QUOTE_ROUTE) + } else { + history.push(BUILD_QUOTE_ROUTE) + } + } + }} + label={ t('swap') } + tooltipRender={(contents) => ( + + {contents} + + )} + /> + ) : null} + )} className={className} icon={( diff --git a/ui/app/components/ui/button-group/index.scss b/ui/app/components/ui/button-group/index.scss index 5a275972a..b82ae60e0 100644 --- a/ui/app/components/ui/button-group/index.scss +++ b/ui/app/components/ui/button-group/index.scss @@ -50,9 +50,9 @@ padding: 0; &--active { - font-weight: bold; - background: $Blue-300; + background: $Blue-500; color: white; + border: none; } &--danger { @@ -61,7 +61,6 @@ background: $white; } - &:hover { box-shadow: 0 0 14px rgba(0, 0, 0, 0.18); }; diff --git a/ui/app/components/ui/icon-button/icon-button.js b/ui/app/components/ui/icon-button/icon-button.js new file mode 100644 index 000000000..400fbc8f5 --- /dev/null +++ b/ui/app/components/ui/icon-button/icon-button.js @@ -0,0 +1,37 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classNames from 'classnames' + +const defaultRender = (inner) => inner + +export default function IconButton ({ onClick, Icon, disabled, label, tooltipRender, className, ...props }) { + const renderWrapper = tooltipRender ?? defaultRender + return ( + + ) +} + +IconButton.propTypes = { + onClick: PropTypes.func.isRequired, + Icon: PropTypes.element.isRequired, + disabled: PropTypes.bool, + label: PropTypes.string.isRequired, + tooltipRender: PropTypes.func, + className: PropTypes.string, + 'data-testid': PropTypes.string, +} diff --git a/ui/app/components/ui/icon-button/icon-button.scss b/ui/app/components/ui/icon-button/icon-button.scss new file mode 100644 index 000000000..2c57b90d6 --- /dev/null +++ b/ui/app/components/ui/icon-button/icon-button.scss @@ -0,0 +1,30 @@ +.icon-button { + display: flex; + flex-direction: column; + align-items: center; + background-color: unset; + text-align: center; + + @include H7; + + font-size: 13px; + cursor: pointer; + color: $Blue-500; + + &__circle { + display: flex; + justify-content: center; + align-items: center; + height: 36px; + width: 36px; + background: #037dd6; + border-radius: 18px; + margin-top: 6px; + margin-bottom: 5px; + } + + &--disabled { + opacity: 0.3; + cursor: auto; + } +} diff --git a/ui/app/components/ui/icon-button/index.js b/ui/app/components/ui/icon-button/index.js new file mode 100644 index 000000000..5e3ded37f --- /dev/null +++ b/ui/app/components/ui/icon-button/index.js @@ -0,0 +1 @@ +export { default } from './icon-button' diff --git a/ui/app/components/ui/icon-with-fallback/icon-with-fallback.component.js b/ui/app/components/ui/icon-with-fallback/icon-with-fallback.component.js index 898861468..55bb546a6 100644 --- a/ui/app/components/ui/icon-with-fallback/icon-with-fallback.component.js +++ b/ui/app/components/ui/icon-with-fallback/icon-with-fallback.component.js @@ -1,11 +1,14 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' +import classnames from 'classnames' export default class IconWithFallback extends PureComponent { static propTypes = { icon: PropTypes.string, name: PropTypes.string, - size: PropTypes.number.isRequired, + size: PropTypes.number, + className: PropTypes.string, + fallbackClassName: PropTypes.string, } static defaultProps = { @@ -18,8 +21,8 @@ export default class IconWithFallback extends PureComponent { } render () { - const { icon, name, size } = this.props - const style = { height: `${size}px`, width: `${size}px` } + const { icon, name, size, className, fallbackClassName } = this.props + const style = size ? { height: `${size}px`, width: `${size}px` } : {} return !this.state.iconError && icon ? ( @@ -27,10 +30,11 @@ export default class IconWithFallback extends PureComponent { onError={() => this.setState({ iconError: true })} src={icon} style={style} + className={className} /> ) : ( - + { name.length ? name.charAt(0).toUpperCase() : '' } ) diff --git a/ui/app/components/ui/icon/overview-buy-icon.component.js b/ui/app/components/ui/icon/overview-buy-icon.component.js new file mode 100644 index 000000000..100b9d7e1 --- /dev/null +++ b/ui/app/components/ui/icon/overview-buy-icon.component.js @@ -0,0 +1,25 @@ +import React from 'react' +import PropTypes from 'prop-types' + +export default function BuyIcon ({ + width = '17', + height = '21', + fill = 'white', +}) { + return ( + + + + + + ) +} + +BuyIcon.propTypes = { + width: PropTypes.string, + height: PropTypes.string, + fill: PropTypes.string, +} diff --git a/ui/app/components/ui/icon/overview-send-icon.component.js b/ui/app/components/ui/icon/overview-send-icon.component.js new file mode 100644 index 000000000..d1a7e6a5b --- /dev/null +++ b/ui/app/components/ui/icon/overview-send-icon.component.js @@ -0,0 +1,23 @@ +import React from 'react' +import PropTypes from 'prop-types' + +export default function SwapIcon ({ + width = '15', + height = '15', + fill = 'white', +}) { + return ( + + + + ) +} + +SwapIcon.propTypes = { + width: PropTypes.string, + height: PropTypes.string, + fill: PropTypes.string, +} diff --git a/ui/app/components/ui/icon/sun-check-icon.component.js b/ui/app/components/ui/icon/sun-check-icon.component.js new file mode 100644 index 000000000..08984b87c --- /dev/null +++ b/ui/app/components/ui/icon/sun-check-icon.component.js @@ -0,0 +1,23 @@ +import React from 'react' +import PropTypes from 'prop-types' + +export default function SunCheck ({ reverseColors }) { + const sunColor = reverseColors ? '#037DD6' : 'white' + const checkColor = reverseColors ? 'white' : '#037DD6' + return ( + + + + + ) +} + +SunCheck.propTypes = { + reverseColors: PropTypes.bool, +} diff --git a/ui/app/components/ui/icon/swap-icon-for-list.component.js b/ui/app/components/ui/icon/swap-icon-for-list.component.js new file mode 100644 index 000000000..e686b6d16 --- /dev/null +++ b/ui/app/components/ui/icon/swap-icon-for-list.component.js @@ -0,0 +1,25 @@ +import React from 'react' +import PropTypes from 'prop-types' + +const Swap = ({ + className, + size, + color, +}) => ( + + + + +) + +Swap.defaultProps = { + className: undefined, +} + +Swap.propTypes = { + className: PropTypes.string, + size: PropTypes.number.isRequired, + color: PropTypes.string.isRequired, +} + +export default Swap diff --git a/ui/app/components/ui/icon/swap-icon.component.js b/ui/app/components/ui/icon/swap-icon.component.js new file mode 100644 index 000000000..0ab832f9a --- /dev/null +++ b/ui/app/components/ui/icon/swap-icon.component.js @@ -0,0 +1,25 @@ +import React from 'react' +import PropTypes from 'prop-types' + +export default function SwapIcon ({ + width = '17', + height = '17', + color = 'white', +}) { + return ( + + + + ) +} + +SwapIcon.propTypes = { + width: PropTypes.string, + height: PropTypes.string, + color: PropTypes.string, +} diff --git a/ui/app/components/ui/info-tooltip/index.scss b/ui/app/components/ui/info-tooltip/index.scss index 6f13837b7..ba0ca6259 100644 --- a/ui/app/components/ui/info-tooltip/index.scss +++ b/ui/app/components/ui/info-tooltip/index.scss @@ -1,7 +1,7 @@ .info-tooltip { img { - height: 10px; - width: 10px; + height: 12px; + width: 12px; } } @@ -32,7 +32,7 @@ padding-bottom: 15px; .tippy-tooltip-content { - @include H8; + @include H7; text-align: left; color: $Grey-500; diff --git a/ui/app/components/ui/page-container/page-container-footer/page-container-footer.component.js b/ui/app/components/ui/page-container/page-container-footer/page-container-footer.component.js index 5468999a4..3f59498c6 100644 --- a/ui/app/components/ui/page-container/page-container-footer/page-container-footer.component.js +++ b/ui/app/components/ui/page-container/page-container-footer/page-container-footer.component.js @@ -1,5 +1,6 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' +import classnames from 'classnames' import Button from '../../button' export default class PageContainerFooter extends Component { @@ -15,6 +16,8 @@ export default class PageContainerFooter extends Component { submitButtonType: PropTypes.string, hideCancel: PropTypes.bool, buttonSizeLarge: PropTypes.bool, + footerClassName: PropTypes.string, + footerButtonClassName: PropTypes.string, } static contextTypes = { @@ -33,17 +36,19 @@ export default class PageContainerFooter extends Component { hideCancel, cancelButtonType, buttonSizeLarge = false, + footerClassName, + footerButtonClassName, } = this.props return ( -
+
{!hideCancel && ( + ) + } + > +
+
+ {t('swapIntroLiquiditySourcesLabel')} +
+
+ +
+
+ {t('swapIntroLearnMoreHeader')} +
+
{ + global.platform.openTab({ url: 'https://medium.com/metamask/introducing-metamask-swaps-84318c643785' }) + blogPostVisitedEvent() + }} + > + {t('swapIntroLearnMoreLink')} +
+
{ + global.platform.openTab({ url: 'https://diligence.consensys.net/audits/private/lsjipyllnw2/' }) + contractAuditVisitedEvent() + }} + > + {t('swapLearnMoreContractsAuditReview')} +
+
+ +
+ ) +} + +IntroPopup.propTypes = { + onClose: PropTypes.func.isRequired, +} diff --git a/ui/app/pages/swaps/loading-swaps-quotes/aggregator-logo.js b/ui/app/pages/swaps/loading-swaps-quotes/aggregator-logo.js new file mode 100644 index 000000000..637cae7a6 --- /dev/null +++ b/ui/app/pages/swaps/loading-swaps-quotes/aggregator-logo.js @@ -0,0 +1,24 @@ +import React from 'react' +import PropTypes from 'prop-types' + +// Inspired by https://stackoverflow.com/a/28056903/4727685 +function hexToRGB (hex, alpha) { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + +export default function AggregatorLogo ({ icon, color }) { + return ( +
+
+
+ ) +} + +AggregatorLogo.propTypes = { + icon: PropTypes.string.isRequired, + color: PropTypes.string.isRequired, +} diff --git a/ui/app/pages/swaps/loading-swaps-quotes/background-animation.js b/ui/app/pages/swaps/loading-swaps-quotes/background-animation.js new file mode 100644 index 000000000..62d37a3bf --- /dev/null +++ b/ui/app/pages/swaps/loading-swaps-quotes/background-animation.js @@ -0,0 +1,77 @@ +import React from 'react' + +export default function BackgroundAnimation () { + return ( + <> +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + +
+ + ) +} diff --git a/ui/app/pages/swaps/loading-swaps-quotes/index.js b/ui/app/pages/swaps/loading-swaps-quotes/index.js new file mode 100644 index 000000000..205fb024b --- /dev/null +++ b/ui/app/pages/swaps/loading-swaps-quotes/index.js @@ -0,0 +1 @@ +export { default } from './loading-swaps-quotes' diff --git a/ui/app/pages/swaps/loading-swaps-quotes/index.scss b/ui/app/pages/swaps/loading-swaps-quotes/index.scss new file mode 100644 index 000000000..9c70f0503 --- /dev/null +++ b/ui/app/pages/swaps/loading-swaps-quotes/index.scss @@ -0,0 +1,134 @@ +.loading-swaps-quotes { + display: flex; + flex-flow: column; + align-items: center; + flex: 1; + width: 100%; + + &__content { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + max-height: 445px; + } + + &__quote-counter { + @include H7; + + color: $Grey-500; + margin-top: 3px; + display: flex; + justify-content: center; + width: 100%; + margin-bottom: 4px; + } + + &__quote-name-check { + @include H4; + + font-weight: bold; + color: $Black-100; + display: flex; + justify-content: center; + width: 100%; + text-transform: capitalize; + } + + &__background-1, + &__background-2 { + width: 265.18px; + height: 221.02px; + display: flex; + justify-content: center; + align-items: center; + position: absolute; + -webkit-animation: spin 38s linear infinite; + -moz-animation: spin 38s linear infinite; + animation: spin 38s linear infinite; + + @-moz-keyframes spin { + 100% { + -moz-transform: rotate(360deg); + } + } + + @-webkit-keyframes spin { + 100% { + -webkit-transform: rotate(360deg); + } + } + + @keyframes spin { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } + } + } + + &__background-2 { + width: 182.8px; + height: 195.39px; + -webkit-animation: spin 42s linear infinite; + -moz-animation: spin 42s linear infinite; + animation: spin 42s linear infinite; + } + + &__mascot-container { + position: absolute; + } + + &__animation { + display: flex; + justify-content: center; + align-items: center; + position: relative; + height: 60%; + width: 316px; + } + + &__logo { + position: fixed; + + &--transition { + -webkit-transition: opacity 0.4s linear; + -moz-transition: opacity 0.4s linear; + -ms-transition: opacity 0.4s linear; + -o-transition: opacity 0.4s linear; + transition: opacity 0.4s linear; + } + + div { + height: 40px; + width: 94px; + border-radius: 50px; + display: flex; + justify-content: center; + align-items: center; + } + + img { + width: 74px; + height: 30px; + } + } + + &__loading-bar-container { + width: 248px; + height: 3px; + background: $Grey-100; + display: flex; + margin-top: 16px; + } + + &__loading-bar { + height: 3px; + background: $Blue-500; + -webkit-transition: width 0.5s linear; + -moz-transition: width 0.5s linear; + -o-transition: width 0.5s linear; + transition: width 0.5s linear; + } +} diff --git a/ui/app/pages/swaps/loading-swaps-quotes/loading-swaps-quotes-stories-metadata.js b/ui/app/pages/swaps/loading-swaps-quotes/loading-swaps-quotes-stories-metadata.js new file mode 100644 index 000000000..ca0426fdd --- /dev/null +++ b/ui/app/pages/swaps/loading-swaps-quotes/loading-swaps-quotes-stories-metadata.js @@ -0,0 +1,26 @@ +export const storiesMetadata = { + 'totle': { + 'color': '#283B4C', + 'icon': 'data:image/png;base64,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', + }, + 'dexag': { + 'color': '#13171F', + 'icon': 'data:image/png;base64,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', + }, + 'airswap': { + 'color': '#2B71FF', + 'icon': 'data:image/png;base64,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', + }, + 'paraswap': { + 'color': '#0058D4', + 'icon': 'data:image/png;base64,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', + }, + 'zeroExV1': { + 'color': '#000', + 'icon': 'data:image/png;base64,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', + }, + 'oneInch': { + 'color': '#323232', + 'icon': 'data:image/png;base64,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', + }, +} diff --git a/ui/app/pages/swaps/loading-swaps-quotes/loading-swaps-quotes.js b/ui/app/pages/swaps/loading-swaps-quotes/loading-swaps-quotes.js new file mode 100644 index 000000000..f7090492d --- /dev/null +++ b/ui/app/pages/swaps/loading-swaps-quotes/loading-swaps-quotes.js @@ -0,0 +1,236 @@ +import EventEmitter from 'events' +import React, { useState, useEffect, useRef, useContext } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import PropTypes from 'prop-types' +import { shuffle } from 'lodash' +import { useHistory } from 'react-router-dom' +import classnames from 'classnames' +import { navigateBackToBuildQuote, getFetchParams, getQuotesFetchStartTime } from '../../../ducks/swaps/swaps' +import { I18nContext } from '../../../contexts/i18n' +import { MetaMetricsContext } from '../../../contexts/metametrics.new' +import Mascot from '../../../components/ui/mascot' +import SwapsFooter from '../swaps-footer' +import BackgroundAnimation from './background-animation' +import AggregatorLogo from './aggregator-logo' + +// These locations reference where we want the top-left corner of the logo div to appear in relation to the +// centre point of the fox +const AGGREGATOR_LOCATIONS = [ + { x: -125, y: -75 }, + { x: 30, y: -75 }, + { x: -145, y: 0 }, + { x: 50, y: 0 }, + { x: -135, y: 46 }, + { x: 40, y: 46 }, +] + +function getRandomLocations (numberOfLocations) { + const randomLocations = shuffle(AGGREGATOR_LOCATIONS) + if (numberOfLocations <= AGGREGATOR_LOCATIONS.length) { + return randomLocations.slice(0, numberOfLocations) + } + const numberOfExtraLocations = numberOfLocations - AGGREGATOR_LOCATIONS.length + return [...randomLocations, ...getRandomLocations(numberOfExtraLocations)] +} + +function getMascotTarget (aggregatorName, centerPoint, aggregatorLocationMap) { + const location = aggregatorLocationMap[aggregatorName] + + if (!location || !centerPoint) { + return centerPoint ?? {} + } + + // The aggregator logos are 94px x 40px. For the fox to look at the center of each logo, the target needs to be + // the coordinates for the centre point of the fox + the desired top and left coordinates of the logo + half + // the height and width of the logo. + return { + x: location.x + centerPoint.x + 47, + y: location.y + centerPoint.y + 20, + } +} + +export default function LoadingSwapsQuotes ({ + aggregatorMetadata, + loadingComplete, + onDone, +}) { + const t = useContext(I18nContext) + const metaMetricsEvent = useContext(MetaMetricsContext) + const dispatch = useDispatch() + const history = useHistory() + const animationEventEmitter = useRef(new EventEmitter()) + + const fetchParams = useSelector(getFetchParams) + const quotesFetchStartTime = useSelector(getQuotesFetchStartTime) + const quotesRequestCancelledEventConfig = { + event: 'Quotes Request Cancelled', + category: 'swaps', + } + const anonymousQuotesRequestCancelledEventConfig = { + event: 'Quotes Request Cancelled', + category: 'swaps', + excludeMetaMetricsId: true, + properties: { + 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, + response_time: Date.now() - quotesFetchStartTime, + }, + } + + const [aggregatorNames] = useState(() => shuffle(Object.keys(aggregatorMetadata))) + const numberOfQuotes = aggregatorNames.length + const mascotContainer = useRef() + const currentMascotContainer = mascotContainer.current + + const [quoteCount, updateQuoteCount] = useState(0) + // is an array of randomized items from AGGREGATOR_LOCATIONS, containing + // numberOfQuotes number of items it is randomized so that the order in + // which the fox looks at locations is random + const [aggregatorLocations] = useState(() => getRandomLocations(numberOfQuotes)) + const _aggregatorLocationMap = aggregatorNames.reduce((nameLocationMap, name, index) => ({ + ...nameLocationMap, + [name]: aggregatorLocations[index], + }), {}) + const [aggregatorLocationMap] = useState(_aggregatorLocationMap) + const [midPointTarget, setMidpointTarget] = useState(null) + + useEffect(() => { + let timeoutLength + + // The below logic simulates a sequential loading of the aggregator quotes, even though we are fetching them all with a single call. + // This is to give the user a sense of progress. The callback passed to `setTimeout` updates the quoteCount and therefore causes + // a new logo to be shown, the fox to look at that logo, the logo bar and aggregator name to update. + + // If loading is complete and all logos + aggregator names have been shown, give the user 1.2 seconds to read the + // "Finalizing message" and prepare for the screen change + if (quoteCount === numberOfQuotes && loadingComplete) { + timeoutLength = 1200 + } else if (loadingComplete) { + // If loading is complete, but the quoteCount is not, we quickly display the remaining logos/names/fox looks. 0.5s each + timeoutLength = 500 + } else { + // If loading is not complete, we display remaining logos/names/fox looks at random intervals between 0.5s and 2s, to simulate the + // sort of loading a user would experience in most async scenarios + timeoutLength = 500 + Math.floor(Math.random() * 1500) + } + const quoteCountTimeout = setTimeout(() => { + if (quoteCount < numberOfQuotes) { + updateQuoteCount(quoteCount + 1) + } else if (quoteCount === numberOfQuotes && loadingComplete) { + onDone() + } + }, timeoutLength) + + return function cleanup () { + clearTimeout(quoteCountTimeout) + } + }, [quoteCount, loadingComplete, onDone, numberOfQuotes]) + + useEffect(() => { + if (currentMascotContainer) { + const { top, left, width, height } = currentMascotContainer.getBoundingClientRect() + const center = { x: left + (width / 2), y: top + (height / 2) } + setMidpointTarget(center) + } + }, [currentMascotContainer]) + + return ( +
+
+ <> +
+ + { + t('swapQuoteNofN', [ + Math.min(quoteCount + 1, numberOfQuotes), + numberOfQuotes, + ]) + } + +
+
+ + { + quoteCount === numberOfQuotes + ? t('swapFinalizing') + : t('swapCheckingQuote', [ + aggregatorMetadata[aggregatorNames[quoteCount]].title, + ]) + } + +
+
+
+
+ +
+ +
+ +
+ {currentMascotContainer && midPointTarget && aggregatorNames.map((aggName) => ( +
+ +
+ ))} +
+
+ { + metaMetricsEvent(quotesRequestCancelledEventConfig) + metaMetricsEvent(anonymousQuotesRequestCancelledEventConfig) + await dispatch(navigateBackToBuildQuote(history)) + }} + hideCancel + /> +
+ ) +} + +LoadingSwapsQuotes.propTypes = { + loadingComplete: PropTypes.bool.isRequired, + onDone: PropTypes.func.isRequired, + aggregatorMetadata: PropTypes.objectOf(PropTypes.shape({ + color: PropTypes.string, + icon: PropTypes.string, + })), +} diff --git a/ui/app/pages/swaps/loading-swaps-quotes/loading-swaps-quotes.stories.js b/ui/app/pages/swaps/loading-swaps-quotes/loading-swaps-quotes.stories.js new file mode 100644 index 000000000..ac39d1850 --- /dev/null +++ b/ui/app/pages/swaps/loading-swaps-quotes/loading-swaps-quotes.stories.js @@ -0,0 +1,91 @@ +import React, { useState, useEffect } from 'react' +import { storiesMetadata } from './loading-swaps-quotes-stories-metadata' +import LoadingSwapsQuotes from './loading-swaps-quotes' + +export default { + title: 'LoadingSwapsQuotes', +} + +export const FasterThanExpectedCompletion = () => { + const [loading, setLoading] = useState(false) + const [loadingComplete, setLoadingComplete] = useState(false) + const [done, setDone] = useState(false) + + useEffect(() => { + if (!done && !loading) { + setLoading(true) + setTimeout(() => { + setLoading(false) + setLoadingComplete(true) + }, 3000) + } + }, [done, loading]) + + return ( +
+
+ setDone(true)} + aggregatorMetadata={storiesMetadata} + /> +
+
+ ) +} + +export const SlowerThanExpectedCompletion = () => { + const [loading, setLoading] = useState(false) + const [loadingComplete, setLoadingComplete] = useState(false) + const [done, setDone] = useState(false) + + useEffect(() => { + if (!done && !loading) { + setLoading(true) + setTimeout(() => { + setLoading(false) + setLoadingComplete(true) + }, 10000) + } + }, [done, loading]) + + return ( +
+
+ setDone(true)} + aggregatorMetadata={storiesMetadata} + /> +
+
+ ) +} + +export const FasterThanExpectedCompletionWithError = () => { + const [loading, setLoading] = useState(false) + const [loadingComplete, setLoadingComplete] = useState(false) + const [done, setDone] = useState(false) + + useEffect(() => { + if (!done && !loading) { + setLoading(true) + setTimeout(() => { + setLoading(false) + setLoadingComplete(true) + }, 3000) + } + }, [done, loading]) + + return ( +
+
+ setDone(true)} + aggregatorMetadata={storiesMetadata} + /> +
+
+ ) +} diff --git a/ui/app/pages/swaps/main-quote-summary/index.js b/ui/app/pages/swaps/main-quote-summary/index.js new file mode 100644 index 000000000..47c9cd3cb --- /dev/null +++ b/ui/app/pages/swaps/main-quote-summary/index.js @@ -0,0 +1 @@ +export { default } from './main-quote-summary' diff --git a/ui/app/pages/swaps/main-quote-summary/index.scss b/ui/app/pages/swaps/main-quote-summary/index.scss new file mode 100644 index 000000000..ff53942a0 --- /dev/null +++ b/ui/app/pages/swaps/main-quote-summary/index.scss @@ -0,0 +1,109 @@ +.main-quote-summary { + display: flex; + flex-flow: column; + align-items: center; + position: relative; + height: 196px; + width: 100%; + color: $white; + + &__quote-backdrop-with-top-tab, + &__quote-backdrop { + position: absolute; + box-shadow: 0 10px 39px rgba(3, 125, 214, 0.15); + border-radius: 8px; + background: #fafcff; + } + + &__quote-backdrop-with-top-tab { + width: 348px; + height: 215px; + } + + &__quote-backdrop { + width: 310px; + height: 179.15px; + } + + &__details { + display: flex; + flex-flow: column; + align-items: center; + width: 310px; + position: relative; + } + + &__best-quote { + @include H7; + + font-weight: bold; + position: relative; + display: flex; + padding-top: 6px; + letter-spacing: 0.12px; + min-height: 16px; + + > span { + margin-left: 4px; + } + } + + &__quote-details-top { + height: 137px; + display: flex; + flex-flow: column; + justify-content: center; + align-items: center; + width: 100%; + padding: 12px; + margin-top: 4px; + } + + &__bold { + font-weight: 900; + } + + &__quote-small-white { + white-space: nowrap; + width: 100%; + text-align: center; + font-size: 14px; + margin-bottom: 8px; + margin-top: 6px; + } + + &__quote-large { + display: flex; + align-items: flex-end; + } + + &__quote-large-number { + font-size: 40px; + line-height: 32px; + margin-right: 6px; + } + + &__quote-large-symbol { + display: flex; + align-items: flex-end; + font-size: 32px; + line-height: 32px; + } + + &__quote-large-white { + font-size: 40px; + text-overflow: ellipsis; + width: 295px; + overflow: hidden; + white-space: nowrap; + } + + &__exchange-rate-container { + display: flex; + justify-content: center; + align-items: center; + width: 287px; + border-top: 1px solid rgba(255, 255, 255, 0.2); + height: 42px; + } +} diff --git a/ui/app/pages/swaps/main-quote-summary/main-quote-summary.js b/ui/app/pages/swaps/main-quote-summary/main-quote-summary.js new file mode 100644 index 000000000..a034ed731 --- /dev/null +++ b/ui/app/pages/swaps/main-quote-summary/main-quote-summary.js @@ -0,0 +1,138 @@ +import React, { useContext } from 'react' +import PropTypes from 'prop-types' +import BigNumber from 'bignumber.js' +import classnames from 'classnames' +import { I18nContext } from '../../../contexts/i18n' +import { calcTokenAmount } from '../../../helpers/utils/token-util' +import { toPrecisionWithoutTrailingZeros } from '../../../helpers/utils/util' +import Tooltip from '../../../components/ui/tooltip' +import SunCheckIcon from '../../../components/ui/icon/sun-check-icon.component' +import ExchangeRateDisplay from '../exchange-rate-display' +import QuoteBackdrop from './quote-backdrop' + +function getFontSizes (fontSizeScore) { + if (fontSizeScore <= 11) { + return [40, 32] + } + if (fontSizeScore <= 16) { + return [30, 24] + } + return [24, 14] +} + +function getLineHeight (fontSizeScore) { + if (fontSizeScore <= 11) { + return 32 + } + if (fontSizeScore <= 16) { + return 26 + } + return 18 +} + +// Returns a numerical value based on the length of the two passed strings: amount and symbol. +// The returned value equals the number of digits in the amount string plus a value calculated +// from the length of the symbol string. The returned number will be passed to the getFontSizes function +// to determine the font size to apply to the amount and symbol strings when rendered. The +// desired maximum digits and letters to show in the ultimately rendered string is 20, and in +// such cases there can also be ellipsis shown and a decimal, combinding for a rendered "string" +// length of ~22. As the symbol will always have a smaller font size than the amount, the +// additive value of the symbol length to the font size score is corrected based on the total +// number of alphanumeric characters in both strings and the desired rendered length of 22. +function getFontSizeScore (amount, symbol) { + const amountLength = amount.match(/\d+/gu).join('').length + const symbolModifier = Math.min((amountLength + symbol.length) / 22, 1) + return amountLength + (symbol.length * symbolModifier) +} + +export default function MainQuoteSummary ({ + isBestQuote, + sourceValue, + sourceSymbol, + sourceDecimals, + destinationValue, + destinationSymbol, + destinationDecimals, +}) { + + const t = useContext(I18nContext) + + const sourceAmount = toPrecisionWithoutTrailingZeros(calcTokenAmount(sourceValue, sourceDecimals).toString(10), 12) + const destinationAmount = calcTokenAmount(destinationValue, destinationDecimals) + + let amountToDisplay = toPrecisionWithoutTrailingZeros(destinationAmount, 12) + if (amountToDisplay.match(/e[+-]/u)) { + amountToDisplay = (new BigNumber(amountToDisplay)).toFixed() + } + const fontSizeScore = getFontSizeScore(amountToDisplay, destinationSymbol) + const [numberFontSize, symbolFontSize] = getFontSizes(fontSizeScore) + const lineHeight = getLineHeight(fontSizeScore) + + let ellipsedAmountToDisplay = amountToDisplay + if (fontSizeScore > 20) { + ellipsedAmountToDisplay = `${amountToDisplay.slice(0, amountToDisplay.length - (fontSizeScore - 20))}...` + } + + return ( +
+
+ +
+
+ {isBestQuote && } + {isBestQuote && t('swapsBestQuote')} +
+
+
+ + {t('swapsConvertToAbout', [{`${sourceAmount} ${sourceSymbol}`}])} + +
+ + {`${ellipsedAmountToDisplay}`} + + {`${destinationSymbol}`} +
+
+
+ +
+
+
+ ) +} + +MainQuoteSummary.propTypes = { + isBestQuote: PropTypes.bool, + sourceValue: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.instanceOf(BigNumber), + ]).isRequired, + sourceDecimals: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + sourceSymbol: PropTypes.string.isRequired, + destinationValue: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.instanceOf(BigNumber), + ]).isRequired, + destinationDecimals: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + destinationSymbol: PropTypes.string.isRequired, +} diff --git a/ui/app/pages/swaps/main-quote-summary/main-quote-summary.stories.js b/ui/app/pages/swaps/main-quote-summary/main-quote-summary.stories.js new file mode 100644 index 000000000..f6c1ffeb2 --- /dev/null +++ b/ui/app/pages/swaps/main-quote-summary/main-quote-summary.stories.js @@ -0,0 +1,35 @@ +import React from 'react' +import { text, number, boolean } from '@storybook/addon-knobs/react' +import MainQuoteSummary from './main-quote-summary' + +export default { + title: 'MainQuoteSummary', +} + +export const BestQuote = () => { + return ( + + ) +} + +export const NotBestQuote = () => { + return ( + + ) +} diff --git a/ui/app/pages/swaps/main-quote-summary/quote-backdrop.js b/ui/app/pages/swaps/main-quote-summary/quote-backdrop.js new file mode 100644 index 000000000..f8f06ff33 --- /dev/null +++ b/ui/app/pages/swaps/main-quote-summary/quote-backdrop.js @@ -0,0 +1,39 @@ +import React from 'react' +import PropTypes from 'prop-types' + +export default function QuotesBackdrop ({ + withTopTab, +}) { + return ( + + + + {withTopTab && } + + + + + + + + + + + + + + + + + + + + + + + ) +} + +QuotesBackdrop.propTypes = { + withTopTab: PropTypes.bool, +} diff --git a/ui/app/pages/swaps/searchable-item-list/index.js b/ui/app/pages/swaps/searchable-item-list/index.js new file mode 100644 index 000000000..2a15a8de3 --- /dev/null +++ b/ui/app/pages/swaps/searchable-item-list/index.js @@ -0,0 +1 @@ +export { default } from './searchable-item-list' diff --git a/ui/app/pages/swaps/searchable-item-list/index.scss b/ui/app/pages/swaps/searchable-item-list/index.scss new file mode 100644 index 000000000..e5895a265 --- /dev/null +++ b/ui/app/pages/swaps/searchable-item-list/index.scss @@ -0,0 +1,178 @@ +.searchable-item-list { + background: $white; + width: 100%; + position: relative; + + &__search { + > div { + border: none; + border-bottom: 1px solid $Grey-100; + border-radius: 0; + height: 55px; + font-size: 12px; + + input { + @include H6; + + color: $Grey-500; + line-height: 100%; + + &::-webkit-input-placeholder { + color: $Grey-500; + opacity: 1; + } + + &:-moz-placeholder { + color: $Grey-500; + opacity: 1; + } + + &::-moz-placeholder { + color: $Grey-500; + opacity: 1; + } + + &::placeholder { + color: $Grey-500; + opacity: 1; + } + } + } + } + + &__list-container { + display: flex; + flex-direction: column; + overflow-y: scroll; + } + + &__item { + transition: 200ms ease-in-out; + display: flex; + flex-flow: row nowrap; + align-items: center; + padding: 8px 12px; + box-sizing: border-box; + cursor: pointer; + border-top: 1px solid $Grey-100; + position: relative; + min-height: 50px; + + &:first-of-type { + border-top: none; + } + + &:last-of-type { + border-bottom: 1px solid $Grey-100; + } + + &:hover { + background: $Grey-000; + } + + &--selected { + border: 2px solid $malibu-blue !important; + } + + &--disabled { + opacity: 0.4; + pointer-events: none; + } + + > img { + margin-top: -2px; + } + } + + &__primary-label { + display: flex; + flex-direction: row; + align-items: center; + min-width: 0; + + @include H6; + + line-height: 100%; + font-weight: bold; + padding-top: 4px; + padding-bottom: 3px; + } + + &__item-name { + /*rtl:ignore*/ + direction: ltr; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__labels { + display: flex; + justify-content: space-between; + max-width: 237px; + flex: 1; + -moz-animation: fadein 1s; + -webkit-animation: fadein 1s; + -o-animation: fadein 1s; + } + + &__item-labels { + display: flex; + flex-direction: column; + justify-content: center; + margin-left: 12px; + } + + &__right-labels { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-end; + max-width: 100%; + flex: 1 1 auto; + } + + &__secondary-label, + &__right-primary-label { + @include H7; + + line-height: 100%; + color: $Grey-500; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + padding-bottom: 4px; + } + + &__right-primary-label { + margin-top: 3px; + } + + &__right-secondary-label { + @include H7; + + line-height: 100%; + color: $Grey-500; + opacity: 0.5; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + &__list-container { + z-index: 1002; + background: white; + } + + &__search { + z-index: 1001; + } + + &__item--highlighted { + background: $Grey-000; + } + + &__identicon { + margin-top: -2px; + } +} diff --git a/ui/app/pages/swaps/searchable-item-list/item-list/index.js b/ui/app/pages/swaps/searchable-item-list/item-list/index.js new file mode 100644 index 000000000..420fdc9e5 --- /dev/null +++ b/ui/app/pages/swaps/searchable-item-list/item-list/index.js @@ -0,0 +1 @@ +export { default } from './item-list.component' diff --git a/ui/app/pages/swaps/searchable-item-list/item-list/item-list.component.js b/ui/app/pages/swaps/searchable-item-list/item-list/item-list.component.js new file mode 100644 index 000000000..e7a841e66 --- /dev/null +++ b/ui/app/pages/swaps/searchable-item-list/item-list/item-list.component.js @@ -0,0 +1,107 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import Identicon from '../../../../components/ui/identicon' +import UrlIcon from '../../../../components/ui/url-icon' + +export default function ItemList ({ + results = [], + onClickItem, + Placeholder, + listTitle, + maxListItems = 6, + searchQuery = '', + containerRef, + hideRightLabels, + hideItemIf, + listContainerClassName, +}) { + + return results.length === 0 + ? Placeholder && + : ( +
+ {listTitle && ( +
+ { listTitle } +
+ )} +
+ { + results.slice(0, maxListItems) + .map((result, i) => { + if (hideItemIf && hideItemIf(result)) { + return null + } + + const { + iconUrl, + identiconAddress, + selected, + disabled, + primaryLabel, + secondaryLabel, + rightPrimaryLabel, + rightSecondaryLabel, + IconComponent, + } = result + return ( +
onClickItem && onClickItem(result)} + key={`searchable-item-list-item-${i}`} + > + {(iconUrl || primaryLabel) && ()} + {!(iconUrl || primaryLabel) && identiconAddress && ( +
+ +
+ )} + {IconComponent && } +
+
+ {primaryLabel && { primaryLabel }} + {secondaryLabel && { secondaryLabel }} +
+ {!hideRightLabels && (rightPrimaryLabel || rightSecondaryLabel) && ( +
+ {rightPrimaryLabel && { rightPrimaryLabel }} + {rightSecondaryLabel && { rightSecondaryLabel }} +
+ )} +
+
+ ) + }) + } +
+
+ ) +} + +ItemList.propTypes = { + results: PropTypes.arrayOf(PropTypes.shape({ + iconUrl: PropTypes.string, + selected: PropTypes.bool, + disabled: PropTypes.bool, + primaryLabel: PropTypes.string, + secondaryLabel: PropTypes.string, + rightPrimaryLabel: PropTypes.string, + rightSecondaryLabel: PropTypes.string, + })), + onClickItem: PropTypes.func, + Placeholder: PropTypes.func, + listTitle: PropTypes.string, + maxListItems: PropTypes.number, + searchQuery: PropTypes.string, + containerRef: PropTypes.shape({ current: PropTypes.instanceOf(window.Element) }), + hideRightLabels: PropTypes.bool, + hideItemIf: PropTypes.func, + listContainerClassName: PropTypes.string, +} diff --git a/ui/app/pages/swaps/searchable-item-list/list-item-search/index.js b/ui/app/pages/swaps/searchable-item-list/list-item-search/index.js new file mode 100644 index 000000000..cfa646b5a --- /dev/null +++ b/ui/app/pages/swaps/searchable-item-list/list-item-search/index.js @@ -0,0 +1 @@ +export { default } from './list-item-search.component' diff --git a/ui/app/pages/swaps/searchable-item-list/list-item-search/list-item-search.component.js b/ui/app/pages/swaps/searchable-item-list/list-item-search/list-item-search.component.js new file mode 100644 index 000000000..7cb93211a --- /dev/null +++ b/ui/app/pages/swaps/searchable-item-list/list-item-search/list-item-search.component.js @@ -0,0 +1,84 @@ +import React, { useState, useEffect, useRef } from 'react' +import PropTypes from 'prop-types' +import Fuse from 'fuse.js' +import InputAdornment from '@material-ui/core/InputAdornment' +import TextField from '../../../../components/ui/text-field' +import { usePrevious } from '../../../../hooks/usePrevious' + +const renderAdornment = () => ( + + + +) + +export default function ListItemSearch ({ + onSearch, + error, + listToSearch = [], + fuseSearchKeys, + searchPlaceholderText, + defaultToAll, +}) { + const fuseRef = useRef() + const [searchQuery, setSearchQuery] = useState('') + + const handleSearch = (newSearchQuery) => { + setSearchQuery(newSearchQuery) + const fuseSearchResult = fuseRef.current.search(newSearchQuery) + onSearch({ + searchQuery: newSearchQuery, + results: defaultToAll && newSearchQuery === '' ? listToSearch : fuseSearchResult, + }) + } + + useEffect(() => { + if (!fuseRef.current) { + fuseRef.current = new Fuse(listToSearch, { + shouldSort: true, + threshold: 0.45, + location: 0, + distance: 100, + maxPatternLength: 32, + minMatchCharLength: 1, + keys: fuseSearchKeys, + }) + } + }, [fuseSearchKeys, listToSearch]) + + const previousListToSearch = usePrevious(listToSearch) || [] + useEffect(() => { + if (fuseRef.current && searchQuery && previousListToSearch !== listToSearch) { + fuseRef.current.setCollection(listToSearch) + const fuseSearchResult = fuseRef.current.search(searchQuery) + onSearch({ searchQuery, results: fuseSearchResult }) + } + }, [listToSearch, searchQuery, onSearch, previousListToSearch]) + + return ( + handleSearch(e.target.value)} + error={error} + fullWidth + startAdornment={renderAdornment()} + autoComplete="off" + autoFocus + /> + ) +} + +ListItemSearch.propTypes = { + onSearch: PropTypes.func, + error: PropTypes.string, + listToSearch: PropTypes.array.isRequired, + fuseSearchKeys: PropTypes.arrayOf(PropTypes.object).isRequired, + searchPlaceholderText: PropTypes.string, + defaultToAll: PropTypes.bool, +} diff --git a/ui/app/pages/swaps/searchable-item-list/searchable-item-list.js b/ui/app/pages/swaps/searchable-item-list/searchable-item-list.js new file mode 100644 index 000000000..7eecfbb5f --- /dev/null +++ b/ui/app/pages/swaps/searchable-item-list/searchable-item-list.js @@ -0,0 +1,72 @@ +import React, { useState, useRef } from 'react' +import PropTypes from 'prop-types' +import ItemList from './item-list' +import ListItemSearch from './list-item-search' + +export default function SearchableItemList ({ + className, + defaultToAll, + fuseSearchKeys, + itemSelectorError, + itemsToSearch = [], + listTitle, + maxListItems, + onClickItem, + Placeholder, + searchPlaceholderText, + hideRightLabels, + hideItemIf, + listContainerClassName, +}) { + const itemListRef = useRef() + + const [results, setResults] = useState(defaultToAll ? itemsToSearch : []) + const [searchQuery, setSearchQuery] = useState('') + + return ( +
+ { + setSearchQuery(newSearchQuery) + setResults(newResults) + }} + error={itemSelectorError} + searchPlaceholderText={searchPlaceholderText} + defaultToAll={defaultToAll} + /> + +
+ ) +} + +SearchableItemList.propTypes = { + itemSelectorError: PropTypes.string, + itemsToSearch: PropTypes.array, + onClickItem: PropTypes.func, + Placeholder: PropTypes.func, + className: PropTypes.string, + searchPlaceholderText: PropTypes.string, + fuseSearchKeys: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string, + weight: PropTypes.number, + })), + listTitle: PropTypes.string, + defaultToAll: PropTypes.bool, + maxListItems: PropTypes.number, + hideRightLabels: PropTypes.bool, + hideItemIf: PropTypes.func, + listContainerClassName: PropTypes.string, +} diff --git a/ui/app/pages/swaps/select-quote-popover/index.js b/ui/app/pages/swaps/select-quote-popover/index.js new file mode 100644 index 000000000..6ffabdf2d --- /dev/null +++ b/ui/app/pages/swaps/select-quote-popover/index.js @@ -0,0 +1 @@ +export { default } from './select-quote-popover' diff --git a/ui/app/pages/swaps/select-quote-popover/index.scss b/ui/app/pages/swaps/select-quote-popover/index.scss new file mode 100644 index 000000000..cf22800ec --- /dev/null +++ b/ui/app/pages/swaps/select-quote-popover/index.scss @@ -0,0 +1,282 @@ +@import 'quote-details/index'; + +.select-quote-popover { + &__button { + border-radius: 100px; + height: 39px; + width: 140px; + } + + &__popover-wrap { + height: 100%; + + @media screen and (min-width: 576px) { + height: 620px; + width: 348px; + } + + .popover-content { + height: 100%; + padding-left: 8px; + padding-right: 8px; + } + + .swaps__footer { + padding: 16px 24px; + } + } + + &__popover-bg { + height: 100%; + width: 100%; + background: $Grey-100; + opacity: 1; + + @media screen and (min-width: 576px) { + opacity: 0.5; + } + } + + &__sort-list { + display: flex; + flex-flow: column; + align-items: center; + } + + &__column-headers { + @include H8; + + font-weight: bold; + color: $Black-100; + display: flex; + align-items: center; + height: 43px; + width: 100%; + padding-left: 20px; + margin-bottom: 4px; + + .select-quote-popover__receiving { + width: 96px; + } + + .select-quote-popover__quote-source { + margin-right: 60px; + } + } + + &__column-header { + cursor: pointer; + font-size: 12px; + } + + &__rows { + display: flex; + flex-flow: column; + align-items: center; + width: 100%; + } + + &__row { + @include H6; + + cursor: pointer; + color: $black; + display: flex; + align-items: center; + height: 49px; + width: 100%; + border-bottom: 1px solid $Grey-100; + padding-left: 20px; + margin-bottom: 8px; + border-radius: 8px; + background: $Grey-000; + padding-right: 12px; + border: none; + + &:hover { + border: 2px solid $Blue-500; + width: 101%; + padding-right: 11px; + padding-left: 19.5px; + } + + &--no-hover { + &:hover { + border: 1px solid $Grey-100; + width: 100%; + padding-right: 12px; + padding-left: 20px; + } + } + + &--selected { + color: $white; + background: linear-gradient(90deg, $Blue-500 0%, $Blue-400 101.32%); + box-shadow: 0 10px 39px rgba(3, 125, 214, 0.15); + border-radius: 8px; + border-bottom: none; + border-top: none; + height: 64px; + + &:hover { + background: linear-gradient(90deg, $Blue-500 0%, $Blue-400 101.32%); + width: 100%; + padding-left: 20px; + padding-right: 12px; + border: none; + } + + .select-quote-popover__caret-right { + color: $white; + + &:hover { + color: $Grey-500; + } + } + + .select-quote-popover__zero-slippage { + color: $white; + } + } + } + + &__receiving { + display: flex; + flex-direction: column; + width: 102px; + } + + &__receiving-value { + display: flex; + align-items: center; + + svg { + margin-right: 2px; + } + } + + &__receiving-value-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__zero-slippage { + font-size: 12px; + line-height: 100%; + color: #6a737d; + font-weight: normal; + } + + &__network-fees { + width: 110px; + } + + &__quote-source { + width: 28px; + display: flex; + align-items: flex-end; + margin-right: 18px; + + @media screen and (min-width: 576px) { + margin-right: 36px; + } + } + + &__quote-source-toggle { + margin-left: 2px; + height: 12px; + } + + &__receiving-header { + display: flex; + flex-flow: column; + } + + &__network-fees-header { + display: flex; + flex-flow: row; + align-items: flex-end; + + > span { + width: 77px; + } + + > div { + height: 12px; + } + + > svg { + margin-bottom: 2px; + } + } + + + &__receiving-symbol { + color: $Grey-500; + + > div { + width: 12px; + height: 12px; + } + } + + &__receiving-label { + display: flex; + align-items: center; + + img { + height: 10px; + width: 10px; + margin-top: 2px; + } + + > div { + margin-left: 3px; + } + } + + &__caret-right { + color: $Grey-500; + width: 32px; + height: 32px; + display: flex; + justify-content: center; + align-items: center; + + i { + transform: rotate(90deg); + } + + &:hover { + border-radius: 8px; + background: white; + border: 1px solid $Blue-500; + } + } + + &__quote-source-label { + height: 18px; + display: flex; + justify-content: center; + align-items: center; + color: white; + border-radius: 5px; + padding: 4px; + font-size: 10px; + font-weight: bold; + padding-bottom: 2px; + + &--blue { + background: $Blue-300; + } + + &--orange { + background: $Orange-400; + } + + &--green { + background: $Green-500; + } + } +} diff --git a/ui/app/pages/swaps/select-quote-popover/mock-quote-data.js b/ui/app/pages/swaps/select-quote-popover/mock-quote-data.js new file mode 100644 index 000000000..f85aada1f --- /dev/null +++ b/ui/app/pages/swaps/select-quote-popover/mock-quote-data.js @@ -0,0 +1,106 @@ +const quoteDataRows = [ + { + aggId: 'Agg1', + amountReceiving: '100 DAI', + destinationTokenDecimals: 18, + destinationTokenSymbol: 'DAI', + destinationTokenValue: '100000000000000000000', + isBestQuote: false, + liquiditySource: 'AGG', + metaMaskFee: '1.00 DAI', + networkFees: '$15.25', + quoteSource: 'AGG', + rawNetworkFees: 10.25, + slippage: '1%', + sourceTokenDecimals: 18, + sourceTokenSymbol: 'ETH', + sourceTokenValue: '250000000000000000', + }, + { + aggId: 'Agg2', + amountReceiving: '101 DAI', + destinationTokenDecimals: 18, + destinationTokenSymbol: 'DAI', + destinationTokenValue: '101000000000000000000', + isBestQuote: false, + liquiditySource: 'RFQ', + metaMaskFee: '1.01 DAI', + networkFees: '$14.26', + quoteSource: 'RFQ', + rawNetworkFees: 10.26, + slippage: '1%', + sourceTokenDecimals: 18, + sourceTokenSymbol: 'ETH', + sourceTokenValue: '250000000000000000', + }, + { + aggId: 'Agg3', + amountReceiving: '102 DAI', + destinationTokenDecimals: 18, + destinationTokenSymbol: 'DAI', + destinationTokenValue: '102000000000000000000', + isBestQuote: false, + liquiditySource: 'DEX', + metaMaskFee: '1.02 DAI', + networkFees: '$13.27', + quoteSource: 'DEX', + rawNetworkFees: 10.27, + slippage: '1%', + sourceTokenDecimals: 18, + sourceTokenSymbol: 'ETH', + sourceTokenValue: '250000000000000000', + }, + { + aggId: 'Agg4', + amountReceiving: '150 DAI', + destinationTokenDecimals: 18, + destinationTokenSymbol: 'DAI', + destinationTokenValue: '150000000000000000000', + isBestQuote: true, + liquiditySource: 'AGG', + metaMaskFee: '1.00 DAI', + networkFees: '$12.28', + quoteSource: 'AGG', + rawNetworkFees: 10.28, + slippage: '1%', + sourceTokenDecimals: 18, + sourceTokenSymbol: 'ETH', + sourceTokenValue: '250000000000000000', + }, + { + aggId: 'Agg5', + amountReceiving: '104 DAI', + destinationTokenDecimals: 18, + destinationTokenSymbol: 'DAI', + destinationTokenValue: '104000000000000000000', + isBestQuote: false, + liquiditySource: 'RFQ', + metaMaskFee: '1.04 DAI', + networkFees: '$11.29', + quoteSource: 'RFQ', + rawNetworkFees: 10.29, + slippage: '1%', + sourceTokenDecimals: 18, + sourceTokenSymbol: 'ETH', + sourceTokenValue: '250000000000000000', + }, + { + aggId: 'Agg6', + amountReceiving: '105 DAI', + destinationTokenDecimals: 18, + destinationTokenSymbol: 'DAI', + destinationTokenValue: '105000000000000000000', + isBestQuote: false, + liquiditySource: 'DEX', + metaMaskFee: '1.05 DAI', + networkFees: '$10.30', + quoteSource: 'DEX', + rawNetworkFees: 10.30, + slippage: '1%', + sourceTokenDecimals: 18, + sourceTokenSymbol: 'ETH', + sourceTokenValue: '250000000000000000', + }, +] + +export default quoteDataRows diff --git a/ui/app/pages/swaps/select-quote-popover/quote-details/index.js b/ui/app/pages/swaps/select-quote-popover/quote-details/index.js new file mode 100644 index 000000000..ffe541b1e --- /dev/null +++ b/ui/app/pages/swaps/select-quote-popover/quote-details/index.js @@ -0,0 +1 @@ +export { default } from './quote-details' diff --git a/ui/app/pages/swaps/select-quote-popover/quote-details/index.scss b/ui/app/pages/swaps/select-quote-popover/quote-details/index.scss new file mode 100644 index 000000000..8de9ed0c5 --- /dev/null +++ b/ui/app/pages/swaps/select-quote-popover/quote-details/index.scss @@ -0,0 +1,102 @@ +.quote-details { + display: flex; + flex-flow: column; + padding-left: 16px; + padding-right: 16px; + + &__detail-header { + @include H7; + + color: $Grey-400; + margin-bottom: 2px; + display: flex; + } + + &__detail-content { + @include H6; + + color: $Black-100; + + > div { + justify-content: flex-start; + } + } + + &__conversion-rate { + color: $Black-100; + justify-content: flex-start; + align-items: center; + height: inherit; + + .view-quote__conversion-rate-eth-label { + color: $Black-100; + } + + i { + color: $Blue-500; + } + + * { + margin-right: 4px; + } + } + + .view-quote__view-other-button { + margin-top: 20px; + } + + &__popover-wrap { + .popover-content { + margin-left: 24px; + } + } + + &__light-grey { + color: #bbc0c5; + } + + &__row { + height: 60px; + padding-top: 16px; + padding-bottom: 16px; + display: flex; + flex-flow: column; + justify-content: center; + border-top: 1px solid $Grey-100; + + &--high { + min-height: 60px; + height: inherit; + } + } + + .view-quote__conversion-rate-token-label { + @include H6; + + color: $Black-100; + font-weight: bold; + margin-left: 2px; + } + + &__metafox-logo { + width: 17px; + margin-right: 4px; + } + + .info-tooltip { + margin-left: 2px; + } + + &--high { + min-height: 60px; + height: inherit; + } + + &__font-small { + @include H7; + } + + &__bold { + font-weight: bold; + } +} diff --git a/ui/app/pages/swaps/select-quote-popover/quote-details/quote-details.js b/ui/app/pages/swaps/select-quote-popover/quote-details/quote-details.js new file mode 100644 index 000000000..3944996f4 --- /dev/null +++ b/ui/app/pages/swaps/select-quote-popover/quote-details/quote-details.js @@ -0,0 +1,110 @@ +import React, { useContext } from 'react' +import PropTypes from 'prop-types' +import { useSelector } from 'react-redux' +import { I18nContext } from '../../../../contexts/i18n' +import { getMetaMaskFeeAmount } from '../../../../ducks/swaps/swaps' +import InfoTooltip from '../../../../components/ui/info-tooltip' +import ExchangeRateDisplay from '../../exchange-rate-display' + +const QuoteDetails = ({ + slippage, + sourceTokenValue, + sourceTokenSymbol, + destinationTokenValue, + destinationTokenSymbol, + liquiditySourceKey, + minimumAmountReceived, + feeInEth, + networkFees, +}) => { + const t = useContext(I18nContext) + const metaMaskFee = useSelector(getMetaMaskFeeAmount) + return ( +
+
+
{t('swapRate')}
+
+ +
+
+
+
+ {t('swapMaxSlippage')} + +
+
+ {`${slippage}%`} +
+
+
+
+ {t('swapAmountReceived')} + +
+
+ {minimumAmountReceived}{` ${destinationTokenSymbol}`} +
+
+
+
+ {t('swapEstimatedNetworkFees')} + +
+
+ {feeInEth}{` (${networkFees})`} +
+
+
+
+ {t('swapSource')} + +
+
+ {t(liquiditySourceKey)} +
+
+
+
+ + {t('swapMetaMaskFee')} +
+
+ {t('swapMetaMaskFeeDescription', [metaMaskFee])} +
+
+
+ ) +} + +QuoteDetails.propTypes = { + slippage: PropTypes.string.isRequired, + sourceTokenValue: PropTypes.string.isRequired, + sourceTokenSymbol: PropTypes.string.isRequired, + destinationTokenValue: PropTypes.string.isRequired, + destinationTokenSymbol: PropTypes.string.isRequired, + liquiditySourceKey: PropTypes.string.isRequired, + minimumAmountReceived: PropTypes.string.isRequired, + feeInEth: PropTypes.string.isRequired, + networkFees: PropTypes.string.isRequired, +} + +export default QuoteDetails diff --git a/ui/app/pages/swaps/select-quote-popover/select-quote-popover-constants.js b/ui/app/pages/swaps/select-quote-popover/select-quote-popover-constants.js new file mode 100644 index 000000000..7002ce485 --- /dev/null +++ b/ui/app/pages/swaps/select-quote-popover/select-quote-popover-constants.js @@ -0,0 +1,19 @@ +import PropTypes from 'prop-types' + +export const QUOTE_DATA_ROWS_PROPTYPES_SHAPE = PropTypes.shape({ + aggregatorId: PropTypes.string.isRequired, + amountReceiving: PropTypes.string.isRequired, + destinationTokenDecimals: PropTypes.number.isRequired, + destinationTokenSymbol: PropTypes.string.isRequired, + destinationTokenValue: PropTypes.string.isRequired, + isBestQuote: PropTypes.bool.isRequired, + liquiditySource: PropTypes.string.isRequired, + metaMaskFee: PropTypes.string.isRequired, + networkFees: PropTypes.string.isRequired, + quoteSource: PropTypes.string.isRequired, + rawNetworkFees: PropTypes.number.isRequired, + slippage: PropTypes.string.isRequired, + sourceTokenDecimals: PropTypes.number.isRequired, + sourceTokenSymbol: PropTypes.string.isRequired, + sourceTokenValue: PropTypes.string.isRequired, +}) diff --git a/ui/app/pages/swaps/select-quote-popover/select-quote-popover.js b/ui/app/pages/swaps/select-quote-popover/select-quote-popover.js new file mode 100644 index 000000000..905dedf87 --- /dev/null +++ b/ui/app/pages/swaps/select-quote-popover/select-quote-popover.js @@ -0,0 +1,112 @@ +import React, { useState, useCallback, useContext } from 'react' +import PropTypes from 'prop-types' +import { I18nContext } from '../../../contexts/i18n' +import Popover from '../../../components/ui/popover' +import Button from '../../../components/ui/button' +import QuoteDetails from './quote-details' +import SortList from './sort-list' +import { QUOTE_DATA_ROWS_PROPTYPES_SHAPE } from './select-quote-popover-constants' + +const SelectQuotePopover = ({ + quoteDataRows = [], + onClose = null, + onSubmit = null, + swapToSymbol, + initialAggId, + onQuoteDetailsIsOpened, +}) => { + const t = useContext(I18nContext) + + const [sortDirection, setSortDirection] = useState(1) + const [sortColumn, setSortColumn] = useState(null) + + const [selectedAggId, setSelectedAggId] = useState(initialAggId) + const [contentView, setContentView] = useState('sortList') + const [viewingAgg, setViewingAgg] = useState(null) + + const onSubmitClick = useCallback(() => { + onSubmit(selectedAggId) + onClose() + }, [selectedAggId, onClose, onSubmit]) + + const closeQuoteDetails = useCallback(() => { + setViewingAgg(null) + setContentView('sortList') + }, []) + + const onRowClick = useCallback((aggId) => setSelectedAggId(aggId), [setSelectedAggId]) + + const onCaretClick = useCallback((aggId) => { + const agg = quoteDataRows.find((quote) => quote.aggId === aggId) + setContentView('quoteDetails') + onQuoteDetailsIsOpened() + setViewingAgg(agg) + }, [quoteDataRows, onQuoteDetailsIsOpened]) + + const CustomBackground = useCallback(() => (
), [onClose]) + const footer = ( + <> + + + + + ) + + return ( +
+ + {contentView === 'sortList' && ( + + )} + {contentView === 'quoteDetails' && viewingAgg && ( + + )} + +
+ ) +} + +SelectQuotePopover.propTypes = { + onClose: PropTypes.func, + onSubmit: PropTypes.func, + swapToSymbol: PropTypes.string, + renderableData: PropTypes.array, + quoteDataRows: PropTypes.arrayOf(QUOTE_DATA_ROWS_PROPTYPES_SHAPE), + initialAggId: PropTypes.string, + onQuoteDetailsIsOpened: PropTypes.func, +} + +export default SelectQuotePopover diff --git a/ui/app/pages/swaps/select-quote-popover/select-quote-popover.stories.js b/ui/app/pages/swaps/select-quote-popover/select-quote-popover.stories.js new file mode 100644 index 000000000..b033e34ed --- /dev/null +++ b/ui/app/pages/swaps/select-quote-popover/select-quote-popover.stories.js @@ -0,0 +1,29 @@ +import React, { useState } from 'react' +import { action } from '@storybook/addon-actions' +import { object } from '@storybook/addon-knobs/react' +import Button from '../../../components/ui/button' +import mockQuoteData from './mock-quote-data' +import SelectQuotePopover from '.' + +export default { + title: 'SelectQuotePopover', +} + +export const Default = () => { + const [showPopover, setShowPopover] = useState(false) + + return ( +
+ + {showPopover && ( + setShowPopover(false)} + onSubmit={action('submit SelectQuotePopover')} + swapToSymbol="DAI" + initialAggId="Agg4" + /> + )} +
+ ) +} diff --git a/ui/app/pages/swaps/select-quote-popover/sort-list/index.js b/ui/app/pages/swaps/select-quote-popover/sort-list/index.js new file mode 100644 index 000000000..272a7ef4e --- /dev/null +++ b/ui/app/pages/swaps/select-quote-popover/sort-list/index.js @@ -0,0 +1 @@ +export { default } from './sort-list' diff --git a/ui/app/pages/swaps/select-quote-popover/sort-list/sort-list.js b/ui/app/pages/swaps/select-quote-popover/sort-list/sort-list.js new file mode 100644 index 000000000..e4cc24f10 --- /dev/null +++ b/ui/app/pages/swaps/select-quote-popover/sort-list/sort-list.js @@ -0,0 +1,176 @@ +import React, { useState, useContext, useMemo } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import BigNumber from 'bignumber.js' +import SunCheckIcon from '../../../../components/ui/icon/sun-check-icon.component' +import { I18nContext } from '../../../../contexts/i18n' +import { QUOTE_DATA_ROWS_PROPTYPES_SHAPE } from '../select-quote-popover-constants' +import InfoTooltip from '../../../../components/ui/info-tooltip' + +const ToggleArrows = () => ( + + + +) + +export default function SortList ({ + quoteDataRows, + selectedAggId, + onSelect, + onCaretClick, + swapToSymbol, + sortDirection, + setSortDirection, + sortColumn, + setSortColumn, +}) { + const t = useContext(I18nContext) + const [noRowHover, setRowNowHover] = useState(false) + + const onColumnHeaderClick = (nextSortColumn) => { + if (nextSortColumn === sortColumn) { + setSortDirection(sortDirection * -1) + } else { + setSortColumn(nextSortColumn) + } + } + + // This sort aims to do the following: + // If there is no selected sort column, then the best quotes should be first in the list + // If there is no selected sort column, then quotes that are not the best quotes should be in random order, after the first in the list + // If the sort column is 'liquiditySource', sort alphabetically by 'liquiditySource' + // Otherwise, sort in either ascending or descending numerical order on the selected column + const sortedRows = useMemo(() => { + return [...quoteDataRows].sort((rowDataA, rowDataB) => { + if (sortColumn === null && rowDataA.isBestQuote) { + return -1 + } else if (sortColumn === null && rowDataB.isBestQuote) { + return 1 + } else if (sortColumn === null) { + // Here, the last character in the destinationTokenValue is used as a source of randomness for sorting + const aHex = (new BigNumber(rowDataA.destinationTokenValue).toString(16)) + const bHex = (new BigNumber(rowDataB.destinationTokenValue).toString(16)) + return aHex[aHex.length - 1] < bHex[bHex.length - 1] ? -1 : 1 + } else if (sortColumn === 'liquiditySource') { + return rowDataA[sortColumn] > rowDataB[sortColumn] + ? sortDirection * -1 + : sortDirection + } + return (new BigNumber(rowDataA[sortColumn])).gt(rowDataB[sortColumn]) + ? sortDirection * -1 + : sortDirection + + }) + }, [quoteDataRows, sortColumn, sortDirection]) + const selectedRow = sortedRows.findIndex(({ aggId }) => selectedAggId === aggId) + + return ( +
+
+
onColumnHeaderClick('destinationTokenValue')} + > +
+ {swapToSymbol} +
+ {t('swapReceiving')} + + +
+
+
+
onColumnHeaderClick('rawNetworkFees')} + > +
+ {t('swapEstimatedNetworkFees')} + + +
+
+
onColumnHeaderClick('liquiditySource')} + > + <> + {t('swapQuoteSource')} +
+ +
+
+
+ { + sortedRows.map(({ destinationTokenValue, networkFees, isBestQuote, quoteSource, aggId }, i) => ( +
onSelect(aggId)} + key={`select-quote-popover-row-${i}`} + > +
+
+ {isBestQuote && } +
{destinationTokenValue}
+
+ { quoteSource === 'RFQ' && {t('swapZeroSlippage')} } +
+
+ {networkFees} +
+
+
+ {quoteSource} +
+
+
{ + event.stopPropagation() + onCaretClick(aggId) + }} + onMouseEnter={() => setRowNowHover(true)} + onMouseLeave={() => setRowNowHover(false)} + > + +
+
+ )) + } +
+
+ ) +} + +SortList.propTypes = { + selectedAggId: PropTypes.string.isRequired, + onSelect: PropTypes.func.isRequired, + onCaretClick: PropTypes.func.isRequired, + swapToSymbol: PropTypes.string.isRequired, + quoteDataRows: PropTypes.arrayOf(QUOTE_DATA_ROWS_PROPTYPES_SHAPE).isRequired, + sortDirection: PropTypes.number.isRequired, + setSortDirection: PropTypes.func.isRequired, + sortColumn: PropTypes.string.isRequired, + setSortColumn: PropTypes.func.isRequired, +} diff --git a/ui/app/pages/swaps/slippage-buttons/index.js b/ui/app/pages/swaps/slippage-buttons/index.js new file mode 100644 index 000000000..1f0be2fd6 --- /dev/null +++ b/ui/app/pages/swaps/slippage-buttons/index.js @@ -0,0 +1 @@ +export { default } from './slippage-buttons' diff --git a/ui/app/pages/swaps/slippage-buttons/index.scss b/ui/app/pages/swaps/slippage-buttons/index.scss new file mode 100644 index 000000000..bcc95ff58 --- /dev/null +++ b/ui/app/pages/swaps/slippage-buttons/index.scss @@ -0,0 +1,132 @@ +.slippage-buttons { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + + &__header { + display: flex; + align-items: center; + color: $Blue-500; + margin-bottom: 0; + margin-left: auto; + margin-right: auto; + cursor: pointer; + + &--open { + margin-bottom: 8px; + } + } + + &__header-text { + @include H6; + + margin-right: 6px; + color: $Blue-500; + font-weight: 900; + } + + &__content { + padding-left: 10px; + } + + &__dropdown-content { + display: flex; + align-items: center; + } + + &__buttons-prefix { + display: flex; + align-items: center; + margin-right: 8px; + } + + &__prefix-text { + @include H6; + + margin-right: 4px; + color: black; + font-weight: 900; + } + + &__error-text { + @include H7; + + color: $Red-500; + margin-top: 8px; + } + + &__button-group { + & &-custom-button { + cursor: text; + display: flex; + align-items: center; + justify-content: center; + position: relative; + min-width: 72px; + margin-right: 0; + } + } + + &__custom-input { + display: flex; + justify-content: center; + + input { + border: none; + width: 64px; + text-align: center; + background: $Blue-500; + color: white; + font-weight: inherit; + + &::-webkit-input-placeholder { /* WebKit, Blink, Edge */ + color: white; + } + + &:-moz-placeholder { /* Mozilla Firefox 4 to 18 */ + color: white; + opacity: 1; + } + + &::-moz-placeholder { /* Mozilla Firefox 19+ */ + color: white; + opacity: 1; + } + + &:-ms-input-placeholder { /* Internet Explorer 10-11 */ + color: white; + } + + &::-ms-input-placeholder { /* Microsoft Edge */ + color: white; + } + + &::placeholder { /* Most modern browsers support this now. */ + color: white; + } + } + + input::-webkit-outer-spin-button, + input::-webkit-inner-spin-button { + /* display: none; <- Crashes Chrome on hover */ + -webkit-appearance: none; + margin: 0; /* <-- Apparently some margin are still there even though it's hidden */ + } + + input[type=number] { + -moz-appearance: textfield; + } + + &--danger { + input { + background: $Red-500; + } + } + } + + &__percentage-suffix { + position: absolute; + right: 5px; + } +} diff --git a/ui/app/pages/swaps/slippage-buttons/slippage-buttons.js b/ui/app/pages/swaps/slippage-buttons/slippage-buttons.js new file mode 100644 index 000000000..839074f8f --- /dev/null +++ b/ui/app/pages/swaps/slippage-buttons/slippage-buttons.js @@ -0,0 +1,142 @@ +import React, { useState, useEffect, useContext } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { I18nContext } from '../../../contexts/i18n' +import ButtonGroup from '../../../components/ui/button-group' +import Button from '../../../components/ui/button' +import InfoTooltip from '../../../components/ui/info-tooltip' + +export default function SlippageButtons ({ + onSelect, +}) { + const t = useContext(I18nContext) + const [open, setOpen] = useState(false) + const [customValue, setCustomValue] = useState('') + const [enteringCustomValue, setEnteringCustomValue] = useState(false) + const [activeButtonIndex, setActiveButtonIndex] = useState(1) + const [inputRef, setInputRef] = useState(null) + + let errorText = '' + if (customValue && Number(customValue) <= 0) { + errorText = t('swapSlippageTooLow') + } else if (customValue && Number(customValue) < 0.5) { + errorText = t('swapLowSlippageError') + } else if (customValue && Number(customValue) > 5) { + errorText = t('swapHighSlippageWarning') + } + + const customValueText = customValue || t('swapCustom') + + useEffect(() => { + if (inputRef && enteringCustomValue && window.document.activeElement !== inputRef) { + inputRef.focus() + } + }, [inputRef, enteringCustomValue]) + + return ( +
+
setOpen(!open)} + className={classnames('slippage-buttons__header', { + 'slippage-buttons__header--open': open, + })} + > +
{t('swapsAdvancedOptions')}
+ {open ? : } +
+
+ {open && ( +
+
+
{t('swapsMaxSlippage')}
+ +
+ + + + + +
+ )} + {errorText && ( +
+ { errorText } +
+ )} +
+
+ ) +} + +SlippageButtons.propTypes = { + onSelect: PropTypes.func.isRequired, +} diff --git a/ui/app/pages/swaps/slippage-buttons/slippage-buttons.stories.js b/ui/app/pages/swaps/slippage-buttons/slippage-buttons.stories.js new file mode 100644 index 000000000..6168cd618 --- /dev/null +++ b/ui/app/pages/swaps/slippage-buttons/slippage-buttons.stories.js @@ -0,0 +1,15 @@ +import React from 'react' +import { action } from '@storybook/addon-actions' +import SlippageButtons from '.' + +export default { + title: 'SlippageButtons', +} + +export const Default = () => ( +
+ +
+) diff --git a/ui/app/pages/swaps/swaps-footer/index.js b/ui/app/pages/swaps/swaps-footer/index.js new file mode 100644 index 000000000..5672e3c1b --- /dev/null +++ b/ui/app/pages/swaps/swaps-footer/index.js @@ -0,0 +1 @@ +export { default } from './swaps-footer' diff --git a/ui/app/pages/swaps/swaps-footer/index.scss b/ui/app/pages/swaps/swaps-footer/index.scss new file mode 100644 index 000000000..411dd59b1 --- /dev/null +++ b/ui/app/pages/swaps/swaps-footer/index.scss @@ -0,0 +1,61 @@ +.swaps-footer { + width: 100%; + + &--warning { + .btn-primary { + background: $Red-500; + border-color: $Red-500; + } + } + + + @media screen and (max-width: 576px) { + &--border { + .swaps-footer__custom-page-container-footer-class { + border-top: 1px solid #d2d8dd; + } + } + } + + &__custom-page-container-footer-class { + border-top: none; + + @media screen and (min-width: 576px) { + height: 96px; + } + } + + &__custom-page-container-footer-button-class { + border-radius: 100px; + height: 39px; + width: 140px; + + &--single { + width: 307px; + } + } + + &__bottom-text { + @include H7; + + color: $Blue-500; + margin-bottom: 16px; + cursor: pointer; + display: flex; + justify-content: center; + + @media screen and (min-width: 576px) { + margin-top: 0; + } + } + + &__buttons { + @media screen and (max-width: 576px) { + &--border { + .swaps-footer__custom-page-container-footer-class { + border-top: 1px solid #d2d8dd; + } + } + } + } +} diff --git a/ui/app/pages/swaps/swaps-footer/swaps-footer.js b/ui/app/pages/swaps/swaps-footer/swaps-footer.js new file mode 100644 index 000000000..5ef4337d6 --- /dev/null +++ b/ui/app/pages/swaps/swaps-footer/swaps-footer.js @@ -0,0 +1,60 @@ +import React, { useContext } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { I18nContext } from '../../../contexts/i18n' + +import PageContainerFooter from '../../../components/ui/page-container/page-container-footer' + +export default function SwapsFooter ({ + onCancel, + hideCancel, + onSubmit, + submitText, + disabled, + showTermsOfService, + showTopBorder, +}) { + const t = useContext(I18nContext) + + return ( +
+
+ +
+ {showTermsOfService && ( +
global.platform.openTab({ url: 'https://metamask.io/terms.html' })} + > + {t('termsOfService')} +
+ )} +
+ ) +} + +SwapsFooter.propTypes = { + onCancel: PropTypes.func, + hideCancel: PropTypes.bool, + onSubmit: PropTypes.func.isRequired, + submitText: PropTypes.string, + disabled: PropTypes.bool, + showTermsOfService: PropTypes.bool, + showTopBorder: PropTypes.bool, +} diff --git a/ui/app/pages/swaps/swaps-util-test-constants.js b/ui/app/pages/swaps/swaps-util-test-constants.js new file mode 100644 index 000000000..a4cd57677 --- /dev/null +++ b/ui/app/pages/swaps/swaps-util-test-constants.js @@ -0,0 +1,118 @@ +export const TRADES_BASE_PROD_URL = 'https://api.metaswap.codefi.network/trades?' +export const TOKENS_BASE_PROD_URL = 'https://api.metaswap.codefi.network/tokens' +export const AGGREGATOR_METADATA_BASE_PROD_URL = 'https://api.metaswap.codefi.network/aggregatorMetadata' +export const TOP_ASSET_BASE_PROD_URL = 'https://api.metaswap.codefi.network/topAssets' + +export const TOKENS = [ + { erc20: true, symbol: 'META', decimals: 18, address: '0x617b3f8050a0BD94b6b1da02B4384eE5B4DF13F4' }, + { erc20: true, symbol: 'ZRX', decimals: 18, address: '0xE41d2489571d322189246DaFA5ebDe1F4699F498' }, + { erc20: true, symbol: 'AST', decimals: 4, address: '0x27054b13b1B798B345b591a4d22e6562d47eA75a' }, + { erc20: true, symbol: 'BAT', decimals: 18, address: '0x0D8775F648430679A709E98d2b0Cb6250d2887EF' }, + { erc20: true, symbol: 'CVL', decimals: 18, address: '0x01FA555c97D7958Fa6f771f3BbD5CCD508f81e22' }, + { erc20: true, symbol: 'GLA', decimals: 8, address: '0x71D01dB8d6a2fBEa7f8d434599C237980C234e4C' }, + { erc20: true, symbol: 'GNO', decimals: 18, address: '0x6810e776880C02933D47DB1b9fc05908e5386b96' }, + { erc20: true, symbol: 'OMG', decimals: 18, address: '0xd26114cd6EE289AccF82350c8d8487fedB8A0C07' }, + { erc20: true, symbol: 'SAI', decimals: 18, address: '0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359' }, + { erc20: true, symbol: 'USDT', decimals: 6, address: '0xdAC17F958D2ee523a2206206994597C13D831ec7' }, + { erc20: true, symbol: 'WED', decimals: 18, address: '0x7848ae8F19671Dc05966dafBeFbBbb0308BDfAbD' }, + { erc20: true, symbol: 'WBTC', decimals: 8, address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599' }, +] + +export const MOCK_TRADE_RESPONSE_1 = [ + { + 'trade': { // the ethereum transaction data for the swap + 'data': '0xa6c3bf330000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000000000004e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000005591360f8c7640fea5771c9682d6b5ecb776e1f8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000021486a000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005efe3c3b5dfc3a75ffc8add04bbdbac1e42fa234bf4549d8dab1bc44c8056eaf0e1dfe8600000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000003c00000000000000000000000000000000000000000000000000000000000000420000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000000000000000000000000000001c4dc1600f3000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000005591360f8c7640fea5771c9682d6b5ecb776e1f800000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000036691c4f426eb8f42f150ebde43069a31cb080ad000000000000000000000000000000000000000000000000002386f26fc10000000000000000000000000000000000000000000000000000000000000021486a00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000020000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000024f47261b0000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010400000000000000000000000000000000000000000000000000000000000000869584cd0000000000000000000000001000000000000000000000000000000000000011000000000000000000000000000000000000000000000000000000005efe201b', + 'from': '0x2369267687A84ac7B494daE2f1542C40E37f4455', + 'value': '5700000000000000', + 'to': '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', + }, + 'sourceAmount': '10000000000000000', + 'destinationAmount': '2248687', + 'error': null, + 'sourceToken': TOKENS[0].address, + 'destinationToken': TOKENS[1].address, + 'fetchTime': 553, + 'aggregator': 'zeroEx', + 'averageGas': 1, + 'maxGas': 10, + 'aggType': 'AGG', + 'approvalNeeded': { // the ethereum transaction data for the approval (if needed) + 'data': '0x095ea7b300000000000000000000000095e6f48254609a6ee006f7d493c8e5fb97094cef0000000000000000000000000000000000000000004a817c7ffffffdabf41c00', + 'to': '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + 'amount': '0', + 'from': '0x2369267687A84ac7B494daE2f1542C40E37f4455', + 'gas': '12', + 'gasPrice': '34', + }, + }, + { + 'sourceAmount': '10000000000000000', + 'destinationAmount': '2248687', + 'error': null, + 'sourceToken': TOKENS[0].address, + 'destinationToken': TOKENS[1].address, + 'fetchTime': 553, + 'aggregator': 'zeroEx', + 'aggType': 'AGG', + 'approvalNeeded': { // the ethereum transaction data for the approval (if needed) + 'data': '0x095ea7b300000000000000000000000095e6f48254609a6ee006f7d493c8e5fb97094cef0000000000000000000000000000000000000000004a817c7ffffffdabf41c00', + 'to': '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + 'value': '0', + 'from': '0x2369267687A84ac7B494daE2f1542C40E37f4455', + }, + }, + { + 'trade': { // the ethereum transaction data for the swap + 'data': '0xa6c3bf330000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000000000004e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000005591360f8c7640fea5771c9682d6b5ecb776e1f8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000021486a000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005efe3c3b5dfc3a75ffc8add04bbdbac1e42fa234bf4549d8dab1bc44c8056eaf0e1dfe8600000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000003c00000000000000000000000000000000000000000000000000000000000000420000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000000000000000000000000000001c4dc1600f3000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000005591360f8c7640fea5771c9682d6b5ecb776e1f800000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000036691c4f426eb8f42f150ebde43069a31cb080ad000000000000000000000000000000000000000000000000002386f26fc10000000000000000000000000000000000000000000000000000000000000021486a00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000020000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000024f47261b0000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010400000000000000000000000000000000000000000000000000000000000000869584cd0000000000000000000000001000000000000000000000000000000000000011000000000000000000000000000000000000000000000000000000005efe201b', + 'from': '0x2369267687A84ac7B494daE2f1542C40E37f4455', + 'value': '5700000000000000', + 'to': '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', + }, + 'sourceAmount': '10000000000000000', + 'destinationAmount': '2248687', + 'error': true, + 'sourceToken': TOKENS[0].address, + 'destinationToken': TOKENS[1].address, + 'fetchTime': 553, + 'aggregator': 'zeroEx', + 'aggType': 'AGG', + }, +] + +export const MOCK_TRADE_RESPONSE_2 = MOCK_TRADE_RESPONSE_1.map((trade) => ({ ...trade, sourceAmount: '20000000000000000' })) + +export const AGGREGATOR_METADATA = { + 'agg1': { + 'color': '#283B4C', + 'title': 'agg1', + 'icon': 'data:image/png;base64,iVBORw0KGgoAAA', + }, + 'agg2': { + 'color': '#283B4C', + 'title': 'agg2', + 'icon': 'data:image/png;base64,iVBORw0KGgoAAA', + }, +} + +export const TOP_ASSETS = [ + { + 'symbol': 'LINK', + 'address': '0x514910771af9ca656af840dff83e8264ecf986ca', + }, + { + 'symbol': 'UMA', + 'address': '0x04fa0d235c4abf4bcf4787af4cf447de572ef828', + }, + { + 'symbol': 'YFI', + 'address': '0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e', + }, + { + 'symbol': 'LEND', + 'address': '0x80fb784b7ed66730e8b1dbd9820afd29931aab03', + }, + { + 'symbol': 'SNX', + 'address': '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', + }, +] diff --git a/ui/app/pages/swaps/swaps.util.js b/ui/app/pages/swaps/swaps.util.js new file mode 100644 index 000000000..679e51929 --- /dev/null +++ b/ui/app/pages/swaps/swaps.util.js @@ -0,0 +1,398 @@ +import log from 'loglevel' +import BigNumber from 'bignumber.js' +import abi from 'human-standard-token-abi' +import { isValidAddress } from 'ethereumjs-util' +import { calcTokenValue, calcTokenAmount } from '../../helpers/utils/token-util' +import { constructTxParams, toPrecisionWithoutTrailingZeros } from '../../helpers/utils/util' +import { decimalToHex, getValueFromWeiHex } from '../../helpers/utils/conversions.util' +import { subtractCurrencies } from '../../helpers/utils/conversion-util' +import { formatCurrency } from '../../helpers/utils/confirm-tx.util' +import fetchWithCache from '../../helpers/utils/fetch-with-cache' + +import { calcGasTotal } from '../send/send.utils' + +const TOKEN_TRANSFER_LOG_TOPIC_HASH = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' + +const CACHE_REFRESH_ONE_HOUR = 3600000 + +const getBaseApi = function (type) { + switch (type) { + case 'trade': + return `https://api.metaswap.codefi.network/trades?` + case 'tokens': + return `https://api.metaswap.codefi.network/tokens` + case 'topAssets': + return `https://api.metaswap.codefi.network/topAssets` + case 'featureFlag': + return `https://api.metaswap.codefi.network/featureFlag` + case 'aggregatorMetadata': + return `https://api.metaswap.codefi.network/aggregatorMetadata` + case 'feeAmount': + return `https://api.metaswap.codefi.network/fee` + default: + throw new Error('getBaseApi requires an api call type') + } +} + +const validHex = (string) => Boolean(string?.match(/^0x[a-f0-9]+$/u)) +const truthyString = (string) => Boolean(string?.length) +const truthyDigitString = (string) => truthyString(string) && Boolean(string.match(/^\d+$/u)) + +const QUOTE_VALIDATORS = [ + { + property: 'trade', + type: 'object', + validator: (trade) => trade && validHex(trade.data) && isValidAddress(trade.to) && isValidAddress(trade.from) && truthyString(trade.value), + }, + { + property: 'approvalNeeded', + type: 'object', + validator: (approvalTx) => ( + approvalTx === null || + ( + approvalTx && + validHex(approvalTx.data) && + isValidAddress(approvalTx.to) && + isValidAddress(approvalTx.from) + ) + ), + }, + { + property: 'sourceAmount', + type: 'string', + validator: truthyDigitString, + }, + { + property: 'destinationAmount', + type: 'string', + validator: truthyDigitString, + }, + { + property: 'sourceToken', + type: 'string', + validator: isValidAddress, + }, + { + property: 'destinationToken', + type: 'string', + validator: isValidAddress, + }, + { + property: 'aggregator', + type: 'string', + validator: truthyString, + }, + { + property: 'aggType', + type: 'string', + validator: truthyString, + }, + { + property: 'error', + type: 'object', + validator: (error) => error === null || typeof error === 'object', + }, + { + property: 'averageGas', + type: 'number', + }, + { + property: 'maxGas', + type: 'number', + }, +] + +const TOKEN_VALIDATORS = [ + { + property: 'address', + type: 'string', + validator: isValidAddress, + }, + { + property: 'symbol', + type: 'string', + validator: (string) => truthyString(string) && string.length <= 12, + }, + { + property: 'decimals', + type: 'string|number', + validator: (string) => Number(string) >= 0 && Number(string) <= 36, + }, +] + +const TOP_ASSET_VALIDATORS = TOKEN_VALIDATORS.slice(0, 2) + +const AGGREGATOR_METADATA_VALIDATORS = [ + { + property: 'color', + type: 'string', + validator: (string) => Boolean(string.match(/^#[A-Fa-f0-9]+$/u)), + }, + { + property: 'title', + type: 'string', + validator: truthyString, + }, + { + property: 'icon', + type: 'string', + validator: (string) => Boolean(string.match(/^data:image/u)), + }, +] + +function validateData (validators, object, urlUsed) { + return validators.every(({ property, type, validator }) => { + const types = type.split('|') + + const valid = types.some((_type) => typeof object[property] === _type) && (!validator || validator(object[property])) + if (!valid) { + log.error(`response to GET ${urlUsed} invalid for property ${property}; value was:`, object[property], '| type was: ', typeof object[property]) + } + return valid + }) +} + +export async function fetchTradesInfo ({ + slippage, + sourceToken, + sourceDecimals, + destinationToken, + value, + fromAddress, + exchangeList, +}) { + const urlParams = { + destinationToken, + sourceToken, + sourceAmount: calcTokenValue(value, sourceDecimals).toString(10), + slippage, + timeout: 10000, + walletAddress: fromAddress, + } + + if (exchangeList) { + urlParams.exchangeList = exchangeList + } + + const queryString = new URLSearchParams(urlParams).toString() + const tradeURL = `${getBaseApi('trade')}${queryString}` + const tradesResponse = await fetchWithCache(tradeURL, { method: 'GET' }, { cacheRefreshTime: 0, timeout: 15000 }) + const newQuotes = tradesResponse.reduce((aggIdTradeMap, quote) => { + if (quote.trade && !quote.error && validateData(QUOTE_VALIDATORS, quote, tradeURL)) { + const constructedTrade = constructTxParams({ + to: quote.trade.to, + from: quote.trade.from, + data: quote.trade.data, + amount: decimalToHex(quote.trade.value), + gas: decimalToHex(quote.maxGas), + }) + + let { approvalNeeded } = quote + + if (approvalNeeded) { + approvalNeeded = constructTxParams({ + ...approvalNeeded, + }) + } + + return { + ...aggIdTradeMap, + [quote.aggregator]: { + ...quote, + slippage, + trade: constructedTrade, + approvalNeeded, + }, + } + } + return aggIdTradeMap + }, {}) + + return newQuotes +} + +export async function fetchTokens () { + const tokenUrl = getBaseApi('tokens') + const tokens = await fetchWithCache(tokenUrl, { method: 'GET' }, { cacheRefreshTime: CACHE_REFRESH_ONE_HOUR }) + const filteredTokens = tokens.filter((token) => validateData(TOKEN_VALIDATORS, token, tokenUrl)) + return filteredTokens +} + +export async function fetchAggregatorMetadata () { + const aggregatorMetadataUrl = getBaseApi('aggregatorMetadata') + const aggregators = await fetchWithCache(aggregatorMetadataUrl, { method: 'GET' }, { cacheRefreshTime: CACHE_REFRESH_ONE_HOUR }) + const filteredAggregators = {} + for (const aggKey in aggregators) { + if (validateData(AGGREGATOR_METADATA_VALIDATORS, aggregators[aggKey], aggregatorMetadataUrl)) { + filteredAggregators[aggKey] = aggregators[aggKey] + } + } + return filteredAggregators +} + +export async function fetchTopAssets () { + const topAssetsUrl = getBaseApi('topAssets') + const response = await fetchWithCache(topAssetsUrl, { method: 'GET' }, { cacheRefreshTime: CACHE_REFRESH_ONE_HOUR }) + const topAssetsMap = response.reduce((_topAssetsMap, asset, index) => { + if (validateData(TOP_ASSET_VALIDATORS, asset, topAssetsUrl)) { + return { ..._topAssetsMap, [asset.address]: { index: String(index) } } + } + return _topAssetsMap + }, {}) + return topAssetsMap +} + +export async function fetchSwapsFeatureLiveness () { + const status = await fetchWithCache(getBaseApi('featureFlag'), { method: 'GET' }, { cacheRefreshTime: 600000 }) + return status?.active +} + +export async function fetchMetaMaskFeeAmount () { + const response = await fetchWithCache(getBaseApi('feeAmount'), { method: 'GET' }, { cacheRefreshTime: 600000 }) + return response?.fee +} + +export async function fetchTokenPrice (address) { + const query = `contract_addresses=${address}&vs_currencies=eth` + + const prices = await fetchWithCache(`https://api.coingecko.com/api/v3/simple/token_price/ethereum?${query}`, { method: 'GET' }, { cacheRefreshTime: 60000 }) + return prices && prices[address]?.eth +} + +export async function fetchTokenBalance (address, userAddress) { + const tokenContract = global.eth.contract(abi).at(address) + const tokenBalancePromise = tokenContract + ? tokenContract.balanceOf(userAddress) + : Promise.resolve() + const usersToken = await tokenBalancePromise + return usersToken +} + +export function getRenderableGasFeesForQuote (tradeGas, approveGas, gasPrice, currentCurrency, conversionRate) { + const totalGasLimitForCalculation = (new BigNumber(tradeGas || '0x0', 16)).plus(approveGas || '0x0', 16).toString(16) + const gasTotalInWeiHex = calcGasTotal(totalGasLimitForCalculation, gasPrice) + + const ethFee = getValueFromWeiHex({ + value: gasTotalInWeiHex, + toDenomination: 'ETH', + numberOfDecimals: 6, + }) + const rawNetworkFees = getValueFromWeiHex({ + value: gasTotalInWeiHex, + toCurrency: currentCurrency, + conversionRate, + numberOfDecimals: 2, + }) + const formattedNetworkFee = formatCurrency(rawNetworkFees, currentCurrency) + return { + rawNetworkFees, + rawEthFee: ethFee, + feeInFiat: formattedNetworkFee, + feeInEth: `${ethFee} ETH`, + } +} + +export function quotesToRenderableData (quotes, gasPrice, conversionRate, currentCurrency, approveGas, tokenConversionRates, customGasLimit) { + return Object.values(quotes).map((quote) => { + const { destinationAmount = 0, sourceAmount = 0, sourceTokenInfo, destinationTokenInfo, slippage, aggType, aggregator, gasEstimateWithRefund, averageGas } = quote + const sourceValue = calcTokenAmount(sourceAmount, sourceTokenInfo.decimals || 18).toString(10) + const destinationValue = calcTokenAmount(destinationAmount, destinationTokenInfo.decimals || 18).toPrecision(8) + + const { + feeInFiat, + rawNetworkFees, + rawEthFee, + feeInEth, + } = getRenderableGasFeesForQuote( + ( + customGasLimit || + gasEstimateWithRefund || + decimalToHex(averageGas || 800000) + ), + approveGas, + gasPrice, + currentCurrency, + conversionRate, + ) + + const metaMaskFee = `0.875%` + const slippageMultiplier = (new BigNumber(100 - slippage)).div(100) + const minimumAmountReceived = (new BigNumber(destinationValue)).times(slippageMultiplier).toFixed(6) + + const tokenConversionRate = tokenConversionRates[destinationTokenInfo.address] + const ethValueOfTrade = destinationTokenInfo.symbol === 'ETH' + ? calcTokenAmount(destinationAmount, destinationTokenInfo.decimals || 18).minus(rawEthFee, 10) + : (new BigNumber(tokenConversionRate || 0, 10)) + .times(calcTokenAmount(destinationAmount, destinationTokenInfo.decimals || 18), 10) + .minus(rawEthFee, 10) + + let liquiditySourceKey + let renderedSlippage = slippage + + if (aggType === 'AGG') { + liquiditySourceKey = 'swapAggregator' + } else if (aggType === 'RFQ') { + liquiditySourceKey = 'swapRequestForQuotation' + renderedSlippage = 0 + } else if (aggType === 'DEX') { + liquiditySourceKey = 'swapDecentralizedExchange' + } else { + liquiditySourceKey = 'swapUnknown' + } + + return { + aggId: aggregator, + amountReceiving: `${destinationValue} ${destinationTokenInfo.symbol}`, + destinationTokenDecimals: destinationTokenInfo.decimals, + destinationTokenSymbol: destinationTokenInfo.symbol, + destinationTokenValue: destinationValue, + isBestQuote: quote.isBestQuote, + liquiditySourceKey, + metaMaskFee, + feeInEth, + detailedNetworkFees: `${feeInEth} (${feeInFiat})`, + networkFees: feeInFiat, + quoteSource: aggType, + rawNetworkFees, + slippage: renderedSlippage, + sourceTokenDecimals: sourceTokenInfo.decimals, + sourceTokenSymbol: sourceTokenInfo.symbol, + sourceTokenValue: sourceValue, + ethValueOfTrade, + minimumAmountReceived, + } + }) +} + +export function getSwapsTokensReceivedFromTxMeta (tokenSymbol, txMeta, tokenAddress, accountAddress, tokenDecimals) { + if (tokenSymbol === 'ETH') { + if (!txMeta?.postTxBalance || !txMeta?.preTxBalance) { + return null + } + const ethReceived = subtractCurrencies(txMeta.postTxBalance, txMeta.preTxBalance, { + aBase: 16, + bBase: 16, + fromDenomination: 'WEI', + toDenomination: 'ETH', + toNumericBase: 'dec', + numberOfDecimals: 6, + }) + return ethReceived + } + const txReceipt = txMeta?.txReceipt + const txReceiptLogs = txReceipt?.logs + if (txReceiptLogs && txReceipt?.status !== '0x0') { + const tokenTransferLog = txReceiptLogs.find((txReceiptLog) => { + const isTokenTransfer = txReceiptLog.topics && txReceiptLog.topics[0] === TOKEN_TRANSFER_LOG_TOPIC_HASH + const isTransferFromGivenToken = txReceiptLog.address === tokenAddress + const isTransferFromGivenAddress = txReceiptLog.topics && txReceiptLog.topics[2] && txReceiptLog.topics[2].match(accountAddress.slice(2)) + return isTokenTransfer && isTransferFromGivenToken && isTransferFromGivenAddress + }) + return tokenTransferLog + ? toPrecisionWithoutTrailingZeros( + calcTokenAmount(tokenTransferLog.data, tokenDecimals).toString(10), 6, + ) + : '' + } + return null +} diff --git a/ui/app/pages/swaps/swaps.util.test.js b/ui/app/pages/swaps/swaps.util.test.js new file mode 100644 index 000000000..bf5b166da --- /dev/null +++ b/ui/app/pages/swaps/swaps.util.test.js @@ -0,0 +1,151 @@ +import { strict as assert } from 'assert' +import proxyquire from 'proxyquire' +import { + TRADES_BASE_PROD_URL, + TOKENS_BASE_PROD_URL, + AGGREGATOR_METADATA_BASE_PROD_URL, + TOP_ASSET_BASE_PROD_URL, + TOKENS, + MOCK_TRADE_RESPONSE_2, + AGGREGATOR_METADATA, + TOP_ASSETS, +} from './swaps-util-test-constants' + +const swapsUtils = proxyquire('./swaps.util.js', { + '../../store/actions': { + estimateGasFromTxParams: () => Promise.resolve('0x8888'), + }, + '../../helpers/utils/fetch-with-cache': { + default: (url, fetchObject) => { + assert.equal(fetchObject.method, 'GET') + if (url.match(TRADES_BASE_PROD_URL)) { + assert.equal(url, 'https://api.metaswap.codefi.network/trades?destinationToken=0xE41d2489571d322189246DaFA5ebDe1F4699F498&sourceToken=0x617b3f8050a0BD94b6b1da02B4384eE5B4DF13F4&sourceAmount=2000000000000000000000000000000000000&slippage=3&timeout=10000&walletAddress=0xmockAddress') + return Promise.resolve(MOCK_TRADE_RESPONSE_2) + } + if (url.match(TOKENS_BASE_PROD_URL)) { + assert.equal(url, TOKENS_BASE_PROD_URL) + return Promise.resolve(TOKENS) + } + if (url.match(AGGREGATOR_METADATA_BASE_PROD_URL)) { + assert.equal(url, AGGREGATOR_METADATA_BASE_PROD_URL) + return Promise.resolve(AGGREGATOR_METADATA) + } + if (url.match(TOP_ASSET_BASE_PROD_URL)) { + assert.equal(url, TOP_ASSET_BASE_PROD_URL) + return Promise.resolve(TOP_ASSETS) + } + return Promise.resolve() + }, + }, +}) +const { fetchTradesInfo, fetchTokens, fetchAggregatorMetadata, fetchTopAssets } = swapsUtils + +describe('Swaps Util', function () { + describe('fetchTradesInfo', function () { + const expectedResult1 = { + 'zeroEx': { + 'trade': { // the ethereum transaction data for the swap + 'data': '0xa6c3bf330000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000000000004e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000005591360f8c7640fea5771c9682d6b5ecb776e1f8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000021486a000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005efe3c3b5dfc3a75ffc8add04bbdbac1e42fa234bf4549d8dab1bc44c8056eaf0e1dfe8600000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000003c00000000000000000000000000000000000000000000000000000000000000420000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000000000000000000000000000001c4dc1600f3000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000005591360f8c7640fea5771c9682d6b5ecb776e1f800000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000036691c4f426eb8f42f150ebde43069a31cb080ad000000000000000000000000000000000000000000000000002386f26fc10000000000000000000000000000000000000000000000000000000000000021486a00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000020000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000024f47261b0000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010400000000000000000000000000000000000000000000000000000000000000869584cd0000000000000000000000001000000000000000000000000000000000000011000000000000000000000000000000000000000000000000000000005efe201b', + 'from': '0x2369267687A84ac7B494daE2f1542C40E37f4455', + 'value': '0x14401eab384000', + 'to': '0x61935cbdd02287b511119ddb11aeb42f1593b7ef', + 'gas': '0xa', + 'gasPrice': undefined, + }, + 'sourceAmount': '10000000000000000', + 'destinationAmount': '2248687', + 'error': null, + 'sourceToken': TOKENS[0].address, + 'destinationToken': TOKENS[1].address, + 'fetchTime': 553, + 'aggregator': 'zeroEx', + 'aggType': 'AGG', + 'approvalNeeded': { + 'data': '0x095ea7b300000000000000000000000095e6f48254609a6ee006f7d493c8e5fb97094cef0000000000000000000000000000000000000000004a817c7ffffffdabf41c00', + 'to': '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + 'value': '0x0', + 'from': '0x2369267687A84ac7B494daE2f1542C40E37f4455', + 'gas': '0x12', + 'gasPrice': '0x34', + }, + 'maxGas': 10, + 'averageGas': 1, + 'slippage': '3', + }, + } + const expectedResult2 = { + 'zeroEx': { + ...expectedResult1.zeroEx, + sourceAmount: '20000000000000000', + }, + } + it('should fetch trade info on prod', async function () { + const result = await fetchTradesInfo({ + TOKENS, + slippage: '3', + sourceToken: TOKENS[0].address, + destinationToken: TOKENS[1].address, + value: '2000000000000000000', + fromAddress: '0xmockAddress', + sourceSymbol: TOKENS[0].symbol, + sourceDecimals: TOKENS[0].decimals, + sourceTokenInfo: { ...TOKENS[0] }, + destinationTokenInfo: { ...TOKENS[1] }, + }) + assert.deepEqual(result, expectedResult2) + }) + }) + + describe('fetchTokens', function () { + it('should fetch tokens', async function () { + const result = await fetchTokens(true) + assert.deepEqual(result, TOKENS) + }) + + it('should fetch tokens on prod', async function () { + const result = await fetchTokens(false) + assert.deepEqual(result, TOKENS) + }) + }) + + describe('fetchAggregatorMetadata', function () { + it('should fetch aggregator metadata', async function () { + const result = await fetchAggregatorMetadata(true) + assert.deepEqual(result, AGGREGATOR_METADATA) + }) + + it('should fetch aggregator metadata on prod', async function () { + const result = await fetchAggregatorMetadata(false) + assert.deepEqual(result, AGGREGATOR_METADATA) + }) + }) + + describe('fetchTopAssets', function () { + const expectedResult = { + '0x514910771af9ca656af840dff83e8264ecf986ca': { + index: '0', + }, + '0x04fa0d235c4abf4bcf4787af4cf447de572ef828': { + index: '1', + }, + '0x0bc529c00c6401aef6d220be8c6ea1667f6ad93e': { + index: '2', + }, + '0x80fb784b7ed66730e8b1dbd9820afd29931aab03': { + index: '3', + }, + '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f': { + index: '4', + }, + } + it('should fetch top assets', async function () { + const result = await fetchTopAssets(true) + assert.deepEqual(result, expectedResult) + }) + + it('should fetch top assets on prod', async function () { + const result = await fetchTopAssets(false) + assert.deepEqual(result, expectedResult) + }) + }) +}) diff --git a/ui/app/pages/swaps/view-quote/index.js b/ui/app/pages/swaps/view-quote/index.js new file mode 100644 index 000000000..7e6d2fc6c --- /dev/null +++ b/ui/app/pages/swaps/view-quote/index.js @@ -0,0 +1 @@ +export { default } from './view-quote' diff --git a/ui/app/pages/swaps/view-quote/index.scss b/ui/app/pages/swaps/view-quote/index.scss new file mode 100644 index 000000000..7a6f8cb2d --- /dev/null +++ b/ui/app/pages/swaps/view-quote/index.scss @@ -0,0 +1,161 @@ +.view-quote { + display: flex; + flex-flow: column; + align-items: center; + flex: 1; + width: 100%; + + &__content { + display: flex; + flex-flow: column; + align-items: center; + width: 100%; + height: 100%; + padding-left: 20px; + padding-right: 20px; + + @media screen and (max-width: 576px) { + overflow-y: auto; + max-height: 388px; + } + } + + @media screen and (min-width: 576px) { + width: 348px; + } + + &__new-quote-countdown { + @include H7; + + font-weight: bold; + + &--danger { + span { + color: $Red-500; + } + } + } + + &__view-other-button-container { + border-radius: 28px; + margin-top: 38px; + width: 100%; + position: relative; + display: flex; + align-items: center; + justify-content: center; + + @media screen and (min-width: 576px) { + margin-top: auto; + } + } + + &__view-other-button, + &__view-other-button-fade { + display: flex; + align-items: center; + margin-bottom: 16px; + position: absolute; + + @include H7; + + color: white; + font-weight: bold; + cursor: pointer; + border-radius: 28px; + padding: 5px 18px; + background: linear-gradient(90deg, $Blue-500 0%, $Blue-400 101.32%); + + + @media screen and (min-width: 576px) { + @include H6; + + margin-bottom: 0; + } + + .fa-arrow-right { + margin-left: 4px; + font-size: 10px; + margin-top: 2px; + } + } + + &__view-other-button-fade { + background: #0372c3; + opacity: 0; + transition: opacity ease-in-out 1s; + + &:hover { + opacity: 1; + }; + } + + &__insufficient-eth-warning-wrapper { + margin-top: 8px; + width: 100%; + align-items: center; + justify-content: center; + + @media screen and (min-width: 576px) { + min-height: 36px; + display: flex; + } + } + + &__bold { + font-weight: bold; + } + + &__countdown-timer-container { + @media screen and (max-width: 576px) { + margin-top: 12px; + margin-bottom: 16px; + + &--thin { + margin-top: 8px; + margin-bottom: 8px; + + > div { + margin-top: 0; + margin-bottom: 0; + } + } + } + + @media screen and (min-width: 576px) { + &--thin { + margin-top: 6px; + } + } + } + + &__fee-card-container { + width: 100%; + margin-top: auto; + margin-bottom: 8px; + + @media screen and (max-width: 576px) { + margin-top: 16px; + } + + @media screen and (min-width: 576px) { + margin-bottom: 0; + + &--three-rows { + margin-bottom: -16px; + } + } + } + + &__main-quote-summary-container { + margin-top: 24px; + + @media screen and (max-width: 576px) { + margin-top: 0; + } + + &--thin { + margin-top: 8px; + } + } +} diff --git a/ui/app/pages/swaps/view-quote/view-quote.js b/ui/app/pages/swaps/view-quote/view-quote.js new file mode 100644 index 000000000..bd0b73203 --- /dev/null +++ b/ui/app/pages/swaps/view-quote/view-quote.js @@ -0,0 +1,559 @@ +import React, { useState, useContext, useMemo, useEffect, useRef } from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { useHistory } from 'react-router-dom' +import BigNumber from 'bignumber.js' +import { isEqual } from 'lodash' +import classnames from 'classnames' +import { I18nContext } from '../../../contexts/i18n' +import SelectQuotePopover from '../select-quote-popover' +import { useEqualityCheck } from '../../../hooks/useEqualityCheck' +import { useNewMetricEvent } from '../../../hooks/useMetricEvent' +import { MetaMetricsContext } from '../../../contexts/metametrics.new' +import FeeCard from '../fee-card' +import { setCustomGasLimit } from '../../../ducks/gas/gas.duck' +import { + getQuotes, + getSelectedQuote, + getApproveTxParams, + getFetchParams, + setBalanceError, + getQuotesLastFetched, + getBalanceError, + getMaxMode, + getCustomSwapsGas, + getDestinationTokenInfo, + getSwapsTradeTxParams, + getTopQuote, + navigateBackToBuildQuote, + signAndSendTransactions, + getBackgroundSwapRouteState, +} from '../../../ducks/swaps/swaps' +import { + conversionRateSelector, + getSelectedAccount, + getCurrentCurrency, + getTokenExchangeRates, +} from '../../../selectors' +import { toPrecisionWithoutTrailingZeros } from '../../../helpers/utils/util' +import { getTokens } from '../../../ducks/metamask/metamask' +import { + safeRefetchQuotes, + setCustomApproveTxData, + setSwapsTxGasLimit, + setSelectedQuoteAggId, + setSwapsErrorKey, + showModal, +} from '../../../store/actions' +import { + ASSET_ROUTE, + BUILD_QUOTE_ROUTE, + DEFAULT_ROUTE, + SWAPS_ERROR_ROUTE, + AWAITING_SWAP_ROUTE, +} from '../../../helpers/constants/routes' +import { getTokenData } from '../../../helpers/utils/transactions.util' +import { + calcTokenAmount, + calcTokenValue, + getTokenValueParam, +} from '../../../helpers/utils/token-util' +import { + decimalToHex, + hexMax, + hexToDecimal, + getValueFromWeiHex, +} from '../../../helpers/utils/conversions.util' +import MainQuoteSummary from '../main-quote-summary' +import { calcGasTotal } from '../../send/send.utils' +import { getCustomTxParamsData } from '../../confirm-approve/confirm-approve.util' +import ActionableMessage from '../actionable-message' +import { quotesToRenderableData, getRenderableGasFeesForQuote } from '../swaps.util' +import { useTokenTracker } from '../../../hooks/useTokenTracker' +import { + ETH_SWAPS_TOKEN_OBJECT, + QUOTES_EXPIRED_ERROR, +} from '../../../helpers/constants/swaps' +import CountdownTimer from '../countdown-timer' +import SwapsFooter from '../swaps-footer' + +export default function ViewQuote () { + const history = useHistory() + const dispatch = useDispatch() + const t = useContext(I18nContext) + const metaMetricsEvent = useContext(MetaMetricsContext) + + const [dispatchedSafeRefetch, setDispatchedSafeRefetch] = useState(false) + const [selectQuotePopoverShown, setSelectQuotePopoverShown] = useState(false) + const [warningHidden, setWarningHidden] = useState(false) + const [originalApproveAmount, setOriginalApproveAmount] = useState(null) + + const routeState = useSelector(getBackgroundSwapRouteState) + const quotes = useSelector(getQuotes, isEqual) + useEffect(() => { + if (!Object.values(quotes).length) { + history.push(BUILD_QUOTE_ROUTE) + } else if (routeState === 'awaiting') { + history.push(AWAITING_SWAP_ROUTE) + } + }, [history, quotes, routeState]) + + const quotesLastFetched = useSelector(getQuotesLastFetched) + + // Select necessary data + const tradeTxParams = useSelector(getSwapsTradeTxParams) + const { gasPrice } = tradeTxParams || {} + const customMaxGas = useSelector(getCustomSwapsGas) + const tokenConversionRates = useSelector(getTokenExchangeRates) + const memoizedTokenConversionRates = useEqualityCheck(tokenConversionRates) + const { balance: ethBalance } = useSelector(getSelectedAccount) + const conversionRate = useSelector(conversionRateSelector) + const currentCurrency = useSelector(getCurrentCurrency) + const swapsTokens = useSelector(getTokens) + const balanceError = useSelector(getBalanceError) + const maxMode = useSelector(getMaxMode) + const fetchParams = useSelector(getFetchParams) + const approveTxParams = useSelector(getApproveTxParams) + const selectedQuote = useSelector(getSelectedQuote) + const topQuote = useSelector(getTopQuote) + const usedQuote = selectedQuote || topQuote + + const { isBestQuote } = usedQuote + const fetchParamsSourceToken = fetchParams?.sourceToken + + const usedGasLimit = ( + usedQuote?.gasEstimateWithRefund || + (`0x${decimalToHex(usedQuote?.averageGas || 0)}`) + ) + + const gasLimitForMax = ( + usedQuote?.gasEstimate || + (`0x${decimalToHex(usedQuote?.averageGas || 0)}`) + ) + + const usedGasLimitWithMultiplier = (new BigNumber(gasLimitForMax, 16) + .times(1.4, 10)) + .round(0) + .toString(16) + + const maxGasLimit = (customMaxGas || + hexMax( + (`0x${decimalToHex(usedQuote?.maxGas || 0)}`), + usedGasLimitWithMultiplier, + ) + ) + + const gasTotalInWeiHex = calcGasTotal(usedGasLimit, gasPrice) + + const { tokensWithBalances } = useTokenTracker(swapsTokens) + const balanceToken = fetchParamsSourceToken === ETH_SWAPS_TOKEN_OBJECT.address + ? { ...ETH_SWAPS_TOKEN_OBJECT, balance: ethBalance } + : tokensWithBalances.find(({ address }) => address === fetchParamsSourceToken) + + const selectedFromToken = balanceToken || usedQuote.sourceTokenInfo + const tokenBalance = ( + tokensWithBalances?.length && + calcTokenAmount( + selectedFromToken.balance || '0x0', + selectedFromToken.decimals, + ).toFixed(9) + ) + + const approveData = getTokenData(approveTxParams?.data) + const approveValue = approveData && getTokenValueParam(approveData) + const approveAmount = ( + approveValue && (selectedFromToken?.decimals !== undefined) && + calcTokenAmount(approveValue, selectedFromToken.decimals).toFixed(9) + ) + const approveGas = approveTxParams?.gas + const approveGasTotal = calcGasTotal(approveGas || '0x0', gasPrice) + const approveGasTotalInEth = getValueFromWeiHex({ + value: approveGasTotal, + toDenomination: 'ETH', + numberOfDecimals: 4, + }) + + const renderablePopoverData = useMemo(() => { + return quotesToRenderableData( + quotes, + gasPrice, + conversionRate, + currentCurrency, + approveGas, + memoizedTokenConversionRates, + ) + }, [ + quotes, + gasPrice, + conversionRate, + currentCurrency, + approveGas, + memoizedTokenConversionRates, + ]) + + const renderableDataForUsedQuote = renderablePopoverData.find( + (renderablePopoverDatum) => ( + renderablePopoverDatum.aggId === usedQuote.aggregator + ), + ) + + const { + destinationTokenDecimals, + destinationTokenSymbol, + destinationTokenValue, + sourceTokenDecimals, + sourceTokenSymbol, + sourceTokenValue, + } = renderableDataForUsedQuote + + const { feeInFiat, feeInEth } = getRenderableGasFeesForQuote( + usedGasLimit, + approveGas, + gasPrice, + currentCurrency, + conversionRate, + ) + + const { + feeInFiat: maxFeeInFiat, + feeInEth: maxFeeInEth, + } = getRenderableGasFeesForQuote( + maxGasLimit, + approveGas, + gasPrice, + currentCurrency, + conversionRate, + ) + + const tokenCost = (new BigNumber(usedQuote.sourceAmount)) + const ethCost = (new BigNumber(usedQuote.trade.value || 0, 10)) + .plus((new BigNumber(gasTotalInWeiHex, 16))) + + const insufficientTokens = ( + (tokensWithBalances?.length || balanceError) && + (tokenCost).gt(new BigNumber(selectedFromToken.balance || '0x0')) + ) + + const insufficientEthForGas = (new BigNumber(gasTotalInWeiHex, 16)) + .gt(new BigNumber(ethBalance || '0x0')) + + const insufficientEth = (ethCost).gt(new BigNumber(ethBalance || '0x0')) + + const tokenBalanceNeeded = insufficientTokens + ? toPrecisionWithoutTrailingZeros( + calcTokenAmount( + tokenCost, + selectedFromToken.decimals, + ).minus(tokenBalance).toString(10), 6, + ) + : null + + const ethBalanceNeeded = insufficientEth + ? toPrecisionWithoutTrailingZeros( + ethCost.minus(ethBalance, 16).div('1000000000000000000', 10).toString(10), + 6, + ) + : null + + const destinationToken = useSelector(getDestinationTokenInfo) + + useEffect(() => { + if (insufficientTokens || insufficientEth) { + dispatch(setBalanceError(true)) + } else if (balanceError && !insufficientTokens && !insufficientEth) { + dispatch(setBalanceError(false)) + } + }, [insufficientTokens, insufficientEth, balanceError, dispatch]) + + useEffect(() => { + const currentTime = Date.now() + const timeSinceLastFetched = currentTime - quotesLastFetched + if (timeSinceLastFetched > 60000 && !dispatchedSafeRefetch) { + setDispatchedSafeRefetch(true) + dispatch(safeRefetchQuotes()) + } else if (timeSinceLastFetched > 60000) { + dispatch(setSwapsErrorKey(QUOTES_EXPIRED_ERROR)) + history.push(SWAPS_ERROR_ROUTE) + } + }, [quotesLastFetched, dispatchedSafeRefetch, dispatch, history]) + + useEffect(() => { + if (!originalApproveAmount && approveAmount) { + setOriginalApproveAmount(approveAmount) + } + }, [originalApproveAmount, approveAmount]) + + const allowedMaxModeSubmit = ( + maxMode && + sourceTokenSymbol === 'ETH' && + !insufficientEthForGas + ) + + const showWarning = ( + (balanceError || tokenBalanceNeeded || ethBalanceNeeded) && + !warningHidden && + !allowedMaxModeSubmit + ) + + const numberOfQuotes = Object.values(quotes).length + const bestQuoteReviewedEventSent = useRef() + const eventObjectBase = { + token_from: sourceTokenSymbol, + token_from_amount: sourceTokenValue, + token_to: destinationTokenSymbol, + token_to_amount: destinationTokenValue, + request_type: fetchParams?.balanceError, + slippage: fetchParams?.slippage, + custom_slippage: fetchParams?.slippage !== 2, + response_time: fetchParams?.responseTime, + best_quote_source: topQuote?.aggregator, + available_quotes: numberOfQuotes, + } + + const anonymousAllAvailableQuotesOpened = useNewMetricEvent({ + event: 'All Available Quotes Opened', + properties: { + ...eventObjectBase, + other_quote_selected: usedQuote?.aggregator !== topQuote?.aggregator, + other_quote_selected_source: usedQuote?.aggregator === topQuote?.aggregator ? null : usedQuote?.aggregator, + }, + excludeMetaMetricsId: true, + category: 'swaps', + }) + const allAvailableQuotesOpened = useNewMetricEvent({ event: 'All Available Quotes Opened', category: 'swaps' }) + const anonymousQuoteDetailsOpened = useNewMetricEvent({ + event: 'Quote Details Opened', + properties: { + ...eventObjectBase, + other_quote_selected: usedQuote?.aggregator !== topQuote?.aggregator, + other_quote_selected_source: usedQuote?.aggregator === topQuote?.aggregator ? null : usedQuote?.aggregator, + }, + excludeMetaMetricsId: true, + category: 'swaps', + }) + const quoteDetailsOpened = useNewMetricEvent({ event: 'Quote Details Opened', category: 'swaps' }) + const anonymousEditSpendLimitOpened = useNewMetricEvent({ + event: 'Edit Spend Limit Opened', + properties: { + ...eventObjectBase, + custom_spend_limit_set: originalApproveAmount === approveAmount, + custom_spend_limit_amount: originalApproveAmount === approveAmount ? null : approveAmount, + }, + excludeMetaMetricsId: true, + category: 'swaps', + }) + const editSpendLimitOpened = useNewMetricEvent({ event: 'Edit Spend Limit Opened', category: 'swaps' }) + + const anonymousBestQuoteReviewedEvent = useNewMetricEvent({ event: 'Best Quote Reviewed', properties: { ...eventObjectBase, network_fees: feeInFiat }, excludeMetaMetricsId: true, category: 'swaps' }) + const bestQuoteReviewedEvent = useNewMetricEvent({ event: 'Best Quote Reviewed', category: 'swaps' }) + useEffect(() => { + if (!bestQuoteReviewedEventSent.current && [sourceTokenSymbol, sourceTokenValue, destinationTokenSymbol, destinationTokenValue, fetchParams, topQuote, numberOfQuotes, feeInFiat].every((dep) => dep !== null && dep !== undefined)) { + bestQuoteReviewedEventSent.current = true + bestQuoteReviewedEvent() + anonymousBestQuoteReviewedEvent() + } + }, [sourceTokenSymbol, sourceTokenValue, destinationTokenSymbol, destinationTokenValue, fetchParams, topQuote, numberOfQuotes, feeInFiat, bestQuoteReviewedEvent, anonymousBestQuoteReviewedEvent]) + + const onFeeCardThirdRowClickHandler = () => { + anonymousEditSpendLimitOpened() + editSpendLimitOpened() + dispatch(showModal({ + name: 'EDIT_APPROVAL_PERMISSION', + decimals: selectedFromToken.decimals, + origin: 'MetaMask', + setCustomAmount: (newCustomPermissionAmount) => { + const customPermissionAmount = newCustomPermissionAmount === '' + ? originalApproveAmount + : newCustomPermissionAmount + const newData = getCustomTxParamsData( + approveTxParams.data, + { customPermissionAmount, decimals: selectedFromToken.decimals }, + ) + + if (customPermissionAmount?.length && approveTxParams.data !== newData) { + dispatch(setCustomApproveTxData(newData)) + } + }, + tokenAmount: originalApproveAmount, + customTokenAmount: ( + originalApproveAmount === approveAmount + ? null + : approveAmount + ), + tokenBalance, + tokenSymbol: selectedFromToken.symbol, + requiredMinimum: calcTokenAmount( + usedQuote.sourceAmount, + selectedFromToken.decimals, + ), + })) + } + + const onFeeCardMaxRowClickHandler = () => dispatch(showModal({ + name: 'CUSTOMIZE_GAS', + txData: { txParams: { ...tradeTxParams, gas: maxGasLimit } }, + isSwap: true, + customGasLimitMessage: ( + approveGas + ? t('extraApprovalGas', [hexToDecimal(approveGas)]) + : '' + ), + customTotalSupplement: approveGasTotal, + extraInfoRow: ( + approveGas + ? { + label: t('approvalTxGasCost'), + value: t('amountInEth', [approveGasTotalInEth]), + } + : null + ), + })) + + const thirdRowTextComponent = ( + + {sourceTokenSymbol} + + ) + + const actionableMessage = t('swapApproveNeedMoreTokens', [ + + {tokenBalanceNeeded || ethBalanceNeeded} + , + tokenBalanceNeeded && !(sourceTokenSymbol === 'ETH') + ? sourceTokenSymbol + : 'ETH', + ]) + + return ( +
+
+ {selectQuotePopoverShown && ( + setSelectQuotePopoverShown(false)} + onSubmit={(aggId) => { + dispatch(setSelectedQuoteAggId(aggId)) + dispatch(setCustomGasLimit(null)) + dispatch(setSwapsTxGasLimit('')) + }} + swapToSymbol={destinationTokenSymbol} + initialAggId={usedQuote.aggregator} + onQuoteDetailsIsOpened={() => { + anonymousQuoteDetailsOpened() + quoteDetailsOpened() + }} + /> + )} +
+ {showWarning && ( + setWarningHidden(true)} + /> + )} +
+
+ +
+
+ +
+
+
+ {t('swapNQuotesAvailable', [Object.values(quotes).length])} + +
+
{ + anonymousAllAvailableQuotesOpened() + allAvailableQuotesOpened() + setSelectQuotePopoverShown(true) + }} + > + {t('swapNQuotesAvailable', [Object.values(quotes).length])} + +
+
+
+ +
+
+ { + if (!balanceError) { + dispatch(signAndSendTransactions(history, metaMetricsEvent)) + } else if (destinationToken.symbol === 'ETH') { + history.push(DEFAULT_ROUTE) + } else { + history.push(`${ASSET_ROUTE}/${destinationToken.address}`) + } + }} + submitText={t('swap')} + onCancel={async () => await dispatch(navigateBackToBuildQuote(history))} + disabled={balanceError} + showTermsOfService + showTopBorder + /> +
+ ) +} diff --git a/ui/app/pages/token/exchange-rate-display/index.scss b/ui/app/pages/token/exchange-rate-display/index.scss deleted file mode 100644 index c416f1f53..000000000 --- a/ui/app/pages/token/exchange-rate-display/index.scss +++ /dev/null @@ -1,26 +0,0 @@ -.exchange-rate-display { - @include H6; - - display: flex; - align-items: flex-end; - justify-content: center; - color: $Black-100; - - span { - margin-right: 4px; - } - - &__bold { - font-weight: bold; - } - - &__switch-arrows { - cursor: pointer; - margin-top: 2px; - transition: transform 0.5s ease-in-out; - } - - &--white { - color: $white; - } -} diff --git a/ui/app/pages/token/fee-card/fee-card.js b/ui/app/pages/token/fee-card/fee-card.js deleted file mode 100644 index 42e751d77..000000000 --- a/ui/app/pages/token/fee-card/fee-card.js +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' - -export default function FeeCard ({ - onFeeRowClick = null, - feeRowText, - feeRowLinkText = '', - primaryFee, - secondaryFee = '', - onSecondRowClick = null, - secondRowText = '', - secondRowLinkText = '', - hideSecondRow = false, -}) { - return ( -
-
-
onFeeRowClick && onFeeRowClick()}> -
-
- {feeRowText} -
- {onFeeRowClick && ( -
- {feeRowLinkText} -
- )} -
-
-
- {primaryFee} -
- {secondaryFee && ( -
- {secondaryFee} -
- )} -
-
- {!hideSecondRow && secondRowText && ( -
-
-
- {secondRowText} -
- {secondRowLinkText && ( -
onSecondRowClick && onSecondRowClick()}> - {secondRowLinkText} -
- )} -
-
- )} -
-
- ) -} - -FeeCard.propTypes = { - onFeeRowClick: PropTypes.func, - feeRowText: PropTypes.string.isRequired, - feeRowLinkText: PropTypes.string, - primaryFee: PropTypes.string.isRequired, - secondaryFee: PropTypes.string, - onSecondRowClick: PropTypes.func, - secondRowText: PropTypes.string, - secondRowLinkText: PropTypes.string, - hideSecondRow: PropTypes.bool, -} diff --git a/ui/app/pages/token/fee-card/fee-card.stories.js b/ui/app/pages/token/fee-card/fee-card.stories.js deleted file mode 100644 index d29807d99..000000000 --- a/ui/app/pages/token/fee-card/fee-card.stories.js +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react' -import { action } from '@storybook/addon-actions' -import { text } from '@storybook/addon-knobs/react' -import FeeCard from './fee-card' - -const containerStyle = { - width: '300px', -} - -export default { - title: 'FeeCard', -} - -export const WithSecondRow = () => { - return ( -
- -
- ) -} - -export const WithoutSecondRow = () => { - return ( -
- -
- ) -} diff --git a/ui/app/pages/token/index.scss b/ui/app/pages/token/index.scss deleted file mode 100644 index 9cf06a407..000000000 --- a/ui/app/pages/token/index.scss +++ /dev/null @@ -1,3 +0,0 @@ -@import 'fee-card/index'; -@import 'exchange-rate-display/index'; -@import 'actionable-message/index'; diff --git a/ui/app/selectors/selectors.js b/ui/app/selectors/selectors.js index fe32cd1d5..40bce4dc6 100644 --- a/ui/app/selectors/selectors.js +++ b/ui/app/selectors/selectors.js @@ -326,3 +326,7 @@ export function getOriginOfCurrentTab (state) { export function getIpfsGateway (state) { return state.metamask.ipfsGateway } + +export function getCustomNetworkId (state) { + return state.metamask.settings?.network +} diff --git a/ui/app/store/actions.js b/ui/app/store/actions.js index 280ee342b..38baa67db 100644 --- a/ui/app/store/actions.js +++ b/ui/app/store/actions.js @@ -796,9 +796,40 @@ const updateMetamaskStateFromBackground = () => { }) } -export function updateTransaction (txData) { +export function estimateGasMethod ({ + gasPrice, + blockGasLimit, + selectedAddress, + sendToken, + to, + value, + data, +}) { + return estimateGas({ + estimateGasMethod: promisifiedBackground.estimateGas, + blockGasLimit, + selectedAddress, + sendToken, + to, + value, + gasPrice, + data, + }) +} + +export async function estimateGasFromTxParams (txParams) { + const backgroundState = await updateMetamaskStateFromBackground() + const blockGasLimit = backgroundState.currentBlockGasLimit + return estimateGasMethod({ + ...txParams, + selectedAddress: txParams.from, + blockGasLimit, + }) +} + +export function updateTransaction (txData, dontShowLoadingIndicator) { return (dispatch) => { - dispatch(showLoadingIndication()) + !dontShowLoadingIndicator && dispatch(showLoadingIndication()) return new Promise((resolve, reject) => { background.updateTransaction(txData, (err) => { @@ -824,9 +855,25 @@ export function updateTransaction (txData) { } } -export function updateAndApproveTx (txData) { +export function addUnapprovedTransaction (txParams, origin) { + log.debug('background.addUnapprovedTransaction') + + return () => { + return new Promise((resolve, reject) => { + background.addUnapprovedTransaction(txParams, origin, (err, txMeta) => { + if (err) { + reject(err) + return + } + resolve(txMeta) + }) + }) + } +} + +export function updateAndApproveTx (txData, dontShowLoadingIndicator) { return (dispatch) => { - dispatch(showLoadingIndication()) + !dontShowLoadingIndicator && dispatch(showLoadingIndication()) return new Promise((resolve, reject) => { background.updateAndApproveTransaction(txData, (err) => { dispatch(updateTransactionParams(txData.id, txData.txParams)) @@ -1278,9 +1325,9 @@ export function showConfTxPage ({ id } = {}) { } } -export function addToken (address, symbol, decimals, image) { +export function addToken (address, symbol, decimals, image, dontShowLoadingIndicator) { return (dispatch) => { - dispatch(showLoadingIndication()) + !dontShowLoadingIndicator && dispatch(showLoadingIndication()) return new Promise((resolve, reject) => { background.addToken(address, symbol, decimals, image, (err, tokens) => { dispatch(hideLoadingIndication()) @@ -2093,6 +2140,138 @@ export function setPendingTokens (pendingTokens) { } } +// Swaps + +export function setSwapsLiveness (swapsFeatureIsLive) { + return async (dispatch) => { + await promisifiedBackground.setSwapsLiveness(swapsFeatureIsLive) + await forceUpdateMetamaskState(dispatch) + } +} + +export function fetchAndSetQuotes (fetchParams, fetchParamsMetaData) { + return async (dispatch) => { + const [quotes, selectedAggId] = await promisifiedBackground.fetchAndSetQuotes(fetchParams, fetchParamsMetaData) + await forceUpdateMetamaskState(dispatch) + return [quotes, selectedAggId] + } +} + +export function setSelectedQuoteAggId (aggId) { + return async (dispatch) => { + await promisifiedBackground.setSelectedQuoteAggId(aggId) + await forceUpdateMetamaskState(dispatch) + + } +} + +export function setSwapsTokens (tokens) { + return async (dispatch) => { + await promisifiedBackground.setSwapsTokens(tokens) + await forceUpdateMetamaskState(dispatch) + } +} + +export function resetBackgroundSwapsState () { + return async (dispatch) => { + const id = await promisifiedBackground.resetSwapsState() + await forceUpdateMetamaskState(dispatch) + return id + } +} + +export function setMaxMode (maxMode) { + return async (dispatch) => { + await promisifiedBackground.setMaxMode(maxMode) + await forceUpdateMetamaskState(dispatch) + } +} + +export function setCustomApproveTxData (data) { + return async (dispatch) => { + await promisifiedBackground.setCustomApproveTxData(data) + await forceUpdateMetamaskState(dispatch) + } +} + +export function setSwapsTxGasPrice (gasPrice) { + return async (dispatch) => { + await promisifiedBackground.setSwapsTxGasPrice(gasPrice) + await forceUpdateMetamaskState(dispatch) + } +} + +export function setSwapsTxGasLimit (gasLimit) { + return async (dispatch) => { + await promisifiedBackground.setSwapsTxGasLimit(gasLimit, true) + await forceUpdateMetamaskState(dispatch) + } +} + +export function setSwapsTxGasParams (gasLimit, gasPrice) { + return async (dispatch) => { + await promisifiedBackground.setSwapsTxGasPrice(gasPrice) + await promisifiedBackground.setSwapsTxGasLimit(gasLimit, true) + await forceUpdateMetamaskState(dispatch) + } +} + +export function setTradeTxId (tradeTxId) { + return async (dispatch) => { + await promisifiedBackground.setTradeTxId(tradeTxId) + await forceUpdateMetamaskState(dispatch) + } +} + +export function setApproveTxId (approveTxId) { + return async (dispatch) => { + await promisifiedBackground.setApproveTxId(approveTxId) + await forceUpdateMetamaskState(dispatch) + } +} + +export function safeRefetchQuotes () { + return async (dispatch) => { + await promisifiedBackground.safeRefetchQuotes() + await forceUpdateMetamaskState(dispatch) + } +} + +export function stopPollingForQuotes () { + return async (dispatch) => { + await promisifiedBackground.stopPollingForQuotes() + await forceUpdateMetamaskState(dispatch) + } +} + +export function setBackgroundSwapRouteState (routeState) { + return async (dispatch) => { + await promisifiedBackground.setBackgroundSwapRouteState(routeState) + await forceUpdateMetamaskState(dispatch) + } +} + +export function resetSwapsPostFetchState () { + return async (dispatch) => { + await promisifiedBackground.resetPostFetchState() + await forceUpdateMetamaskState(dispatch) + } +} + +export function setSwapsErrorKey (errorKey) { + return async (dispatch) => { + await promisifiedBackground.setSwapsErrorKey(errorKey) + await forceUpdateMetamaskState(dispatch) + } +} + +export function setInitialGasEstimate (initialAggId, baseGasEstimate) { + return async (dispatch) => { + await promisifiedBackground.setInitialGasEstimate(initialAggId, baseGasEstimate) + await forceUpdateMetamaskState(dispatch) + } +} + // Permissions export function requestAccountsPermissionWithId (origin) { @@ -2204,6 +2383,16 @@ export function setConnectedStatusPopoverHasBeenShown () { } } +export function setSwapsWelcomeMessageHasBeenShown () { + return () => { + background.setSwapsWelcomeMessageHasBeenShown((err) => { + if (err) { + throw new Error(err.message) + } + }) + } +} + export function setAlertEnabledness (alertId, enabledness) { return async () => { await promisifiedBackground.setAlertEnabledness(alertId, enabledness)