From c861d95d6988ef721422cc7ca0763ad57c191d1d Mon Sep 17 00:00:00 2001 From: Brian Sztamfater Date: Wed, 4 Sep 2024 13:40:06 -0300 Subject: [PATCH] feat(swap): fetch swap proposals (#21040) Signed-off-by: Brian Sztamfater --- .../banners/alert_banner/schema.cljs | 1 + .../banners/alert_banner/style.cljs | 15 +- .../components/banners/alert_banner/view.cljs | 4 +- .../components/settings/data_item/view.cljs | 2 +- .../components/wallet/swap_input/style.cljs | 13 +- .../components/wallet/swap_input/view.cljs | 16 +- src/status_im/constants.cljs | 3 + .../contexts/wallet/send/events.cljs | 40 ++- .../contexts/wallet/swap/events.cljs | 129 ++++++- .../wallet/swap/select_asset_to_pay/view.cljs | 24 +- .../wallet/swap/setup_swap/style.cljs | 13 +- .../contexts/wallet/swap/setup_swap/view.cljs | 325 +++++++++++++----- src/status_im/subs/wallet/swap.cljs | 70 +++- src/status_im/subs/wallet/swap_test.cljs | 123 ++++++- src/utils/money.cljs | 11 +- src/utils/number.cljs | 11 + src/utils/number_test.cljs | 20 ++ src/utils/string.cljs | 6 + translations/en.json | 1 + 19 files changed, 657 insertions(+), 170 deletions(-) diff --git a/src/quo/components/banners/alert_banner/schema.cljs b/src/quo/components/banners/alert_banner/schema.cljs index 9ec599c809..8885cf517e 100644 --- a/src/quo/components/banners/alert_banner/schema.cljs +++ b/src/quo/components/banners/alert_banner/schema.cljs @@ -7,6 +7,7 @@ [:map {:closed true} [:action? {:optional true} [:maybe boolean?]] [:text {:optional true} [:maybe string?]] + [:container-style {:optional true} [:maybe :map]] [:button-text {:optional true} [:maybe string?]] [:on-button-press {:optional true} [:maybe fn?]]]]] :any]) diff --git a/src/quo/components/banners/alert_banner/style.cljs b/src/quo/components/banners/alert_banner/style.cljs index b6536786f7..fe8fcbdcd0 100644 --- a/src/quo/components/banners/alert_banner/style.cljs +++ b/src/quo/components/banners/alert_banner/style.cljs @@ -1,12 +1,15 @@ (ns quo.components.banners.alert-banner.style (:require [quo.foundations.colors :as colors])) -(def container - {:flex-direction :row - :align-items :center - :height 50 - :padding-horizontal 20 - :padding-vertical 12}) +(defn container + [container-style] + (merge + {:flex-direction :row + :align-items :center + :height 50 + :padding-horizontal 20 + :padding-vertical 12} + container-style)) (defn label [theme] diff --git a/src/quo/components/banners/alert_banner/view.cljs b/src/quo/components/banners/alert_banner/view.cljs index cb55436801..bdb0d66ac6 100644 --- a/src/quo/components/banners/alert_banner/view.cljs +++ b/src/quo/components/banners/alert_banner/view.cljs @@ -11,12 +11,12 @@ [schema.core :as schema])) (defn- view-internal - [{:keys [action? text button-text on-button-press]}] + [{:keys [action? text button-text container-style on-button-press]}] (let [theme (quo.theme/use-theme)] [rn/view {:accessibility-label :alert-banner} [linear-gradient/linear-gradient - {:style style/container + {:style (style/container container-style) :start {:x 0 :y 0} :end {:x 0 :y 1} :colors [(colors/theme-colors diff --git a/src/quo/components/settings/data_item/view.cljs b/src/quo/components/settings/data_item/view.cljs index cce92cfeca..4a7ccf122c 100644 --- a/src/quo/components/settings/data_item/view.cljs +++ b/src/quo/components/settings/data_item/view.cljs @@ -126,7 +126,7 @@ [:subtitle-type {:optional true} [:maybe [:enum :default :icon :network :account :editable]]] [:size {:optional true} [:maybe [:enum :default :small :large]]] [:title :string] - [:subtitle {:optional true} [:maybe :string]] + [:subtitle {:optional true} [:maybe [:or :string :double]]] [:custom-subtitle {:optional true} [:maybe fn?]] [:icon {:optional true} [:maybe :keyword]] [:emoji {:optional true} [:maybe :string]] diff --git a/src/quo/components/wallet/swap_input/style.cljs b/src/quo/components/wallet/swap_input/style.cljs index a32e944c1f..19928831ad 100644 --- a/src/quo/components/wallet/swap_input/style.cljs +++ b/src/quo/components/wallet/swap_input/style.cljs @@ -1,5 +1,6 @@ (ns quo.components.wallet.swap-input.style (:require [quo.foundations.colors :as colors] + [quo.foundations.shadows :as shadows] [quo.foundations.typography :as typography])) (defn- border-color @@ -11,11 +12,13 @@ (colors/theme-colors colors/neutral-5 colors/neutral-90 theme)) (defn content - [theme] - {:border-width 1 - :border-radius 16 - :border-color (border-color theme) - :background-color (colors/theme-colors colors/white colors/neutral-95 theme)}) + [typing? theme] + (merge + {:border-width 1 + :border-radius 16 + :border-color (border-color theme) + :background-color (colors/theme-colors colors/white colors/neutral-95 theme)} + (when typing? (shadows/get 1 theme)))) (defn row-1 [loading?] diff --git a/src/quo/components/wallet/swap_input/view.cljs b/src/quo/components/wallet/swap_input/view.cljs index 9d4444fba0..bacf3c1c48 100644 --- a/src/quo/components/wallet/swap_input/view.cljs +++ b/src/quo/components/wallet/swap_input/view.cljs @@ -19,13 +19,15 @@ [:props [:map {:closed true} [:type {:optional true} [:maybe [:enum :pay :receive]]] - [:status {:optional true} [:maybe [:enum :default :disabled :loading]]] + [:status {:optional true} [:maybe [:enum :default :typing :disabled :loading]]] [:token {:optional true} [:maybe :string]] [:value {:optional true} [:maybe :string]] [:default-value {:optional true} [:maybe :string]] [:currency-symbol {:optional true} [:maybe :string]] [:fiat-value {:optional true} [:maybe :string]] [:show-approval-label? {:optional true} [:maybe :boolean]] + [:auto-focus? {:optional true} [:maybe :boolean]] + [:input-disabled? {:optional true} [:maybe :boolean]] [:error? {:optional true} [:maybe :boolean]] [:show-keyboard? {:optional true} [:maybe :boolean]] [:approval-label-props {:optional true} [:maybe approval-label.schema/?schema]] @@ -33,6 +35,7 @@ [:on-change-text {:optional true} [:maybe fn?]] [:enable-swap? {:optional true} [:maybe :boolean]] [:on-swap-press {:optional true} [:maybe fn?]] + [:on-input-focus {:optional true} [:maybe fn?]] [:on-token-press {:optional true} [:maybe fn?]] [:on-max-press {:optional true} [:maybe fn?]] [:customization-color {:optional true} [:maybe :schema.common/customization-color]] @@ -41,13 +44,14 @@ (defn view-internal [{:keys [type status token value fiat-value show-approval-label? error? network-tag-props - approval-label-props default-value enable-swap? + approval-label-props default-value auto-focus? input-disabled? enable-swap? currency-symbol on-change-text show-keyboard? - container-style on-swap-press on-token-press on-max-press]}] + container-style on-swap-press on-token-press on-max-press on-input-focus]}] (let [theme (quo.theme/use-theme) pay? (= type :pay) disabled? (= status :disabled) loading? (= status :loading) + typing? (= status :typing) controlled-input? (some? value) input-ref (rn/use-ref-atom nil) set-input-ref (rn/use-callback (fn [ref] (reset! input-ref ref)) []) @@ -58,7 +62,7 @@ [rn/view {:style container-style :accessibility-label :swap-input} - [rn/view {:style (style/content theme)} + [rn/view {:style (style/content typing? theme)} [rn/view {:style (style/row-1 loading?)} [rn/pressable {:on-press on-token-press} @@ -78,7 +82,9 @@ colors/neutral-50 theme) :keyboard-type :numeric - :auto-focus true + :editable (not input-disabled?) + :auto-focus auto-focus? + :on-focus on-input-focus :on-change-text on-change-text :show-soft-input-on-focus show-keyboard? :default-value default-value diff --git a/src/status_im/constants.cljs b/src/status_im/constants.cljs index 23cd9911f2..688328af0b 100644 --- a/src/status_im/constants.cljs +++ b/src/status_im/constants.cljs @@ -551,6 +551,7 @@ (def ^:const send-type-bridge 5) (def ^:const send-type-erc-721-transfer 6) (def ^:const send-type-erc-1155-transfer 7) +(def ^:const send-type-swap 8) (def ^:const multi-transaction-type-send 0) (def ^:const multi-transaction-type-approve 1) @@ -596,3 +597,5 @@ :color :blue :contract-address "0xdef171fe48cf0115b1d80b88dc8eab59176fee57" :terms-and-conditions-url "https://files.paraswap.io/tos_v4.pdf"}) + +(def ^:const token-for-fees-symbol "ETH") diff --git a/src/status_im/contexts/wallet/send/events.cljs b/src/status_im/contexts/wallet/send/events.cljs index eaf4f24032..96ecf61fae 100644 --- a/src/status_im/contexts/wallet/send/events.cljs +++ b/src/status_im/contexts/wallet/send/events.cljs @@ -518,23 +518,29 @@ (rf/reg-event-fx :wallet/handle-suggested-routes - (fn [_ [data]] - (if-let [{:keys [code details]} (-> data :ErrorResponse)] - (let [error-message (if (= code "0") "An error occurred" details)] - (log/error "failed to get suggested routes (async)" - {:event :wallet/handle-suggested-routes - :error error-message}) - {:fx [[:dispatch [:wallet/suggested-routes-error error-message]]]}) - (let [best-routes-fix (comp ->old-route-paths - remove-invalid-bonder-fees-routes - remove-multichain-routes) - candidates-fix (comp ->old-route-paths - remove-invalid-bonder-fees-routes) - routes (-> data - (data-store/rpc->suggested-routes) - (update :best best-routes-fix) - (update :candidates candidates-fix))] - {:fx [[:dispatch [:wallet/suggested-routes-success routes]]]})))) + (fn [{:keys [db]} [data]] + (let [swap? (get-in db [:wallet :ui :swap]) + {:keys [code details] :as error-response} (-> data :ErrorResponse)] + (if (and (not swap?) error-response) + (let [error-message (if (= code "0") "An error occurred" details)] + (log/error "failed to get suggested routes (async)" + {:event :wallet/handle-suggested-routes + :error error-message}) + {:fx [(if swap? + [:dispatch [:wallet/swap-proposal-error error-message]] + [:dispatch [:wallet/suggested-routes-error error-message]])]}) + (let [best-routes-fix (comp ->old-route-paths + remove-invalid-bonder-fees-routes + remove-multichain-routes) + candidates-fix (comp ->old-route-paths + remove-invalid-bonder-fees-routes) + routes (-> data + (data-store/rpc->suggested-routes) + (update :best best-routes-fix) + (update :candidates candidates-fix))] + {:fx [(if swap? + [:dispatch [:wallet/swap-proposal-success routes]] + [:dispatch [:wallet/suggested-routes-success routes]])]}))))) (rf/reg-event-fx :wallet/add-authorized-transaction (fn [{:keys [db]} [transaction]] diff --git a/src/status_im/contexts/wallet/swap/events.cljs b/src/status_im/contexts/wallet/swap/events.cljs index cef429a259..f8eca1454e 100644 --- a/src/status_im/contexts/wallet/swap/events.cljs +++ b/src/status_im/contexts/wallet/swap/events.cljs @@ -1,7 +1,9 @@ (ns status-im.contexts.wallet.swap.events (:require [re-frame.core :as rf] [status-im.constants :as constants] + [status-im.contexts.wallet.send.utils :as send-utils] [status-im.contexts.wallet.sheets.network-selection.view :as network-selection] + [taoensso.timbre :as log] [utils.number])) (rf/reg-event-fx :wallet.swap/start @@ -14,7 +16,9 @@ (assoc-in [:wallet :ui :swap :asset-to-pay] token) (assoc-in [:wallet :ui :swap :network] network)) :fx (if network - [[:dispatch [:navigate-to :screen/wallet.swap-propasal]] + [[:dispatch + [:navigate-to-within-stack + [:screen/wallet.setup-swap :screen/wallet.swap-select-asset-to-pay]]] [:dispatch [:wallet.swap/set-default-slippage]]] [[:dispatch [:show-bottom-sheet @@ -30,10 +34,6 @@ :stack-id :screen/wallet.swap-select-asset-to-pay}]))}])}]]])})) -(rf/reg-event-fx :wallet.swap/clean-asset-to-pay - (fn [{:keys [db]}] - {:db (update-in db [:wallet :ui :swap] dissoc :asset-to-pay)})) - (rf/reg-event-fx :wallet.swap/set-default-slippage (fn [{:keys [db]}] {:db @@ -47,18 +47,113 @@ (fn [{:keys [db]} [{:keys [token]}]] {:db (assoc-in db [:wallet :ui :swap :asset-to-receive] token)})) -(rf/reg-event-fx :wallet.swap/set-pay-amount - (fn [{:keys [db]} [amount]] - {:db (assoc-in db [:wallet :ui :swap :pay-amount] amount)})) - -(rf/reg-event-fx :wallet.swap/set-swap-proposal - (fn [{:keys [db]} [swap-proposal]] - {:db (assoc-in db [:wallet :ui :swap :swap-proposal] swap-proposal)})) - -(rf/reg-event-fx :wallet.swap/set-provider - (fn [{:keys [db]}] - {:db (assoc-in db [:wallet :ui :swap :providers] [constants/swap-default-provider])})) - (rf/reg-event-fx :wallet.swap/recalculate-fees (fn [{:keys [db]} [loading-fees?]] {:db (assoc-in db [:wallet :ui :swap :loading-fees?] loading-fees?)})) + +(rf/reg-event-fx :wallet/start-get-swap-proposal + (fn [{:keys [db]} [{:keys [amount-in amount-out]}]] + (let [wallet-address (get-in db [:wallet :current-viewing-account-address]) + {:keys [asset-to-pay asset-to-receive + network]} (get-in db [:wallet :ui :swap]) + test-networks-enabled? (get-in db [:profile/profile :test-networks-enabled?]) + networks ((if test-networks-enabled? :test :prod) + (get-in db [:wallet :networks])) + network-chain-ids (map :chain-id networks) + pay-token-decimal (:decimals asset-to-pay) + pay-token-id (:symbol asset-to-pay) + receive-token-id (:symbol asset-to-receive) + receive-token-decimals (:decimals asset-to-receive) + gas-rates constants/gas-rate-medium + amount-in-hex (if amount-in + (send-utils/amount-in-hex amount-in pay-token-decimal) + 0) + amount-out-hex (when amount-out + (send-utils/amount-in-hex amount-out receive-token-decimals)) + to-address wallet-address + from-address wallet-address + swap-chain-id (:chain-id network) + disabled-to-chain-ids (filter #(not= % swap-chain-id) network-chain-ids) + disabled-from-chain-ids (filter #(not= % swap-chain-id) network-chain-ids) + from-locked-amount {} + send-type constants/send-type-swap + request-uuid (str (random-uuid)) + params [(cond-> + {:uuid request-uuid + :sendType send-type + :addrFrom from-address + :addrTo to-address + :tokenID pay-token-id + :toTokenID receive-token-id + :disabledFromChainIDs disabled-from-chain-ids + :disabledToChainIDs disabled-to-chain-ids + :gasFeeMode gas-rates + :fromLockedAmount from-locked-amount} + amount-in (assoc :amountIn amount-in-hex) + amount-out (assoc :amountOut amount-out-hex))]] + (when-let [amount (or amount-in amount-out)] + {:db (update-in db + [:wallet :ui :swap] + #(-> % + (assoc + :last-request-uuid request-uuid + :amount amount + :loading-swap-proposal? true) + (dissoc :error-response))) + :json-rpc/call [{:method "wallet_getSuggestedRoutesV2Async" + :params params + :on-error (fn [error] + (rf/dispatch [:wallet/swap-proposal-error error]) + (log/error "failed to get suggested routes (async)" + {:event :wallet/start-get-swap-proposal + :error (:message error) + :params params}))}]})))) + +(rf/reg-event-fx :wallet/swap-proposal-success + (fn [{:keys [db]} [swap-proposal]] + (let [last-request-uuid (get-in db [:wallet :ui :swap :last-request-uuid]) + request-uuid (:uuid swap-proposal) + best-routes (:best swap-proposal) + error-response (:error-response swap-proposal)] + (when (= request-uuid last-request-uuid) + {:db (update-in db + [:wallet :ui :swap] + assoc + :swap-proposal (first best-routes) + :error-response (when (empty? best-routes) error-response) + :loading-swap-proposal? false)})))) + +(rf/reg-event-fx :wallet/swap-proposal-error + (fn [{:keys [db]} [error-message]] + {:db (-> db + (update-in [:wallet :ui :swap] dissoc :route :swap-proposal) + (assoc-in [:wallet :ui :swap :loading-swap-proposal?] false) + (assoc-in [:wallet :ui :swap :error-response] error-message)) + :fx [[:dispatch + [:toasts/upsert + {:id :swap-proposal-error + :type :negative + :text error-message}]]]})) + +(rf/reg-event-fx :wallet/stop-get-swap-proposal + (fn [] + {:json-rpc/call [{:method "wallet_stopSuggestedRoutesV2AsyncCalcualtion" + :params [] + :on-error (fn [error] + (log/error "failed to stop fetching swap proposals" + {:event :wallet/stop-get-swap-proposal + :error error}))}]})) + +(rf/reg-event-fx :wallet/clean-swap-proposal + (fn [{:keys [db]}] + {:db (update-in db + [:wallet :ui :swap] + dissoc + :last-request-uuid + :swap-proposal + :error-response + :loading-swap-proposal?)})) + +(rf/reg-event-fx :wallet/clean-swap + (fn [{:keys [db]}] + {:db (update-in db [:wallet :ui] dissoc :swap)})) diff --git a/src/status_im/contexts/wallet/swap/select_asset_to_pay/view.cljs b/src/status_im/contexts/wallet/swap/select_asset_to_pay/view.cljs index 2f606687ce..c42af3dfaa 100644 --- a/src/status_im/contexts/wallet/swap/select_asset_to_pay/view.cljs +++ b/src/status_im/contexts/wallet/swap/select_asset_to_pay/view.cljs @@ -18,22 +18,6 @@ :value search-text :on-change-text on-change-text}]]) -(def dummy-swap-proposal - {:from {:chain-id 1 - :native-currency-symbol "ETH"} - :to {:chain-id 1 - :native-currency-symbol "ETH"} - :gas-amount "23487" - :gas-fees {:base-fee "32.325296406" - :max-priority-fee-per-gas "0.011000001" - :eip1559-enabled true} - :estimated-time 3 - :receive-amount "99.98" - :pay-token {:symbol "SNT" - :address "0x432492384728934239789"} - :receive-token {:symbol "USDT" - :address "0x432492384728934239789"}}) - (defn- assets-view [search-text on-change-text] (let [on-token-press (fn [token] @@ -44,10 +28,8 @@ :network (when (= (count token-networks) 1) (first token-networks)) :stack-id :screen/wallet.swap-select-asset-to-pay}]) - (rf/dispatch [:wallet.swap/select-asset-to-receive {:token asset-to-receive}]) - (rf/dispatch [:wallet.swap/set-pay-amount 100]) - (rf/dispatch [:wallet.swap/set-swap-proposal dummy-swap-proposal]) - (rf/dispatch [:wallet.swap/set-provider])))] + (rf/dispatch [:wallet.swap/select-asset-to-receive + {:token asset-to-receive}])))] [:<> [search-input search-text on-change-text] [asset-list/view @@ -59,7 +41,7 @@ (let [[search-text set-search-text] (rn/use-state "") on-change-text #(set-search-text %) on-close (fn [] - (rf/dispatch [:wallet.swap/clean-asset-to-pay]) + (rf/dispatch [:wallet/clean-swap]) (rf/dispatch [:navigate-back]))] [rn/safe-area-view {:style style/container} [account-switcher/view diff --git a/src/status_im/contexts/wallet/swap/setup_swap/style.cljs b/src/status_im/contexts/wallet/swap/setup_swap/style.cljs index fd38634d5f..84b02717c6 100644 --- a/src/status_im/contexts/wallet/swap/setup_swap/style.cljs +++ b/src/status_im/contexts/wallet/swap/setup_swap/style.cljs @@ -22,14 +22,19 @@ :height 36 :background-color :transparent}) -(def swap-order-button - {:margin-top -9 +(defn swap-order-button + [approval-required?] + {:margin-top (if approval-required? 3 -9) :z-index 2 :align-self :center}) -(def receive-token-swap-input-container - {:margin-top -9}) +(defn receive-token-swap-input-container + [approval-required?] + {:margin-top (if approval-required? 3 -9)}) (def footer-container {:flex 1 :justify-content :flex-end}) + +(def alert-banner + {:height 40}) diff --git a/src/status_im/contexts/wallet/swap/setup_swap/view.cljs b/src/status_im/contexts/wallet/swap/setup_swap/view.cljs index 1ae3df5d7e..61ead58004 100644 --- a/src/status_im/contexts/wallet/swap/setup_swap/view.cljs +++ b/src/status_im/contexts/wallet/swap/setup_swap/view.cljs @@ -1,13 +1,35 @@ (ns status-im.contexts.wallet.swap.setup-swap.view - (:require [quo.core :as quo] + (:require [clojure.string :as string] + [native-module.core :as native-module] + [quo.core :as quo] [react-native.core :as rn] [react-native.safe-area :as safe-area] + [status-im.common.controlled-input.utils :as controlled-input] [status-im.common.events-helper :as events-helper] + [status-im.constants :as constants] [status-im.contexts.wallet.common.account-switcher.view :as account-switcher] [status-im.contexts.wallet.common.utils :as utils] [status-im.contexts.wallet.swap.setup-swap.style :as style] + [utils.hex :as hex] [utils.i18n :as i18n] - [utils.re-frame :as rf])) + [utils.money :as money] + [utils.number :as number] + [utils.re-frame :as rf] + [utils.string :as utils.string])) + +(def ^:private min-token-decimals-to-display 6) +(def ^:private default-text-for-unfocused-input "0.00") + +(defn- on-close + [] + (rf/dispatch [:wallet/clean-swap-proposal]) + (events-helper/navigate-back)) + +(defn- fetch-swap-proposal + [{:keys [amount valid-input?]}] + (if valid-input? + (rf/dispatch [:wallet/start-get-swap-proposal {:amount-in amount}]) + (rf/dispatch [:wallet/clean-swap-proposal]))) (defn- data-item [{:keys [title subtitle size subtitle-icon loading?]}] @@ -23,110 +45,243 @@ :icon subtitle-icon}]) (defn- transaction-details - [{:keys [max-slippage native-currency-symbol loading-fees?]}] - (let [max-fees (rf/sub [:wallet/wallet-send-fee-fiat-formatted native-currency-symbol])] + [] + (let [max-fees (rf/sub [:wallet/wallet-swap-proposal-fee-fiat-formatted + constants/token-for-fees-symbol]) + max-slippage (rf/sub [:wallet/swap-max-slippage]) + loading-fees? (rf/sub [:wallet/swap-loading-fees?]) + loading-swap-proposal? (rf/sub [:wallet/swap-loading-swap-proposal?]) + loading? (or loading-fees? loading-swap-proposal?)] [rn/view {:style style/details-container} [data-item {:title (i18n/label :t/max-fees) :subtitle max-fees - :loading? loading-fees? + :loading? loading? :size :small}] [data-item {:title (i18n/label :t/max-slippage) :subtitle max-slippage :subtitle-icon :i/edit - :loading? loading-fees?}]])) + :size :small + :loading? loading?}]])) + +(defn- pay-token-input + [{:keys [input-state on-max-press on-input-focus on-token-press on-approve-press input-focused?]}] + (let [account-color (rf/sub [:wallet/current-viewing-account-color]) + network (rf/sub [:wallet/swap-network]) + asset-to-pay (rf/sub [:wallet/swap-asset-to-pay]) + currency (rf/sub [:profile/currency]) + loading-swap-proposal? (rf/sub [:wallet/swap-loading-swap-proposal?]) + swap-proposal (rf/sub [:wallet/swap-proposal]) + approval-required (rf/sub [:wallet/swap-proposal-approval-required]) + approval-amount-required (rf/sub [:wallet/swap-proposal-approval-amount-required]) + currency-symbol (rf/sub [:profile/currency-symbol]) + pay-input-num-value (controlled-input/numeric-value input-state) + pay-input-amount (controlled-input/input-value input-state) + pay-token-symbol (:symbol asset-to-pay) + pay-token-decimals (:decimals asset-to-pay) + pay-token-balance-selected-chain (get-in asset-to-pay + [:balances-per-chain + (:chain-id network) :balance] + 0) + pay-token-fiat-value (str + (utils/calculate-token-fiat-value + {:currency currency + :balance pay-input-num-value + :token asset-to-pay})) + available-crypto-limit (number/remove-trailing-zeroes + (.toFixed (money/bignumber + pay-token-balance-selected-chain) + (min pay-token-decimals + min-token-decimals-to-display))) + approval-amount-required-num (when approval-amount-required + (str (number/convert-to-whole-number + (native-module/hex-to-number + (hex/normalize-hex + approval-amount-required)) + pay-token-decimals))) + pay-input-error? (and (not (string/blank? pay-input-amount)) + (money/greater-than + (money/bignumber pay-input-num-value) + (money/bignumber + pay-token-balance-selected-chain))) + valid-pay-input? (and + (not (string/blank? + pay-input-amount)) + (> pay-input-amount 0) + (not pay-input-error?)) + request-fetch-swap-proposal (rn/use-callback + (fn [] + (fetch-swap-proposal + {:amount pay-input-amount + :valid-input? valid-pay-input?})) + [pay-input-amount])] + (rn/use-effect + (fn [] + (request-fetch-swap-proposal)) + [pay-input-amount]) + [quo/swap-input + {:type :pay + :error? pay-input-error? + :token pay-token-symbol + :customization-color :blue + :show-approval-label? (and swap-proposal approval-required) + :auto-focus? true + :status (cond + (and loading-swap-proposal? (not input-focused?)) :loading + input-focused? :typing + :else :disabled) + :currency-symbol currency-symbol + :on-token-press on-token-press + :on-max-press on-max-press + :on-input-focus on-input-focus + :value pay-input-amount + :fiat-value pay-token-fiat-value + :network-tag-props {:title (i18n/label :t/max-token + {:number available-crypto-limit + :token-symbol pay-token-symbol}) + :networks [{:source (:source network)}]} + :approval-label-props {:status :approve + :token-value approval-amount-required-num + :button-props {:on-press on-approve-press} + :customization-color account-color + :token-symbol pay-token-symbol}}])) + +(defn- swap-order-button + [{:keys [on-press]}] + (let [approval-required? (rf/sub [:wallet/swap-proposal-approval-required])] + [quo/swap-order-button + {:container-style (style/swap-order-button approval-required?) + :on-press on-press}])) + +(defn- receive-token-input + [{:keys [on-input-focus on-token-press input-focused?]}] + (let [account-color (rf/sub [:wallet/current-viewing-account-color]) + asset-to-receive (rf/sub [:wallet/swap-asset-to-receive]) + loading-swap-proposal? (rf/sub [:wallet/swap-loading-swap-proposal?]) + currency (rf/sub [:profile/currency]) + currency-symbol (rf/sub [:profile/currency-symbol]) + amount-out (rf/sub [:wallet/swap-proposal-amount-out]) + approval-required? (rf/sub [:wallet/swap-proposal-approval-required]) + receive-token-symbol (:symbol asset-to-receive) + receive-token-decimals (:decimals asset-to-receive) + amount-out-whole-number (when amount-out + (number/convert-to-whole-number + (native-module/hex-to-number + (utils.hex/normalize-hex + amount-out)) + receive-token-decimals)) + amount-out-num (if amount-out-whole-number + (str amount-out-whole-number) + default-text-for-unfocused-input) + receive-token-fiat-value (str (utils/calculate-token-fiat-value + {:currency currency + :balance (or amount-out-whole-number 0) + :token asset-to-receive}))] + [quo/swap-input + {:type :receive + :error? false + :token receive-token-symbol + :customization-color account-color + :show-approval-label? false + :enable-swap? true + :input-disabled? true + :status (cond + (and loading-swap-proposal? (not input-focused?)) :loading + input-focused? :typing + :else :disabled) + :currency-symbol currency-symbol + :on-token-press on-token-press + :on-input-focus on-input-focus + :value amount-out-num + :fiat-value receive-token-fiat-value + :container-style (style/receive-token-swap-input-container approval-required?)}])) + +(defn- action-button + [{:keys [on-press]}] + (let [account-color (rf/sub [:wallet/current-viewing-account-color]) + swap-proposal (rf/sub [:wallet/swap-proposal]) + loading-fees? (rf/sub [:wallet/swap-loading-fees?]) + loading-swap-proposal? (rf/sub [:wallet/swap-loading-swap-proposal?]) + approval-required? (rf/sub [:wallet/swap-proposal-approval-required])] + [quo/bottom-actions + {:actions :one-action + :button-one-label (i18n/label :t/review-swap) + :button-one-props {:disabled? (or (not swap-proposal) + approval-required? + loading-swap-proposal? + loading-fees?) + :customization-color account-color + :on-press on-press}}])) (defn view [] - (let [[pay-value set-pay-value] (rn/use-state "") - {:keys [color]} (rf/sub [:wallet/current-viewing-account]) - {:keys [max-slippage swap-proposal loading-fees? - receive-amount network]} (rf/sub [:wallet/swap]) - currency (rf/sub [:profile/currency]) - currency-symbol (rf/sub [:profile/currency-symbol]) - asset-to-pay (rf/sub [:wallet/swap-asset-to-pay]) - asset-to-receive (rf/sub [:wallet/swap-asset-to-receive]) - - pay-token-fiat-value (utils/calculate-token-fiat-value - {:currency currency - :balance (or pay-value 0) - :token asset-to-pay}) - receive-token-fiat-value (utils/calculate-token-fiat-value - {:currency currency - :balance (or receive-amount 0) - :token asset-to-receive}) - native-currency-symbol (get-in swap-proposal - [:from :native-currency-symbol]) - pay-token-symbol (:symbol asset-to-pay) - receive-token-symbol (:symbol asset-to-receive) - on-press (fn [v] (set-pay-value (str pay-value v))) - delete (fn [] - (set-pay-value #(subs % 0 (dec (count %)))))] + (let [[pay-input-state set-pay-input-state] (rn/use-state controlled-input/init-state) + [pay-input-focused? set-pay-input-focused?] (rn/use-state true) + error-response (rf/sub [:wallet/swap-error-response]) + network (rf/sub [:wallet/swap-network]) + loading-swap-proposal? (rf/sub [:wallet/swap-loading-swap-proposal?]) + swap-proposal (rf/sub [:wallet/swap-proposal]) + asset-to-pay (rf/sub [:wallet/swap-asset-to-pay]) + pay-input-amount (controlled-input/input-value pay-input-state) + pay-token-decimals (:decimals asset-to-pay) + network-chain-id (:chain-id network) + pay-token-balance-selected-chain (get-in asset-to-pay + [:balances-per-chain network-chain-id + :balance] + 0) + on-press (rn/use-callback + (fn [c] + (let + [new-text (str pay-input-amount c) + valid-amount? + (utils.string/valid-amount-for-token-decimals? + pay-token-decimals + new-text)] + (when valid-amount? + (set-pay-input-state + #(controlled-input/add-character % c)))))) + on-long-press (rn/use-callback + (fn [] + (set-pay-input-state controlled-input/delete-all) + (rf/dispatch [:wallet/clean-suggested-routes]))) + delete (rn/use-callback + (fn [] + (set-pay-input-state + controlled-input/delete-last) + (rf/dispatch [:wallet/clean-swap-proposal])))] [rn/view {:style style/container} [account-switcher/view - {:on-press events-helper/navigate-back + {:on-press on-close :icon-name :i/arrow-left :margin-top (safe-area/get-top) :switcher-type :select-account}] [rn/view {:style style/inputs-container} - [quo/swap-input - {:type :pay - :error? false - :token pay-token-symbol - :customization-color :blue - :show-approval-label? false - :status :default - :currency-symbol currency-symbol - :on-swap-press #(js/alert "Swap Pressed") - :on-token-press #(js/alert "Token Pressed") - :on-max-press #(js/alert "Max Pressed") - :value pay-value - :fiat-value pay-token-fiat-value - :network-tag-props {:title (i18n/label :t/max-token - {:number 200 - :token-symbol pay-token-symbol}) - :networks [{:source (:source network)}]} - :approval-label-props {:status :approve - :token-value pay-value - :button-props {:on-press - #(js/alert "Approve Pressed")} - :customization-color color - :token-symbol pay-token-symbol}}] - [quo/swap-order-button - {:container-style style/swap-order-button - :on-press #(js/alert "Pressed")}] - [quo/swap-input - {:type :receive - :error? false - :token receive-token-symbol - :customization-color color - :show-approval-label? false - :enable-swap? true - :status :default - :currency-symbol currency-symbol - :on-swap-press #(js/alert "Swap Pressed") - :on-token-press #(js/alert "Token Pressed") - :on-max-press #(js/alert "Max Pressed") - :value receive-amount - :fiat-value receive-token-fiat-value - :container-style style/receive-token-swap-input-container}]] + [pay-token-input + {:input-state pay-input-state + :on-max-press #(set-pay-input-state pay-token-balance-selected-chain) + :input-focused? pay-input-focused? + :on-token-press #(js/alert "Token Pressed") + :on-approve-press #(js/alert "Approve Pressed") + :on-input-focus #(set-pay-input-focused? true)}] + [swap-order-button {:on-press #(js/alert "Swap Order Pressed")}] + [receive-token-input + {:input-focused? (not pay-input-focused?) + :on-token-press #(js/alert "Token Pressed") + :on-input-focus #(set-pay-input-focused? false)}]] [rn/view {:style style/footer-container} - (when swap-proposal - [transaction-details - {:native-currency-symbol native-currency-symbol - :max-slippage max-slippage - :loading-fees? loading-fees?}]) - [quo/bottom-actions - {:actions :one-action - :button-one-label (i18n/label :t/review-swap) - :button-one-props {:disabled? (or (not swap-proposal) - loading-fees?) - :customization-color color - :on-press #(js/alert "Review swap pressed")}}]] + (when error-response + [quo/alert-banner + {:container-style style/alert-banner + :text (i18n/label :t/something-went-wrong-please-try-again-later)}]) + (when (or loading-swap-proposal? swap-proposal) + [transaction-details]) + [action-button + {:on-press #(js/alert "Review swap pressed")}]] [quo/numbered-keyboard {:container-style style/keyboard-container :left-action :dot :delete-key? true :on-press on-press - :on-delete delete}]])) + :on-delete delete + :on-long-press on-long-press}]])) diff --git a/src/status_im/subs/wallet/swap.cljs b/src/status_im/subs/wallet/swap.cljs index b71409493e..c8f013ba08 100644 --- a/src/status_im/subs/wallet/swap.cljs +++ b/src/status_im/subs/wallet/swap.cljs @@ -1,7 +1,9 @@ (ns status-im.subs.wallet.swap - (:require [re-frame.core :as rf] + (:require [clojure.string :as string] + [re-frame.core :as rf] [status-im.constants :as constants] [status-im.contexts.wallet.common.utils :as utils] + [status-im.contexts.wallet.send.utils :as send-utils] [utils.money :as money])) (rf/reg-sub @@ -19,6 +21,16 @@ :<- [:wallet/swap] :-> :asset-to-receive) +(rf/reg-sub + :wallet/swap-network + :<- [:wallet/swap] + :-> :network) + +(rf/reg-sub + :wallet/swap-error-response + :<- [:wallet/swap] + :-> :error-response) + (rf/reg-sub :wallet/swap-asset-to-pay-token-symbol :<- [:wallet/swap-asset-to-pay] @@ -62,3 +74,59 @@ :wallet/swap-max-slippage :<- [:wallet/swap] :-> :max-slippage) + +(rf/reg-sub + :wallet/swap-loading-fees? + :<- [:wallet/swap] + :-> :loading-fees?) + +(rf/reg-sub + :wallet/swap-proposal + :<- [:wallet/swap] + :-> :swap-proposal) + +(rf/reg-sub + :wallet/swap-loading-swap-proposal? + :<- [:wallet/swap] + :-> :loading-swap-proposal?) + +(rf/reg-sub + :wallet/swap-proposal-amount-out + :<- [:wallet/swap-proposal] + :-> :amount-out) + +(rf/reg-sub + :wallet/swap-proposal-approval-required + :<- [:wallet/swap-proposal] + :-> :approval-required) + +(rf/reg-sub + :wallet/swap-proposal-approval-amount-required + :<- [:wallet/swap-proposal] + :-> :approval-amount-required) + +(rf/reg-sub + :wallet/wallet-swap-proposal-fee-fiat-formatted + :<- [:wallet/current-viewing-account] + :<- [:wallet/swap-proposal] + :<- [:profile/currency] + :<- [:profile/currency-symbol] + (fn [[account swap-proposal currency currency-symbol] [_ token-symbol-for-fees]] + (when token-symbol-for-fees + (let [tokens (:tokens account) + token-for-fees (first (filter #(= (string/lower-case (:symbol %)) + (string/lower-case token-symbol-for-fees)) + tokens)) + fee-in-native-token (send-utils/calculate-full-route-gas-fee [swap-proposal]) + fee-in-crypto-formatted (utils/get-standard-crypto-format + token-for-fees + fee-in-native-token) + fee-in-fiat (utils/calculate-token-fiat-value + {:currency currency + :balance fee-in-native-token + :token token-for-fees}) + fee-formatted (utils/get-standard-fiat-format + fee-in-crypto-formatted + currency-symbol + fee-in-fiat)] + fee-formatted)))) diff --git a/src/status_im/subs/wallet/swap_test.cljs b/src/status_im/subs/wallet/swap_test.cljs index d1a8bab0f4..6598cee89c 100644 --- a/src/status_im/subs/wallet/swap_test.cljs +++ b/src/status_im/subs/wallet/swap_test.cljs @@ -16,6 +16,27 @@ :popular? true :token? false}}) +(def ^:private accounts-with-tokens + {:0x1 {:tokens [{:symbol "ETH" + :balances-per-chain {1 {:raw-balance "100"}} + :market-values-per-currency {:usd {:price 10000}}} + {:symbol "SNT" + :balances-per-chain {1 {:raw-balance "100"}} + :market-values-per-currency {:usd {:price 10000}}}] + :network-preferences-names #{} + :customization-color nil + :operable? true + :operable :fully + :address "0x1"} + :0x2 {:tokens [{:symbol "SNT" + :balances-per-chain {1 {:raw-balance "200"}} + :market-values-per-currency {:usd {:price 10000}}}] + :network-preferences-names #{} + :customization-color nil + :operable? true + :operable :partially + :address "0x2"}}) + (def networks {:mainnet-network {:full-name "Mainnet" @@ -110,7 +131,19 @@ :token-list-id "" :built-on "ETH" :verified true} - :network nil}) + :network (networks :mainnet-network) + :swap-proposal {:amount-out "0x10000" + :amount-in "0x10000" + :approval-required true + :approval-amount-required "0x10000" + :gas-amount "25000" + :gas-fees {:max-fee-per-gas-medium "4" + :eip-1559-enabled true + :l-1-gas-fee "0"}} + :error-response "Error" + :loading-fees? false + :loading-swap-proposal? false + :max-slippage 0.5}) (h/deftest-sub :wallet/swap [sub-name] @@ -160,3 +193,91 @@ (assoc :currencies currencies) (assoc-in [:wallet :ui :swap] swap-data))) (is (match? {:crypto "1 SNT" :fiat "$0.03"} (rf/sub [sub-name 1]))))) + +(h/deftest-sub :wallet/swap-network + [sub-name] + (testing "Return the current swap network" + (swap! rf-db/app-db assoc-in + [:wallet :ui :swap] + swap-data) + (is (match? (swap-data :network) (rf/sub [sub-name]))))) + +(h/deftest-sub :wallet/swap-error-response + [sub-name] + (testing "Return the swap error response" + (swap! rf-db/app-db assoc-in + [:wallet :ui :swap] + swap-data) + (is (match? (swap-data :error-response) (rf/sub [sub-name]))))) + +(h/deftest-sub :wallet/swap-max-slippage + [sub-name] + (testing "Return the max slippage for the swap" + (swap! rf-db/app-db assoc-in + [:wallet :ui :swap] + swap-data) + (is (match? 0.5 (rf/sub [sub-name]))))) + +(h/deftest-sub :wallet/swap-loading-fees? + [sub-name] + (testing "Return if swap is loading fees" + (swap! rf-db/app-db assoc-in + [:wallet :ui :swap] + swap-data) + (is (false? (rf/sub [sub-name]))))) + +(h/deftest-sub :wallet/swap-loading-swap-proposal? + [sub-name] + (testing "Return if swap is loading the swap proposal" + (swap! rf-db/app-db assoc-in + [:wallet :ui :swap] + swap-data) + (is (false? (rf/sub [sub-name]))))) + +(h/deftest-sub :wallet/swap-proposal + [sub-name] + (testing "Return the swap proposal" + (swap! rf-db/app-db assoc-in + [:wallet :ui :swap] + swap-data) + (is (match? (swap-data :swap-proposal) (rf/sub [sub-name]))))) + +(h/deftest-sub :wallet/swap-proposal-amount-out + [sub-name] + (testing "Return the amount out in the swap proposal" + (swap! rf-db/app-db assoc-in + [:wallet :ui :swap] + swap-data) + (is (match? "0x10000" (rf/sub [sub-name]))))) + +(h/deftest-sub :wallet/swap-proposal-approval-required + [sub-name] + (testing "Return if approval is required in the swap proposal" + (swap! rf-db/app-db assoc-in + [:wallet :ui :swap] + swap-data) + (is (true? (rf/sub [sub-name]))))) + +(h/deftest-sub :wallet/swap-proposal-approval-amount-required + [sub-name] + (testing "Return the approval amount required in the swap proposal" + (swap! rf-db/app-db assoc-in + [:wallet :ui :swap] + swap-data) + (is (match? "0x10000" (rf/sub [sub-name]))))) + +(h/deftest-sub :wallet/wallet-swap-proposal-fee-fiat-formatted + [sub-name] + (testing "wallet send fee calculated and formatted in fiat" + (swap! rf-db/app-db + #(-> % + (assoc-in [:wallet :accounts] accounts-with-tokens) + (assoc-in [:wallet :current-viewing-account-address] "0x1") + (assoc-in [:wallet :ui :swap] swap-data) + (assoc-in [:currencies] currencies) + (assoc-in [:profile/profile :currency] :usd) + (assoc-in [:profile/profile :currency-symbol] "$"))) + + (let [token-symbol-for-fees "ETH" + result (rf/sub [sub-name token-symbol-for-fees])] + (is (match? result "$1.00"))))) diff --git a/src/utils/money.cljs b/src/utils/money.cljs index 0d77fc2ab7..c035d83b84 100644 --- a/src/utils/money.cljs +++ b/src/utils/money.cljs @@ -202,11 +202,6 @@ [gas gas-price] (.times ^js (bignumber gas) ^js (bignumber gas-price))) -(defn crypto->fiat - [crypto fiat-price] - (when-let [^js bn (bignumber crypto)] - (.times bn ^js (bignumber fiat-price)))) - (defn percent-change [from to] (let [^js bnf (bignumber from) @@ -221,6 +216,12 @@ (when-let [^js bn (bignumber n)] (.round bn decimals))) +(defn crypto->fiat + [crypto fiat-price] + (when-let [^js bn (bignumber crypto)] + (-> (.times bn ^js (bignumber fiat-price)) + (with-precision 2)))) + (defn sufficient-funds? [^js amount ^js balance] (when (and amount balance) diff --git a/src/utils/number.cljs b/src/utils/number.cljs index 6e5d758530..a8e7b29952 100644 --- a/src/utils/number.cljs +++ b/src/utils/number.cljs @@ -15,6 +15,17 @@ (/ (Math/round (* n scale)) scale))) +(defn convert-to-whole-number + "Converts a fractional `amount` to its corresponding whole number representation + by dividing it by 10 raised to the power of `decimals`. This is often used in financial + calculations where amounts are stored in their smallest units (e.g., cents) and need + to be converted to their whole number equivalents (e.g., dollars). + + Example usage: + (convert-to-whole-number 12345 2) ; => 123.45" + [amount decimals] + (/ amount (Math/pow 10 decimals))) + (defn parse-int "Parses `n` as an integer. Defaults to zero or `default` instead of NaN." ([n] diff --git a/src/utils/number_test.cljs b/src/utils/number_test.cljs index ebfd9a2abe..57c3e3c783 100644 --- a/src/utils/number_test.cljs +++ b/src/utils/number_test.cljs @@ -3,6 +3,26 @@ [cljs.test :refer [deftest is testing]] [utils.number])) +(deftest convert-to-whole-number-test + (testing "correctly converts fractional amounts to whole numbers" + (is (= 123.45 (utils.number/convert-to-whole-number 12345 2))) + (is (= 1.2345 (utils.number/convert-to-whole-number 12345 4))) + (is (= 12345.0 (utils.number/convert-to-whole-number 1234500 2))) + (is (= 0.123 (utils.number/convert-to-whole-number 123 3))) + (is (= 1000.0 (utils.number/convert-to-whole-number 1000000 3)))) + + (testing "handles zero decimals" + (is (= 12345 (utils.number/convert-to-whole-number 12345 0)))) + + (testing "handles negative amounts" + (is (= -123.45 (utils.number/convert-to-whole-number -12345 2))) + (is (= -1.2345 (utils.number/convert-to-whole-number -12345 4))) + (is (= -0.123 (utils.number/convert-to-whole-number -123 3)))) + + (testing "handles zero amount" + (is (= 0 (utils.number/convert-to-whole-number 0 2))) + (is (= 0 (utils.number/convert-to-whole-number 0 0))))) + (deftest parse-int-test (testing "defaults to zero" (is (= 0 (utils.number/parse-int nil)))) diff --git a/src/utils/string.cljs b/src/utils/string.cljs index b5ffe92562..d1836b86ff 100644 --- a/src/utils/string.cljs +++ b/src/utils/string.cljs @@ -88,3 +88,9 @@ [url] (when (string? url) (string/replace url #"^https?://" ""))) + +(defn valid-amount-for-token-decimals? + [token-decimals amount-text] + (let [regex-pattern (str "^\\d*\\.?\\d{0," token-decimals "}$") + regex (re-pattern regex-pattern)] + (re-matches regex amount-text))) diff --git a/translations/en.json b/translations/en.json index 2cbf28182f..28bd3d71ed 100644 --- a/translations/en.json +++ b/translations/en.json @@ -2302,6 +2302,7 @@ "slow": "Slow", "something-about-you": "Something about you", "something-went-wrong": "Something went wrong", + "something-went-wrong-please-try-again-later": "Something went wrong, please try again later", "soon": "Soon", "sort-communities": "Sort communities", "special-characters": "Special characters",