mobile-app/app/screens/AppNavigator/screens/Portfolio/screens/ConvertScreen.tsx
import { NavigationProp, useNavigation } from "@react-navigation/native";
import { StackScreenProps } from "@react-navigation/stack";
import BigNumber from "bignumber.js";
import React, { useEffect, useState } from "react";
import { useThemeContext } from "@waveshq/walletkit-ui";
import { useSelector } from "react-redux";
import { View } from "@components";
import {
ThemedIcon,
ThemedScrollViewV2,
ThemedTextInputV2,
ThemedTextV2,
ThemedTouchableOpacityV2,
ThemedViewV2,
} from "@components/themed";
import { useWhaleApiClient } from "@waveshq/walletkit-ui/dist/contexts";
import { RootState } from "@store";
import {
hasOceanTXQueued,
hasTxQueued,
tokensSelector,
} from "@waveshq/walletkit-ui/dist/store";
import { getColor, tailwind } from "@tailwind";
import { translate } from "@translations";
import { useLogger } from "@shared-contexts/NativeLoggingProvider";
import { ButtonV2 } from "@components/ButtonV2";
import {
AmountButtonTypes,
TransactionCard,
} from "@components/TransactionCard";
import { useToast } from "react-native-toast-notifications";
import { NumericFormat as NumberFormat } from "react-number-format";
import { getNumberFormatValue } from "@api/number-format-value";
import { ConvertDirection } from "@screens/enum";
import {
TokenDropdownButton,
TokenDropdownButtonStatus,
} from "@components/TokenDropdownButton";
import { DomainType, useDomainContext } from "@contexts/DomainContext";
import { getNativeIcon } from "@components/icons/assets";
import { EVMLinearGradient } from "@components/EVMLinearGradient";
import { PortfolioParamList } from "../PortfolioNavigator";
import {
SelectionToken,
TokenListType,
} from "../../Dex/CompositeSwap/SwapTokenSelectionScreen";
import { useTokenPrice } from "../hooks/TokenPrice";
import { DomainToken, useTokenBalance } from "../hooks/TokenBalance";
type Props = StackScreenProps<PortfolioParamList, "ConvertScreen">;
enum InlineTextStatus {
Default,
Warning,
Error,
}
export function ConvertScreen(props: Props): JSX.Element {
const { getTokenPrice } = useTokenPrice();
const { isLight } = useThemeContext();
const { domain, isEvmFeatureEnabled } = useDomainContext();
const isEvmDomain = domain === DomainType.EVM;
const client = useWhaleApiClient();
const logger = useLogger();
const tokens = useSelector((state: RootState) =>
tokensSelector(state.wallet),
);
const toast = useToast();
const TOAST_DURATION = 2000;
// global state
const hasPendingJob = useSelector((state: RootState) =>
hasTxQueued(state.transactionQueue),
);
const hasPendingBroadcastJob = useSelector((state: RootState) =>
hasOceanTXQueued(state.ocean),
);
const navigation = useNavigation<NavigationProp<PortfolioParamList>>();
const [convertDirection, setConvertDirection] = useState(
props.route.params.convertDirection,
);
const [sourceToken, setSourceToken] = useState<DomainToken>(
props.route.params.sourceToken,
);
const [targetToken, setTargetToken] = useState<DomainToken | undefined>(
props.route.params.targetToken,
);
const [convAmount, setConvAmount] = useState<string>("0");
const [fee, setFee] = useState<BigNumber>(new BigNumber(0.0001));
const [amount, setAmount] = useState<string>("");
const [inlineTextStatus, setInlineTextStatus] = useState<InlineTextStatus>(
InlineTextStatus.Default,
);
const { dvmTokens, evmTokens } = useTokenBalance();
useEffect(() => {
client.fee
.estimate()
.then((f) => setFee(new BigNumber(f)))
.catch(logger.error);
}, []);
useEffect(() => {
const conversionNum = new BigNumber(amount).isNaN()
? new BigNumber(0)
: new BigNumber(amount);
const conversion = conversionNum.toString();
setConvAmount(conversion);
if (conversionNum.gt(sourceToken.available)) {
setInlineTextStatus(InlineTextStatus.Error);
} else if (
convertDirection === ConvertDirection.utxosToAccount &&
!sourceToken.available.isZero() &&
conversionNum.toFixed(8) === sourceToken.available.toFixed(8)
) {
setInlineTextStatus(InlineTextStatus.Warning);
} else {
setInlineTextStatus(InlineTextStatus.Default);
}
}, [convertDirection, JSON.stringify(tokens), amount]);
useEffect(() => {
let updatedConvertDirection: ConvertDirection = convertDirection;
if (sourceToken.tokenId === "0_utxo" && targetToken?.tokenId === "0") {
updatedConvertDirection = ConvertDirection.utxosToAccount;
} else if (
sourceToken.tokenId === "0" &&
targetToken?.tokenId === "0_utxo"
) {
updatedConvertDirection = ConvertDirection.accountToUtxos;
} else if (
sourceToken.token.domainType === DomainType.DVM &&
targetToken?.token.domainType === DomainType.EVM
) {
updatedConvertDirection = ConvertDirection.dvmToEvm;
} else if (
sourceToken.token.domainType === DomainType.EVM &&
targetToken?.token.domainType === DomainType.DVM
) {
updatedConvertDirection = ConvertDirection.evmToDvm;
}
setConvertDirection(updatedConvertDirection);
}, [sourceToken, targetToken]);
if (sourceToken === undefined) {
return <></>;
}
function convert(sourceToken: DomainToken, targetToken?: DomainToken): void {
if (hasPendingJob || hasPendingBroadcastJob || targetToken === undefined) {
return;
}
navigation.navigate({
name: "ConvertConfirmationScreen",
params: {
amount: new BigNumber(amount),
convertDirection,
fee,
sourceToken: {
tokenId: sourceToken.tokenId,
displaySymbol: sourceToken.token.displaySymbol,
balance: BigNumber.maximum(
new BigNumber(sourceToken.available).minus(convAmount),
0,
),
displayTextSymbol: sourceToken.token.displayTextSymbol,
},
targetToken: {
tokenId: targetToken.tokenId,
displaySymbol: targetToken.token.displaySymbol,
balance: BigNumber.maximum(
new BigNumber(targetToken.available).plus(convAmount),
0,
),
displayTextSymbol: targetToken.token.displayTextSymbol,
},
},
merge: true,
});
}
function onPercentagePress(amount: string, type: AmountButtonTypes): void {
setAmount(amount);
showToast(type);
}
function showToast(type: AmountButtonTypes): void {
if (sourceToken === undefined) {
return;
}
toast.hideAll();
const isMax = type === AmountButtonTypes.Max;
const toastMessage = isMax
? "Max available {{unit}} entered"
: "{{percent}} of available {{unit}} entered";
const toastOption = {
unit: translate(
"screens/ConvertScreen",
`${sourceToken.token.displayTextSymbol}${isEvmDomain ? " (EVM)" : ""}`,
),
percent: type,
};
toast.show(translate("screens/ConvertScreen", toastMessage, toastOption), {
type: "wallet_toast",
placement: "top",
duration: TOAST_DURATION,
});
}
function onTogglePress(): void {
if (!targetToken || !sourceToken) {
return;
}
setSourceToken(targetToken);
setTargetToken(sourceToken);
setAmount("");
}
function getListByDomain(listType: TokenListType): DomainToken[] {
if (listType === TokenListType.To) {
const evmDFIToken = evmTokens.find(({ tokenId }) => tokenId === "0_evm");
const defaultEvmTargetToken = {
tokenId: `${sourceToken.tokenId}_evm`,
available: new BigNumber(evmDFIToken?.available ?? 0),
token: {
...sourceToken.token,
displaySymbol: "DFI",
displayTextSymbol: "DFI",
name: `${sourceToken.token.name} for EVM`,
domainType: DomainType.EVM,
},
};
if (domain === DomainType.DVM) {
if (sourceToken.tokenId === "0") {
return isEvmFeatureEnabled
? [
defaultEvmTargetToken,
...dvmTokens.filter((token) => token.tokenId === "0_utxo"),
]
: dvmTokens.filter((token) => token.tokenId === "0_utxo");
} else if (sourceToken.tokenId === "0_utxo") {
return dvmTokens.filter((token) => token.tokenId === "0");
} else {
return isEvmFeatureEnabled ? [defaultEvmTargetToken] : [];
}
} else if (isEvmDomain && sourceToken.tokenId === "0_evm") {
return isEvmFeatureEnabled ? [defaultEvmTargetToken] : [];
}
}
return domain === DomainType.DVM ? dvmTokens : evmTokens;
}
function onTokenSelect(item: SelectionToken, listType: TokenListType): void {
let updatedConvertDirection = convertDirection;
if (
sourceToken.tokenId === "0" &&
listType === TokenListType.To &&
item.tokenId === "0_utxo"
) {
// If from:DFI-DVM -> to: accountToUtxos
updatedConvertDirection = ConvertDirection.accountToUtxos;
} else if (
sourceToken.tokenId === "0_utxo" &&
listType === TokenListType.To &&
item.tokenId === "0"
) {
// If from:DFI-UTXO -> to: utxosToAccount
updatedConvertDirection = ConvertDirection.utxosToAccount;
} else if (
sourceToken.tokenId === "0" &&
listType === TokenListType.To &&
item.tokenId === "0_evm"
) {
updatedConvertDirection = ConvertDirection.dvmToEvm;
}
let updatedTargetToken: SelectionToken | undefined;
const defaultTargetToken = {
tokenId:
domain === DomainType.DVM
? `${item.tokenId}_evm`
: item.tokenId.replace("_evm", ""),
available: new BigNumber(0),
token: {
...item.token,
name:
domain === DomainType.DVM
? `${item.token.name} for EVM`
: item.token.name,
domainType: DomainType.EVM,
},
};
if (listType === TokenListType.From) {
/* Move to a hook since it's used in portfolio page and convert screen */
if (domain === DomainType.DVM && item.tokenId === "0_utxo") {
// If DFI UTXO -> choose DFI Token
updatedTargetToken =
dvmTokens.find((token) => token.tokenId === "0") ??
defaultTargetToken;
} else if (domain === DomainType.DVM && item.tokenId === "0") {
// If DFI Token -> no default
updatedTargetToken = undefined;
} else if (isEvmDomain) {
// If EVM -> choose DVM equivalent
updatedTargetToken =
dvmTokens.find(
(token) => token.tokenId === item.tokenId.replace("_evm", ""),
) ?? defaultTargetToken;
} else if (domain === DomainType.DVM) {
// If DVM -> choose EVM equivalent
updatedTargetToken =
evmTokens.find((token) => token.tokenId === `${item.tokenId}_evm`) ??
defaultTargetToken;
}
/* End of what will be moved into a hook */
} else {
updatedTargetToken = item;
}
navigation.navigate({
name: "ConvertScreen",
params: {
sourceToken: listType === TokenListType.From ? item : sourceToken,
targetToken: updatedTargetToken,
convertDirection: updatedConvertDirection,
},
key: updatedTargetToken?.tokenId,
merge: true,
});
}
function navigateToTokenSelectionScreen(listType: TokenListType): void {
navigation.navigate("SwapTokenSelectionScreen", {
fromToken: {
symbol: sourceToken.token.symbol,
displaySymbol: sourceToken.token.displaySymbol,
},
listType: listType,
list: getListByDomain(listType),
onTokenPress: (item) => {
onTokenSelect(item, listType);
},
isFutureSwap: false,
isConvert: true,
isSearchDTokensOnly: false,
});
}
return (
<ThemedScrollViewV2 testID="convert_screen">
<ThemedTextV2
style={tailwind(
"mx-10 text-xs font-normal-v2 mt-8 mb-4 tracking-wide-v2",
)}
light={tailwind("text-mono-light-v2-500")}
dark={tailwind("text-mono-dark-v2-500")}
testID="source_balance"
>
{translate(
"screens/CompositeSwapScreen",
"I HAVE {{totalAmount}} {{token}}",
{
totalAmount:
sourceToken != null
? BigNumber(sourceToken.available).toFixed(8)
: "",
token:
convertDirection === ConvertDirection.evmToDvm
? `${sourceToken.token.displayTextSymbol} (EVM)`
: sourceToken.token.displayTextSymbol,
},
)}
</ThemedTextV2>
<View style={tailwind("mx-5")}>
<TransactionCard
maxValue={sourceToken.available}
onChange={onPercentagePress}
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"),
}}
>
<View
style={tailwind("flex flex-row justify-between items-center pl-5")}
>
<View style={tailwind("w-6/12 mr-2")}>
<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={amount}
onChangeText={setAmount}
placeholder="0.00"
placeholderTextColor={getColor(
isLight ? "mono-light-v2-900" : "mono-dark-v2-900",
)}
testID="convert_input"
/>
<NumberFormat
value={getNumberFormatValue(
getTokenPrice(sourceToken.token.symbol, BigNumber(amount)),
2,
)}
thousandSeparator
displayType="text"
prefix="$"
renderText={(value) => (
<ThemedTextV2
light={tailwind("text-mono-light-v2-700")}
dark={tailwind("text-mono-dark-v2-700")}
style={tailwind("text-sm font-normal-v2")}
>
{value}
</ThemedTextV2>
)}
/>
</View>
{isEvmFeatureEnabled && (
<TokenDropdownButton
tokenId={sourceToken.tokenId}
isEvmToken={sourceToken?.token.domainType === DomainType.EVM}
symbol={sourceToken.token.displaySymbol}
displayedTextSymbol={sourceToken.token.displayTextSymbol}
testID={TokenListType.From}
onPress={() => {
navigateToTokenSelectionScreen(TokenListType.From);
}}
status={TokenDropdownButtonStatus.Enabled}
/>
)}
{!isEvmFeatureEnabled && (
<FixedTokenButton
testID={TokenListType.From}
symbol={sourceToken.token.displaySymbol}
unit={sourceToken.token.displayTextSymbol}
isEvmToken={false}
/>
)}
</View>
</TransactionCard>
<ThemedTextV2
style={tailwind("font-normal-v2 text-xs mx-5", {
"mt-2":
inlineTextStatus === InlineTextStatus.Error ||
inlineTextStatus === InlineTextStatus.Warning,
})}
light={tailwind("text-mono-light-v2-500", {
"text-red-v2": inlineTextStatus === InlineTextStatus.Error,
"text-orange-v2": inlineTextStatus === InlineTextStatus.Warning,
})}
dark={tailwind("text-mono-dark-v2-500", {
"text-red-v2": inlineTextStatus === InlineTextStatus.Error,
"text-orange-v2": inlineTextStatus === InlineTextStatus.Warning,
})}
testID="source_balance_label"
>
{translate(
"screens/ConvertScreen",
inlineTextStatus === InlineTextStatus.Error
? "Insufficient balance"
: inlineTextStatus === InlineTextStatus.Warning
? "A small amount of UTXO is reserved for fees"
: "",
{
amount: new BigNumber(sourceToken.available).toFixed(8),
unit: sourceToken.token.displaySymbol,
},
)}
</ThemedTextV2>
<View style={tailwind("my-8 flex-row")}>
<ThemedViewV2
dark={tailwind("border-mono-dark-v2-300")}
light={tailwind("border-mono-light-v2-300")}
style={tailwind("border-b-0.5 flex-1 h-1/2")}
/>
<ConvertToggleButton
onPress={onTogglePress}
isDisabled={!sourceToken || !targetToken}
/>
<ThemedViewV2
dark={tailwind("border-mono-dark-v2-300")}
light={tailwind("border-mono-light-v2-300")}
style={tailwind("border-b-0.5 flex-1 h-1/2")}
/>
</View>
<ThemedTextV2
style={tailwind("px-5 text-xs font-normal-v2 tracking-wide-v2")}
light={tailwind("text-mono-light-v2-500")}
dark={tailwind("text-mono-dark-v2-500")}
testID="tokenB_displaySymbol"
>
{translate("screens/ConvertScreen", "TO CONVERT")}
</ThemedTextV2>
<View
style={tailwind(
"flex flex-row justify-between items-center pl-5 mt-4",
)}
>
<View style={tailwind("w-6/12 mr-2")}>
<NumberFormat
value={
getNumberFormatValue(convAmount, 8) ===
getNumberFormatValue(0, 8)
? "0.00"
: getNumberFormatValue(convAmount, 8)
}
thousandSeparator
displayType="text"
renderText={(value) => (
<ThemedTextV2
style={tailwind("text-left font-normal-v2 text-xl")}
light={tailwind("text-mono-light-v2-700")}
dark={tailwind("text-mono-dark-v2-700")}
>
{value}
</ThemedTextV2>
)}
/>
<NumberFormat
value={getNumberFormatValue(
targetToken === undefined
? 0
: getTokenPrice(
targetToken.token.symbol,
BigNumber(convAmount),
),
2,
)}
thousandSeparator
displayType="text"
prefix="$"
renderText={(value) => (
<ThemedTextV2
light={tailwind("text-mono-light-v2-700")}
dark={tailwind("text-mono-dark-v2-700")}
style={tailwind("text-sm font-normal-v2")}
>
{value}
</ThemedTextV2>
)}
/>
</View>
{sourceToken.tokenId === "0" &&
isEvmFeatureEnabled &&
!isEvmDomain && (
<TokenDropdownButton
tokenId={targetToken?.tokenId}
isEvmToken={targetToken?.token.domainType === DomainType.EVM}
symbol={targetToken?.token.displaySymbol}
displayedTextSymbol={targetToken?.token.displayTextSymbol}
testID={TokenListType.To}
onPress={() => {
navigateToTokenSelectionScreen(TokenListType.To);
}}
status={TokenDropdownButtonStatus.Enabled}
/>
)}
{((sourceToken.tokenId !== "0" && targetToken) ||
(!isEvmFeatureEnabled && targetToken) ||
(isEvmFeatureEnabled && isEvmDomain && targetToken)) && (
<FixedTokenButton
testID={TokenListType.To}
symbol={targetToken.token.displaySymbol}
unit={targetToken.token.displayTextSymbol}
isEvmToken={targetToken?.token.domainType === DomainType.EVM}
/>
)}
</View>
{targetToken !== undefined && (
<View style={tailwind("flex-col w-full")}>
<ConversionResultCard
unit={`${targetToken.token.displayTextSymbol}${
convertDirection === ConvertDirection.dvmToEvm ? " (EVM)" : ""
}`}
oriTargetAmount={targetToken.available}
totalTargetAmount={
amount !== ""
? BigNumber.maximum(
targetToken.available.plus(convAmount),
0,
).toFixed(8)
: "-"
}
/>
{canConvert(convAmount, sourceToken.available) && (
<ThemedTextV2
style={tailwind("font-normal-v2 text-xs text-center pt-12")}
light={tailwind("text-mono-light-v2-500")}
dark={tailwind("text-mono-dark-v2-500")}
>
{translate(
"screens/ConvertScreen",
"Review full details in the next screen",
)}
</ThemedTextV2>
)}
</View>
)}
</View>
<View
style={tailwind("w-full px-12 pb-10 mt-20", {
"mt-5": canConvert(convAmount, sourceToken.available),
})}
>
<ButtonV2
fillType="fill"
label={translate("components/Button", "Continue")}
disabled={
!canConvert(convAmount, sourceToken.available) ||
hasPendingJob ||
hasPendingBroadcastJob ||
targetToken === undefined
}
styleProps="w-full"
onPress={() => convert(sourceToken, targetToken)}
testID="button_continue_convert"
/>
</View>
</ThemedScrollViewV2>
);
}
function ConvertToggleButton(props: {
isDisabled: boolean;
onPress: () => void;
}): JSX.Element {
return (
<ThemedTouchableOpacityV2
style={tailwind("border-0 items-center")}
onPress={props.onPress}
disabled={props.isDisabled}
>
<ThemedViewV2
testID="button_convert_mode_toggle"
style={tailwind("w-10 h-10 rounded-full items-center justify-center", {
"opacity-30": props.isDisabled,
})}
light={tailwind("bg-mono-light-v2-900")}
dark={tailwind("bg-mono-dark-v2-900")}
>
<ThemedIcon
iconType="MaterialIcons"
name="swap-calls"
size={24}
light={tailwind("text-mono-light-v2-00")}
dark={tailwind("text-mono-dark-v2-00")}
/>
</ThemedViewV2>
</ThemedTouchableOpacityV2>
);
}
function ConversionResultCard(props: {
unit: string;
oriTargetAmount: BigNumber;
totalTargetAmount: string;
}): JSX.Element {
return (
<ThemedViewV2
style={tailwind("flex-col w-full p-5 mt-6 rounded-lg-v2 border-0.5")}
testID="convert_result_card"
light={tailwind("border-mono-light-v2-300")}
dark={tailwind("border-mono-dark-v2-300")}
>
<ThemedViewV2 style={tailwind("flex-row items-center pb-5")}>
<ThemedTextV2
style={tailwind("font-normal-v2 text-sm pr-2")}
testID="convert_available_label"
light={tailwind("text-mono-light-v2-500")}
dark={tailwind("text-mono-dark-v2-500")}
>
{translate("screens/ConvertScreen", "Available {{unit}}", {
unit: translate("screens/ConvertScreen", props.unit),
})}
</ThemedTextV2>
<NumberFormat
displayType="text"
renderText={(value) => (
<ThemedTextV2
style={tailwind("flex-1 font-normal-v2 text-sm text-right")}
light={tailwind("text-mono-light-v2-800")}
dark={tailwind("text-mono-dark-v2-800")}
testID="convert_available_amount"
>
{value}
</ThemedTextV2>
)}
thousandSeparator
value={new BigNumber(props.oriTargetAmount).toFixed(8)}
/>
</ThemedViewV2>
<ThemedViewV2
style={tailwind("flex-row items-center pt-5 border-t-0.5")}
light={tailwind("border-mono-light-v2-300")}
dark={tailwind("border-mono-dark-v2-300")}
>
<ThemedTextV2
style={tailwind("font-normal-v2 text-sm pr-2")}
testID="convert_resulting_label"
light={tailwind("text-mono-light-v2-500")}
dark={tailwind("text-mono-dark-v2-500")}
>
{translate("screens/ConvertScreen", "Resulting {{unit}}", {
unit: translate("screens/ConvertScreen", props.unit),
})}
</ThemedTextV2>
<NumberFormat
decimalScale={8}
displayType="text"
renderText={(value) => (
<ThemedTextV2
style={tailwind("flex-1 font-normal-v2 text-sm text-right")}
light={tailwind("text-mono-light-v2-800")}
dark={tailwind("text-mono-dark-v2-800")}
testID="convert_result_amount"
>
{value}
</ThemedTextV2>
)}
thousandSeparator
value={props.totalTargetAmount}
/>
</ThemedViewV2>
</ThemedViewV2>
);
}
function canConvert(amount: string, balance: BigNumber): boolean {
return (
new BigNumber(balance).gte(amount) &&
!new BigNumber(amount).isZero() &&
new BigNumber(amount).isPositive()
);
}
function FixedTokenButton(props: {
symbol: string;
testID: string;
unit: string;
isEvmToken?: boolean;
}): JSX.Element {
const Icon = getNativeIcon(props.symbol);
return (
<ThemedTouchableOpacityV2
testID={`token_select_button_${props.testID}`}
dark={tailwind("bg-mono-dark-v2-00 text-mono-dark-v2-500")}
light={tailwind("bg-mono-light-v2-00 text-mono-light-v2-500")}
style={tailwind("flex flex-row rounded-lg-v2 px-3")}
disabled
>
{props.symbol !== undefined && Icon !== undefined && (
<View style={tailwind("flex flex-row items-center")}>
<EVMLinearGradient isEvmToken={props.isEvmToken}>
<Icon testID="fixed_token_icon" height={24} width={24} />
</EVMLinearGradient>
<ThemedTextV2
style={tailwind("ml-2 text-sm font-semibold-v2 my-2.5")}
dark={tailwind("text-mono-dark-v2-900")}
light={tailwind("text-mono-light-v2-900")}
testID={`convert_token_button_${props.testID}_display_symbol`}
>
{props.unit}
</ThemedTextV2>
</View>
)}
</ThemedTouchableOpacityV2>
);
}