DeFiCh/wallet

View on GitHub
mobile-app/app/screens/AppNavigator/screens/Dex/DexAddLiquidity.tsx

Summary

Maintainability
F
4 days
Test Coverage
import { PoolPairData } from "@defichain/whale-api-client/dist/api/poolpairs";
import { NavigationProp, useNavigation } from "@react-navigation/native";
import { StackScreenProps } from "@react-navigation/stack";
import BigNumber from "bignumber.js";
import { useCallback, useEffect, useMemo, useState } from "react";
import { View, Platform } from "react-native";
import {
  ThemedIcon,
  ThemedScrollViewV2,
  ThemedTextV2,
  ThemedTouchableOpacityV2,
} from "@components/themed";
import { tailwind } from "@tailwind";
import { translate } from "@translations";
import {
  DFITokenSelector,
  DFIUtxoSelector,
  tokensSelector,
  WalletToken,
  hasTxQueued,
  hasOceanTXQueued,
} from "@waveshq/walletkit-ui/dist/store";
import { useSelector } from "react-redux";
import { RootState } from "@store";
import {
  queueConvertTransaction,
  useConversion,
} from "@hooks/wallet/Conversion";
import { useLogger } from "@shared-contexts/NativeLoggingProvider";
import { useAppDispatch } from "@hooks/useAppDispatch";
import {
  AmountButtonTypes,
  TransactionCardStatus,
} from "@components/TransactionCard";
import {
  BottomSheetWebWithNavV2,
  BottomSheetWithNavV2,
} from "@components/BottomSheetWithNavV2";
import { useThemeContext } from "@waveshq/walletkit-ui";
import { ButtonV2 } from "@components/ButtonV2";
import { useToast } from "react-native-toast-notifications";
import { useBottomSheet } from "@hooks/useBottomSheet";
import { useDisplayUtxoWarning } from "@hooks/wallet/DisplayUtxoWarning";
import { ConvertDirection } from "@screens/enum";
import { ViewPoolHeader } from "./components/ViewPoolHeader";
import { ViewPoolDetails, DataRoutes } from "./components/ViewPoolDetails";
import { LiquidityCalculationSummary } from "./components/LiquidityCalculationSummary";
import { AddLiquidityInputCard } from "./components/AddLiquidityInputCard";
import { useTokenPrice } from "../Portfolio/hooks/TokenPrice";
import { DexParamList } from "./DexNavigator";

type Props = StackScreenProps<DexParamList, "AddLiquidity">;
type EditingAmount = "primary" | "secondary";

interface ExtPoolPairData extends PoolPairData {
  aSymbol: string;
  bSymbol: string;
  aToBRate: BigNumber;
  bToARate: BigNumber;
}

export function AddLiquidityScreen(props: Props): JSX.Element {
  const logger = useLogger();
  const navigation = useNavigation<NavigationProp<DexParamList>>();
  const dispatch = useAppDispatch();
  const DFIToken = useSelector((state: RootState) =>
    DFITokenSelector(state.wallet),
  );
  const DFIUtxo = useSelector((state: RootState) =>
    DFIUtxoSelector(state.wallet),
  );
  const hasPendingJob = useSelector((state: RootState) =>
    hasTxQueued(state.transactionQueue),
  );
  const hasPendingBroadcastJob = useSelector((state: RootState) =>
    hasOceanTXQueued(state.ocean),
  );
  const pairs = useSelector((state: RootState) => state.wallet.poolpairs);
  const tokens = useSelector((state: RootState) =>
    tokensSelector(state.wallet),
  );
  const { getTokenPrice } = useTokenPrice();
  const { pair: pairData, pairInfo, originScreen } = props.route.params;

  // breakdown summary state
  const [hasAInputAmount, setHasAInputAmount] = useState(false);
  const [hasBInputAmount, setHasBInputAmount] = useState(false);

  // transaction card component
  const [tokenATransactionCardStatus, setTokenATransactionCardStatus] =
    useState<TransactionCardStatus>();
  const [tokenBTransactionCardStatus, setTokenBTransactionCardStatus] =
    useState<TransactionCardStatus>();
  const [hasAError, setHasAError] = useState(false);
  const [hasBError, setHasBError] = useState(false);
  const [isInputAFocus, setIsInputAFocus] = useState(false);
  const [isInputBFocus, setIsInputBFocus] = useState(false);

  const { isLight } = useThemeContext();
  const modalSortingSnapPoints = { ios: ["50%"], android: ["50%"] };

  const { getDisplayUtxoWarningStatus } = useDisplayUtxoWarning();
  const [showUTXOFeesAMsg, setShowUTXOFeesAMsg] = useState<boolean>(false);
  const [showUTXOFeesBMsg, setShowUTXOFeesBMsg] = useState<boolean>(false);

  // this component UI state
  const [tokenAAmount, setTokenAAmount] = useState<string>("");
  const [tokenBAmount, setTokenBAmount] = useState<string>("");
  const [sharePercentage, setSharePercentage] = useState<BigNumber>(
    new BigNumber(0),
  );
  const [canContinue, setCanContinue] = useState(false);

  // derived from props
  const [balanceA, setBalanceA] = useState(new BigNumber(0));
  const [balanceB, setBalanceB] = useState(new BigNumber(0));
  const [pair, setPair] = useState<ExtPoolPairData>();
  const { isConversionRequired, conversionAmount } = useConversion({
    inputToken: {
      type: "token",
      amount: new BigNumber(
        pair?.tokenA.id === "0" ? tokenAAmount : tokenBAmount,
      ),
    },
    deps: [pair, tokenAAmount, tokenBAmount, balanceA, balanceB],
  });

  const toast = useToast();
  const TOAST_DURATION = 2000;
  const {
    bottomSheetRef,
    containerRef,
    expandModal,
    dismissModal,
    isModalDisplayed,
  } = useBottomSheet();

  const BottomSheetHeader = {
    headerStatusBarHeight: 2,
    headerTitle: "",
    headerBackTitleVisible: false,
    headerStyle: tailwind("rounded-t-xl-v2 border-b-0", {
      "bg-mono-light-v2-100": isLight,
      "bg-mono-dark-v2-100": !isLight,
    }),
    headerRight: (): JSX.Element => {
      return (
        <ThemedTouchableOpacityV2
          style={tailwind("mr-5 mt-4 -mb-4")}
          onPress={dismissModal}
          testID="close_bottom_sheet_button"
        >
          <ThemedIcon iconType="Feather" name="x-circle" size={22} />
        </ThemedTouchableOpacityV2>
      );
    },
    headerLeft: () => <></>,
  };

  const buildSummary = useCallback(
    (ref: EditingAmount, amountString: string): void => {
      const refAmount =
        amountString.length === 0 || isNaN(+amountString)
          ? new BigNumber(0)
          : new BigNumber(amountString);
      if (pair === undefined) {
        return;
      }
      if (ref === "primary") {
        setTokenAAmount(amountString);
        setTokenBAmount(refAmount.times(pair.aToBRate).toFixed(8));
        setSharePercentage(refAmount.div(pair.tokenA.reserve));
      } else {
        setTokenBAmount(amountString);
        setTokenAAmount(refAmount.times(pair.bToARate).toFixed(8));
        setSharePercentage(refAmount.div(pair.tokenB.reserve));
      }
    },
    [pair],
  );

  const getAddressTokenById = (
    poolpairTokenId: string,
  ): WalletToken | undefined => {
    return tokens.find((token) => {
      if (poolpairTokenId === "0" || poolpairTokenId === "0_utxo") {
        return token.id === "0_unified";
      }
      return token.id === poolpairTokenId;
    });
  };

  const ViewPoolContents = useMemo(() => {
    return [
      {
        stackScreenName: "ViewPoolShare",
        component: ViewPoolDetails({
          dataRoutes: DataRoutes.AddLiquidity,
          pairData: pairData,
          pairInfo: pairInfo,
        }),
        option: BottomSheetHeader,
      },
    ];
  }, [isLight]);

  function onPercentagePress(
    _amount: string,
    type: AmountButtonTypes,
    displaySymbolA: string,
    displaySymbolB: string,
  ): void {
    showToast(type, displaySymbolA, displaySymbolB);
  }

  async function onSubmit(): Promise<void> {
    if (hasPendingJob || hasPendingBroadcastJob) {
      return;
    }

    if (!canContinue || pair === undefined) {
      return;
    }

    if (isConversionRequired) {
      queueConvertTransaction(
        {
          mode: ConvertDirection.utxosToAccount,
          amount: conversionAmount,
        },
        dispatch,
        () => {
          // onbroadcast starts = called
          navigation.navigate({
            name: "ConfirmAddLiquidity",
            params: {
              summary: {
                fee: new BigNumber(0.0001),
                tokenAAmount: new BigNumber(tokenAAmount),
                tokenBAmount: new BigNumber(tokenBAmount),
                percentage: sharePercentage,
                tokenABalance: balanceA,
                tokenBBalance: balanceB,
                lmTotalTokens: lmTotalTokens,
              },
              pair,
              conversion: {
                isConversionRequired,
                DFIToken,
                DFIUtxo,
                conversionAmount,
              },
              pairInfo,
              originScreen: originScreen,
            },
            merge: true,
          });
        },
        logger,
        () => {
          navigation.navigate({
            name: "ConfirmAddLiquidity",
            params: {
              summary: {
                fee: new BigNumber(0.0001),
                tokenAAmount: new BigNumber(tokenAAmount),
                tokenBAmount: new BigNumber(tokenBAmount),
                percentage: sharePercentage,
                tokenABalance: balanceA,
                tokenBBalance: balanceB,
                lmTotalTokens: lmTotalTokens,
              },
              pair,
              conversion: {
                isConversionRequired,
                DFIToken,
                DFIUtxo,
                conversionAmount,
                isConverted: true, // to pass loading component for tokens conversion in confirm screen
              },
              pairInfo,
              originScreen: originScreen,
            },
            merge: true,
          });
        },
      );
    } else {
      navigation.navigate({
        name: "ConfirmAddLiquidity",
        params: {
          summary: {
            fee: new BigNumber(0.0001),
            tokenAAmount: new BigNumber(tokenAAmount),
            tokenBAmount: new BigNumber(tokenBAmount),
            percentage: sharePercentage,
            tokenABalance: balanceA,
            tokenBBalance: balanceB,
            lmTotalTokens: lmTotalTokens,
          },
          pair,
          pairInfo,
          originScreen: originScreen,
        },
        merge: true,
      });
    }
  }

  function showToast(
    type: AmountButtonTypes,
    displaySymbolA: string,
    displaySymbolB: string,
  ): void {
    if (displaySymbolA === undefined || displaySymbolB === undefined) {
      return;
    }

    toast.hideAll(); // hides old toast everytime user clicks on a new percentage
    const isMax = type === AmountButtonTypes.Max;
    const toastMessage = isMax
      ? "Max available {{unit}} entered"
      : "{{percent}} of available {{unit}} entered";
    const toastOption = {
      unit: `${displaySymbolA}-${displaySymbolB}`,
      percent: type,
    };
    toast.show(translate("screens/AddLiquidity", toastMessage, toastOption), {
      type: "wallet_toast",
      placement: "top",
      duration: TOAST_DURATION,
    });
  }

  // handle breadkown summary state
  useEffect(() => {
    if (new BigNumber(tokenAAmount).isGreaterThan(0)) {
      setHasAInputAmount(true);
    } else {
      setHasAInputAmount(false);
    }
  }, [tokenAAmount]);

  useEffect(() => {
    if (new BigNumber(tokenBAmount).isGreaterThan(0)) {
      setHasBInputAmount(true);
    } else {
      setHasBInputAmount(false);
    }
  }, [tokenBAmount]);

  // display UTXO fees msg only for DFI tokens in input card
  useEffect(() => {
    if (
      pair !== undefined &&
      getDisplayUtxoWarningStatus(
        new BigNumber(tokenAAmount),
        pair?.tokenA.displaySymbol,
      ) &&
      new BigNumber(tokenAAmount).isGreaterThan(0)
    ) {
      return setShowUTXOFeesAMsg(true);
    } else {
      return setShowUTXOFeesAMsg(false);
    }
  }, [tokenAAmount]);

  useEffect(() => {
    if (
      pair !== undefined &&
      getDisplayUtxoWarningStatus(
        new BigNumber(tokenBAmount),
        pair?.tokenB.displaySymbol,
      ) &&
      new BigNumber(tokenBAmount).isGreaterThan(0)
    ) {
      return setShowUTXOFeesBMsg(true);
    } else {
      return setShowUTXOFeesBMsg(false);
    }
  }, [tokenBAmount]);

  // display err msg for insufficient balance
  useEffect(() => {
    if (new BigNumber(tokenAAmount).isGreaterThan(balanceA)) {
      setHasAError(true);
    } else {
      setHasAError(false);
    }
  }, [tokenAAmount, balanceA]);

  useEffect(() => {
    if (new BigNumber(tokenBAmount).isGreaterThan(balanceB)) {
      setHasBError(true);
    } else {
      setHasBError(false);
    }
  }, [tokenBAmount, balanceB]);

  // set focus on current transaction card
  useEffect(() => {
    setTokenATransactionCardStatus(
      hasAError
        ? TransactionCardStatus.Error
        : isInputAFocus
        ? TransactionCardStatus.Active
        : undefined,
    );
    setTokenBTransactionCardStatus(
      hasBError
        ? TransactionCardStatus.Error
        : isInputBFocus
        ? TransactionCardStatus.Active
        : undefined,
    );
  }, [hasAError, hasBError, isInputAFocus, isInputBFocus]);

  useEffect(() => {
    if (pair === undefined) {
      return;
    }
    setCanContinue(
      canAddLiquidity(
        pair,
        new BigNumber(tokenAAmount),
        new BigNumber(tokenBAmount),
        balanceA,
        balanceB,
      ),
    );
  }, [pair, tokenAAmount, tokenBAmount, balanceA, balanceB]);

  // prop/global state change
  useEffect(() => {
    const { pair: poolPairData } = props.route.params;
    const poolpair = pairs.find((p) => p.data.id === poolPairData.id)?.data;
    const reservedDfi = 0.1;
    if (poolpair !== undefined) {
      const [aSymbol, bSymbol] = poolpair.symbol.split("-");
      const addressTokenA = getAddressTokenById(poolpair.tokenA.id);
      const addressTokenB = getAddressTokenById(poolpair.tokenB.id);

      // side effect to state
      setPair({
        ...poolpair,
        aSymbol,
        bSymbol,
        aToBRate: new BigNumber(poolpair.tokenB.reserve).div(
          poolpair.tokenA.reserve,
        ),
        bToARate: new BigNumber(poolpair.tokenA.reserve).div(
          poolpair.tokenB.reserve,
        ),
      });
      if (addressTokenA !== undefined) {
        setBalanceA(
          addressTokenA.id === "0_unified"
            ? BigNumber.max(
                new BigNumber(addressTokenA.amount).minus(reservedDfi),
                0,
              )
            : new BigNumber(addressTokenA.amount),
        );
      }
      if (addressTokenB !== undefined) {
        setBalanceB(
          addressTokenB.id === "0_unified"
            ? BigNumber.max(
                new BigNumber(addressTokenB.amount).minus(reservedDfi),
                0,
              )
            : new BigNumber(addressTokenB.amount),
        );
      }
    }
  }, [props.route.params.pair, JSON.stringify(tokens), pairs]);

  if (pair === undefined) {
    return <></>;
  }

  const lmTotalTokens = sharePercentage
    .times(pair.totalLiquidity.token)
    .toFixed(8);

  return (
    <View ref={containerRef} style={tailwind("flex-col flex-1")}>
      <ThemedScrollViewV2
        ref={containerRef}
        contentContainerStyle={tailwind("flex-grow py-8 mx-5 justify-between")}
        style={tailwind("w-full")}
      >
        <View>
          <ViewPoolHeader
            tokenASymbol={pair.tokenA.displaySymbol}
            tokenBSymbol={pair.tokenB.displaySymbol}
            headerLabel={translate("screens/AddLiquidity", "View pool info")}
            onPress={expandModal}
            testID="view_pool_shares"
          />
          <View style={tailwind("mt-8")}>
            <ThemedTextV2
              light={tailwind("text-mono-light-v2-500")}
              dark={tailwind("text-mono-dark-v2-500")}
              style={tailwind("pl-5 pb-2 text-xs font-normal-v2")}
            >
              {translate("screens/AddLiquidity", "I WANT TO ADD")}
            </ThemedTextV2>
            <AddLiquidityInputCard
              balance={balanceA}
              current={tokenAAmount}
              onChange={(amount) => buildSummary("primary", amount)}
              onPercentageChange={(amount, type) => {
                onPercentagePress(
                  amount,
                  type,
                  pair.tokenA.displaySymbol,
                  pair.tokenB.displaySymbol,
                );
              }}
              symbol={pair.tokenA.displaySymbol}
              type="primary"
              setIsInputFocus={setIsInputAFocus}
              status={tokenATransactionCardStatus}
              showInsufficientTokenMsg={hasAError}
              showUTXOFeesMsg={showUTXOFeesAMsg}
              hasInputAmount={hasAInputAmount}
            />
            <AddLiquidityInputCard
              balance={balanceB}
              current={tokenBAmount}
              onChange={(amount) => {
                buildSummary("secondary", amount);
              }}
              onPercentageChange={(amount, type) => {
                buildSummary("secondary", amount);
                onPercentagePress(
                  amount,
                  type,
                  pair.tokenA.displaySymbol,
                  pair.tokenB.displaySymbol,
                );
              }}
              symbol={pair.tokenB.displaySymbol}
              type="secondary"
              setIsInputFocus={setIsInputBFocus}
              status={tokenBTransactionCardStatus}
              showInsufficientTokenMsg={hasBError}
              showUTXOFeesMsg={showUTXOFeesBMsg}
              hasInputAmount={hasBInputAmount}
            />
          </View>

          {hasAInputAmount && hasBInputAmount && (
            <>
              <LiquidityCalculationSummary
                containerStyle={tailwind("pt-5 px-5 border rounded-lg-v2")}
                priceRatesOption={[
                  {
                    label: `1 ${pair.tokenA.displaySymbol} =`,
                    value: pair.aToBRate.toFixed(8),
                    aSymbol: pair.tokenA.displaySymbol,
                    bSymbol: pair.tokenB.displaySymbol,
                    symbolUSDValue: getTokenPrice(pair.bSymbol, pair.aToBRate),
                    usdTextStyle: tailwind("text-sm"),
                  },
                  {
                    label: `1 ${pair.tokenB.displaySymbol} =`,
                    value: pair.bToARate.toFixed(8),
                    aSymbol: pair.tokenB.displaySymbol,
                    bSymbol: pair.tokenA.displaySymbol,
                    symbolUSDValue: getTokenPrice(pair.aSymbol, pair.bToARate),
                    usdTextStyle: tailwind("text-sm"),
                  },
                ]}
                resultingLplhs={{
                  value: translate(
                    "screens/AddLiquidity",
                    "LP Tokens to receive",
                  ),
                  testID: "lp_tokens_to_receive",
                  themedProps: {
                    light: tailwind("text-mono-light-v2-500"),
                    dark: tailwind("text-mono-dark-v2-500"),
                  },
                }}
                resultingLprhs={{
                  value: sharePercentage
                    .times(pair.totalLiquidity.token)
                    .toFixed(8),
                  testID: "lp_tokens_to_receive_value",
                  usdAmount: getTokenPrice(
                    pair.aSymbol,
                    new BigNumber(tokenAAmount),
                  ).plus(
                    getTokenPrice(pair.bSymbol, new BigNumber(tokenBAmount)),
                  ),
                  themedProps: {
                    style: tailwind("font-semibold-v2 text-sm"),
                  },
                  usdTextStyle: tailwind("text-sm"),
                }}
              />
              <View style={tailwind("items-center pt-12 px-5")}>
                <ThemedTextV2
                  testID="transaction_details_hint_text"
                  light={tailwind("text-mono-light-v2-500")}
                  dark={tailwind("text-mono-dark-v2-500")}
                  style={tailwind("text-xs font-normal-v2 text-center")}
                >
                  {isConversionRequired
                    ? translate(
                        "screens/AddLiquidity",
                        "By continuing, the required amount of DFI will be converted",
                      )
                    : translate(
                        "screens/AddLiquidity",
                        "Review full details in the next screen",
                      )}
                </ThemedTextV2>
              </View>
            </>
          )}
        </View>

        <View style={tailwind("mt-5 mx-7")}>
          <ButtonV2
            fillType="fill"
            label={translate("components/Button", "Continue")}
            styleProps="w-full"
            disabled={!canContinue}
            onPress={onSubmit}
            testID="button_continue_add_liq"
          />
        </View>

        {Platform.OS === "web" ? (
          <BottomSheetWebWithNavV2
            modalRef={containerRef}
            screenList={ViewPoolContents}
            isModalDisplayed={isModalDisplayed}
            // eslint-disable-next-line react-native/no-inline-styles
            modalStyle={{
              position: "absolute",
              bottom: "0",
              height: "404px",
              width: "375px",
              zIndex: 50,
              borderTopLeftRadius: 15,
              borderTopRightRadius: 15,
              overflow: "hidden",
            }}
          />
        ) : (
          <BottomSheetWithNavV2
            modalRef={bottomSheetRef}
            screenList={ViewPoolContents}
            snapPoints={modalSortingSnapPoints}
          />
        )}
      </ThemedScrollViewV2>
    </View>
  );
}

// just leave it as it is now, will be moved to network drawer
function canAddLiquidity(
  pair: ExtPoolPairData,
  tokenAAmount: BigNumber,
  tokenBAmount: BigNumber,
  balanceA: BigNumber | undefined,
  balanceB: BigNumber | undefined,
): boolean {
  if (tokenAAmount.isNaN() || tokenBAmount.isNaN()) {
    // empty string, use still input-ing
    return false;
  }

  if (tokenAAmount.lte(0) || tokenBAmount.lte(0)) {
    return false;
  }

  if (
    tokenAAmount.gt(pair.tokenA.reserve) ||
    tokenBAmount.gt(pair.tokenB.reserve)
  ) {
    return false;
  }

  return !(
    balanceA === undefined ||
    balanceA.lt(tokenAAmount) ||
    balanceB === undefined ||
    balanceB.lt(tokenBAmount)
  );
}