MP-2912 Margin trading implementation (#342)

* fix(useSWR): request hooks revalidation on focus disabled

* [trade] add margin

* [trade] include borrow rate in receipt

* [trade] add tooltip margin and pr comments

* updated regardign comments

---------

Co-authored-by: Yusuf Seyrek <yusuf@delphilabs.io>
This commit is contained in:
Bob van der Helm 2023-08-07 12:51:52 +02:00 committed by GitHub
parent c25d8607e8
commit b35c743286
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 302 additions and 173 deletions

View File

@ -24,6 +24,7 @@
"bignumber.js": "^9.1.1", "bignumber.js": "^9.1.1",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"debounce-promise": "^3.1.2", "debounce-promise": "^3.1.2",
"lodash.throttle": "^4.1.1",
"moment": "^2.29.4", "moment": "^2.29.4",
"next": "13.4.9", "next": "13.4.9",
"react": "^18.2.0", "react": "^18.2.0",
@ -46,6 +47,7 @@
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^14.0.0", "@testing-library/react": "^14.0.0",
"@types/debounce-promise": "^3.1.6", "@types/debounce-promise": "^3.1.6",
"@types/lodash.throttle": "^4.1.7",
"@types/node": "^20.4.4", "@types/node": "^20.4.4",
"@types/react": "18.2.14", "@types/react": "18.2.14",
"@types/react-dom": "18.2.7", "@types/react-dom": "18.2.7",

View File

@ -6,12 +6,13 @@ interface Props {
max: number max: number
} }
const THUMB_WIDTH = 33
function InputOverlay({ max, value, marginThreshold }: Props) { function InputOverlay({ max, value, marginThreshold }: Props) {
// 33 is the thumb width const thumbPosPercent = max === 0 ? 0 : 100 / (max / value)
const thumbPosPercent = 100 / (max / value) const thumbPadRight = (thumbPosPercent / 100) * THUMB_WIDTH
const thumbPadRight = (thumbPosPercent / 100) * 33
const markPosPercent = 100 / (max / (marginThreshold ?? 1)) const markPosPercent = 100 / (max / (marginThreshold ?? 1))
const markPadRight = (markPosPercent / 100) * 33 const markPadRight = (markPosPercent / 100) * THUMB_WIDTH
const hasPastMarginThreshold = marginThreshold ? value >= marginThreshold : undefined const hasPastMarginThreshold = marginThreshold ? value >= marginThreshold : undefined
return ( return (

View File

@ -3,6 +3,8 @@ import classNames from 'classnames'
import InputOverlay from 'components/RangeInput/InputOverlay' import InputOverlay from 'components/RangeInput/InputOverlay'
const LEFT_MARGIN = 5
type Props = { type Props = {
max: number max: number
value: number value: number
@ -23,6 +25,8 @@ function RangeInput(props: Props) {
[onChange], [onChange],
) )
const markPosPercent = 100 / (max / (marginThreshold ?? 1))
return ( return (
<div <div
className={classNames(className.containerDefault, wrapperClassName, { className={classNames(className.containerDefault, wrapperClassName, {
@ -46,7 +50,7 @@ function RangeInput(props: Props) {
/> />
</div> </div>
<div className={className.legendWrapper}> <div className={className.legendWrapper}>
<span>0</span> <span>{markPosPercent > LEFT_MARGIN ? 0 : ''}</span>
<span>{max.toFixed(2)}</span> <span>{max.toFixed(2)}</span>
</div> </div>
</div> </div>

View File

@ -202,8 +202,8 @@ export class OsmosisTheGraphDataFeed implements IDatafeedChartApi {
body: JSON.stringify({ query }), body: JSON.stringify({ query }),
}) })
.then((res) => res.json()) .then((res) => res.json())
.then((json: { data: { candles: BarQueryData[] } }) => { .then((json: { data?: { candles: BarQueryData[] } }) => {
return this.resolveBarData(json.data.candles, base) return this.resolveBarData(json.data?.candles || [], base)
}) })
.catch((err) => { .catch((err) => {
if (this.debug) console.error(err) if (this.debug) console.error(err)

View File

@ -7,6 +7,7 @@ import ConditionalWrapper from 'hocs/ConditionalWrapper'
interface Props { interface Props {
checked: boolean checked: boolean
onChange: (value: boolean) => void onChange: (value: boolean) => void
borrowAssetSymbol: string
disabled?: boolean disabled?: boolean
} }
@ -18,14 +19,24 @@ export default function MarginToggle(props: Props) {
<ConditionalWrapper <ConditionalWrapper
condition={!!props.disabled} condition={!!props.disabled}
wrapper={(children) => ( wrapper={(children) => (
<Tooltip type='info' content={<Text size='sm'>Margin is not supported yet.</Text>}> <Tooltip
type='info'
content={
<Text size='sm'>
{props.borrowAssetSymbol} is not a borrowable asset. Please choose another asset to
sell in order to margin trade.
</Text>
}
>
{children} {children}
</Tooltip> </Tooltip>
)} )}
> >
<div className='flex flex-row'> <div className='flex flex-row'>
<Switch {...props} name='margin' /> <Switch {...props} name='margin' />
<InfoCircle width={16} height={16} className='ml-2 mt-0.5 opacity-20' /> {props.disabled && (
<InfoCircle width={16} height={16} className='ml-2 mt-0.5 opacity-20' />
)}
</div> </div>
</ConditionalWrapper> </ConditionalWrapper>
</div> </div>

View File

@ -1,18 +1,21 @@
import classNames from 'classnames' import classNames from 'classnames'
import { useCallback, useMemo, useState } from 'react' import { useMemo } from 'react'
import ActionButton from 'components/Button/ActionButton' import ActionButton from 'components/Button/ActionButton'
import useSwapRoute from 'hooks/useSwapRoute' import useSwapRoute from 'hooks/useSwapRoute'
import { getAssetByDenom } from 'utils/assets' import { getAssetByDenom } from 'utils/assets'
import { hardcodedFee } from 'utils/constants' import { hardcodedFee } from 'utils/constants'
import { formatAmountWithSymbol } from 'utils/formatters' import { formatAmountWithSymbol, formatPercent } from 'utils/formatters'
interface Props { interface Props {
buyAsset: Asset buyAsset: Asset
sellAsset: Asset sellAsset: Asset
borrowRate?: number | null
buyButtonDisabled: boolean buyButtonDisabled: boolean
containerClassName?: string containerClassName?: string
showProgressIndicator: boolean showProgressIndicator: boolean
isMargin?: boolean
borrowAmount: BigNumber
buyAction: () => void buyAction: () => void
} }
@ -20,9 +23,12 @@ export default function TradeSummary(props: Props) {
const { const {
buyAsset, buyAsset,
sellAsset, sellAsset,
borrowRate,
buyAction, buyAction,
buyButtonDisabled, buyButtonDisabled,
containerClassName, containerClassName,
isMargin,
borrowAmount,
showProgressIndicator, showProgressIndicator,
} = props } = props
const { data: routes, isLoading: isRouteLoading } = useSwapRoute(sellAsset.denom, buyAsset.denom) const { data: routes, isLoading: isRouteLoading } = useSwapRoute(sellAsset.denom, buyAsset.denom)
@ -49,6 +55,24 @@ export default function TradeSummary(props: Props) {
<span className={className.infoLineLabel}>Fees</span> <span className={className.infoLineLabel}>Fees</span>
<span>{formatAmountWithSymbol(hardcodedFee.amount[0])}</span> <span>{formatAmountWithSymbol(hardcodedFee.amount[0])}</span>
</div> </div>
{isMargin && (
<>
<div className={className.infoLine}>
<span className={className.infoLineLabel}>Borrowing</span>
<span>
{formatAmountWithSymbol({
denom: sellAsset.denom,
amount: borrowAmount.toString(),
})}
</span>
</div>
<div className={className.infoLine}>
<span className={className.infoLineLabel}>Borrow rate</span>
<span>{formatPercent(borrowRate || 0)}</span>
</div>
</>
)}
<div className={className.infoLine}> <div className={className.infoLine}>
<span className={className.infoLineLabel}>Route</span> <span className={className.infoLineLabel}>Route</span>
<span>{parsedRoutes}</span> <span>{parsedRoutes}</span>

View File

@ -8,10 +8,10 @@ import useCurrentAccount from 'hooks/useCurrentAccount'
import useLocalStorage from 'hooks/useLocalStorage' import useLocalStorage from 'hooks/useLocalStorage'
import usePrices from 'hooks/usePrices' import usePrices from 'hooks/usePrices'
import useStore from 'store' import useStore from 'store'
import { byDenom } from 'utils/array' import { byDenom, byTokenDenom } from 'utils/array'
import { hardcodedFee } from 'utils/constants' import { hardcodedFee } from 'utils/constants'
import RangeInput from 'components/RangeInput' import RangeInput from 'components/RangeInput'
import { BN } from 'utils/helpers' import { asyncThrottle, BN } from 'utils/helpers'
import AssetAmountInput from 'components/Trade/TradeModule/SwapForm/AssetAmountInput' import AssetAmountInput from 'components/Trade/TradeModule/SwapForm/AssetAmountInput'
import MarginToggle from 'components/Trade/TradeModule/SwapForm/MarginToggle' import MarginToggle from 'components/Trade/TradeModule/SwapForm/MarginToggle'
import OrderTypeSelector from 'components/Trade/TradeModule/SwapForm/OrderTypeSelector' import OrderTypeSelector from 'components/Trade/TradeModule/SwapForm/OrderTypeSelector'
@ -20,6 +20,7 @@ import TradeSummary from 'components/Trade/TradeModule/SwapForm/TradeSummary'
import { BNCoin } from 'types/classes/BNCoin' import { BNCoin } from 'types/classes/BNCoin'
import estimateExactIn from 'api/swap/estimateExactIn' import estimateExactIn from 'api/swap/estimateExactIn'
import useHealthComputer from 'hooks/useHealthComputer' import useHealthComputer from 'hooks/useHealthComputer'
import useMarketBorrowings from 'hooks/useMarketBorrowings'
interface Props { interface Props {
buyAsset: Asset buyAsset: Asset
@ -33,20 +34,77 @@ export default function SwapForm(props: Props) {
const swap = useStore((s) => s.swap) const swap = useStore((s) => s.swap)
const [slippage] = useLocalStorage(SLIPPAGE_KEY, DEFAULT_SETTINGS.slippage) const [slippage] = useLocalStorage(SLIPPAGE_KEY, DEFAULT_SETTINGS.slippage)
const { computeMaxSwapAmount } = useHealthComputer(account) const { computeMaxSwapAmount } = useHealthComputer(account)
const buyAmountEstimationTimeout = useRef<NodeJS.Timeout | undefined>(undefined) const { data: borrowAssets } = useMarketBorrowings()
const [isMarginChecked, setMarginChecked] = useState(false) const [isMarginChecked, setMarginChecked] = useState(false)
const [buyAssetAmount, setBuyAssetAmount] = useState(BN_ZERO) const [buyAssetAmount, setBuyAssetAmount] = useState(BN_ZERO)
const [sellAssetAmount, setSellAssetAmount] = useState(BN_ZERO) const [sellAssetAmount, setSellAssetAmount] = useState(BN_ZERO)
const focusedInput = useRef<'buy' | 'sell' | null>(null)
const [maxBuyableAmountEstimation, setMaxBuyableAmountEstimation] = useState(BN_ZERO) const [maxBuyableAmountEstimation, setMaxBuyableAmountEstimation] = useState(BN_ZERO)
const [selectedOrderType, setSelectedOrderType] = useState<AvailableOrderType>('Market') const [selectedOrderType, setSelectedOrderType] = useState<AvailableOrderType>('Market')
const [isTransactionExecuting, setTransactionExecuting] = useState(false) const [isConfirming, setIsConfirming] = useState(false)
const [maxSellAssetAmount, maxSellAssetAmountStr] = useMemo(() => { const throttledEstimateExactIn = useMemo(() => asyncThrottle(estimateExactIn, 250), [])
const amount = computeMaxSwapAmount(sellAsset.denom, buyAsset.denom, 'default')
return [amount, amount.toString()] const borrowAsset = useMemo(
}, [computeMaxSwapAmount, sellAsset.denom, buyAsset.denom]) () => borrowAssets.find(byDenom(sellAsset.denom)),
[borrowAssets, sellAsset.denom],
)
const onChangeSellAmount = useCallback(
(amount: BigNumber) => {
setSellAssetAmount(amount)
throttledEstimateExactIn(
{ denom: sellAsset.denom, amount: amount.toString() },
buyAsset.denom,
).then(setBuyAssetAmount)
},
[sellAsset.denom, throttledEstimateExactIn, buyAsset.denom],
)
const onChangeBuyAmount = useCallback(
(amount: BigNumber) => {
setBuyAssetAmount(amount)
const swapFrom = {
denom: buyAsset.denom,
amount: amount.toString(),
}
throttledEstimateExactIn(swapFrom, sellAsset.denom).then(setSellAssetAmount)
},
[buyAsset.denom, throttledEstimateExactIn, sellAsset.denom],
)
const handleRangeInputChange = useCallback(
(value: number) => {
onChangeSellAmount(BN(value).shiftedBy(sellAsset.decimals).integerValue())
},
[sellAsset.decimals, onChangeSellAmount],
)
const [maxSellAmount, marginThreshold] = useMemo(() => {
const maxAmount = computeMaxSwapAmount(sellAsset.denom, buyAsset.denom, 'default')
const maxAmountOnMargin = computeMaxSwapAmount(sellAsset.denom, buyAsset.denom, 'margin')
estimateExactIn(
{
denom: sellAsset.denom,
amount: (isMarginChecked ? maxAmountOnMargin : maxAmount).toString(),
},
buyAsset.denom,
).then(setMaxBuyableAmountEstimation)
if (isMarginChecked) return [maxAmountOnMargin, maxAmount]
if (sellAssetAmount.isGreaterThan(maxAmount)) onChangeSellAmount(maxAmount)
return [maxAmount, maxAmount]
}, [
computeMaxSwapAmount,
sellAsset.denom,
buyAsset.denom,
isMarginChecked,
onChangeSellAmount,
sellAssetAmount,
])
const [buyAssetValue, sellAssetValue] = useMemo(() => { const [buyAssetValue, sellAssetValue] = useMemo(() => {
const buyAssetPrice = prices.find(byDenom(buyAsset.denom))?.amount ?? BN_ZERO const buyAssetPrice = prices.find(byDenom(buyAsset.denom))?.amount ?? BN_ZERO
@ -67,55 +125,22 @@ export default function SwapForm(props: Props) {
]) ])
useEffect(() => { useEffect(() => {
focusedInput.current = null
setBuyAssetAmount(BN_ZERO) setBuyAssetAmount(BN_ZERO)
setSellAssetAmount(BN_ZERO) setSellAssetAmount(BN_ZERO)
}, [buyAsset.denom, sellAsset.denom]) }, [buyAsset.denom, sellAsset.denom])
useEffect(() => {
estimateExactIn({ denom: sellAsset.denom, amount: maxSellAssetAmountStr }, buyAsset.denom).then(
setMaxBuyableAmountEstimation,
)
}, [maxSellAssetAmountStr, buyAsset.denom, sellAsset.denom])
useEffect(() => {
if (focusedInput.current === 'sell') {
if (buyAmountEstimationTimeout.current) {
clearTimeout(buyAmountEstimationTimeout.current)
}
buyAmountEstimationTimeout.current = setTimeout(
() =>
estimateExactIn(
{ denom: sellAsset.denom, amount: sellAssetAmount.toString() },
buyAsset.denom,
)
.then(setBuyAssetAmount)
.then(() => clearTimeout(buyAmountEstimationTimeout?.current)),
250,
)
}
}, [buyAsset.denom, focusedInput, sellAsset.denom, sellAssetAmount])
useEffect(() => {
if (focusedInput.current === 'buy') {
estimateExactIn(
{
denom: buyAsset.denom,
amount: buyAssetAmount.toString(),
},
sellAsset.denom,
).then(setSellAssetAmount)
}
}, [buyAsset.denom, buyAssetAmount, focusedInput, sellAsset.denom])
const handleBuyClick = useCallback(async () => { const handleBuyClick = useCallback(async () => {
if (account?.id) { if (account?.id) {
setTransactionExecuting(true) setIsConfirming(true)
const borrowCoin = sellAssetAmount.isGreaterThan(marginThreshold)
? BNCoin.fromDenomAndBigNumber(sellAsset.denom, sellAssetAmount.minus(marginThreshold))
: undefined
const isSucceeded = await swap({ const isSucceeded = await swap({
fee: hardcodedFee, fee: hardcodedFee,
accountId: account.id, accountId: account.id,
coinIn: BNCoin.fromDenomAndBigNumber(sellAsset.denom, sellAssetAmount.integerValue()), coinIn: BNCoin.fromDenomAndBigNumber(sellAsset.denom, sellAssetAmount.integerValue()),
borrow: borrowCoin,
denomOut: buyAsset.denom, denomOut: buyAsset.denom,
slippage, slippage,
}) })
@ -123,72 +148,78 @@ export default function SwapForm(props: Props) {
setSellAssetAmount(BN_ZERO) setSellAssetAmount(BN_ZERO)
setBuyAssetAmount(BN_ZERO) setBuyAssetAmount(BN_ZERO)
} }
setTransactionExecuting(false) setIsConfirming(false)
} }
}, [account?.id, buyAsset.denom, sellAsset.denom, sellAssetAmount, slippage, swap]) }, [
account?.id,
const dismissInputFocus = useCallback(() => (focusedInput.current = null), []) buyAsset.denom,
sellAsset.denom,
const handleRangeInputChange = useCallback( sellAssetAmount,
(value: number) => { slippage,
focusedInput.current = 'sell' swap,
setSellAssetAmount(BN(value).shiftedBy(sellAsset.decimals).integerValue()) marginThreshold,
}, ])
[sellAsset.decimals],
)
return ( return (
<> <>
<Divider /> <Divider />
<MarginToggle checked={isMarginChecked} onChange={setMarginChecked} disabled /> <MarginToggle
checked={isMarginChecked}
onChange={setMarginChecked}
disabled={!borrowAsset?.isMarket}
borrowAssetSymbol={sellAsset.symbol}
/>
<Divider /> <Divider />
<OrderTypeSelector selected={selectedOrderType} onChange={setSelectedOrderType} /> <OrderTypeSelector selected={selectedOrderType} onChange={setSelectedOrderType} />
<AssetAmountInput <AssetAmountInput
label='Buy' label='Buy'
max={maxBuyableAmountEstimation} max={maxBuyableAmountEstimation}
amount={buyAssetAmount} amount={buyAssetAmount}
setAmount={setBuyAssetAmount} setAmount={onChangeBuyAmount}
asset={buyAsset} asset={buyAsset}
assetUSDValue={buyAssetValue} assetUSDValue={buyAssetValue}
maxButtonLabel='Max Amount:' maxButtonLabel='Max Amount:'
containerClassName='mx-3 my-6' containerClassName='mx-3 my-6'
onBlur={dismissInputFocus} disabled={isConfirming}
onFocus={() => (focusedInput.current = 'buy')}
disabled={isTransactionExecuting}
/> />
<RangeInput <RangeInput
wrapperClassName='p-4' wrapperClassName='p-4'
onBlur={dismissInputFocus} disabled={isConfirming || maxSellAmount.isZero()}
disabled={isTransactionExecuting || maxSellAssetAmount.isZero()}
onChange={handleRangeInputChange} onChange={handleRangeInputChange}
value={sellAssetAmount.shiftedBy(-sellAsset.decimals).toNumber()} value={sellAssetAmount.shiftedBy(-sellAsset.decimals).toNumber()}
max={maxSellAssetAmount.shiftedBy(-sellAsset.decimals).toNumber()} max={maxSellAmount.shiftedBy(-sellAsset.decimals).toNumber()}
marginThreshold={
isMarginChecked ? marginThreshold.shiftedBy(-sellAsset.decimals).toNumber() : undefined
}
/> />
<AssetAmountInput <AssetAmountInput
label='Sell' label='Sell'
max={maxSellAssetAmount} max={maxSellAmount}
amount={sellAssetAmount} amount={sellAssetAmount}
setAmount={setSellAssetAmount} setAmount={onChangeSellAmount}
assetUSDValue={sellAssetValue} assetUSDValue={sellAssetValue}
asset={sellAsset} asset={sellAsset}
maxButtonLabel='Balance:' maxButtonLabel='Balance:'
containerClassName='mx-3' containerClassName='mx-3'
onBlur={dismissInputFocus} disabled={isConfirming}
onFocus={() => (focusedInput.current = 'sell')}
disabled={isTransactionExecuting}
/> />
<TradeSummary <TradeSummary
containerClassName='m-3 mt-10' containerClassName='m-3 mt-10'
buyAsset={buyAsset} buyAsset={buyAsset}
sellAsset={sellAsset} sellAsset={sellAsset}
borrowRate={borrowAsset?.borrowRate}
buyAction={handleBuyClick} buyAction={handleBuyClick}
buyButtonDisabled={sellAssetAmount.isZero()} buyButtonDisabled={sellAssetAmount.isZero()}
showProgressIndicator={isTransactionExecuting} showProgressIndicator={isConfirming}
isMargin={isMarginChecked}
borrowAmount={
sellAssetAmount.isGreaterThan(marginThreshold)
? sellAssetAmount.minus(marginThreshold)
: BN_ZERO
}
/> />
</> </>
) )

View File

@ -1,6 +1,6 @@
import { CosmWasmClient } from '@cosmjs/cosmwasm-stargate' import { CosmWasmClient } from '@cosmjs/cosmwasm-stargate'
import { useShuttle } from '@delphi-labs/shuttle-react' import { useShuttle } from '@delphi-labs/shuttle-react'
import { useEffect } from 'react' import { useCallback, useEffect } from 'react'
import { CircularProgress } from 'components/CircularProgress' import { CircularProgress } from 'components/CircularProgress'
import FullOverlayContent from 'components/FullOverlayContent' import FullOverlayContent from 'components/FullOverlayContent'
@ -35,48 +35,64 @@ export default function WalletConnecting(props: Props) {
const providerId = props.providerId ?? recentWallet?.providerId const providerId = props.providerId ?? recentWallet?.providerId
const isAutoConnect = props.autoConnect const isAutoConnect = props.autoConnect
const handleConnect = async (extensionProviderId: string) => { const handleConnect = useCallback(
setIsConnecting(true) (extensionProviderId: string) => {
async function handleConnectAsync() {
setIsConnecting(true)
try { try {
const provider = extensionProviders.find((p) => p.id === providerId) const provider = extensionProviders.find((p) => p.id === providerId)
const response = const response =
isAutoConnect && provider isAutoConnect && provider
? await provider.connect({ chainId: currentChainId }) ? await provider.connect({ chainId: currentChainId })
: await connect({ extensionProviderId, chainId: currentChainId }) : await connect({ extensionProviderId, chainId: currentChainId })
const cosmClient = await CosmWasmClient.connect(response.network.rpc) const cosmClient = await CosmWasmClient.connect(response.network.rpc)
const walletClient: WalletClient = { const walletClient: WalletClient = {
broadcast, broadcast,
cosmWasmClient: cosmClient, cosmWasmClient: cosmClient,
connectedWallet: response, connectedWallet: response,
sign, sign,
simulate, simulate,
}
setIsConnecting(false)
useStore.setState({
client: walletClient,
address: response.account.address,
focusComponent: <WalletFetchBalancesAndAccounts />,
})
} catch (error) {
if (error instanceof Error) {
setIsConnecting(false)
useStore.setState({
client: undefined,
address: undefined,
accounts: null,
focusComponent: (
<WalletSelect
error={{
title: 'Failed to connect to wallet',
message: mapErrorMessages(extensionProviderId, error.message),
}}
/>
),
})
}
}
} }
setIsConnecting(false)
useStore.setState({ handleConnectAsync()
client: walletClient, },
address: response.account.address, [
focusComponent: <WalletFetchBalancesAndAccounts />, broadcast,
}) connect,
} catch (error) { extensionProviders,
if (error instanceof Error) { isAutoConnect,
setIsConnecting(false) providerId,
useStore.setState({ setIsConnecting,
client: undefined, sign,
address: undefined, simulate,
accounts: null, ],
focusComponent: ( )
<WalletSelect
error={{
title: 'Failed to connect to wallet',
message: mapErrorMessages(extensionProviderId, error.message),
}}
/>
),
})
}
}
}
useEffect(() => { useEffect(() => {
if (isConnecting || !providerId) return if (isConnecting || !providerId) return

View File

@ -43,57 +43,66 @@ export default function useHealthComputer(account?: Account) {
const vaultPositionValues = useMemo(() => { const vaultPositionValues = useMemo(() => {
if (!account?.vaults) return null if (!account?.vaults) return null
return account.vaults.reduce((prev, curr) => { return account.vaults.reduce(
const baseCoinPrice = prices.find((price) => price.denom === curr.denoms.lp)?.amount || 0 (prev, curr) => {
prev[curr.address] = { const baseCoinPrice = prices.find((price) => price.denom === curr.denoms.lp)?.amount || 0
base_coin: { prev[curr.address] = {
amount: '0', // Not used by healthcomputer base_coin: {
denom: curr.denoms.lp, amount: '0', // Not used by healthcomputer
value: curr.amounts.unlocking.times(baseCoinPrice).integerValue().toString(), denom: curr.denoms.lp,
}, value: curr.amounts.unlocking.times(baseCoinPrice).integerValue().toString(),
vault_coin: { },
amount: '0', // Not used by healthcomputer vault_coin: {
denom: curr.denoms.vault, amount: '0', // Not used by healthcomputer
value: curr.values.primary denom: curr.denoms.vault,
.div(baseCurrencyPrice) value: curr.values.primary
.plus(curr.values.secondary.div(baseCurrencyPrice)) .div(baseCurrencyPrice)
.integerValue() .plus(curr.values.secondary.div(baseCurrencyPrice))
.toString(), .integerValue()
}, .toString(),
} },
return prev }
}, {} as { [key: string]: VaultPositionValue }) return prev
},
{} as { [key: string]: VaultPositionValue },
)
}, [account?.vaults, prices, baseCurrencyPrice]) }, [account?.vaults, prices, baseCurrencyPrice])
const priceData = useMemo(() => { const priceData = useMemo(() => {
const baseCurrencyPrice = const baseCurrencyPrice =
prices.find((price) => price.denom === baseCurrency.denom)?.amount || 0 prices.find((price) => price.denom === baseCurrency.denom)?.amount || 0
return prices.reduce((prev, curr) => { return prices.reduce(
prev[curr.denom] = curr.amount.div(baseCurrencyPrice).decimalPlaces(18).toString() (prev, curr) => {
return prev prev[curr.denom] = curr.amount.div(baseCurrencyPrice).decimalPlaces(18).toString()
}, {} as { [key: string]: string }) return prev
},
{} as { [key: string]: string },
)
}, [prices, baseCurrency.denom]) }, [prices, baseCurrency.denom])
const denomsData = useMemo( const denomsData = useMemo(
() => () =>
assetParams.reduce((prev, curr) => { assetParams.reduce(
const params: AssetParamsBaseForAddr = { (prev, curr) => {
...curr, const params: AssetParamsBaseForAddr = {
// The following overrides are required as testnet is 'broken' and new contracts are not updated yet ...curr,
// These overrides are not used by the healthcomputer internally, so they're not important anyways. // The following overrides are required as testnet is 'broken' and new contracts are not updated yet
protocol_liquidation_fee: '1', // These overrides are not used by the healthcomputer internally, so they're not important anyways.
liquidation_bonus: { protocol_liquidation_fee: '1',
max_lb: '1', liquidation_bonus: {
min_lb: '1', max_lb: '1',
slope: '1', min_lb: '1',
starting_lb: '1', slope: '1',
}, starting_lb: '1',
} },
prev[params.denom] = params }
prev[params.denom] = params
return prev return prev
}, {} as { [key: string]: AssetParamsBaseForAddr }), },
{} as { [key: string]: AssetParamsBaseForAddr },
),
[assetParams], [assetParams],
) )
@ -103,10 +112,13 @@ export default function useHealthComputer(account?: Account) {
const vaultPositionDenoms = positions.vaults.map((vault) => vault.vault.address) const vaultPositionDenoms = positions.vaults.map((vault) => vault.vault.address)
return vaultConfigs return vaultConfigs
.filter((config) => vaultPositionDenoms.includes(config.addr)) .filter((config) => vaultPositionDenoms.includes(config.addr))
.reduce((prev, curr) => { .reduce(
prev[curr.addr] = curr (prev, curr) => {
return prev prev[curr.addr] = curr
}, {} as { [key: string]: VaultConfigBaseForString }) return prev
},
{} as { [key: string]: VaultConfigBaseForString },
)
}, [vaultConfigs, positions]) }, [vaultConfigs, positions])
const healthComputer: HealthComputer | null = useMemo(() => { const healthComputer: HealthComputer | null = useMemo(() => {

View File

@ -363,6 +363,7 @@ export default function createBroadcastSlice(
fee: StdFee fee: StdFee
accountId: string accountId: string
coinIn: BNCoin coinIn: BNCoin
borrow?: BNCoin
denomOut: string denomOut: string
slippage: number slippage: number
}) => { }) => {
@ -370,6 +371,7 @@ export default function createBroadcastSlice(
update_credit_account: { update_credit_account: {
account_id: options.accountId, account_id: options.accountId,
actions: [ actions: [
...(options.borrow ? [{ borrow: options.borrow.toCoin() }] : []),
{ {
swap_exact_in: { swap_exact_in: {
coin_in: options.coinIn.toActionCoin(), coin_in: options.coinIn.toActionCoin(),

View File

@ -61,6 +61,7 @@ interface BroadcastSlice {
fee: StdFee fee: StdFee
accountId: string accountId: string
coinIn: BNCoin coinIn: BNCoin
borrow: BNCoin
denomOut: string denomOut: string
slippage: number slippage: number
}) => Promise<boolean> }) => Promise<boolean>

View File

@ -100,7 +100,7 @@ export function convertAccountToPositions(account: Account): Positions {
], ],
}, },
}, },
} as VaultPosition), }) as VaultPosition,
), ),
} }
} }

View File

@ -1,4 +1,5 @@
import BigNumber from 'bignumber.js' import BigNumber from 'bignumber.js'
import throttle from 'lodash.throttle'
BigNumber.config({ EXPONENTIAL_AT: 1e9 }) BigNumber.config({ EXPONENTIAL_AT: 1e9 })
export function BN(n: BigNumber.Value) { export function BN(n: BigNumber.Value) {
@ -10,3 +11,15 @@ export function getApproximateHourlyInterest(amount: string, borrowRate: number)
.dividedBy(24 * 365) .dividedBy(24 * 365)
.multipliedBy(amount) .multipliedBy(amount)
} }
export function asyncThrottle<F extends (...args: any[]) => Promise<any>>(func: F, wait?: number) {
const throttled = throttle((resolve, reject, args: Parameters<F>) => {
func(...args)
.then(resolve)
.catch(reject)
}, wait)
return (...args: Parameters<F>): ReturnType<F> =>
new Promise((resolve, reject) => {
throttled(resolve, reject, args)
}) as ReturnType<F>
}

View File

@ -3564,6 +3564,13 @@
resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz" resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
"@types/lodash.throttle@^4.1.7":
version "4.1.7"
resolved "https://registry.yarnpkg.com/@types/lodash.throttle/-/lodash.throttle-4.1.7.tgz#4ef379eb4f778068022310ef166625f420b6ba58"
integrity sha512-znwGDpjCHQ4FpLLx19w4OXDqq8+OvREa05H89obtSyXyOFKL3dDjCslsmfBz0T2FU8dmf5Wx1QvogbINiGIu9g==
dependencies:
"@types/lodash" "*"
"@types/lodash.values@^4.3.6": "@types/lodash.values@^4.3.6":
version "4.3.7" version "4.3.7"
resolved "https://registry.npmjs.org/@types/lodash.values/-/lodash.values-4.3.7.tgz" resolved "https://registry.npmjs.org/@types/lodash.values/-/lodash.values-4.3.7.tgz"
@ -7317,6 +7324,11 @@ lodash.sortby@^4.7.0:
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA== integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==
lodash.throttle@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4"
integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==
lodash.values@^4.3.0: lodash.values@^4.3.0:
version "4.3.0" version "4.3.0"
resolved "https://registry.npmjs.org/lodash.values/-/lodash.values-4.3.0.tgz" resolved "https://registry.npmjs.org/lodash.values/-/lodash.values-4.3.0.tgz"