mobile-app/app/screens/AppNavigator/screens/Loans/components/AddOrRemoveCollateralForm.tsx
import { BottomSheetWithNavRouteParam } from "@components/BottomSheetWithNav";
import { Button } from "@components/Button";
import { InputHelperText } from "@components/InputHelperText";
import { SymbolIcon } from "@components/SymbolIcon";
import {
ThemedIcon,
ThemedScrollView,
ThemedText,
ThemedView,
} from "@components/themed";
import { WalletTextInput } from "@components/WalletTextInput";
import { StackScreenProps } from "@react-navigation/stack";
import { tailwind } from "@tailwind";
import { translate } from "@translations";
import BigNumber from "bignumber.js";
import { memo, useEffect, useState } from "react";
import { Platform, TouchableOpacity, View, Text } from "react-native";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { TokenData } from "@defichain/whale-api-client/dist/api/tokens";
import { useThemeContext } from "@waveshq/walletkit-ui";
import { NumericFormat as NumberFormat } from "react-number-format";
import { useSelector } from "react-redux";
import { RootState } from "@store";
import {
hasTxQueued,
hasOceanTXQueued,
DFITokenSelector,
} from "@waveshq/walletkit-ui/dist/store";
import { ConversionInfoText } from "@components/ConversionInfoText";
import {
AmountButtonTypes,
SetAmountButton,
} from "@components/SetAmountButton";
import { LoanVaultActive } from "@defichain/whale-api-client/dist/api/loan";
import { getPrecisedTokenValue } from "@screens/AppNavigator/screens/Auctions/helpers/precision-token-value";
import { useFeatureFlagContext } from "@contexts/FeatureFlagContext";
import { TokenIconGroup } from "@components/TokenIconGroup";
import { IconTooltip } from "@components/tooltip/IconTooltip";
import { getNumberFormatValue } from "@api/number-format-value";
import { CollateralItem } from "../screens/EditCollateralScreen";
import {
getCollateralPrice,
useTotalCollateralValue,
useValidCollateralRatio,
} from "../hooks/CollateralPrice";
import {
useCollateralizationRatioColor,
useResultingCollateralizationRatioByCollateral,
} from "../hooks/CollateralizationRatio";
export interface AddOrRemoveCollateralFormProps {
collateralItem: CollateralItem;
token: TokenData;
activePrice: BigNumber;
collateralFactor: BigNumber;
available: string;
current?: BigNumber;
onButtonPress: (item: AddOrRemoveCollateralResponse) => void;
onCloseButtonPress: () => void;
isAdd: boolean;
vault: LoanVaultActive;
collateralTokens: CollateralItem[];
}
const COLOR_BARS_COUNT = 6;
type Props = StackScreenProps<
BottomSheetWithNavRouteParam,
"AddOrRemoveCollateralFormProps"
>;
export interface AddOrRemoveCollateralResponse {
token: TokenData;
amount: BigNumber;
}
export const AddOrRemoveCollateralForm = memo(
({ route }: Props): JSX.Element => {
const { isLight } = useThemeContext();
const {
token,
activePrice,
available,
onButtonPress,
onCloseButtonPress,
collateralFactor,
isAdd,
vault,
collateralItem,
collateralTokens,
} = route.params;
const hasPendingJob = useSelector((state: RootState) =>
hasTxQueued(state.transactionQueue)
);
const hasPendingBroadcastJob = useSelector((state: RootState) =>
hasOceanTXQueued(state.ocean)
);
const DFIToken = useSelector((state: RootState) =>
DFITokenSelector(state.wallet)
);
const { isFeatureAvailable } = useFeatureFlagContext();
const [collateralValue, setCollateralValue] = useState<string>("");
const [vaultValue, setVaultValue] = useState<string>("");
const isConversionRequired =
isAdd && token.id === "0"
? new BigNumber(collateralValue).isGreaterThan(DFIToken.amount) &&
new BigNumber(collateralValue).isLessThanOrEqualTo(available)
: false;
const [isValid, setIsValid] = useState(false);
const collateralInputValue = new BigNumber(collateralValue).isNaN()
? 0
: collateralValue;
const { totalCollateralValueInUSD } = useTotalCollateralValue({
vault,
token,
isAdd,
collateralInputValue,
activePriceAmount: activePrice.isNaN()
? new BigNumber(0)
: new BigNumber(activePrice),
collateralTokens,
});
const { displayedColorBars, resultingColRatio } =
useResultingCollateralizationRatioByCollateral({
collateralValue: collateralValue,
collateralRatio: new BigNumber(vault.informativeRatio ?? NaN),
minCollateralRatio: new BigNumber(vault.loanScheme.minColRatio),
totalLoanAmount: new BigNumber(vault.loanValue),
numOfColorBars: COLOR_BARS_COUNT,
totalCollateralValueInUSD,
});
const colors = useCollateralizationRatioColor({
colRatio: resultingColRatio,
minColRatio: new BigNumber(vault.loanScheme.minColRatio ?? NaN),
totalLoanAmount: new BigNumber(vault.loanValue ?? NaN),
totalCollateralValue: totalCollateralValueInUSD,
});
const validateInput = (input: string): void => {
const formattedInput = new BigNumber(input);
if (
formattedInput.isGreaterThan(available) ||
formattedInput.isLessThanOrEqualTo(0) ||
formattedInput.isNaN()
) {
setIsValid(false);
} else {
setIsValid(true);
}
};
const onAmountChange = (amount: string): void => {
setCollateralValue(amount);
};
const currentBalance =
vault?.collateralAmounts?.find((c) => c.id === token.id)?.amount ?? "0";
const totalCollateralVaultValue =
new BigNumber(vault?.collateralValue) ?? new BigNumber(0);
const inputValue = new BigNumber(collateralValue).isNaN()
? "0"
: collateralValue;
const totalAmount = isAdd
? new BigNumber(currentBalance)?.plus(inputValue)
: BigNumber.max(0, new BigNumber(currentBalance)?.minus(inputValue));
const initialPrices = getCollateralPrice(
new BigNumber(inputValue),
collateralItem,
new BigNumber(vault.collateralValue)
);
const totalCalculatedCollateralValue = isAdd
? new BigNumber(totalCollateralVaultValue).plus(
initialPrices?.collateralPrice
)
: new BigNumber(totalCollateralVaultValue).minus(
initialPrices.collateralPrice
);
const prices = getCollateralPrice(
totalAmount,
collateralItem,
totalCalculatedCollateralValue
);
const {
requiredVaultShareTokens,
requiredTokensShare,
minRequiredTokensShare,
hasLoan,
} = useValidCollateralRatio(
vault?.collateralAmounts ?? [],
totalCalculatedCollateralValue,
new BigNumber(vault.loanValue),
token.id,
totalAmount
);
const isValidCollateralRatio = requiredTokensShare?.gte(
minRequiredTokensShare
);
const removeMaxCollateralAmount =
!isAdd &&
new BigNumber(collateralValue).isEqualTo(new BigNumber(available)) &&
prices.vaultShare.isNaN() &&
collateralItem !== undefined;
const displayNA =
new BigNumber(collateralValue).isZero() ||
collateralValue === "" ||
removeMaxCollateralAmount;
useEffect(() => {
setVaultValue(prices.vaultShare.toFixed(2));
}, [prices.vaultShare]);
useEffect(() => {
validateInput(collateralValue);
}, [collateralValue]);
const bottomSheetComponents = {
mobile: BottomSheetScrollView,
web: ThemedScrollView,
};
const ScrollView =
Platform.OS === "web"
? bottomSheetComponents.web
: bottomSheetComponents.mobile;
const hasInvalidColRatio =
resultingColRatio.isLessThanOrEqualTo(0) ||
resultingColRatio.isNaN() ||
!resultingColRatio.isFinite();
return (
<ScrollView
style={tailwind([
"p-4 flex-1",
{
"bg-white": isLight,
"bg-gray-800": !isLight,
},
])}
>
<View style={tailwind("flex flex-row items-center mb-2")}>
<ThemedText
testID="form_title"
style={tailwind("flex-1 mb-2 text-lg font-medium")}
>
{translate(
"components/AddOrRemoveCollateralForm",
`How much {{symbol}} to ${isAdd ? "add" : "remove"}?`,
{
symbol: token.displaySymbol,
}
)}
</ThemedText>
{onCloseButtonPress !== undefined && (
<TouchableOpacity onPress={onCloseButtonPress}>
<ThemedIcon iconType="MaterialIcons" name="close" size={20} />
</TouchableOpacity>
)}
</View>
<View style={tailwind("flex flex-row items-center mb-2")}>
<SymbolIcon
symbol={token.displaySymbol}
styleProps={tailwind("w-6 h-6")}
/>
<ThemedText
testID={`token_symbol_${token.displaySymbol}`}
style={tailwind("mx-2")}
>
{token.displaySymbol}
</ThemedText>
<ThemedView
light={tailwind("text-gray-700 border-gray-700")}
dark={tailwind("text-gray-300 border-gray-300")}
style={tailwind("border rounded")}
>
<NumberFormat
value={collateralFactor.toFixed(2)}
displayType="text"
suffix={`% ${translate(
"components/AddOrRemoveCollateralForm",
"collateral factor"
)}`}
renderText={(value) => (
<ThemedText
light={tailwind("text-gray-700")}
dark={tailwind("text-gray-300")}
style={tailwind("text-xs font-medium px-1")}
>
{value}
</ThemedText>
)}
/>
</ThemedView>
</View>
<WalletTextInput
value={collateralValue}
inputType="numeric"
displayClearButton={collateralValue !== ""}
onChangeText={onAmountChange}
onClearButtonPress={() => {
setCollateralValue("");
setVaultValue("");
}}
placeholder={translate(
"components/AddOrRemoveCollateralForm",
"Enter an amount"
)}
style={tailwind("h-9 w-6/12 flex-grow")}
hasBottomSheet
testID="form_input_text"
>
<ThemedView
dark={tailwind("bg-gray-800")}
light={tailwind("bg-white")}
style={tailwind("flex-row items-center")}
>
<SetAmountButton
amount={new BigNumber(available)}
onPress={onAmountChange}
type={AmountButtonTypes.half}
/>
<SetAmountButton
amount={new BigNumber(available)}
onPress={onAmountChange}
type={AmountButtonTypes.max}
/>
</ThemedView>
</WalletTextInput>
<InputHelperText
label={`${translate(
"components/AddOrRemoveCollateralForm",
isAdd ? "Available" : "Current"
)}: `}
content={available}
testID="form_balance_text"
suffixType="component"
styleProps={tailwind("font-medium")}
>
<View style={tailwind("flex flex-row items-center")}>
<ThemedText
light={tailwind("text-gray-700")}
dark={tailwind("text-gray-200")}
style={tailwind("text-sm font-medium")}
>
<Text> </Text>
{token.displaySymbol}
{!new BigNumber(activePrice).isZero() && (
<NumberFormat
value={getPrecisedTokenValue(
activePrice.multipliedBy(available)
)}
thousandSeparator
displayType="text"
prefix="$"
renderText={(val: string) => (
<ThemedText
dark={tailwind("text-gray-400")}
light={tailwind("text-gray-500")}
style={tailwind("text-xs leading-5")}
>
{` /${val}`}
</ThemedText>
)}
/>
)}
</ThemedText>
<IconTooltip />
</View>
</InputHelperText>
{isFeatureAvailable("dusd_vault_share") ? (
<View
style={tailwind(
"flex justify-between items-center flex-row w-full"
)}
>
<ThemedText style={tailwind("mr-2 w-6/12")}>
{translate(
"components/AddOrRemoveCollateralForm",
"Vault requirement"
)}
</ThemedText>
<ThemedView
style={tailwind(
"flex flex-row items-center mb-0 p-1 rounded-2xl"
)}
>
<TokenIconGroup
testID="required_collateral_token_group"
symbols={requiredVaultShareTokens}
maxIconToDisplay={2}
offsetContainer
/>
<NumberFormat
value={requiredTokensShare.toFixed(2)}
thousandSeparator
displayType="text"
suffix="%"
renderText={(val: string) => (
<ThemedView style={tailwind("flex flex-row px-1 rounded")}>
<ThemedText
light={tailwind([
"text-gray-900",
{ "text-error-500": !isValidCollateralRatio },
])}
dark={tailwind([
"text-gray-50",
{ "text-error-500": !isValidCollateralRatio },
])}
style={tailwind("text-sm font-medium")}
testID="bottom-sheet-vault-requirement-text"
>
{val}
</ThemedText>
<Text> </Text>
<ThemedText
dark={tailwind("text-gray-400")}
light={tailwind("text-gray-500")}
style={tailwind("text-sm font-medium")}
>
{`/${minRequiredTokensShare}%`}
</ThemedText>
</ThemedView>
)}
/>
</ThemedView>
</View>
) : (
<ScrollView
horizontal
contentContainerStyle={tailwind([
"flex justify-between items-center flex-row",
{
"flex-grow h-7": Platform.OS !== "web",
"w-full": Platform.OS === "web",
},
])}
>
<ThemedText style={tailwind("mr-2")}>
{translate("components/AddOrRemoveCollateralForm", "Vault %")}
</ThemedText>
<ThemedView
style={tailwind(
"flex flex-row items-center mb-0 py-1 px-1.5 rounded-2xl"
)}
>
<SymbolIcon symbol={token.displaySymbol} />
{displayNA ? (
<ThemedText
light={tailwind("text-gray-900")}
dark={tailwind("text-gray-50")}
style={tailwind("px-1 text-sm font-medium")}
testID="bottom-sheet-vault-percentage-text"
>
{translate("components/AddOrRemoveCollateralForm", "N/A")}
</ThemedText>
) : (
<NumberFormat
value={getNumberFormatValue(vaultValue, 2)}
thousandSeparator
displayType="text"
suffix="%"
renderText={(val: string) => (
<ThemedView style={tailwind("px-1 rounded")}>
<ThemedText
light={tailwind("text-gray-900")}
dark={tailwind("text-gray-50")}
style={tailwind("text-sm font-medium")}
testID="bottom-sheet-vault-percentage-text"
>
{val}
</ThemedText>
</ThemedView>
)}
/>
)}
</ThemedView>
</ScrollView>
)}
<View style={tailwind("pt-2 flex justify-between flex-row")}>
<ThemedText style={tailwind("mr-2")}>
{translate(
"components/AddOrRemoveCollateralForm",
"Resulting collateralization"
)}
</ThemedText>
{hasInvalidColRatio ? (
<ThemedText
style={tailwind("font-semibold pr-2")}
light={tailwind("text-gray-300")}
dark={tailwind("text-gray-300")}
testID="resulting_collateralization"
>
{translate("components/AddOrRemoveCollateralForm", "N/A")}
</ThemedText>
) : (
<NumberFormat
displayType="text"
suffix="%"
renderText={(val: string) => (
<ThemedText
style={tailwind("font-semibold pr-2")}
light={colors.light}
dark={colors.dark}
testID="resulting_collateralization"
>
{val}
</ThemedText>
)}
thousandSeparator
value={resultingColRatio.toFixed(2)}
/>
)}
</View>
<ColorBar
displayedBarsLen={displayedColorBars}
colorBarsLen={COLOR_BARS_COUNT}
/>
{isConversionRequired && (
<View style={tailwind("mt-4 mb-6")}>
<ConversionInfoText />
</View>
)}
<Button
disabled={
!isValid ||
hasPendingJob ||
hasPendingBroadcastJob ||
(isFeatureAvailable("dusd_vault_share") &&
!isAdd &&
!isValidCollateralRatio &&
hasLoan)
}
label={translate(
"components/AddOrRemoveCollateralForm",
isAdd ? "ADD TOKEN AS COLLATERAL" : "REMOVE COLLATERAL AMOUNT"
)}
onPress={() =>
onButtonPress({
token,
amount: new BigNumber(collateralValue),
})
}
margin="mt-6 mb-2"
testID="add_collateral_button_submit"
/>
{isFeatureAvailable("dusd_vault_share") &&
!isAdd &&
!isValidCollateralRatio &&
requiredVaultShareTokens.includes(token.symbol) &&
hasLoan && (
<ThemedText
dark={tailwind("text-error-500")}
light={tailwind("text-error-500")}
style={tailwind("text-sm text-center")}
testID="vault_min_share_warning"
>
{translate(
"screens/BorrowLoanTokenScreen",
"Your vault needs at least 50% of DFI and/or DUSD as collateral"
)}
</ThemedText>
)}
<ThemedText
style={tailwind("text-xs text-center p-2 px-6 pb-12")}
light={tailwind("text-gray-500")}
dark={tailwind("text-gray-400")}
>
{translate(
"components/AddOrRemoveCollateralForm",
"The collateral factor determines the degree of contribution of each collateral token."
)}
</ThemedText>
</ScrollView>
);
}
);
function ColorBar(props: {
colorBarsLen: number;
displayedBarsLen: number;
}): JSX.Element {
const width = 100 / props.colorBarsLen;
return (
<View style={tailwind("flex flex-row mt-1 mr-3")}>
{Array.from(Array(props.colorBarsLen), (_v, i) => i + 1).map((index) => {
const isLiquidated = index <= props.colorBarsLen / 3;
const isAtRisk = index <= (props.colorBarsLen / 3) * 2 && !isLiquidated;
const isHealthy = !isLiquidated && !isAtRisk;
const isWithinRange =
!isNaN(props.displayedBarsLen) && props.displayedBarsLen >= index;
return (
<ThemedView
key={index}
light={tailwind({
"bg-error-200": isLiquidated && isWithinRange,
"bg-warning-300": isAtRisk && isWithinRange,
"bg-success-300": isHealthy && isWithinRange,
"bg-gray-200": !isWithinRange,
})}
dark={tailwind({
"bg-darkerror-200": isLiquidated && isWithinRange,
"bg-darkwarning-300": isAtRisk && isWithinRange,
"bg-darksuccess-300": isHealthy && isWithinRange,
"bg-gray-200": !isWithinRange,
})}
style={[tailwind("h-1 mr-0.5"), { width: `${width}%` }]}
/>
);
})}
</View>
);
}