From 9bc09c68af503883edee2bf996c6a0cdca15a660 Mon Sep 17 00:00:00 2001 From: Gustavo Mauricio Date: Mon, 7 Nov 2022 16:36:12 +0000 Subject: [PATCH] Withdraw assets from account (#43) * withdraw modal and respective hooks initial commit * max withdraw amount hook logic improvements * withdraw flow code cleanup * reset modal state when reopened * memoize withdraw amount. percentage value fix * unused store selector * credit manager and accountnft clients added to store * script to pull generated types from rover repo --- components/CreditManager/index.tsx | 27 +- components/WithdrawModal.tsx | 329 ++++++++++++++++++++++++ generate_types.sh | 19 ++ hooks/mutations/useWithdrawFunds.tsx | 76 ++++++ hooks/useCalculateMaxWithdrawAmount.tsx | 108 ++++++++ pages/_app.tsx | 1 - pages/borrow.tsx | 2 +- stores/useWalletStore.tsx | 39 ++- 8 files changed, 593 insertions(+), 8 deletions(-) create mode 100644 components/WithdrawModal.tsx create mode 100755 generate_types.sh create mode 100644 hooks/mutations/useWithdrawFunds.tsx create mode 100644 hooks/useCalculateMaxWithdrawAmount.tsx diff --git a/components/CreditManager/index.tsx b/components/CreditManager/index.tsx index 30d61685..bd0bca89 100644 --- a/components/CreditManager/index.tsx +++ b/components/CreditManager/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useRef, useState } from 'react' import BigNumber from 'bignumber.js' import Button from '../Button' @@ -11,10 +11,15 @@ import useTokenPrices from 'hooks/useTokenPrices' import useAccountStats from 'hooks/useAccountStats' import useMarkets from 'hooks/useMarkets' import ContainerSecondary from 'components/ContainerSecondary' +import WithdrawModal from 'components/WithdrawModal' import FundAccountModal from 'components/FundAccountModal' const CreditManager = () => { const [showFundWalletModal, setShowFundWalletModal] = useState(false) + const [showWithdrawModal, setShowWithdrawModal] = useState(false) + + // recreate modals and reset state whenever ref changes + const modalId = useRef(0) const address = useWalletStore((s) => s.address) const selectedAccount = useCreditManagerStore((s) => s.selectedAccount) @@ -49,12 +54,21 @@ const CreditManager = () => { return (
-
) } diff --git a/components/WithdrawModal.tsx b/components/WithdrawModal.tsx new file mode 100644 index 00000000..0672fa3c --- /dev/null +++ b/components/WithdrawModal.tsx @@ -0,0 +1,329 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { Transition, Dialog, Switch } from '@headlessui/react' +import * as RSlider from '@radix-ui/react-slider' +import BigNumber from 'bignumber.js' +import { toast } from 'react-toastify' + +import useCreditAccountPositions from 'hooks/useCreditAccountPositions' +import { getTokenDecimals, getTokenSymbol } from 'utils/tokens' +import ContainerSecondary from './ContainerSecondary' +import useCreditManagerStore from 'stores/useCreditManagerStore' +import Button from './Button' +import useMarkets from 'hooks/useMarkets' +import useTokenPrices from 'hooks/useTokenPrices' +import { formatCurrency } from 'utils/formatters' +import ProgressBar from './ProgressBar' +import SemiCircleProgress from './SemiCircleProgress' +import useAccountStats from 'hooks/useAccountStats' +import useWithdrawFunds from 'hooks/mutations/useWithdrawFunds' +import Spinner from './Spinner' +import useCalculateMaxWithdrawAmount from 'hooks/useCalculateMaxWithdrawAmount' +import useAllBalances from 'hooks/useAllBalances' +import Slider from 'components/Slider' + +const WithdrawModal = ({ show, onClose }: any) => { + const [amount, setAmount] = useState(0) + const [selectedToken, setSelectedToken] = useState('') + const [isBorrowEnabled, setIsBorrowEnabled] = useState(false) + + const selectedAccount = useCreditManagerStore((s) => s.selectedAccount) + const { data: positionsData, isLoading: isLoadingPositions } = useCreditAccountPositions( + selectedAccount ?? '' + ) + + const { data: balancesData } = useAllBalances() + const { data: tokenPrices } = useTokenPrices() + const { data: marketsData } = useMarkets() + const accountStats = useAccountStats() + + const selectedTokenSymbol = getTokenSymbol(selectedToken) + const selectedTokenDecimals = getTokenDecimals(selectedToken) + + const tokenAmountInCreditAccount = useMemo(() => { + return BigNumber(positionsData?.coins.find((coin) => coin.denom === selectedToken)?.amount ?? 0) + .div(10 ** selectedTokenDecimals) + .toNumber() + }, [positionsData, selectedTokenDecimals, selectedToken]) + + const { borrowAmount, withdrawAmount } = useMemo(() => { + const borrowAmount = + amount > tokenAmountInCreditAccount + ? BigNumber(amount) + .minus(tokenAmountInCreditAccount) + .times(10 ** selectedTokenDecimals) + .toNumber() + : 0 + + const withdrawAmount = BigNumber(amount) + .times(10 ** selectedTokenDecimals) + .toNumber() + + return { + borrowAmount, + withdrawAmount, + } + }, [amount, selectedTokenDecimals, tokenAmountInCreditAccount]) + + const { mutate, isLoading } = useWithdrawFunds(withdrawAmount, borrowAmount, selectedToken, { + onSuccess: () => { + onClose() + toast.success(`${amount} ${selectedTokenSymbol} successfully withdrawn`) + }, + }) + + const maxWithdrawAmount = useCalculateMaxWithdrawAmount(selectedToken, isBorrowEnabled) + + const walletAmount = useMemo(() => { + if (!selectedToken) return 0 + + return BigNumber(balancesData?.find((balance) => balance.denom === selectedToken)?.amount ?? 0) + .div(10 ** selectedTokenDecimals) + .toNumber() + }, [balancesData, selectedToken, selectedTokenDecimals]) + + useEffect(() => { + if (positionsData && positionsData.coins.length > 0) { + // initialize selected token when allowedCoins fetch data is available + setSelectedToken(positionsData.coins[0].denom) + } + }, [positionsData]) + + const handleTokenChange = (e: React.ChangeEvent) => { + setSelectedToken(e.target.value) + + if (e.target.value !== selectedToken) setAmount(0) + } + + const handleValueChange = (value: number) => { + if (value > maxWithdrawAmount) { + setAmount(maxWithdrawAmount) + return + } + + setAmount(value) + } + + const handleBorrowChange = () => { + setIsBorrowEnabled((c) => !c) + // reset amount due to max value calculations changing depending on wheter the user is borrowing or not + setAmount(0) + } + + const getTokenTotalUSDValue = (amount: string, denom: string) => { + // early return if prices are not fetched yet + if (!tokenPrices) return 0 + + return ( + BigNumber(amount) + .div(10 ** getTokenDecimals(denom)) + .toNumber() * tokenPrices[denom] + ) + } + + const percentageValue = useMemo(() => { + if (isNaN(amount) || maxWithdrawAmount === 0) return 0 + + return (amount * 100) / maxWithdrawAmount + }, [amount, maxWithdrawAmount]) + + return ( + + + +
+ + +
+
+ + + {isLoading && ( +
+ +
+ )} +
+ + Withdraw from Account {selectedAccount} + +
+ +
+
+
Asset:
+ +
+
+
Amount:
+ handleValueChange(e.target.valueAsNumber)} + /> +
+
+

In wallet: {walletAmount.toLocaleString()}

+ { + const decimal = value[0] / 100 + // limit decimal precision based on token contract decimals + const newAmount = Number( + (decimal * maxWithdrawAmount).toFixed(selectedTokenDecimals) + ) + + setAmount(newAmount) + }} + onMaxClick={() => setAmount(maxWithdrawAmount)} + /> +
+ +
+

Withdraw with borrowing

+
Explanation....
+
+ + + + +
+
+ +
+
+

About

+

Subaccount {selectedAccount}

+
+ {accountStats && ( +
+

{formatCurrency(accountStats.netWorth)}

+ {/* TOOLTIP */} +
+ +
+ + +
+ )} +
+
+
+
Total Position:
+
+ {formatCurrency(accountStats?.totalPosition ?? 0)} +
+
+
+
Total Liabilities:
+
+ {formatCurrency(accountStats?.totalDebt ?? 0)} +
+
+
+
+

Balances

+ {isLoadingPositions ? ( +
Loading...
+ ) : ( + + + + + + + + + + + {positionsData?.coins.map((coin) => ( + + + + + + + ))} + {positionsData?.debts.map((coin) => ( + + + + + + + ))} + +
AssetValueSizeAPY
{getTokenSymbol(coin.denom)} + {formatCurrency(getTokenTotalUSDValue(coin.amount, coin.denom))} + + {BigNumber(coin.amount) + .div(10 ** getTokenDecimals(coin.denom)) + .toNumber() + .toLocaleString(undefined, { + maximumFractionDigits: getTokenDecimals(coin.denom), + })} + -
{getTokenSymbol(coin.denom)} + -{formatCurrency(getTokenTotalUSDValue(coin.amount, coin.denom))} + + - + {BigNumber(coin.amount) + .div(10 ** getTokenDecimals(coin.denom)) + .toNumber() + .toLocaleString(undefined, { + maximumFractionDigits: 6, + })} + + -{(Number(marketsData?.[coin.denom].borrow_rate) * 100).toFixed(1)}% +
+ )} +
+
+
+
+
+
+
+
+ ) +} + +export default WithdrawModal diff --git a/generate_types.sh b/generate_types.sh new file mode 100755 index 00000000..607bc443 --- /dev/null +++ b/generate_types.sh @@ -0,0 +1,19 @@ +# generates smart contracts type definitions and copies respective files to types directory +# Usage: ./generate-types.sh + +R='\033[0;31m' #'0;31' is Red's ANSI color code +G='\033[0;32m' #'0;32' is Green's ANSI color code + +dir=$(pwd) +echo $dir + +if [ -d "../rover" ]; then + echo "Fetching latest changes from rover repo" + cd ../rover && git fetch && git checkout master && git pull + cd $dir + echo "Generating types for rover..." + cp -r ../rover/scripts/types/generated ./types + echo "${G}Success" +else + echo "${R}Directory rover not found..." +fi diff --git a/hooks/mutations/useWithdrawFunds.tsx b/hooks/mutations/useWithdrawFunds.tsx new file mode 100644 index 00000000..e0310a64 --- /dev/null +++ b/hooks/mutations/useWithdrawFunds.tsx @@ -0,0 +1,76 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useMemo } from 'react' +import { toast } from 'react-toastify' + +import useWalletStore from 'stores/useWalletStore' +import { hardcodedFee } from 'utils/contants' +import useCreditManagerStore from 'stores/useCreditManagerStore' +import { queryKeys } from 'types/query-keys-factory' + +const useWithdrawFunds = ( + amount: number, + borrowAmount: number, + denom: string, + options?: { + onSuccess?: () => void + } +) => { + const selectedAccount = useCreditManagerStore((s) => s.selectedAccount ?? '') + const address = useWalletStore((s) => s.address) + const creditManagerClient = useWalletStore((s) => s.clients.creditManager) + + const queryClient = useQueryClient() + + const actions = useMemo(() => { + if (borrowAmount > 0) { + return [ + { + borrow: { + denom, + amount: String(borrowAmount), + }, + }, + { + withdraw: { + denom, + amount: String(amount), + }, + }, + ] + } + + return [ + { + withdraw: { + denom, + amount: String(amount), + }, + }, + ] + }, [amount, borrowAmount, denom]) + + const { onSuccess } = { ...options } + + return useMutation( + async () => + creditManagerClient?.updateCreditAccount( + { accountId: selectedAccount, actions }, + hardcodedFee + ), + { + onSuccess: () => { + queryClient.invalidateQueries(queryKeys.creditAccountsPositions(selectedAccount)) + queryClient.invalidateQueries(queryKeys.tokenBalance(address, denom)) + queryClient.invalidateQueries(queryKeys.allBalances(address)) + queryClient.invalidateQueries(queryKeys.redbankBalances()) + + onSuccess && onSuccess() + }, + onError: (err: Error) => { + toast.error(err.message) + }, + } + ) +} + +export default useWithdrawFunds diff --git a/hooks/useCalculateMaxWithdrawAmount.tsx b/hooks/useCalculateMaxWithdrawAmount.tsx new file mode 100644 index 00000000..44a97e4e --- /dev/null +++ b/hooks/useCalculateMaxWithdrawAmount.tsx @@ -0,0 +1,108 @@ +import { useCallback, useMemo } from 'react' +import BigNumber from 'bignumber.js' + +import { getTokenDecimals } from 'utils/tokens' +import useCreditAccountPositions from './useCreditAccountPositions' +import useCreditManagerStore from 'stores/useCreditManagerStore' +import useTokenPrices from './useTokenPrices' +import useMarkets from './useMarkets' + +const useCalculateMaxWithdrawAmount = (denom: string, borrow: boolean) => { + const selectedAccount = useCreditManagerStore((s) => s.selectedAccount) + + const { data: positionsData } = useCreditAccountPositions(selectedAccount ?? '') + const { data: marketsData } = useMarkets() + const { data: tokenPrices } = useTokenPrices() + + const tokenDecimals = getTokenDecimals(denom) + + const getTokenValue = useCallback( + (amount: string, denom: string) => { + if (!tokenPrices) return 0 + + return BigNumber(amount).times(tokenPrices[denom]).toNumber() + }, + [tokenPrices] + ) + + const tokenAmountInCreditAccount = useMemo(() => { + return positionsData?.coins.find((coin) => coin.denom === denom)?.amount ?? 0 + }, [denom, positionsData]) + + const maxAmount = useMemo(() => { + if (!marketsData || !tokenPrices || !positionsData || !denom) return 0 + + const hasDebt = positionsData.debts.length > 0 + + const borrowTokenPrice = tokenPrices[denom] + const borrowTokenMaxLTV = Number(marketsData[denom].max_loan_to_value) + + const totalLiabilitiesValue = positionsData?.debts.reduce((acc, coin) => { + const tokenUSDValue = BigNumber(getTokenValue(coin.amount, coin.denom)) + + return tokenUSDValue.plus(acc).toNumber() + }, 0) + + const positionsWeightedAverageWithoutAsset = positionsData?.coins.reduce((acc, coin) => { + if (coin.denom === denom) return acc + + const tokenWeightedValue = BigNumber(getTokenValue(coin.amount, coin.denom)).times( + Number(marketsData[coin.denom].max_loan_to_value) + ) + + return tokenWeightedValue.plus(acc).toNumber() + }, 0) + + const isHealthyAfterFullWithdraw = !hasDebt + ? true + : positionsWeightedAverageWithoutAsset / totalLiabilitiesValue > 1 + + let maxAmountCapacity = 0 + + if (isHealthyAfterFullWithdraw) { + const maxBorrow = BigNumber(positionsWeightedAverageWithoutAsset) + .minus(totalLiabilitiesValue) + .div(borrowTokenPrice) + + maxAmountCapacity = maxBorrow + .plus(tokenAmountInCreditAccount) + .decimalPlaces(tokenDecimals) + .toNumber() + } else { + const requiredCollateral = BigNumber(totalLiabilitiesValue) + .minus(positionsWeightedAverageWithoutAsset) + .dividedBy(borrowTokenPrice * borrowTokenMaxLTV) + + maxAmountCapacity = BigNumber(tokenAmountInCreditAccount) + .minus(requiredCollateral) + .decimalPlaces(tokenDecimals) + .toNumber() + } + + const isCapacityHigherThanBalance = BigNumber(maxAmountCapacity).gt(tokenAmountInCreditAccount) + + if (!borrow && isCapacityHigherThanBalance) { + return BigNumber(tokenAmountInCreditAccount) + .div(10 ** tokenDecimals) + .toNumber() + } + + return BigNumber(maxAmountCapacity) + .div(10 ** tokenDecimals) + .decimalPlaces(tokenDecimals) + .toNumber() + }, [ + borrow, + denom, + getTokenValue, + marketsData, + positionsData, + tokenAmountInCreditAccount, + tokenDecimals, + tokenPrices, + ]) + + return maxAmount +} + +export default useCalculateMaxWithdrawAmount diff --git a/pages/_app.tsx b/pages/_app.tsx index d1cff269..e8f18733 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -20,7 +20,6 @@ async function isMetamaskInstalled(): Promise { const queryClient = new QueryClient() function MyApp({ Component, pageProps }: AppProps) { - const address = useWalletStore((s) => s.address) const actions = useWalletStore((s) => s.actions) // init store diff --git a/pages/borrow.tsx b/pages/borrow.tsx index c864b70f..3eb5721d 100644 --- a/pages/borrow.tsx +++ b/pages/borrow.tsx @@ -145,7 +145,7 @@ const Borrow = () => { onClose={() => setModalState({ ...modalState, show: false })} /> setModalState({ ...modalState, show: false })} diff --git a/stores/useWalletStore.tsx b/stores/useWalletStore.tsx index c67033f7..133d5635 100644 --- a/stores/useWalletStore.tsx +++ b/stores/useWalletStore.tsx @@ -4,6 +4,9 @@ import { persist } from 'zustand/middleware' import { Wallet } from 'types' import { CosmWasmClient, SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate' import { chain } from 'utils/chains' +import { contractAddresses } from 'config/contracts' +import { AccountNftClient } from 'types/generated/account-nft/AccountNft.client' +import { CreditManagerClient } from 'types/generated/credit-manager/CreditManager.client' interface WalletStore { address: string @@ -11,8 +14,13 @@ interface WalletStore { wallet: Wallet | null client?: CosmWasmClient signingClient?: SigningCosmWasmClient + clients: { + accountNft: AccountNftClient | null + creditManager: CreditManagerClient | null + } actions: { disconnect: () => void + initClients: (address: string, signingClient: SigningCosmWasmClient) => void initialize: () => void connect: (address: string, wallet: Wallet) => void setMetamaskInstalledStatus: (value: boolean) => void @@ -25,10 +33,33 @@ const useWalletStore = create()( address: '', metamaskInstalled: false, wallet: null, + clients: { + accountNft: null, + creditManager: null, + }, actions: { disconnect: () => { set(() => ({ address: '', wallet: null, signingClient: undefined })) }, + initClients: (address, signingClient) => { + const accountNft = new AccountNftClient( + signingClient, + address, + contractAddresses.accountNft + ) + const creditManager = new CreditManagerClient( + signingClient, + address, + contractAddresses.creditManager + ) + + set(() => ({ + clients: { + accountNft, + creditManager, + }, + })) + }, initialize: async () => { const clientInstance = await CosmWasmClient.connect(chain.rpc) @@ -42,6 +73,8 @@ const useWalletStore = create()( offlineSigner ) + get().actions.initClients(address, signingClientInstance) + set(() => ({ client: clientInstance, signingClient: signingClientInstance, @@ -56,12 +89,14 @@ const useWalletStore = create()( if (!window.keplr) return const offlineSigner = window.keplr.getOfflineSigner(chain.chainId) - const clientInstance = await SigningCosmWasmClient.connectWithSigner( + const signingClientInstance = await SigningCosmWasmClient.connectWithSigner( chain.rpc, offlineSigner ) - set(() => ({ address, wallet, signingClient: clientInstance })) + get().actions.initClients(address, signingClientInstance) + + set(() => ({ address, wallet, signingClient: signingClientInstance })) }, setMetamaskInstalledStatus: (value: boolean) => set(() => ({ metamaskInstalled: value })), },