DeFiCh/wallet

View on GitHub
mobile-app/app/screens/AppNavigator/screens/Loans/screens/PaybackLoanScreen.tsx

Summary

Maintainability
C
1 day
Test Coverage
import { useEffect, useState } from "react";
import { StackScreenProps } from "@react-navigation/stack";
import { LoanParamList } from "@screens/AppNavigator/screens/Loans/LoansNavigator";
import { View } from "react-native";
import {
  ThemedScrollViewV2,
  ThemedTextInputV2,
  ThemedTextV2,
  ThemedViewV2,
} from "@components/themed";
import { getColor, tailwind } from "@tailwind";
import { translate } from "@translations";
import { NumericFormat as NumberFormat } from "react-number-format";
import BigNumber from "bignumber.js";
import {
  LoanVaultActive,
  LoanVaultTokenAmount,
} from "@defichain/whale-api-client/dist/api/loan";
import { useSelector } from "react-redux";
import { RootState } from "@store";
import {
  hasTxQueued,
  hasOceanTXQueued,
  tokensSelector,
} from "@waveshq/walletkit-ui/dist/store";
import { useLoanOperations } from "@screens/AppNavigator/screens/Loans/hooks/LoanOperations";
import { getActivePrice } from "@screens/AppNavigator/screens/Auctions/helpers/ActivePrice";
import {
  activeVaultsSelector,
  fetchCollateralTokens,
  loanTokenByTokenId,
} from "@store/loans";
import {
  TransactionCard,
  AmountButtonTypes,
} from "@components/TransactionCard";
import {
  TokenDropdownButton,
  TokenDropdownButtonStatus,
} from "@components/TokenDropdownButton";
import { Controller, useForm } from "react-hook-form";
import { useThemeContext } from "@waveshq/walletkit-ui";
import { TextRowV2 } from "@components/TextRowV2";
import { NumberRowV2 } from "@components/NumberRowV2";
import { SubmitButtonGroup } from "@components/SubmitButtonGroup";
import { useToast } from "react-native-toast-notifications";
import { useWhaleApiClient } from "@waveshq/walletkit-ui/dist/contexts";
import { useAppDispatch } from "@hooks/useAppDispatch";
import { useLogger } from "@shared-contexts/NativeLoggingProvider";
import { getTokenAmount } from "../hooks/LoanPaymentTokenRate";
import { useResultingCollateralRatio } from "../hooks/CollateralPrice";
import { useInterestPerBlock } from "../hooks/InterestPerBlock";
import { ActiveUSDValueV2 } from "../VaultDetail/components/ActiveUSDValueV2";
import { CollateralizationRatioDisplay } from "../components/CollateralizationRatioDisplay";

type Props = StackScreenProps<LoanParamList, "PaybackLoanScreen">;

export function PaybackLoanScreen({ navigation, route }: Props): JSX.Element {
  const routeParams = route.params;
  const client = useWhaleApiClient();
  const dispatch = useAppDispatch();
  const [vault, setVault] = useState(routeParams.vault);
  const [loanTokenAmount, setLoanTokenAmount] = useState(
    routeParams.loanTokenAmount,
  );
  const vaults = useSelector((state: RootState) =>
    activeVaultsSelector(state.loans),
  );

  useEffect(() => {
    dispatch(fetchCollateralTokens({ client }));
  }, []);

  const collateralTokens = useSelector(
    (state: RootState) => state.loans.collateralTokens,
  );

  useEffect(() => {
    const vault = vaults.find((v) => v.vaultId === routeParams.vault.vaultId);
    if (vault !== undefined) {
      setVault(vault);
    }
    const loanTokenAmount = vault?.loanAmounts.find(
      (l: LoanVaultTokenAmount) => l.id === routeParams.loanTokenAmount.id,
    );
    if (loanTokenAmount !== undefined) {
      setLoanTokenAmount(loanTokenAmount);
    }
  }, [vaults]);

  const tokens = useSelector((state: RootState) =>
    tokensSelector(state.wallet),
  );
  const toast = useToast();
  const TOAST_DURATION = 2000;
  function showToast(message: string): void {
    toast.hideAll(); // hides old toast everytime user clicks on a new percentage
    toast.show(message, {
      type: "wallet_toast",
      placement: "top",
      duration: TOAST_DURATION,
    });
  }
  const loanToken = useSelector((state: RootState) =>
    loanTokenByTokenId(state.loans, loanTokenAmount.id),
  );
  const { isLight } = useThemeContext();
  const canUseOperations = useLoanOperations(vault?.state);
  // form
  const { control, setValue, formState, trigger, watch } = useForm({
    mode: "onChange",
  });
  const { amountToPay } = watch();
  const collateralDUSD = vault?.collateralAmounts?.find(
    ({ symbol }) => symbol === "DUSD",
  );
  const collateralDUSDAmount = collateralDUSD?.amount ?? "0";
  const [fee, setFee] = useState<BigNumber>(new BigNumber(0.0001));
  const logger = useLogger();

  useEffect(() => {
    client.fee
      .estimate()
      .then((f) => setFee(new BigNumber(f)))
      .catch(logger.error);
  }, []);

  useEffect(() => {
    if (routeParams.isPaybackDUSDUsingCollateral) {
      setValue(
        "amountToPay",
        BigNumber.min(collateralDUSDAmount, loanTokenAmount.amount).toFixed(8),
      );
    }
  }, [loanTokenAmount, collateralDUSDAmount]);

  const interestPerBlock = useInterestPerBlock(
    new BigNumber(vault?.loanScheme.interestRate ?? NaN),
    new BigNumber(loanToken?.interest ?? NaN),
  );
  const token = tokens?.find((t) => t.id === loanTokenAmount.id);
  const tokenBalance =
    token != null ? getTokenAmount(token.id, tokens) : new BigNumber(0);
  const loanTokenOutstandingBal = new BigNumber(loanTokenAmount.amount);
  const collateralFactor =
    collateralTokens?.find((t) => t.token.id === loanTokenAmount.id)?.factor ??
    "1";
  const loanTokenActivePriceInUSD = getActivePrice(
    loanTokenAmount.symbol,
    loanTokenAmount.activePrice,
    routeParams.isPaybackDUSDUsingCollateral === true
      ? collateralFactor
      : undefined,
    undefined,
    routeParams.isPaybackDUSDUsingCollateral === true
      ? "COLLATERAL"
      : undefined,
  );

  const hasPendingJob = useSelector((state: RootState) =>
    hasTxQueued(state.transactionQueue),
  );
  const hasPendingBroadcastJob = useSelector((state: RootState) =>
    hasOceanTXQueued(state.ocean),
  );

  const getCollateralValue = (collateralValue: string) => {
    if (routeParams.isPaybackDUSDUsingCollateral) {
      // In case of DUSD payment using collateral
      return new BigNumber(collateralValue).minus(
        new BigNumber(amountToPay).multipliedBy(loanTokenActivePriceInUSD),
      );
    }
    return new BigNumber(collateralValue);
  };
  // Resulting col ratio
  const resultingColRatio = useResultingCollateralRatio(
    getCollateralValue(vault?.collateralValue ?? NaN),
    new BigNumber(vault?.loanValue ?? NaN),
    BigNumber.min(
      new BigNumber(amountToPay).isNaN() ? "0" : amountToPay,
      loanTokenAmount.amount,
    ).multipliedBy(-1),
    new BigNumber(loanTokenActivePriceInUSD),
    interestPerBlock,
  );

  const navigateToConfirmScreen = async (): Promise<void> => {
    navigation.navigate({
      name: "ConfirmPaybackLoanScreen",
      params: {
        fee,
        vault,
        tokenBalance,
        loanTokenAmount,
        resultingColRatio,
        loanTokenActivePriceInUSD,
        amountToPay: new BigNumber(amountToPay),
        isPaybackDUSDUsingCollateral: routeParams.isPaybackDUSDUsingCollateral,
      },
      merge: true,
    });
  };

  const onChangeFromAmount = async (
    amount: string,
    type: AmountButtonTypes,
  ): Promise<void> => {
    const isMax = type === AmountButtonTypes.Max;
    const toastMessage = isMax
      ? "Max available {{unit}} entered"
      : "{{percent}} of available {{unit}} entered";
    setValue("amountToPay", amount);
    const toastOption = {
      unit: loanTokenAmount.displaySymbol,
      percent: type,
    };
    showToast(
      translate("screens/PaybackLoanScreen", toastMessage, toastOption),
    );
    await trigger("amountToPay");
  };

  const isContinueDisabled = routeParams.isPaybackDUSDUsingCollateral
    ? new BigNumber(amountToPay).lte(0)
    : !formState.isValid ||
      hasPendingJob ||
      hasPendingBroadcastJob ||
      !canUseOperations;

  return (
    <ThemedScrollViewV2 contentContainerStyle={tailwind("pb-8")}>
      <ThemedTextV2
        style={tailwind("mx-10 text-xs font-normal-v2 mt-8")}
        light={tailwind("text-mono-light-v2-500")}
        dark={tailwind("text-mono-dark-v2-500")}
        testID="payback_loan_title"
      >
        {translate(
          "screens/PaybackLoanScreen",
          routeParams.isPaybackDUSDUsingCollateral
            ? "I WANT TO PAY WITH DUSD COLLATERAL"
            : "I WANT TO PAY",
        )}
      </ThemedTextV2>

      <View style={tailwind("mx-5")}>
        <TransactionCard
          maxValue={tokenBalance}
          onChange={(amount, type) => {
            onChangeFromAmount(amount, type);
          }}
          componentStyle={{
            light: tailwind("bg-transparent"),
            dark: tailwind("bg-transparent"),
          }}
          containerStyle={{
            light: tailwind("bg-transparent"),
            dark: tailwind("bg-transparent"),
          }}
          amountButtonsStyle={{
            light: tailwind("bg-mono-light-v2-00"),
            dark: tailwind("bg-mono-dark-v2-00"),
            style: tailwind("mt-6 rounded-xl-v2"),
          }}
          disabled={new BigNumber(tokenBalance).isZero()}
          showAmountsBtn={!routeParams.isPaybackDUSDUsingCollateral}
        >
          <View
            style={tailwind(
              "flex flex-row justify-between items-center pl-5 mt-4",
            )}
          >
            <View style={tailwind("w-6/12 mr-2")}>
              <Controller
                control={control}
                defaultValue={
                  routeParams.isPaybackDUSDUsingCollateral
                    ? BigNumber.min(
                        collateralDUSDAmount,
                        loanTokenAmount.amount,
                      ).toFixed(8)
                    : ""
                }
                name="amountToPay"
                render={({ field: { onChange, value } }) => (
                  <>
                    {routeParams.isPaybackDUSDUsingCollateral ? (
                      <ThemedTextV2
                        style={tailwind("text-xl font-semibold-v2 w-full")}
                        light={tailwind("text-mono-light-v2-900")}
                        dark={tailwind("text-mono-dark-v2-900")}
                        testID="payback_input_text"
                      >
                        {value}
                      </ThemedTextV2>
                    ) : (
                      <ThemedTextInputV2
                        style={tailwind("text-xl font-semibold-v2 w-full")}
                        light={tailwind("text-mono-light-v2-900")}
                        dark={tailwind("text-mono-dark-v2-900")}
                        keyboardType="numeric"
                        value={value}
                        onBlur={async () => {
                          await onChange(value?.trim());
                        }}
                        onChangeText={async (amount) => {
                          amount = isNaN(+amount) ? "0" : amount;
                          setValue("amountToPay", amount);
                          await trigger("amountToPay");
                        }}
                        placeholder="0.00"
                        placeholderTextColor={getColor(
                          isLight ? "mono-light-v2-500" : "mono-dark-v2-500",
                        )}
                        testID="payback_input_text"
                        editable={amountToPay !== undefined}
                      />
                    )}
                  </>
                )}
                rules={{
                  required: true,
                  pattern: /^\d*\.?\d*$/,
                  validate: {
                    greaterThanZero: (value: string) =>
                      new BigNumber(
                        value !== undefined && value !== "" ? value : 0,
                      ).isGreaterThan(0),
                    notSufficientFunds: (value) =>
                      new BigNumber(tokenBalance).gte(value),
                  },
                }}
              />
              <ActiveUSDValueV2
                price={
                  new BigNumber(amountToPay).isNaN()
                    ? new BigNumber(0)
                    : new BigNumber(amountToPay).multipliedBy(
                        loanTokenActivePriceInUSD,
                      )
                }
                style={tailwind("text-sm")}
                testId="payback_input_value"
                containerStyle={tailwind("w-full break-words")}
              />
            </View>
            <TokenDropdownButton
              tokenId={loanTokenAmount.id}
              symbol={loanTokenAmount.displaySymbol}
              testID="loan_token_symbol"
              status={TokenDropdownButtonStatus.Locked}
            />
          </View>
        </TransactionCard>
        {!routeParams.isPaybackDUSDUsingCollateral && (
          <View style={tailwind("mt-2 mx-5")}>
            <NumberFormat
              displayType="text"
              renderText={(value) => (
                <ThemedTextV2
                  light={tailwind("text-mono-light-v2-500")}
                  dark={tailwind("text-mono-light-v2-500")}
                  style={tailwind("text-xs font-normal-v2")}
                  testID="available_token_balance"
                >
                  {value}
                </ThemedTextV2>
              )}
              prefix={translate("screens/PaybackLoanScreen", "Available: ")}
              suffix={` ${loanTokenAmount.displaySymbol}`}
              thousandSeparator
              value={tokenBalance.toFixed(8)}
            />
          </View>
        )}
        <TransactionDetailsSection
          outstandingBalance={loanTokenOutstandingBal}
          resultingColRatio={resultingColRatio}
          loanTokenAmount={loanTokenAmount}
          collateralValue={getCollateralValue(vault?.collateralValue ?? NaN)}
          vault={vault}
          amountToPay={
            new BigNumber(amountToPay).isNaN()
              ? new BigNumber(0)
              : new BigNumber(amountToPay)
          }
          isPaybackDUSDUsingCollateral={
            routeParams.isPaybackDUSDUsingCollateral
          }
          loanTokenActivePriceInUSD={loanTokenActivePriceInUSD}
          collateralDUSDAmount={collateralDUSDAmount}
        />
      </View>
      <View
        style={[
          tailwind("mx-5"),
          tailwind(isContinueDisabled ? "mt-16" : "mt-12"),
        ]}
      >
        {!isContinueDisabled && (
          <ThemedTextV2
            style={tailwind("text-xs font-normal-v2 text-center")}
            light={tailwind("text-mono-light-v2-500")}
            dark={tailwind("text-mono-dark-v2-500")}
            testID="continue_payback_loan_message"
          >
            {translate(
              "screens/PaybackLoanScreen",
              routeParams.isPaybackDUSDUsingCollateral
                ? "Use your DUSD collaterals to fully pay off your DUSD loan."
                : new BigNumber(loanTokenOutstandingBal).lt(amountToPay)
                ? "Any excess payment will be returned."
                : "Review full details in the next screen",
            )}
          </ThemedTextV2>
        )}
      </View>
      <SubmitButtonGroup
        isDisabled={isContinueDisabled}
        buttonStyle="mx-12 mt-5"
        label={translate("screens/PaybackLoanScreen", "Continue")}
        onSubmit={navigateToConfirmScreen}
        title="payback_loan_continue"
        displayCancelBtn={false}
      />
    </ThemedScrollViewV2>
  );
}

interface TransactionDetailsProps {
  outstandingBalance: BigNumber;
  resultingColRatio: BigNumber;
  vault: LoanVaultActive;
  loanTokenAmount: LoanVaultTokenAmount;
  amountToPay: BigNumber;
  loanTokenActivePriceInUSD: string;
  collateralDUSDAmount?: string;
  isPaybackDUSDUsingCollateral?: boolean;
  collateralValue: BigNumber;
}

function TransactionDetailsSection({
  outstandingBalance,
  resultingColRatio,
  loanTokenAmount,
  vault,
  amountToPay,
  loanTokenActivePriceInUSD,
  collateralDUSDAmount,
  isPaybackDUSDUsingCollateral,
  collateralValue,
}: TransactionDetailsProps): JSX.Element {
  const rowStyle = {
    containerStyle: {
      style: tailwind("flex-row items-start w-full bg-transparent mt-5"),
      light: tailwind("bg-transparent border-mono-light-v2-300"),
      dark: tailwind("bg-transparent border-mono-dark-v2-300"),
    },
    lhsThemedProps: {
      style: tailwind("text-sm font-normal-v2"),
      light: tailwind("text-mono-light-v2-500"),
      dark: tailwind("text-mono-dark-v2-500"),
    },
    rhsThemedProps: {
      style: tailwind("text-sm font-normal-v2"),
      light: tailwind("text-mono-light-v2-900"),
      dark: tailwind("text-mono-dark-v2-900"),
    },
  };
  const loanRemaining = BigNumber.max(
    new BigNumber(outstandingBalance).minus(amountToPay),
    0,
  );
  return (
    <ThemedViewV2
      light={tailwind("border-mono-light-v2-300")}
      dark={tailwind("border-mono-dark-v2-300")}
      style={tailwind("mt-6 px-5 pb-5 border-0.5 rounded-lg-v2")}
    >
      <TextRowV2
        containerStyle={rowStyle.containerStyle}
        lhs={{
          value: translate("screens/PaybackLoanScreen", "Vault ID"),
          testID: "lhs_vault_id",
          themedProps: rowStyle.lhsThemedProps,
        }}
        rhs={{
          value: vault.vaultId,
          testID: "text_vault_id",
          numberOfLines: 1,
          ellipsizeMode: "middle",
          themedProps: rowStyle.rhsThemedProps,
        }}
      />
      <NumberRowV2
        containerStyle={rowStyle.containerStyle}
        lhs={{
          value: translate("screens/PaybackLoanScreen", "Loan remaining"),
          testID: "total_outstanding_loan_label",
          themedProps: rowStyle.lhsThemedProps,
        }}
        rhs={{
          value: loanRemaining.toFixed(8),
          testID: "total_outstanding_loan_value",
          suffix: ` ${loanTokenAmount.displaySymbol}`,
          usdAmount: new BigNumber(loanRemaining).isNaN()
            ? new BigNumber(0)
            : new BigNumber(loanRemaining).multipliedBy(
                loanTokenActivePriceInUSD,
              ),
          themedProps: rowStyle.rhsThemedProps,
        }}
      />

      {isPaybackDUSDUsingCollateral && (
        <NumberRowV2
          containerStyle={rowStyle.containerStyle}
          lhs={{
            value: translate(
              "screens/PaybackLoanScreen",
              "Resulting collateral",
            ),
            testID: "resulting_collateral_amount_label",
            themedProps: rowStyle.lhsThemedProps,
          }}
          rhs={{
            value: new BigNumber(collateralDUSDAmount ?? 0)
              .minus(amountToPay)
              .toFixed(8),
            testID: "resulting_collateral_amount",
            suffix: ` ${loanTokenAmount.displaySymbol}`,
            usdAmount: new BigNumber(
              new BigNumber(collateralDUSDAmount ?? 0).minus(amountToPay),
            ).multipliedBy(loanTokenActivePriceInUSD),
            themedProps: rowStyle.rhsThemedProps,
          }}
        />
      )}

      <CollateralizationRatioDisplay
        collateralizationRatio={resultingColRatio.toFixed(8)}
        minCollateralizationRatio={vault.loanScheme.minColRatio}
        collateralValue={collateralValue.toFixed(8)}
        totalLoanAmount={vault.loanValue}
        testID="text_resulting_col_ratio"
        showProgressBar
      />
    </ThemedViewV2>
  );
}