feat: implement proper error handling for swaps (#21261)

Signed-off-by: Brian Sztamfater <brian@status.im>
This commit is contained in:
Brian Sztamfater 2024-09-24 11:44:35 -03:00 committed by GitHub
parent 604ee33c52
commit 9258f2a513
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 181 additions and 75 deletions

View File

@ -9,5 +9,6 @@
[:text {:optional true} [:maybe string?]]
[:container-style {:optional true} [:maybe :map]]
[:button-text {:optional true} [:maybe string?]]
[:text-number-of-lines {:optional true} [:maybe number?]]
[:on-button-press {:optional true} [:maybe fn?]]]]]
:any])

View File

@ -11,11 +11,19 @@
:padding-vertical 12}
container-style))
(def content-container
{:flex 1
:flex-direction :row})
(defn label
[theme]
{:flex 1
:color (colors/resolve-color :danger theme)
:margin-horizontal 4})
{:color (colors/resolve-color :danger theme)
:margin-horizontal 4
:flex 1
:flex-wrap :wrap})
(def button-text
{:color colors/white})
(def icon
{:margin-top 2})

View File

@ -11,7 +11,7 @@
[schema.core :as schema]))
(defn- view-internal
[{:keys [action? text button-text container-style on-button-press]}]
[{:keys [action? text button-text text-number-of-lines container-style on-button-press]}]
(let [theme (quo.theme/use-theme)]
[rn/view
{:accessibility-label :alert-banner}
@ -24,15 +24,17 @@
colors/danger-50-opa-10
theme)
colors/danger-50-opa-0]}
[icon/icon
:i/alert
{:color (colors/resolve-color :danger theme)
:size 16}]
[text/text
{:style (style/label theme)
:size :paragraph-2
:number-of-lines 1}
text]
[rn/view {:style style/content-container}
[icon/icon
:i/alert
{:color (colors/resolve-color :danger theme)
:size 16
:container-style style/icon}]
[text/text
{:style (style/label theme)
:size :paragraph-2
:number-of-lines (or text-number-of-lines 1)}
text]]
(when action?
[button/button
{:accessibility-label :button

View File

@ -55,7 +55,7 @@
(set-pressed-state nil)
(when on-press-out (on-press-out))))]
[rn/touchable-without-feedback
{:disabled disabled?
{:disabled (boolean disabled?)
:accessibility-label accessibility-label
:on-press-in on-press-in-cb
:on-press-out on-press-out-cb

View File

@ -84,12 +84,13 @@
:height 16})
(defn description
[blur? theme]
{:color (if blur?
colors/white
(colors/theme-colors colors/neutral-100
colors/white
theme))})
[subtitle-color blur? theme]
{:color (or subtitle-color
(if blur?
colors/white
(colors/theme-colors colors/neutral-100
colors/white
theme)))})
(def right-icon
{:margin-left 12})

View File

@ -16,7 +16,8 @@
[rn/view {:style (style/loading-container size blur? theme)}]))
(defn- left-subtitle
[{:keys [size subtitle-type icon icon-color blur? subtitle customization-color emoji network-image]
[{:keys [size subtitle-type subtitle-color icon icon-color blur? subtitle customization-color emoji
network-image]
:or {subtitle-type :default}}]
(let [theme (quo.theme/use-theme)]
[rn/view {:style style/subtitle-container}
@ -40,7 +41,7 @@
[text/text
{:weight :medium
:size :paragraph-2
:style (style/description blur? theme)}
:style (style/description subtitle-color blur? theme)}
subtitle]
(when (= subtitle-type :editable)
[icons/icon :i/edit
@ -65,7 +66,7 @@
(defn- left-side
"The description can either be given as a string `subtitle-type` or a component `custom-subtitle`"
[{:keys [title status size blur? custom-subtitle icon subtitle subtitle-type icon-color
[{:keys [title status size blur? custom-subtitle icon subtitle subtitle-type subtitle-color icon-color
customization-color network-image emoji]
:as props}]
(let [theme (quo.theme/use-theme)]
@ -85,6 +86,7 @@
{:theme theme
:size size
:subtitle-type subtitle-type
:subtitle-color subtitle-color
:icon icon
:icon-color icon-color
:blur? blur?
@ -124,6 +126,7 @@
[:icon-color {:optional true} [:maybe :schema.common/customization-color]]
[:status {:optional true} [:maybe [:enum :default :loading]]]
[:subtitle-type {:optional true} [:maybe [:enum :default :icon :network :account :editable]]]
[:subtitle-color {:optional true} [:maybe :schema.common/customization-color]]
[:size {:optional true} [:maybe [:enum :default :small :large]]]
[:title :string]
[:subtitle {:optional true} [:maybe [:or :string :double]]]

View File

@ -4,6 +4,8 @@
[status-im.contexts.wallet.common.utils :as wallet-utils]
[utils.money :as money]))
(def ^:private default-max-limit 12)
(def init-state
{:value ""
:error? false
@ -101,9 +103,8 @@
(def ^:private dot ".")
(defn- can-add-character?
[state character]
(let [max-length 12
current (input-value state)
[state character max-length]
(let [current (input-value state)
length-overflow? (>= (count current) max-length)
extra-dot? (and (= character dot) (string/includes? current dot))
extra-leading-zero? (and (= current "0") (= "0" (str character)))
@ -123,11 +124,13 @@
(str value character)))
(defn add-character
[state character]
(if (can-add-character? state character)
(set-input-value state
(normalize-value-as-numeric (input-value state) character))
state))
([state character]
(add-character state character default-max-limit))
([state character max-length]
(if (can-add-character? state character max-length)
(set-input-value state
(normalize-value-as-numeric (input-value state) character))
state)))
(defn delete-last
([state]

View File

@ -609,3 +609,10 @@
(def ^:const min-token-decimals-to-display 6)
(def ^:const swap-proposal-refresh-interval-ms 15000)
(def router-error-code-generic "0")
(def router-error-code-paraswap-custom-error "WPP-030")
(def router-error-code-price-timeout "WPP-037")
(def router-error-code-not-enough-liquidity "WPP-038")
(def router-error-code-price-impact-too-high "WPP-039")
(def router-error-code-not-enough-native-balance "WR-002")

View File

@ -35,7 +35,7 @@
(def ^:private initial-tab (:id (first tabs)))
(defn view
[]
[{:keys [title]}]
(rn/use-mount (fn []
(rf/dispatch [:wallet/get-crypto-on-ramps])))
(let [crypto-on-ramps (rf/sub [:wallet/crypto-on-ramps])
@ -45,7 +45,7 @@
#(set-min-height
(oops/oget % :nativeEvent :layout :height)))]
[:<>
[quo/drawer-top {:title (i18n/label :t/buy-assets)}]
[quo/drawer-top {:title (or title (i18n/label :t/buy-assets))}]
[quo/segmented-control
{:size 32
:container-style style/tabs

View File

@ -134,15 +134,8 @@
assoc
:swap-proposal (when-not (empty? best-routes)
(assoc (first best-routes) :uuid request-uuid))
:error-response (when (empty? best-routes) error-response)
:error-response error-response
:loading-swap-proposal? false)}
(empty? best-routes)
(assoc :fx
[[:dispatch
[:toasts/upsert
{:id :swap-proposal-error
:type :negative
:text error-response}]]])
;; Router is unstable and it can return a swap proposal and after auto-refetching it can
;; return an error. Ideally this shouldn't happen, but adding this behavior so if the
;; user is in swap confirmation screen or in token approval confirmation screen, we
@ -157,12 +150,7 @@
{: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}]]]}))
(assoc-in [:wallet :ui :swap :error-response] error-message))}))
(rf/reg-event-fx :wallet/stop-get-swap-proposal
(fn []

View File

@ -37,4 +37,6 @@
:justify-content :flex-end})
(def alert-banner
{:height 40})
{:height :auto
:min-height 40
:max-height 62})

View File

@ -1,6 +1,8 @@
(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]
@ -9,7 +11,9 @@
[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.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]
@ -35,7 +39,7 @@
{:clean-approval-transaction? clean-approval-transaction?}])))
(defn- data-item
[{:keys [title subtitle size subtitle-icon loading?]}]
[{:keys [title subtitle size subtitle-icon subtitle-color loading?]}]
[quo/data-item
{:container-style style/detail-item
:blur? false
@ -45,20 +49,27 @@
:title title
:subtitle subtitle
:size size
:icon subtitle-icon}])
:icon subtitle-icon
:subtitle-color subtitle-color}])
(defn- transaction-details
[]
(let [max-fees (rf/sub [:wallet/wallet-swap-proposal-fee-fiat-formatted
(let [theme (quo.theme/use-theme)
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-swap-proposal? (rf/sub [:wallet/swap-loading-swap-proposal?])]
loading-swap-proposal? (rf/sub [:wallet/swap-loading-swap-proposal?])
error-response (rf/sub [:wallet/swap-error-response])]
[rn/view {:style style/details-container}
[data-item
{:title (i18n/label :t/max-fees)
:subtitle max-fees
:loading? loading-swap-proposal?
:size :small}]
(cond-> {:title (i18n/label :t/max-fees)
:subtitle max-fees
:loading? loading-swap-proposal?
:size :small}
error-response (assoc :subtitle-color
(colors/theme-colors colors/danger-50
colors/danger-60
theme)))]
[data-item
{:title (i18n/label :t/max-slippage)
:subtitle max-slippage
@ -79,6 +90,7 @@
approval-transaction-status (rf/sub [:wallet/swap-approval-transaction-status])
approval-transaction-id (rf/sub [:wallet/swap-approval-transaction-id])
approved-amount (rf/sub [:wallet/swap-approved-amount])
error-response (rf/sub [:wallet/swap-error-response])
pay-input-num-value (controlled-input/value-numeric input-state)
pay-input-amount (controlled-input/input-value input-state)
pay-token-symbol (:symbol asset-to-pay)
@ -92,9 +104,10 @@
{:currency currency
:balance (or pay-input-num-value 0)
:token asset-to-pay}))
available-crypto-limit (number/remove-trailing-zeroes
(.toFixed (money/bignumber
pay-token-balance-selected-chain)
available-crypto-limit (money/bignumber
pay-token-balance-selected-chain)
available-crypto-limit-display (number/remove-trailing-zeroes
(.toFixed available-crypto-limit
(min pay-token-decimals
constants/min-token-decimals-to-display)))
approval-amount-required-num (when approval-amount-required
@ -102,11 +115,10 @@
pay-token-decimals)))
pay-input-error? (or (and (not (string/blank? pay-input-amount))
(money/greater-than
(money/bignumber pay-input-num-value)
(money/bignumber
pay-token-balance-selected-chain)))
(money/bignumber pay-input-amount)
available-crypto-limit))
(money/equal-to (money/bignumber
available-crypto-limit)
available-crypto-limit-display)
(money/bignumber 0)))
valid-pay-input? (and
(not (string/blank?
@ -139,12 +151,12 @@
:else :disabled)
:currency-symbol currency-symbol
:on-token-press on-token-press
:on-max-press #(on-max-press available-crypto-limit)
:on-max-press #(on-max-press (str available-crypto-limit))
: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
{:number available-crypto-limit-display
:token-symbol pay-token-symbol})
:networks [{:source (:source network)}]}
:approval-label-props {:status (case approval-transaction-status
@ -153,7 +165,9 @@
:finalised :approved
:approve)
:token-value (or approval-amount-required-num approved-amount)
:button-props {:on-press on-approve-press}
:button-props (merge {:on-press on-approve-press}
(when error-response
{:disabled? true}))
:customization-color account-color
:token-symbol pay-token-symbol}}]))
@ -204,10 +218,41 @@
:fiat-value receive-token-fiat-value
:container-style (style/receive-token-swap-input-container approval-required?)}]))
(defn- alert-banner
[{:keys [pay-input-error?]}]
(let [error-response (rf/sub [:wallet/swap-error-response])
error-response-code (rf/sub [:wallet/swap-error-response-code])
error-response-details (rf/sub [:wallet/swap-error-response-details])
error-text (if pay-input-error?
(i18n/label :t/insufficient-funds-for-swaps)
(swap-utils/error-message-from-code error-response-code
error-response-details))
props (cond-> {:container-style style/alert-banner
:text-number-of-lines 0
:text error-text}
pay-input-error?
(merge {:action? true
:on-button-press #(rf/dispatch [:show-bottom-sheet
{:content buy-token/view}])
:button-text (i18n/label :t/buy-assets)})
(= error-response-code
constants/router-error-code-not-enough-native-balance)
(merge {:action? true
:on-button-press #(rf/dispatch
[:show-bottom-sheet
{:content (fn []
[buy-token/view
{:title (i18n/label
:t/buy-ethereum)}])}])
:button-text (i18n/label :t/buy-eth)}))]
(when (or pay-input-error? error-response)
[quo/alert-banner props])))
(defn- action-button
[{:keys [on-press]}]
(let [account-color (rf/sub [:wallet/current-viewing-account-color])
swap-proposal (rf/sub [:wallet/swap-proposal-without-fees])
error-response (rf/sub [:wallet/swap-error-response])
loading-swap-proposal? (rf/sub [:wallet/swap-loading-swap-proposal?])
approval-required? (rf/sub [:wallet/swap-proposal-approval-required])
approval-transaction-status (rf/sub [:wallet/swap-approval-transaction-status])]
@ -215,6 +260,7 @@
{:actions :one-action
:button-one-label (i18n/label :t/review-swap)
:button-one-props {:disabled? (or (not swap-proposal)
error-response
(and approval-required?
(not= approval-transaction-status :confirmed))
loading-swap-proposal?)
@ -233,14 +279,13 @@
network (rf/sub [:wallet/swap-network])
pay-input-amount (controlled-input/input-value pay-input-state)
pay-token-decimals (:decimals asset-to-pay)
pay-input-num-value (controlled-input/value-numeric pay-input-state)
pay-token-balance-selected-chain (get-in asset-to-pay
[:balances-per-chain
(:chain-id network) :balance]
0)
pay-input-error? (and (not (string/blank? pay-input-amount))
(money/greater-than
(money/bignumber pay-input-num-value)
(money/bignumber pay-input-amount)
(money/bignumber
pay-token-balance-selected-chain)))
valid-pay-input? (and
@ -263,7 +308,10 @@
new-text)]
(when valid-amount?
(set-pay-input-state
#(controlled-input/add-character % c))))))
#(controlled-input/add-character %
c
##Inf)))))
[pay-input-amount pay-token-decimals])
on-long-press (rn/use-callback
(fn []
(set-pay-input-state controlled-input/delete-all)
@ -371,10 +419,7 @@
:on-token-press #(js/alert "Token Pressed")
:on-input-focus #(set-pay-input-focused? false)}]]
[rn/view {:style style/footer-container}
(when error-response
[quo/alert-banner
{:container-style style/alert-banner
:text (i18n/label :t/something-went-wrong-please-try-again-later)}])
[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

@ -0,0 +1,29 @@
(ns status-im.contexts.wallet.swap.utils
(:require [status-im.constants :as constants]
[utils.i18n :as i18n]))
(defn error-message-from-code
[error-code error-details]
(cond
(= error-code
constants/router-error-code-not-enough-liquidity)
(i18n/label :t/not-enough-liquidity)
(= error-code
constants/router-error-code-price-timeout)
(i18n/label :t/fetching-the-price-took-longer-than-expected)
(= error-code
constants/router-error-code-price-impact-too-high)
(i18n/label :t/price-impact-too-high)
(= error-code
constants/router-error-code-paraswap-custom-error)
(i18n/label :t/paraswap-error
{:paraswap-error error-details})
(= error-code
constants/router-error-code-generic)
(i18n/label :t/generic-error
{:generic-error error-details})
(= error-code
constants/router-error-code-not-enough-native-balance)
(i18n/label :t/not-enough-assets-to-pay-gas-fees)
:else
(i18n/label :t/something-went-wrong-please-try-again-later)))

View File

@ -32,6 +32,16 @@
:<- [:wallet/swap]
:-> :error-response)
(rf/reg-sub
:wallet/swap-error-response-code
:<- [:wallet/swap-error-response]
:-> :code)
(rf/reg-sub
:wallet/swap-error-response-details
:<- [:wallet/swap-error-response]
:-> :details)
(rf/reg-sub
:wallet/swap-asset-to-pay-token-symbol
:<- [:wallet/swap-asset-to-pay]

View File

@ -93,4 +93,4 @@
[token-decimals amount-text]
(let [regex-pattern (str "^\\d*\\.?\\d{0," token-decimals "}$")
regex (re-pattern regex-pattern)]
(re-matches regex amount-text)))
(boolean (re-matches regex amount-text))))

View File

@ -273,6 +273,7 @@
"buy-crypto-leaving": "You are leaving Status and entering a third party website to complete your purchase",
"buy-crypto-title": "Looks like your wallet is empty",
"buy-eth": "Buy ETH",
"buy-ethereum": "Buy Ethereum",
"by-continuing-you-accept": "By continuing you accept our ",
"camera-access-error": "To grant the required camera permission, please go to your system settings and make sure that Status > Camera is selected.",
"camera-permission-denied": "Permission denied",
@ -1019,6 +1020,7 @@
"fetch-messages": "Fetch messages",
"fetch-timeline": "↓ Fetch",
"fetching-community": "Fetching community...",
"fetching-the-price-took-longer-than-expected": "Fetching the price took longer than expected.\nPlease, try again later.",
"finalized-on": "Finalized on",
"find": "Find",
"find-it-in-setting": "Find it in Settings on your other synced device",
@ -1069,6 +1071,7 @@
"generating-keypair": "Generating key pair...",
"generating-keys": "Generating keys...",
"generating-mnemonic": "Generating seed phrase",
"generic-error": "Error: {{generic-error}}",
"get-a-keycard": "Get a Keycard",
"get-started": "Get started",
"get-status-at": "Get Status at http://status.im",
@ -1183,6 +1186,7 @@
"install": "↓ Install",
"instruction-after-qr-generated": "On your other device, navigate to the Syncing screen and select “Scan sync”",
"insufficient-balance-to-cover-fee": "not enough balance to cover transaction fee",
"insufficient-funds-for-swaps": "Insufficient funds for swap",
"intro-message1": "Welcome to Status!\nTap this message to set your password and get started.",
"intro-privacy-policy": "Privacy Policy",
"intro-privacy-policy-note1": "Status does not collect or profit from your personal data. By continuing, you agree with the ",
@ -1734,6 +1738,7 @@
"not-connected-to-peers": "Not connected to any peers",
"not-enough-assets": "Not enough assets to complete transaction",
"not-enough-assets-to-pay-gas-fees": "Not enough assets to pay gas fees",
"not-enough-liquidity": "Not enough liquidity. Lower token amount or try again later.",
"not-enough-snt": "Not enough SNT",
"not-found": "Not found",
"not-keycard-text": "The card you used is not a Keycard. You need to purchase a Keycard to use it",
@ -1848,6 +1853,7 @@
"pairing-new-installation-detected-title": "New device detected",
"pairing-no-info": "No info",
"pairing-please-set-a-name": "Please set a name for your device.",
"paraswap-error": "Paraswap error: {{paraswap-error}}",
"participate-in-the-metaverse": "Participate in the truly free metaverse",
"passphrase": "Passphrase",
"password": "Password",
@ -1934,6 +1940,7 @@
"previewing-may-share-metadata": "Previewing links from these websites may share your metadata with their owners",
"price-impact": "Price impact",
"price-impact-desc": "Estimated price impact for this transaction. If the current block base fee exceeds this, your transaction will be included in a following block with a lower base fee.",
"price-impact-too-high": "Price impact too high. Lower token amount or try again later.",
"principles": "Principles",
"priority": "Priority",
"privacy": "Privacy",
@ -2320,7 +2327,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",
"something-went-wrong-please-try-again-later": "Something went wrong. Modify swap parameters or try again later.",
"soon": "Soon",
"sort-communities": "Sort communities",
"special-characters": "Special characters",