From 0f8e656651acb66284711b8dfea8f7ff783b8ff1 Mon Sep 17 00:00:00 2001 From: Bob van der Helm <34470358+bobthebuidlr@users.noreply.github.com> Date: Wed, 7 Jun 2023 16:22:13 +0200 Subject: [PATCH] Mp 2543 farm adv borrow (#247) * add calculation for max borrowings * implement borrow side for modal * add test and resolve comments --- .../Modals/vault/VaultBorrowings.test.tsx | 38 ++++++ package.json | 1 + src/components/Icons/TrashBin.svg | 5 +- .../Modals/vault/VaultBorrowings.tsx | 113 +++++++++++++++++- src/components/Modals/vault/VaultDeposit.tsx | 2 +- .../Modals/vault/VaultModalContent.tsx | 10 +- src/components/TokenInput.tsx | 8 +- src/hooks/useMarketAssets.tsx | 10 ++ src/hooks/useUpdateAccount.tsx | 53 ++++++++ src/types/interfaces/market.d.ts | 1 + src/utils/accounts.ts | 35 +++++- src/utils/helpers.ts | 6 + src/utils/resolvers.ts | 1 + src/utils/tokens.ts | 8 ++ src/utils/vaults.ts | 29 +++++ 15 files changed, 311 insertions(+), 9 deletions(-) create mode 100644 __tests__/components/Modals/vault/VaultBorrowings.test.tsx create mode 100644 src/hooks/useMarketAssets.tsx create mode 100644 src/hooks/useUpdateAccount.tsx diff --git a/__tests__/components/Modals/vault/VaultBorrowings.test.tsx b/__tests__/components/Modals/vault/VaultBorrowings.test.tsx new file mode 100644 index 00000000..75301cb0 --- /dev/null +++ b/__tests__/components/Modals/vault/VaultBorrowings.test.tsx @@ -0,0 +1,38 @@ +import { render } from '@testing-library/react' + +import VaultBorrowings from 'components/Modals/vault/VaultBorrowings' +import BigNumber from 'bignumber.js' + +jest.mock('hooks/usePrices', () => + jest.fn(() => ({ + data: [], + })), +) +jest.mock('hooks/useMarketAssets', () => + jest.fn(() => ({ + data: [], + })), +) + +describe('', () => { + const defaultProps: { + account: Account + defaultBorrowDenom: string + onChangeBorrowings: (borrowings: Map) => void + } = { + account: { + id: 'test', + deposits: [], + debts: [], + vaults: [], + lends: [], + }, + defaultBorrowDenom: 'test-denom', + onChangeBorrowings: jest.fn(), + } + + it('should render', () => { + const { container } = render() + expect(container).toBeInTheDocument() + }) +}) diff --git a/package.json b/package.json index 15ba1cdf..2aa5d95f 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "build": "yarn validate-env && next build", "dev": "next dev", "test": "jest", + "test:cov": "jest --coverage", "lint": "eslint ./src/ && yarn prettier-check", "format": "eslint ./src/ ./__tests__/ --fix && prettier --write ./src/ ./__tests__/", "prettier-check": "prettier --ignore-path .gitignore --check ./src/", diff --git a/src/components/Icons/TrashBin.svg b/src/components/Icons/TrashBin.svg index c209f968..0e1fbc31 100644 --- a/src/components/Icons/TrashBin.svg +++ b/src/components/Icons/TrashBin.svg @@ -1,4 +1,3 @@ - - + + - diff --git a/src/components/Modals/vault/VaultBorrowings.tsx b/src/components/Modals/vault/VaultBorrowings.tsx index 450ccb27..bd8252cf 100644 --- a/src/components/Modals/vault/VaultBorrowings.tsx +++ b/src/components/Modals/vault/VaultBorrowings.tsx @@ -1,3 +1,112 @@ -export default function VaultBorrowings() { - return null +import { useMemo, useState } from 'react' +import BigNumber from 'bignumber.js' + +import { BN } from 'utils/helpers' +import { getAssetByDenom } from 'utils/assets' +import Button from 'components/Button' +import TokenInput from 'components/TokenInput' +import Divider from 'components/Divider' +import Text from 'components/Text' +import { ArrowRight } from 'components/Icons' +import { formatPercent } from 'utils/formatters' +import Slider from 'components/Slider' +import usePrices from 'hooks/usePrices' +import useMarketAssets from 'hooks/useMarketAssets' +import { calculateMaxBorrowAmounts } from 'utils/vaults' +import React from 'react' + +interface Props { + account: Account + defaultBorrowDenom: string + onChangeBorrowings: (borrowings: Map) => void +} + +export default function VaultBorrowings(props: Props) { + const { data: prices } = usePrices() + const { data: marketAssets } = useMarketAssets() + + const [borrowings, setBorrowings] = useState>( + new Map().set(props.defaultBorrowDenom, BN(0)), + ) + + const maxAmounts: Map = useMemo( + () => + calculateMaxBorrowAmounts(props.account, marketAssets, prices, Array.from(borrowings.keys())), + [borrowings, marketAssets, prices, props.account], + ) + + const [percentage, setPercentage] = useState(0) + + function onChangeSlider(value: number) { + if (borrowings.size !== 1) return + + const denom = Array.from(borrowings.keys())[0] + + const newBorrowings = new Map().set( + denom, + maxAmounts.get(denom)?.times(value).div(100).toPrecision(0) || BN(0), + ) + setBorrowings(newBorrowings) + props.onChangeBorrowings(newBorrowings) + setPercentage(value) + } + + function updateAssets(denom: string, amount: BigNumber) { + const newborrowings = new Map(borrowings) + newborrowings.set(denom, amount) + setBorrowings(newborrowings) + props.onChangeBorrowings(newborrowings) + } + + function onDelete(denom: string) { + const newborrowings = new Map(borrowings) + newborrowings.delete(denom) + setBorrowings(newborrowings) + props.onChangeBorrowings(newborrowings) + } + + function addAsset() { + const newborrowings = new Map(borrowings) + // Replace with denom parameter from the modal (MP-2546) + newborrowings.set('', BN(0)) + setBorrowings(newborrowings) + props.onChangeBorrowings(newborrowings) + } + + return ( +
+ {Array.from(borrowings.entries()).map(([denom, amount]) => { + const asset = getAssetByDenom(denom) + if (!asset) return + return ( + updateAssets(denom, amount)} + onDelete={() => onDelete(denom)} + /> + ) + })} + {borrowings.size === 1 && } +
+ ) } diff --git a/src/components/Modals/vault/VaultDeposit.tsx b/src/components/Modals/vault/VaultDeposit.tsx index 4a1d8a04..3d967261 100644 --- a/src/components/Modals/vault/VaultDeposit.tsx +++ b/src/components/Modals/vault/VaultDeposit.tsx @@ -82,7 +82,7 @@ export default function VaultDeposit(props: Props) { ) const [percentage, setPercentage] = useState( - primaryValue.dividedBy(maxAssetValueNonCustom).times(100).decimalPlaces(0).toNumber(), + primaryValue.dividedBy(maxAssetValueNonCustom).times(100).decimalPlaces(0).toNumber() || 0, ) const disableInput = (availablePrimaryAmount.isZero() || availableSecondaryAmount.isZero()) && !props.isCustomRatio diff --git a/src/components/Modals/vault/VaultModalContent.tsx b/src/components/Modals/vault/VaultModalContent.tsx index e926e2b8..8f303727 100644 --- a/src/components/Modals/vault/VaultModalContent.tsx +++ b/src/components/Modals/vault/VaultModalContent.tsx @@ -8,6 +8,7 @@ import VaultDeposit from 'components/Modals/vault/VaultDeposit' import VaultDepositSubTitle from 'components/Modals/vault/VaultDepositSubTitle' import useIsOpenArray from 'hooks/useIsOpenArray' import { BN } from 'utils/helpers' +import useUpdateAccount from 'hooks/useUpdateAccount' interface Props { vault: Vault @@ -17,6 +18,7 @@ interface Props { } export default function VaultModalContent(props: Props) { + const { updatedAccount, onChangeBorrowings } = useUpdateAccount(props.account) const [isOpen, toggleOpen] = useIsOpenArray(2, false) const [primaryAmount, setPrimaryAmount] = useState(BN(0)) const [secondaryAmount, setSecondaryAmount] = useState(BN(0)) @@ -68,7 +70,13 @@ export default function VaultModalContent(props: Props) { toggleOpen: (index: number) => toggleOpen(index), }, { - renderContent: () => , + renderContent: () => ( + + ), title: 'Borrow', isOpen: isOpen[1], toggleOpen: (index: number) => toggleOpen(index), diff --git a/src/components/TokenInput.tsx b/src/components/TokenInput.tsx index 861eb8e8..4c2e7a13 100644 --- a/src/components/TokenInput.tsx +++ b/src/components/TokenInput.tsx @@ -11,7 +11,7 @@ import useStore from 'store' import { BN } from 'utils/helpers' import { FormattedNumber } from 'components/FormattedNumber' import Button from 'components/Button' -import { ExclamationMarkTriangle } from 'components/Icons' +import { ExclamationMarkTriangle, TrashBin } from 'components/Icons' import { Tooltip } from 'components/Tooltip' interface Props { @@ -27,6 +27,7 @@ interface Props { maxText?: string warning?: string onChangeAsset?: (asset: Asset) => void + onDelete?: () => void } export default function TokenInput(props: Props) { @@ -79,6 +80,11 @@ export default function TokenInput(props: Props) { max={props.max} className='border-none p-3' /> + {props.onDelete && ( +
+ +
+ )} {props.warning && (
(account) + + function getCoin(denom: string, amount: BigNumber): Coin { + return { + denom, + amount: amount.decimalPlaces(0).toString(), + } + } + + const onChangeBorrowings = useCallback( + (borrowings: Map) => { + const debts: Coin[] = [...account.debts] + const deposits: Coin[] = [...account.deposits] + const currentDebtDenoms = debts.map((debt) => debt.denom) + const currentDepositDenoms = deposits.map((deposit) => deposit.denom) + + borrowings.forEach((amount, denom) => { + if (amount.isZero()) return + + if (currentDebtDenoms.includes(denom)) { + const index = currentDebtDenoms.indexOf(denom) + const newAmount = BN(debts[index].amount).plus(amount) + debts[index] = getCoin(denom, newAmount) + } else { + debts.push(getCoin(denom, amount)) + } + + if (currentDepositDenoms.includes(denom)) { + const index = currentDepositDenoms.indexOf(denom) + const newAmount = BN(deposits[index].amount).plus(amount) + deposits[index] = getCoin(denom, newAmount) + } else { + deposits.push(getCoin(denom, amount)) + } + }) + + setUpdatedAccount({ + ...account, + debts, + deposits, + }) + }, + [account], + ) + + return { updatedAccount, onChangeBorrowings } +} diff --git a/src/types/interfaces/market.d.ts b/src/types/interfaces/market.d.ts index e28559b2..0907b921 100644 --- a/src/types/interfaces/market.d.ts +++ b/src/types/interfaces/market.d.ts @@ -6,4 +6,5 @@ interface Market { depositEnabled: boolean borrowEnabled: boolean depositCap: string + maxLtv: number } diff --git a/src/utils/accounts.ts b/src/utils/accounts.ts index 3200f4ad..b96a09aa 100644 --- a/src/utils/accounts.ts +++ b/src/utils/accounts.ts @@ -1,6 +1,7 @@ import BigNumber from 'bignumber.js' -import { BN } from 'utils/helpers' +import { BN, getApproximateHourlyInterest } from 'utils/helpers' +import { getTokenValue } from 'utils/tokens' export const calculateAccountBalance = ( account: Account | AccountChange, @@ -59,3 +60,35 @@ export const calculateAccountBorrowRate = ( export function getAmount(denom: string, coins: Coin[]): BigNumber { return BN(coins.find((asset) => asset.denom === denom)?.amount ?? 0) } + +export function getNetCollateralValue(account: Account, marketAssets: Market[], prices: Coin[]) { + const depositCollateralValue = account.deposits.reduce((acc, coin) => { + const asset = marketAssets.find((asset) => asset.denom === coin.denom) + + if (!asset) return acc + + const marketValue = BN(getTokenValue(coin, prices)) + const collateralValue = marketValue.times(asset.maxLtv) + + return collateralValue.plus(acc) + }, BN(0)) + + // Implement Vault Collateral calculation (MP-2915) + + const liabilitiesValue = account.debts.reduce((acc, coin) => { + const asset = marketAssets.find((asset) => asset.denom === coin.denom) + + if (!asset) return acc + + const estimatedInterestAmount = getApproximateHourlyInterest(coin.amount, asset.borrowRate) + const liability = BN(getTokenValue(coin, prices)).plus(estimatedInterestAmount) + + return liability.plus(acc) + }, BN(0)) + + if (liabilitiesValue.isGreaterThan(depositCollateralValue)) { + return BN(0) + } + + return depositCollateralValue.minus(liabilitiesValue) +} diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index dbf20a0f..7e87655f 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -4,3 +4,9 @@ BigNumber.config({ EXPONENTIAL_AT: 1e9 }) export function BN(n: BigNumber.Value) { return new BigNumber(n) } + +export function getApproximateHourlyInterest(amount: string, borrowRate: number) { + return BigNumber(borrowRate) + .div(24 * 365) + .times(amount) +} diff --git a/src/utils/resolvers.ts b/src/utils/resolvers.ts index 9ff44168..0f791eda 100644 --- a/src/utils/resolvers.ts +++ b/src/utils/resolvers.ts @@ -21,5 +21,6 @@ export function resolveMarketResponses(responses: MarketResponse[]): Market[] { depositEnabled: response.deposit_enabled, borrowEnabled: response.borrow_enabled, depositCap: response.deposit_cap, + maxLtv: Number(response.max_loan_to_value), })) } diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index 55d6631b..d9dd979a 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -1,4 +1,7 @@ +import BigNumber from 'bignumber.js' + import { getBaseAsset } from 'utils/assets' +import { BN } from 'utils/helpers' export const getTokenSymbol = (denom: string, marketAssets: Asset[]) => marketAssets.find((asset) => asset.denom.toLowerCase() === denom.toLowerCase())?.symbol || '' @@ -11,3 +14,8 @@ export const getTokenIcon = (denom: string, marketAssets: Asset[]) => export const getTokenInfo = (denom: string, marketAssets: Asset[]) => marketAssets.find((asset) => asset.denom.toLowerCase() === denom.toLowerCase()) || getBaseAsset() + +export function getTokenValue(coin: Coin, prices: Coin[]): BigNumber { + const price = prices.find((price) => price.denom === coin.denom)?.amount || '0' + return BN(price).times(coin.amount) +} diff --git a/src/utils/vaults.ts b/src/utils/vaults.ts index c3f5aaea..2837379f 100644 --- a/src/utils/vaults.ts +++ b/src/utils/vaults.ts @@ -1,7 +1,36 @@ +import BigNumber from 'bignumber.js' + import { IS_TESTNET } from 'constants/env' import { TESTNET_VAULTS, VAULTS } from 'constants/vaults' +import { BN } from 'utils/helpers' +import { getNetCollateralValue } from 'utils/accounts' export function getVaultMetaData(address: string) { const vaults = IS_TESTNET ? TESTNET_VAULTS : VAULTS return vaults.find((vault) => vault.address === address) } + +// This should be replaced when the calculation is made part of the Health Computer (MP-2877) +export function calculateMaxBorrowAmounts( + account: Account, + marketAssets: Market[], + prices: Coin[], + denoms: string[], +): Map { + const maxAmounts = new Map() + const collateralValue = getNetCollateralValue(account, marketAssets, prices) + + for (const denom of denoms) { + const borrowAsset = marketAssets.find((asset) => asset.denom === denom) + const borrowAssetPrice = prices.find((price) => price.denom === denom)?.amount + + if (!borrowAssetPrice || !borrowAsset) continue + + const borrowValue = BN(1).minus(borrowAsset.maxLtv).times(borrowAssetPrice) + const amount = collateralValue.dividedBy(borrowValue).decimalPlaces(0) + + maxAmounts.set(denom, amount) + } + + return maxAmounts +}