fix(swap): display very small max values, fix scientific notation, handle decimal mismatch when changing tokens, display long numbers (#21388)

Signed-off-by: Brian Sztamfater <brian@status.im>
This commit is contained in:
Brian Sztamfater 2024-10-09 13:49:45 -03:00 committed by GitHub
parent a9e0b3dd6b
commit 5c44cb6399
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 283 additions and 134 deletions

View File

@ -75,3 +75,16 @@
(def fiat-amount
{:color colors/neutral-50})
(def gradient-common
{:width 64
:position :absolute
:top 0
:bottom 0
:z-index 1})
(def gradient-start
(assoc gradient-common :left 0))
(def gradient-end
(assoc gradient-common :right 0))

View File

@ -11,6 +11,7 @@
[quo.foundations.colors :as colors]
quo.theme
[react-native.core :as rn]
[react-native.linear-gradient :as linear-gradient]
[schema.core :as schema]))
(def ?schema
@ -18,6 +19,7 @@
[:catn
[:props
[:map {:closed true}
[:get-ref {:optional true} [:maybe fn?]]
[:type {:optional true} [:maybe [:enum :pay :receive]]]
[:status {:optional true} [:maybe [:enum :default :typing :disabled :loading]]]
[:token {:optional true} [:maybe :string]]
@ -42,26 +44,65 @@
[:container-style {:optional true} [:maybe :map]]]]]
:any])
(def icon-size 32)
(def container-padding (* 2 (:padding (style/row-1 false))))
(def max-cursor-position 5)
(defn view-internal
[{:keys [type status token value fiat-value show-approval-label? error? network-tag-props
approval-label-props default-value auto-focus? input-disabled? enable-swap?
currency-symbol on-change-text show-keyboard?
currency-symbol on-change-text show-keyboard? get-ref
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)) [])
focus-input (rn/use-callback (fn []
(some-> @input-ref
(oops/ocall "focus")))
[input-ref])]
(let [theme (quo.theme/use-theme)
pay? (= type :pay)
disabled? (= status :disabled)
loading? (= status :loading)
typing? (= status :typing)
controlled-input? (some? value)
[container-width
set-container-width] (rn/use-state)
[overflow?
set-overflow?] (rn/use-state false)
[cursor-close-to-start?
set-cursor-close-to-start?] (rn/use-state false)
[label-width
set-label-width] (rn/use-state 0)
input-ref (rn/use-ref-atom nil)
set-input-ref (rn/use-callback (fn [ref]
(reset! input-ref ref)
(when get-ref (get-ref ref)))
[])
focus-input (rn/use-callback (fn []
(some-> @input-ref
(oops/ocall "focus")))
[input-ref])
on-layout-container (rn/use-callback
(fn [e]
(let [width (oops/oget e "nativeEvent.layout.width")]
(set-container-width width))))
on-layout-text-input (rn/use-callback
(fn [e]
(let [width (oops/oget e "nativeEvent.layout.width")
max-width (- container-width
icon-size
container-padding
(* label-width 2))]
(set-overflow? (> width max-width))))
[container-width label-width])
on-layout-label (rn/use-callback
(fn [e]
(let [width (oops/oget e "nativeEvent.layout.width")]
(set-label-width width))))
on-selection-change (rn/use-callback
(fn [e]
(let [selection-start (oops/oget e
"nativeEvent.selection.start")]
(set-cursor-close-to-start?
(< selection-start max-cursor-position)))))]
[rn/view
{:style container-style
:accessibility-label :swap-input}
:accessibility-label :swap-input
:on-layout on-layout-container}
[rn/view {:style (style/content typing? theme)}
[rn/view
{:style (style/row-1 loading?)}
@ -75,25 +116,43 @@
[rn/pressable
{:style style/input-container
:on-press focus-input}
[rn/text-input
(cond-> {:ref set-input-ref
:style (style/input disabled? error? theme)
:placeholder-text-color (colors/theme-colors colors/neutral-40
colors/neutral-50
theme)
:keyboard-type :numeric
: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
:placeholder "0"}
controlled-input? (assoc :value value))]
[rn/view {:style {:flex-shrink 1}}
(when (and overflow? typing? (not cursor-close-to-start?))
[linear-gradient/linear-gradient
{:start {:x 0 :y 0}
:end {:x 1 :y 0}
:colors [(colors/theme-colors colors/white colors/neutral-100 theme)
(colors/theme-colors colors/white-opa-10 colors/neutral-100-opa-10 theme)]
:style style/gradient-start}])
(when (and overflow? disabled?)
[linear-gradient/linear-gradient
{:start {:x 0 :y 0}
:end {:x 1 :y 0}
:colors [(colors/theme-colors colors/white-opa-10 colors/neutral-100-opa-10 theme)
(colors/theme-colors colors/white colors/neutral-100 theme)]
:style style/gradient-end}])
[rn/text-input
(cond-> {:ref set-input-ref
:style (style/input disabled? error? theme)
:placeholder-text-color (colors/theme-colors colors/neutral-40
colors/neutral-50
theme)
:keyboard-type :numeric
:editable (not input-disabled?)
:auto-focus auto-focus?
:on-focus on-input-focus
:on-change-text on-change-text
:on-layout on-layout-text-input
:on-selection-change on-selection-change
:show-soft-input-on-focus show-keyboard?
:default-value default-value
:placeholder "0"}
controlled-input? (assoc :value value))]]
[text/text
{:size :paragraph-2
:weight :semi-bold
:style (style/token-symbol theme)}
{:size :paragraph-2
:weight :semi-bold
:style (style/token-symbol theme)
:on-layout on-layout-label}
token]]
(when (and pay? enable-swap?)
[buttons/button

View File

@ -47,7 +47,7 @@
(rf/dispatch [:wallet/bridge-select-token params]))})
(defn- action-swap
[{:keys [token token-symbol testnet-mode?]}]
[{:keys [token asset-to-receive token-symbol testnet-mode?]}]
{:icon :i/swap
:accessibility-label :swap
:label (i18n/label :t/swap)
@ -56,6 +56,7 @@
(rf/dispatch [:hide-bottom-sheet])
(rf/dispatch [:wallet.swap/start
{:asset-to-pay (or token {:symbol token-symbol})
:asset-to-receive asset-to-receive
:open-new-screen? true}]))})
(defn- action-manage-tokens
@ -75,23 +76,26 @@
(defn token-value-drawer
[token watch-only? entry-point]
(let [token-symbol (:token token)
token-data (first (rf/sub [:wallet/current-viewing-account-tokens-filtered
{:query token-symbol}]))
selected-account (rf/sub [:wallet/current-viewing-account-address])
token-owners (rf/sub [:wallet/operable-addresses-with-token-symbol token-symbol])
testnet-mode? (rf/sub [:profile/test-networks-enabled?])
params (cond-> {:start-flow? true
:owners token-owners
:testnet-mode? testnet-mode?}
selected-account
(assoc :token token-data
:stack-id :screen/wallet.accounts
:has-balance? (-> (get-in token [:values :fiat-unformatted-value])
(money/greater-than (money/bignumber "0"))))
(not selected-account)
(assoc :token-symbol token-symbol
:stack-id :wallet-stack))]
(let [token-symbol (:token token)
token-data (first (rf/sub [:wallet/current-viewing-account-tokens-filtered
{:query token-symbol}]))
selected-account (rf/sub [:wallet/current-viewing-account-address])
token-owners (rf/sub [:wallet/operable-addresses-with-token-symbol token-symbol])
testnet-mode? (rf/sub [:profile/test-networks-enabled?])
receive-token-symbol (if (= token-symbol "SNT") "ETH" "SNT")
asset-to-receive (rf/sub [:wallet/token-by-symbol receive-token-symbol])
params (cond-> {:start-flow? true
:owners token-owners
:testnet-mode? testnet-mode?
:asset-to-receive asset-to-receive}
selected-account
(assoc :token token-data
:stack-id :screen/wallet.accounts
:has-balance? (-> (get-in token [:values :fiat-unformatted-value])
(money/greater-than (money/bignumber "0"))))
(not selected-account)
(assoc :token-symbol token-symbol
:stack-id :wallet-stack))]
[quo/action-drawer
[(cond->> [(when (ff/enabled? ::ff/wallet.assets-modal-manage-tokens)
(action-manage-tokens watch-only?))

View File

@ -14,7 +14,7 @@
[utils.number :as number]))
(rf/reg-event-fx :wallet.swap/start
(fn [{:keys [db]} [{:keys [network open-new-screen?] :as data}]]
(fn [{:keys [db]} [{:keys [network asset-to-receive open-new-screen?] :as data}]]
(let [{:keys [wallet]} db
test-networks-enabled? (get-in db [:profile/profile :test-networks-enabled?])
account (swap-utils/wallet-account wallet)
@ -25,12 +25,6 @@
:account account
:test-networks-enabled? test-networks-enabled?
:token-symbol (get-in data [:asset-to-pay :symbol])}))
asset-to-receive (or (:asset-to-receive data)
(swap-utils/select-default-asset-to-receive
{:wallet wallet
:account account
:test-networks-enabled? test-networks-enabled?
:asset-to-pay asset-to-pay}))
network' (or network
(swap-utils/select-network asset-to-pay))]
{:db (-> db
@ -137,8 +131,6 @@
:amount amount
:amount-hex amount-in-hex
:loading-swap-proposal? true)
:always
(dissoc :error-response)
clean-approval-transaction?
(dissoc :approval-transaction-id :approved-amount :swap-proposal)))
:json-rpc/call [{:method "wallet_getSuggestedRoutesAsync"

View File

@ -20,10 +20,15 @@
(defn- assets-view
[search-text on-change-text]
(let [on-token-press (fn [token]
(rf/dispatch [:wallet.swap/start
{:asset-to-pay token
:open-new-screen? false}]))]
(let [snt-token (rf/sub [:wallet/token-by-symbol "SNT"])
eth-token (rf/sub [:wallet/token-by-symbol "ETH"])
on-token-press (fn [token]
(let [pay-token-symbol (:symbol token)
asset-to-receive (if (= pay-token-symbol "SNT") eth-token snt-token)]
(rf/dispatch [:wallet.swap/start
{:asset-to-pay token
:asset-to-receive asset-to-receive
:open-new-screen? false}])))]
[:<>
[search-input search-text on-change-text]
[asset-list/view

View File

@ -1,27 +1,29 @@
(ns status-im.contexts.wallet.swap.setup-swap.view
(:require [clojure.string :as string]
[quo.core :as quo]
[quo.foundations.colors :as colors]
[quo.theme :as quo.theme]
[react-native.core :as rn]
[react-native.platform :as platform]
[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.sheets.buy-token.view :as buy-token]
[status-im.contexts.wallet.sheets.select-asset.view :as select-asset]
[status-im.contexts.wallet.sheets.slippage-settings.view :as slippage-settings]
[status-im.contexts.wallet.swap.setup-swap.style :as style]
[status-im.contexts.wallet.swap.utils :as swap-utils]
[utils.debounce :as debounce]
[utils.i18n :as i18n]
[utils.money :as money]
[utils.number :as number]
[utils.re-frame :as rf]
[utils.string :as utils.string]))
(:require
[clojure.string :as string]
[oops.core :as oops]
[quo.core :as quo]
[quo.foundations.colors :as colors]
[quo.theme :as quo.theme]
[react-native.core :as rn]
[react-native.platform :as platform]
[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.sheets.buy-token.view :as buy-token]
[status-im.contexts.wallet.sheets.select-asset.view :as select-asset]
[status-im.contexts.wallet.sheets.slippage-settings.view :as slippage-settings]
[status-im.contexts.wallet.swap.setup-swap.style :as style]
[status-im.contexts.wallet.swap.utils :as swap-utils]
[utils.debounce :as debounce]
[utils.i18n :as i18n]
[utils.money :as money]
[utils.number :as number]
[utils.re-frame :as rf]
[utils.string :as utils.string]))
(def ^:private default-text-for-unfocused-input "0.00")
@ -97,13 +99,18 @@
approved-amount (rf/sub [:wallet/swap-approved-amount])
error-response (rf/sub [:wallet/swap-error-response])
asset-to-pay (rf/sub [:wallet/token-by-symbol pay-token-symbol])
overlay-shown? (boolean (:sheets (rf/sub [:bottom-sheet])))
input-ref (rn/use-ref-atom nil)
set-input-ref (rn/use-callback (fn [ref] (reset! input-ref ref)))
pay-input-num-value (controlled-input/value-numeric input-state)
pay-input-amount (controlled-input/input-value input-state)
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-balance-selected-chain (number/convert-to-whole-number
(get-in asset-to-pay
[:balances-per-chain
(:chain-id network) :raw-balance]
0)
pay-token-decimals)
pay-token-fiat-value (str
(utils/calculate-token-fiat-value
{:currency currency
@ -111,10 +118,15 @@
:token asset-to-pay}))
available-crypto-limit (money/bignumber
pay-token-balance-selected-chain)
display-decimals (min pay-token-decimals
constants/min-token-decimals-to-display)
available-crypto-limit-display (number/remove-trailing-zeroes
(.toFixed available-crypto-limit
(min pay-token-decimals
constants/min-token-decimals-to-display)))
(.toFixed available-crypto-limit display-decimals))
available-crypto-limit-display (if (and (= available-crypto-limit-display "0")
(money/greater-than available-crypto-limit
(money/bignumber 0)))
(number/small-number-threshold display-decimals)
available-crypto-limit-display)
approval-amount-required-num (when approval-amount-required
(str (number/hex->whole approval-amount-required
pay-token-decimals)))
@ -122,8 +134,7 @@
(money/greater-than
(money/bignumber pay-input-amount)
available-crypto-limit))
(money/equal-to (money/bignumber
available-crypto-limit-display)
(money/equal-to available-crypto-limit
(money/bignumber 0)))
valid-pay-input? (and
(not (string/blank?
@ -141,8 +152,15 @@
(fn []
(request-fetch-swap-proposal))
[pay-input-amount])
(rn/use-effect
(fn []
(when-not overlay-shown?
(some-> @input-ref
(oops/ocall "focus"))))
[overlay-shown?])
[quo/swap-input
{:type :pay
{:get-ref set-input-ref
:type :pay
:error? pay-input-error?
:token pay-token-symbol
:customization-color :blue
@ -156,7 +174,7 @@
:else :disabled)
:currency-symbol currency-symbol
:on-token-press on-token-press
:on-max-press #(on-max-press (str available-crypto-limit))
:on-max-press #(on-max-press (str pay-token-balance-selected-chain))
:on-input-focus on-input-focus
:value pay-input-amount
:fiat-value pay-token-fiat-value
@ -315,10 +333,12 @@
network (rf/sub [:wallet/swap-network])
pay-input-amount (controlled-input/input-value pay-input-state)
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-balance-selected-chain (number/convert-to-whole-number
(get-in asset-to-pay
[:balances-per-chain
(:chain-id network) :raw-balance]
0)
pay-token-decimals)
pay-input-error? (and (not (string/blank? pay-input-amount))
(money/greater-than
(money/bignumber pay-input-amount)
@ -420,13 +440,17 @@
(when (and swap-amount refetch-interval)
(js/clearTimeout @refetch-interval)
(reset! refetch-interval nil))
(if (and swap-amount (not= swap-amount pay-input-amount))
(set-pay-input-state
(fn [input-state]
(controlled-input/set-input-value
input-state
swap-amount)))
(refetch-swap-proposal)))))
(cond (and swap-amount (not= swap-amount pay-input-amount))
(set-pay-input-state
(fn [input-state]
(controlled-input/set-input-value
input-state
swap-amount)))
(and pay-input-amount
(not (number/valid-decimal-count? pay-input-amount (:decimals asset-to-pay))))
(set-pay-input-state controlled-input/delete-all)
:else
(refetch-swap-proposal)))))
[asset-to-pay])
(rn/use-effect
refetch-swap-proposal
@ -461,7 +485,7 @@
:on-token-press #(rf/dispatch [:show-bottom-sheet {:content receive-token-bottom-sheet}])
:on-input-focus #(set-pay-input-focused? false)}]]
[rn/view {:style style/footer-container}
[alert-banner {:pay-input-error? pay-input-error?}]
(when-not loading-swap-proposal? [alert-banner {:pay-input-error? pay-input-error?}])
(when (or loading-swap-proposal? swap-proposal)
[transaction-details])
[action-button {:on-press on-review-swap-press}]]

View File

@ -492,7 +492,7 @@
(rf/reg-sub
:wallet/token-by-symbol
:<- [:wallet/current-viewing-account]
:<- [:wallet/current-viewing-account-or-default]
:<- [:wallet/network-details]
(fn [[{:keys [tokens]} networks] [_ token-symbol chain-ids]]
(->> (utils/tokens-with-balance tokens networks chain-ids)

View File

@ -2,7 +2,8 @@
(:require [clojure.string :as string]
[native-module.core :as native-module]
[utils.hex :as utils.hex]
[utils.money :as utils.money]))
[utils.money :as utils.money]
[utils.money :as money]))
(defn naive-round
"Quickly and naively round number `n` up to `decimal-places`.
@ -18,17 +19,6 @@
(/ (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]
@ -65,15 +55,44 @@
"")
""))))
(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))
(.toFixed decimals)
remove-trailing-zeroes))
(defn hex->whole
[num decimals]
(-> num
utils.hex/normalize-hex
native-module/hex-to-number
(convert-to-whole-number decimals)))
(convert-to-whole-number decimals)
money/bignumber))
(defn to-fixed
[num decimals]
(-> num
(utils.money/to-fixed decimals)
remove-trailing-zeroes))
(defn small-number-threshold
"Receives a decimal count and returns a string like '<0.001' if the decimal count is 3,
'<0.000001' if the decimal count is 6, etc."
[decimal-count]
(if (> decimal-count 0)
(str "<0." (apply str (repeat (dec decimal-count) "0")) "1")
"0"))
(defn valid-decimal-count?
"Returns false if the number has more decimals than the decimal count, otherwise true."
[num decimal-count]
(let [decimal-part (second (string/split (str num) #"\."))]
(or (nil? decimal-part) (<= (count decimal-part) decimal-count))))

View File

@ -5,23 +5,23 @@
(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))))
(is (= "123.45" (utils.number/convert-to-whole-number 12345 2)))
(is (= "1.2345" (utils.number/convert-to-whole-number 12345 4)))
(is (= "12345" (utils.number/convert-to-whole-number 1234500 2)))
(is (= "0.123" (utils.number/convert-to-whole-number 123 3)))
(is (= "1000" (utils.number/convert-to-whole-number 1000000 3))))
(testing "handles zero decimals"
(is (= 12345 (utils.number/convert-to-whole-number 12345 0))))
(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))))
(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)))))
(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"
@ -50,3 +50,36 @@
(is (= 6 (utils.number/parse-float "6")))
(is (= 6.99 (utils.number/parse-float "6.99" 0)))
(is (= -6.9 (utils.number/parse-float "-6.9" 0)))))
(deftest small-number-threshold-test
(testing "correctly generates threshold strings based on decimal count"
(is (= "<0.1" (utils.number/small-number-threshold 1)))
(is (= "<0.01" (utils.number/small-number-threshold 2)))
(is (= "<0.001" (utils.number/small-number-threshold 3)))
(is (= "<0.000001" (utils.number/small-number-threshold 6)))
(is (= "<0.0000000001" (utils.number/small-number-threshold 10)))
(is (= "<0.000000000000000001" (utils.number/small-number-threshold 18)))
(is (= "<0.000000000000000000001" (utils.number/small-number-threshold 21))))
(testing "handles edge cases for decimal count"
(is (= "0" (utils.number/small-number-threshold 0)))
(is (= "0" (utils.number/small-number-threshold -1)))))
(deftest valid-decimal-count?-test
(testing "valid decimal count check for numbers with varying decimals"
(is (true? (utils.number/valid-decimal-count? 123 2)))
(is (true? (utils.number/valid-decimal-count? 123 0)))
(is (true? (utils.number/valid-decimal-count? 123.45 2)))
(is (true? (utils.number/valid-decimal-count? 123.4 2)))
(is (true? (utils.number/valid-decimal-count? 123.456 3)))
(is (false? (utils.number/valid-decimal-count? 123.456 2)))
(is (false? (utils.number/valid-decimal-count? 123.456789 4)))
(is (true? (utils.number/valid-decimal-count? 123.0 1)))
(is (true? (utils.number/valid-decimal-count? -123.45 2)))
(is (false? (utils.number/valid-decimal-count? -123.4567 3)))
(is (true? (utils.number/valid-decimal-count? 1234567890.12 2)))
(is (false? (utils.number/valid-decimal-count? 1234567890.12345 3)))))