diff --git a/pages/index.tsx b/pages/index.tsx index ebec30f2..82340071 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -21,7 +21,10 @@ export default function Home() { [Field.OUTPUT]: { currencyId: null }, }); const activeChain = passphraseToBackendNetworkName[activeNetwork!].toLowerCase(); + useEffect(() => { + if (!activeChain || !xlmTokenList) return undefined + if (prefilledState.INPUT?.currencyId == null) { const newXlmToken = xlmTokenList.find((tList) => tList.network === activeChain)?.assets[0].contract ?? null; diff --git a/pages/swap/index.tsx b/pages/swap/index.tsx index 6a37a537..8a040b41 100644 --- a/pages/swap/index.tsx +++ b/pages/swap/index.tsx @@ -17,6 +17,7 @@ export default function SwapPage() { }); useEffect(() => { + if (!activeChain || !xlmTokenList) return undefined const newXlmToken = xlmTokenList.find((tList) => tList.network === activeChain)?.assets[0].contract ?? null; setXlmToken(newXlmToken); diff --git a/src/components/CurrencyInputPanel/CurrencyBalance.tsx b/src/components/CurrencyInputPanel/CurrencyBalance.tsx index 2bc3bd76..6b8da5fc 100644 --- a/src/components/CurrencyInputPanel/CurrencyBalance.tsx +++ b/src/components/CurrencyInputPanel/CurrencyBalance.tsx @@ -1,14 +1,11 @@ -import { styled, useTheme } from 'soroswap-ui'; -import { RowFixed } from 'components/Row'; -import { BodySmall } from 'components/Text'; -import { MouseoverTooltip } from 'components/Tooltip'; -import useGetMyBalances from 'hooks/useGetMyBalances'; +import { styled } from 'soroswap-ui';; +import XlmCurrencyBalance from './XlmCurrencyBalance'; +import TokenCurrencyBalance from './TokenCurrencyBalance'; import { useSorobanReact, WalletNetwork } from 'stellar-react'; import BigNumber from 'bignumber.js'; -import { TextWithLoadingPlaceholder } from 'components/Swap/AdvancedSwapDetails'; import { xlmTokenList } from 'constants/xlmToken'; -import { useEffect, useMemo, useState } from 'react'; +import { memo, useEffect, useMemo, useState } from 'react'; const StyledBalanceMax = styled('button')<{ disabled?: boolean }>` background-color: transparent; @@ -39,19 +36,14 @@ interface CurrencyBalanceProps { isLoadingNetworkFees?: boolean; } -export default function CurrencyBalance({ +const CurrencyBalance = ({ contract, onMax, hideBalance, showMaxButton, networkFees, isLoadingNetworkFees, -}: CurrencyBalanceProps) { - const { - tokenBalancesResponse, - availableNativeBalance, - isLoading: isLoadingMyBalances, - } = useGetMyBalances(); +}: CurrencyBalanceProps) => { const { activeNetwork } = useSorobanReact(); const [activeChain, setActiveChain] = useState(''); useEffect(() => { @@ -68,61 +60,38 @@ export default function CurrencyBalance({ } }, [activeNetwork]); const xlmTokenContract = useMemo(() => { + return xlmTokenList.find((tList) => tList.network === activeChain)?.assets[0].contract; }, [activeChain]); const isXLM = contract === xlmTokenContract; - const balance = - tokenBalancesResponse?.balances?.find((b) => b?.contract === contract)?.balance || '0'; - let availableBalance = balance; + if (isLoadingNetworkFees || !xlmTokenContract) return + /** + * NOTE: Decomposite Balance view for XLM/OTHER tokens and minifed requests + * */ if (isXLM) { - availableBalance = availableNativeBalance(networkFees); + return ( + + ); + } else { + return ( + + ); } - - const theme = useTheme(); - - return ( - - - {isXLM ? ( - - All Stellar accounts must maintain a minimum balance of lumens.{' '} - - Learn More - - - } - > - - {!hideBalance && contract ? `Available balance: ${Number(availableBalance)}` : null} - - - ) : ( - - {!hideBalance && contract ? `Balance: ${Number(availableBalance)}` : null} - - )} - - {showMaxButton && Number(availableBalance) > 0 ? ( - onMax(availableBalance.toString())}> - Max - - ) : null} - - - ); } + +export default memo(CurrencyBalance); diff --git a/src/components/CurrencyInputPanel/SwapCurrencyInputPanel.tsx b/src/components/CurrencyInputPanel/SwapCurrencyInputPanel.tsx index cdde1a4c..3dfcec16 100644 --- a/src/components/CurrencyInputPanel/SwapCurrencyInputPanel.tsx +++ b/src/components/CurrencyInputPanel/SwapCurrencyInputPanel.tsx @@ -2,7 +2,7 @@ import { ButtonBase, styled, useMediaQuery, useTheme } from 'soroswap-ui'; import { useSorobanReact } from 'stellar-react'; import { darken } from 'polished'; -import { ReactNode, useCallback, useState } from 'react'; +import { memo, ReactNode, useCallback, useState } from 'react'; import { ChevronDown } from 'react-feather'; import { TokenType } from '../../interfaces'; import { flexColumnNoWrap, flexRowNoWrap } from '../../themes/styles'; @@ -186,7 +186,7 @@ interface SwapCurrencyInputPanelProps { isLoadingNetworkFees?: boolean; } -export default function SwapCurrencyInputPanel({ +const SwapCurrencyInputPanel = ({ value, onUserInput, onMax, @@ -210,7 +210,7 @@ export default function SwapCurrencyInputPanel({ networkFees, isLoadingNetworkFees, ...rest -}: SwapCurrencyInputPanelProps) { +}: SwapCurrencyInputPanelProps) => { const [modalOpen, setModalOpen] = useState(false); const { address } = useSorobanReact(); const theme = useTheme(); @@ -310,6 +310,10 @@ export default function SwapCurrencyInputPanel({ showCurrencyAmount={showCurrencyAmount} disableNonToken={disableNonToken} /> + ); } + + +export default memo(SwapCurrencyInputPanel) \ No newline at end of file diff --git a/src/components/CurrencyInputPanel/TokenCurrencyBalance.tsx b/src/components/CurrencyInputPanel/TokenCurrencyBalance.tsx new file mode 100644 index 00000000..7f1877d6 --- /dev/null +++ b/src/components/CurrencyInputPanel/TokenCurrencyBalance.tsx @@ -0,0 +1,75 @@ +import { styled, useTheme } from 'soroswap-ui'; +import { RowFixed } from 'components/Row'; +import { BodySmall } from 'components/Text'; +import useGetMyBalances from 'hooks/useGetMyBalances'; +import BigNumber from 'bignumber.js'; +import { TextWithLoadingPlaceholder } from 'components/Swap/AdvancedSwapDetails'; +import { memo } from 'react'; + +const StyledBalanceMax = styled('button')<{ disabled?: boolean }>` + background-color: transparent; + border: none; + color: ${({ theme }) => theme.palette.custom.borderColor}; + cursor: pointer; + font-size: 14px; + font-weight: 600; + opacity: ${({ disabled }) => (!disabled ? 1 : 0.4)}; + padding: 4px 6px; + pointer-events: ${({ disabled }) => (!disabled ? 'initial' : 'none')}; + + :hover { + opacity: ${({ disabled }) => (!disabled ? 0.8 : 0.4)}; + } + + :focus { + outline: none; + } +`; + +interface TokenCurrencyBalanceProps { + contract: string; + onMax: (maxValue: string) => void; + hideBalance: any; + showMaxButton: any; +} + + const TokenCurrencyBalance = ({ + contract, + onMax, + hideBalance, + showMaxButton, +}: TokenCurrencyBalanceProps) => { + + const { + tokenBalancesResponse, + isLoading: isLoadingMyBalances, + } = useGetMyBalances(); + + const balance = + tokenBalancesResponse?.balances?.find((b) => b?.contract === contract)?.balance || '0'; + + const availableBalance = balance; + + const theme = useTheme(); + + return ( + + + + {!hideBalance && contract ? `Balance: ${Number(availableBalance)}` : null} + + + {showMaxButton && Number(availableBalance) > 0 ? ( + onMax(availableBalance.toString())}> + Max + + ) : null} + + + ); +} + +export default memo(TokenCurrencyBalance); \ No newline at end of file diff --git a/src/components/CurrencyInputPanel/XlmCurrencyBalance.tsx b/src/components/CurrencyInputPanel/XlmCurrencyBalance.tsx new file mode 100644 index 00000000..69b48892 --- /dev/null +++ b/src/components/CurrencyInputPanel/XlmCurrencyBalance.tsx @@ -0,0 +1,91 @@ +import { styled, useTheme } from 'soroswap-ui'; +import { RowFixed } from 'components/Row'; +import { BodySmall } from 'components/Text'; +import { MouseoverTooltip } from 'components/Tooltip'; +import useGetMyBalances, { calculateAvailableBalance } from 'hooks/useGetMyBalances'; +import BigNumber from 'bignumber.js'; +import { TextWithLoadingPlaceholder } from 'components/Swap/AdvancedSwapDetails'; +import useGetSubentryCount from 'hooks/useGetSubentryCount'; +import { memo } from 'react'; + +const StyledBalanceMax = styled('button')<{ disabled?: boolean }>` + background-color: transparent; + border: none; + color: ${({ theme }) => theme.palette.custom.borderColor}; + cursor: pointer; + font-size: 14px; + font-weight: 600; + opacity: ${({ disabled }) => (!disabled ? 1 : 0.4)}; + padding: 4px 6px; + pointer-events: ${({ disabled }) => (!disabled ? 'initial' : 'none')}; + + :hover { + opacity: ${({ disabled }) => (!disabled ? 0.8 : 0.4)}; + } + + :focus { + outline: none; + } +`; + +interface XlmCurrencyBalanceProps { + contract: string; + onMax: (maxValue: string) => void; + hideBalance: any; + showMaxButton: any; + networkFees?: string | number | BigNumber | null; + isLoadingNetworkFees?: boolean; +} + + const XlmCurrencyBalance = ({ + contract, + onMax, + hideBalance, + showMaxButton, + networkFees, + isLoadingNetworkFees, +}: XlmCurrencyBalanceProps) => { + + const { subentryCount, nativeBalance, isLoading: isSubentryLoading } = useGetSubentryCount(); + + const availableBalance = calculateAvailableBalance(nativeBalance, networkFees, subentryCount) + + const theme = useTheme(); + + return ( + + + + All Stellar accounts must maintain a minimum balance of lumens.{' '} + + Learn More + + + } + > + + {!hideBalance && contract ? `Available balance: ${Number(availableBalance)}` : null} + + + + {showMaxButton && Number(availableBalance) > 0 ? ( + onMax(availableBalance.toString())}> + Max + + ) : null} + + + ); +} + +export default memo(XlmCurrencyBalance) \ No newline at end of file diff --git a/src/components/NumericalInput/index.tsx b/src/components/NumericalInput/index.tsx index 79c57ae4..09ddbe1b 100644 --- a/src/components/NumericalInput/index.tsx +++ b/src/components/NumericalInput/index.tsx @@ -1,7 +1,17 @@ import { styled } from 'soroswap-ui'; -import React from 'react'; +import React, { useMemo } from 'react'; import { escapeRegExp } from '../../utils/escapeRegExp'; +interface InnerInputProps extends Omit, 'ref' | 'onChange' | 'as'> { + value: string | number; + onUserInput: (input: string) => void; + error ?: boolean; + fontSize ?: string; + align ?: 'right' | 'left'; + prependSymbol ?: string; +} + + const StyledInput = styled('input')<{ error?: boolean; fontSize?: string; align?: string }>` color: ${({ error, theme }) => (error ? theme.palette.error.main : theme.palette.primary.main)}; width: 0; @@ -46,14 +56,9 @@ export const Input = React.memo(function InnerInput({ placeholder, prependSymbol, ...rest -}: { - value: string | number; - onUserInput: (input: string) => void; - error?: boolean; - fontSize?: string; - align?: 'right' | 'left'; - prependSymbol?: string; -} & Omit, 'ref' | 'onChange' | 'as'>) { +}: InnerInputProps) { + + const enforcer = (nextUserInput: string) => { if (nextUserInput === '' || inputRegex.test(escapeRegExp(nextUserInput))) { onUserInput(nextUserInput); @@ -94,4 +99,5 @@ export const Input = React.memo(function InnerInput({ ); }); + export default Input; diff --git a/src/components/SearchModal/CurrencyList/index.tsx b/src/components/SearchModal/CurrencyList/index.tsx index 5edd42cb..4ff52ea6 100644 --- a/src/components/SearchModal/CurrencyList/index.tsx +++ b/src/components/SearchModal/CurrencyList/index.tsx @@ -71,12 +71,13 @@ export const getCurrencyBalance = async ( sorobanContext: SorobanContextType, account: AccountResponse, ) => { - if (!sorobanContext.address) return '0'; + if (!tokenBalancesResponse || !sorobanContext.address) return '0'; const findInBalances = tokenBalancesResponse?.balances.find( (token) => token.contract === currency.contract, ); + //First find if balance is already in the "my balances" list if (findInBalances) { return findInBalances.balance as string; diff --git a/src/components/SearchModal/CurrencySearch.tsx b/src/components/SearchModal/CurrencySearch.tsx index ce6d0cc4..00a6fe53 100644 --- a/src/components/SearchModal/CurrencySearch.tsx +++ b/src/components/SearchModal/CurrencySearch.tsx @@ -79,6 +79,7 @@ export function CurrencySearch({ const sorobanContext = useSorobanReact(); const theme = useTheme(); + const [tokenLoaderTimerElapsed, setTokenLoaderTimerElapsed] = useState(false); // refs for fixed size lists @@ -137,7 +138,7 @@ export function CurrencySearch({ } const handleConfirmAddUserToken = () => { - if (!searchToken) return; + if (!searchToken || !searchToken.code) return; addUserToken(searchToken, sorobanContext); onCurrencySelect(searchToken); }; @@ -312,3 +313,4 @@ export function CurrencySearch({ ); } + diff --git a/src/components/Swap/AdvancedSwapDetails.tsx b/src/components/Swap/AdvancedSwapDetails.tsx index 7428229a..47a2f215 100644 --- a/src/components/Swap/AdvancedSwapDetails.tsx +++ b/src/components/Swap/AdvancedSwapDetails.tsx @@ -67,7 +67,7 @@ export function AdvancedSwapDetails({ // const { chainId } = useWeb3React() // const nativeCurrency = useNativeCurrency(chainId) // const txCount = getTransactionCount(trade) - const { tokensAsMap, isLoading } = useAllTokens(); + const { isLoading } = useAllTokens(); return ( diff --git a/src/components/Swap/SwapComponent.tsx b/src/components/Swap/SwapComponent.tsx index 63da9e10..62ca2371 100644 --- a/src/components/Swap/SwapComponent.tsx +++ b/src/components/Swap/SwapComponent.tsx @@ -12,13 +12,15 @@ import { sendNotification } from 'functions/sendNotification'; import { formatTokenAmount } from 'helpers/format'; import { requiresTrustline } from 'helpers/stellar'; import { relevantTokensType } from 'hooks'; -import { useToken } from 'hooks/tokens/useToken'; +import { findTokenService, getAllTokensService } from 'services/tokenService'; +import { getDerivedSwapInfoService, SwapInfoService } from 'services/swapService'; // Import the service and its type import useGetNativeTokenBalance from 'hooks/useGetNativeTokenBalance'; import { useSwapCallback } from 'hooks/useSwapCallback'; import useSwapMainButton from 'hooks/useSwapMainButton'; import useSwapNetworkFees from 'hooks/useSwapNetworkFees'; import { TokenType } from 'interfaces'; import { + memo, ReactNode, SetStateAction, useCallback, @@ -31,13 +33,17 @@ import { import { ArrowDown } from 'react-feather'; import { InterfaceTrade, TradeState } from 'state/routing/types'; import { Field } from 'state/swap/actions'; -import { useDerivedSwapInfo, useSwapActionHandlers } from 'state/swap/hooks'; +import { useSwapActionHandlers } from 'state/swap/hooks'; // Keep useSwapActionHandlers import swapReducer, { SwapState, initialState as initialSwapState } from 'state/swap/reducer'; import { opacify } from 'themes/utils'; import SwapCurrencyInputPanel from '../CurrencyInputPanel/SwapCurrencyInputPanel'; import SwapHeader from './SwapHeader'; import { ArrowWrapper, SwapWrapper } from './styleds'; import useGetMyBalances from 'hooks/useGetMyBalances'; +import useHorizonLoadAccount from 'hooks/useHorizonLoadAccount'; // Import useHorizonLoadAccount +import { useFactory } from 'hooks'; // Import useFactory +import { useUserSlippageToleranceWithDefault } from 'state/user/hooks'; // Import useUserSlippageToleranceWithDefault +import { DEFAULT_SLIPPAGE_INPUT_VALUE } from 'components/Settings/MaxSlippageSettings'; // Import DEFAULT_SLIPPAGE_INPUT_VALUE export const SwapSection = styled('div')(({ theme }) => ({ position: 'relative', @@ -118,6 +124,7 @@ export function SwapComponent({ handleDoSwap?: (setSwapState: (value: SetStateAction) => void) => void; }) { const sorobanContext = useSorobanReact(); + const { address } = sorobanContext; const { refetch } = useGetMyBalances(); const { SnackbarContext } = useContext(AppContext); const [showPriceImpactModal, setShowPriceImpactModal] = useState(false); @@ -126,36 +133,133 @@ export function SwapComponent({ const [needTrustline, setNeedTrustline] = useState(true); - const { token: prefilledToken } = useToken(prefilledState.INPUT?.currencyId!); - // modal and loading const [{ showConfirm, tradeToConfirm, swapError, swapResult }, setSwapState] = useState(INITIAL_SWAP_STATE); const [state, dispatch] = useReducer(swapReducer, { ...initialSwapState, ...prefilledState }); + const { typedValue, recipient, independentField } = state; const { onSwitchTokens, onCurrencySelection, onUserInput, onChangeRecipient } = useSwapActionHandlers(dispatch); + const dependentField: Field = independentField === Field.INPUT ? Field.OUTPUT : Field.INPUT; + const [tokensAsMap, setTokensAsMap] = useState({}); + useEffect(() => { - if (prefilledToken) { - onCurrencySelection(Field.INPUT, prefilledToken); - } - }, [onCurrencySelection, prefilledToken]); + + const fetchTokens = async () => { + const { tokensAsMap } = await getAllTokensService(sorobanContext); + setTokensAsMap(tokensAsMap); + }; + + sorobanContext && fetchTokens(); + }, [sorobanContext]); + + useEffect(() => { + const fetchPrefilledToken = async () => { + if (prefilledState.INPUT?.currencyId && Object.keys(tokensAsMap).length > 0) { + + const prefilledToken = await findTokenService( + prefilledState.INPUT.currencyId, + tokensAsMap, + sorobanContext + ); + if (prefilledToken) { + onCurrencySelection(Field.INPUT, prefilledToken); + } + } + }; + + fetchPrefilledToken(); + }, [prefilledState, sorobanContext, tokensAsMap]); + + const { account: horizonAccount } = useHorizonLoadAccount(); // Fetch horizonAccount + const { factory } = useFactory(sorobanContext); // Fetch factory + const { Settings } = useContext(AppContext); // Access AppContext for Settings + // Need to check AppContext definition for how to access maxHops, protocolsStatus, and isAggregator + // For now, using placeholders or assuming direct access if no errors + const maxHops = Settings?.maxHops; // Assuming direct access + const protocolsStatus = Settings?.protocolsStatus; // Assuming direct access + const isAggregator = Settings?.isAggregatorState; // Use isAggregatorState + + const allowedSlippage = useUserSlippageToleranceWithDefault(DEFAULT_SLIPPAGE_INPUT_VALUE); // Fetch allowedSlippage + + const [swapInfoServiceResult, setSwapInfoServiceResult] = useState({ + currencies: {}, + currencyBalances: {}, + parsedAmount: undefined, + inputError: undefined, + trade: { + trade: undefined, + state: TradeState.LOADING, + uniswapXGasUseEstimateUSD: undefined, + error: undefined + }, + allowedSlippage: undefined, + autoSlippage: undefined, + }); + + const [isLoadingSwapInfo, setIsLoadingSwapInfo] = useState(false); + + + useEffect(() => { + const fetchSwapInfo = async () => { + // Ensure tokensAsMap and settings are loaded before fetching swap info + const dependenciesReady = Object.keys(tokensAsMap).length > 0 && protocolsStatus !== undefined && maxHops !== undefined && isAggregator !== undefined; + + if (dependenciesReady) { + setIsLoadingSwapInfo(true); // Set loading to true before the async operation + try { + const result = await getDerivedSwapInfoService( + state, + sorobanContext, + address, + tokensAsMap, + horizonAccount, + allowedSlippage, // Pass allowedSlippage + factory, // Pass factory + maxHops, // Pass maxHops + protocolsStatus, // Pass protocolsStatus + isAggregator, // Pass isAggregator + ); + setSwapInfoServiceResult(result); + } catch (error) { + console.error("Error fetching swap info:", error); + // Optionally set an error state here + } finally { + setIsLoadingSwapInfo(false); // Set loading to false after the operation + } + } else { + // If dependencies are not ready, keep loading state true + // The effect will re-run when dependencies change and the service will be called + setIsLoadingSwapInfo(true); // Keep loading true while waiting for dependencies + } + }; + + fetchSwapInfo(); + }, [ + state, sorobanContext, address, tokensAsMap, horizonAccount, + allowedSlippage, factory, maxHops, protocolsStatus, isAggregator + ]); const { - trade: { state: tradeState, trade, resetRouterSdkCache }, - allowedSlippage, + trade: { state: tradeState, trade }, currencyBalances, parsedAmount, currencies, inputError: swapInputError, - } = useDerivedSwapInfo(state); + + } = useMemo(() => { + return swapInfoServiceResult + }, [swapInfoServiceResult, isLoadingSwapInfo]) + useEffect(() => { if ( - typeof currencyBalances[Field.OUTPUT] != 'string' && + currencyBalances?.[Field.OUTPUT] && + typeof currencyBalances[Field.OUTPUT] !== 'string' && !currencyBalances[Field.OUTPUT].balance ) { setNeedTrustline(true); @@ -169,21 +273,15 @@ export function SwapComponent({ [Field.INPUT]: independentField === Field.INPUT ? parsedAmount : trade?.inputAmount, [Field.OUTPUT]: independentField === Field.OUTPUT ? parsedAmount : trade?.outputAmount, }), - [independentField, parsedAmount, trade], + [independentField, parsedAmount], ); const decimals = useMemo( () => ({ - [Field.INPUT]: - independentField === Field.INPUT - ? trade?.outputAmount?.currency.decimals ?? 7 - : trade?.inputAmount?.currency.decimals ?? 7, - [Field.OUTPUT]: - independentField === Field.OUTPUT - ? trade?.inputAmount?.currency.decimals ?? 7 - : trade?.outputAmount?.currency.decimals ?? 7, + [Field.INPUT]: trade?.inputAmount?.currency.decimals ?? 7, + [Field.OUTPUT]: trade?.outputAmount?.currency.decimals ?? 7, }), - [independentField, trade], + [trade], ); const userHasSpecifiedInputOutput = Boolean( @@ -197,8 +295,8 @@ export function SwapComponent({ const showFiatValueInput = Boolean(parsedAmounts[Field.INPUT]); const showFiatValueOutput = Boolean(parsedAmounts[Field.OUTPUT]); - const maxInputAmount: relevantTokensType | string = useMemo( - () => currencyBalances[Field.INPUT], + const maxInputAmount: relevantTokensType | string | undefined = useMemo( + () => currencyBalances?.[Field.INPUT], // () => maxAmountSpend(currencyBalances[Field.INPUT]), TODO: Create maxAmountSpend if is native token (XLM) should count for the fees and minimum xlm for the account to have [currencyBalances], ); @@ -233,21 +331,19 @@ export function SwapComponent({ (value: string) => { const currency = currencies[Field.INPUT]; const decimals = currency?.decimals ?? 7; - // Prevents user from inputting more decimals than the token supports if (value.split('.').length > 1 && value.split('.')[1].length > decimals) { return; } - onUserInput(Field.INPUT, value); }, [onUserInput, currencies], ); + const handleTypeOutput = useCallback( (value: string) => { const currency = currencies[Field.OUTPUT]; const decimals = currency?.decimals ?? 7; - // Prevents user from inputting more decimals than the token supports if (value.split('.').length > 1 && value.split('.')[1].length > decimals) { return; @@ -260,22 +356,22 @@ export function SwapComponent({ const formattedAmounts = useMemo( () => ({ - [independentField]: typedValue, - [dependentField]: formatTokenAmount(trade?.expectedAmount, decimals[independentField]), + [Field.INPUT]: independentField === Field.INPUT ? typedValue : formatTokenAmount(trade?.inputAmount?.value, decimals[Field.INPUT]), + [Field.OUTPUT]: independentField === Field.OUTPUT ? typedValue : formatTokenAmount(trade?.outputAmount?.value, decimals[Field.OUTPUT]), }), - [decimals, dependentField, independentField, trade?.expectedAmount, typedValue], + [decimals, independentField, trade, typedValue], ); const showMaxButton = Boolean((maxInputAmount as relevantTokensType)?.balance ?? 0 > 0); - const [routeNotFound, routeIsLoading, routeIsSyncing] = useMemo( - () => [ - tradeState === TradeState.NO_ROUTE_FOUND, - tradeState === TradeState.LOADING, - tradeState === TradeState.LOADING && Boolean(trade), - ], - [trade, tradeState], - ); + const {routeNotFound, routeIsLoading, routeIsSyncing} = useMemo(()=> { + return { + routeNotFound: tradeState === TradeState.NO_ROUTE_FOUND, + routeIsLoading: isLoadingSwapInfo || tradeState === TradeState.LOADING, + routeIsSyncing: isLoadingSwapInfo || (tradeState === TradeState.LOADING && Boolean(trade)) + } + }, [isLoadingSwapInfo, tradeState]) + const handleContinueToReview = () => { setSwapState({ @@ -357,23 +453,29 @@ export function SwapComponent({ }; const showDetailsDropdown = Boolean( - userHasSpecifiedInputOutput && (trade || routeIsLoading || routeIsSyncing), + userHasSpecifiedInputOutput && (trade || routeIsLoading || routeIsSyncing) ); - const inputCurrency = currencies[Field.INPUT] ?? undefined; + const inputCurrency = currencies?.[Field.INPUT] ?? undefined; const priceImpactSeverity = 2; //IF is < 2 it shows Swap anyway button in red const showPriceImpactWarning = false; + //SECTION: Hook with effect const nativeBalance = useGetNativeTokenBalance(); - const { networkFees, isLoading: isLoadingNetworkFees } = useSwapNetworkFees(trade, currencies); + //SECTION: Hook with effect + const { networkFees, isLoading: isLoadingNetworkFees } = useSwapNetworkFees(trade, currencies); // Keep useSwapNetworkFees + const { getMainButtonText, isMainButtonDisabled, handleMainButtonClick, getSwapValues } = useSwapMainButton({ currencies, - currencyBalances, + currencyBalances: { // Transform currencyBalances + [Field.INPUT]: currencyBalances?.[Field.INPUT] ?? '', // Provide a default empty string if undefined + [Field.OUTPUT]: currencyBalances?.[Field.OUTPUT] ?? '', // Provide a default empty string if undefined + }, formattedAmounts, - routeNotFound, + routeNotFound, // Use routeNotFound variable onSubmit: handleContinueToReview, trade, networkFees, @@ -381,7 +483,7 @@ export function SwapComponent({ const useConfirmModal = useConfirmModalState({ trade: trade!, - allowedSlippage, + allowedSlippage: allowedSlippage, // Use allowedSlippage variable onSwap: handleSwap, onSetTrustline: handleTrustline, onCurrencySelection, @@ -395,10 +497,10 @@ export function SwapComponent({ onUserInput(Field.INPUT, ''); } - resetRouterSdkCache(); + // resetRouterSdkCache(); // Commenting out as resetRouterSdkCache is an empty method useConfirmModal.resetStates(); - }, [onUserInput, swapResult, useConfirmModal, resetRouterSdkCache]); + }, [onUserInput, swapResult, useConfirmModal]); const handleErrorDismiss = () => { setTxError(false); @@ -406,6 +508,7 @@ export function SwapComponent({ handleConfirmDismiss(); }; + return ( <> @@ -465,13 +568,13 @@ export function SwapComponent({ onMax={(maxBalance) => handleTypeInput(maxBalance.toString())} fiatValue={showFiatValueInput ? fiatValueInput : undefined} onCurrencySelect={handleInputSelect} - otherCurrency={currencies[Field.OUTPUT]} + otherCurrency={currencies?.[Field.OUTPUT]} networkFees={networkFees} isLoadingNetworkFees={isLoadingNetworkFees} // showCommonBases // id={InterfaceSectionName.CURRENCY_INPUT_PANEL} - loading={independentField === Field.OUTPUT && routeIsSyncing} - currency={currencies[Field.INPUT] ?? null} + loading={routeIsLoading && independentField === Field.OUTPUT} // Use routeIsLoading variable + currency={currencies?.[Field.INPUT] ?? null} id={'swap-input'} disableInput={getSwapValues().noCurrencySelected} /> @@ -519,11 +622,12 @@ export function SwapComponent({ /> + {showDetailsDropdown && !getSwapValues().insufficientLiquidity && ( diff --git a/src/components/Swap/SwapDetailsDropdown.tsx b/src/components/Swap/SwapDetailsDropdown.tsx index 63ddfa90..e5ee3581 100644 --- a/src/components/Swap/SwapDetailsDropdown.tsx +++ b/src/components/Swap/SwapDetailsDropdown.tsx @@ -4,7 +4,7 @@ import Column from 'components/Column'; import { LoadingOpacityContainer } from 'components/Loader/styled'; import { RowBetween, RowFixed } from 'components/Row'; import { SubHeaderSmall } from 'components/Text'; -import { useState } from 'react'; +import { memo, useMemo, useState } from 'react'; import { ChevronDown } from 'react-feather'; import { InterfaceTrade } from 'state/routing/types'; import { AdvancedSwapDetails } from './AdvancedSwapDetails'; @@ -88,13 +88,13 @@ interface SwapDetailsInlineProps { networkFees: number | null; } -export default function SwapDetailsDropdown({ + const SwapDetailsDropdown = ({ trade, syncing, loading, allowedSlippage, networkFees, -}: SwapDetailsInlineProps) { +}: SwapDetailsInlineProps) => { const theme = useTheme(); const [showDetails, setShowDetails] = useState(false); @@ -115,7 +115,7 @@ export default function SwapDetailsDropdown({ )} <> - {trade ? ( + {!loading && trade ? ( @@ -132,7 +132,7 @@ export default function SwapDetailsDropdown({ /> - {trade && ( + {!loading && trade && ( ); } + +export default memo(SwapDetailsDropdown); \ No newline at end of file diff --git a/src/components/Swap/SwapPathComponent.tsx b/src/components/Swap/SwapPathComponent.tsx index 54215846..921bb18a 100644 --- a/src/components/Swap/SwapPathComponent.tsx +++ b/src/components/Swap/SwapPathComponent.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { ChevronRight } from 'react-feather'; import { Box, styled } from 'soroswap-ui'; import Row, { RowBetween, RowFixed } from 'components/Row'; @@ -58,50 +58,90 @@ function SwapPathComponent({ trade }: { trade: InterfaceTrade | undefined }) { const sorobanContext = useSorobanReact(); const { tokensAsMap, isLoading } = useAllTokens(); + const memoizedTradePath = useMemo(() => trade, [trade?.path, trade?.distribution]); const [pathArray, setPathArray] = useState([]); const [distributionArray, setDistributionArray] = useState([]); const [totalParts, setTotalParts] = useState(0); const [pathTokensIsLoading, setPathTokensIsLoading] = useState(false); + const getTradedCurrencies = async (pathes: string[]) => { + const promises = pathes.map(async (contract) => { + const asset = await findToken( + contract, + tokensAsMap, + sorobanContext + ); + const code = asset?.code == 'native' ? 'XLM' : asset?.code; + + return code; + }); + const results = await Promise.allSettled(promises); + + return results + .filter((result) => result.status === 'fulfilled' && result.value) + .map((result) => (result.status === 'fulfilled' && result.value ? result.value : '')); + }; + + const getAggregatorPathCurrencyCodes = async ( + path: string[], + tokensAsMap: any, + sorobanContext: any, + ) => { + const promises = path.map(async (contract) => { + const asset = await findToken( + contract, + tokensAsMap, + sorobanContext + ); + const code = asset?.code == 'native' ? 'XLM' : asset?.code; + return code; + }); + const results = await Promise.allSettled(promises); + return results + .filter((result) => result.status === 'fulfilled' && result.value) + .map((result) => (result.status === 'fulfilled' && result.value ? result.value : '')); + }; + useEffect(() => { (async () => { - if (!trade || isLoading) return; - if (trade.platform == PlatformType.ROUTER && trade.path) { - setPathTokensIsLoading(true); - const promises = trade.path.map(async (contract) => { - const asset = await findToken(contract, tokensAsMap, sorobanContext); - const code = asset?.code == 'native' ? 'XLM' : asset?.code; - return code; - }); - const results = await Promise.allSettled(promises); - - const fulfilledValues = results - .filter((result) => result.status === 'fulfilled' && result.value) - .map((result) => (result.status === 'fulfilled' && result.value ? result.value : '')); - setPathArray(fulfilledValues); + if (!memoizedTradePath || isLoading) return; + setPathTokensIsLoading(true); + if (memoizedTradePath.platform == PlatformType.ROUTER && memoizedTradePath.path) { + /** + * NOTE: memoizedTradePath.path.inputAmount && memoizedTradePath.path.output already have this - We don't need * external request + **/ + let pathtokenNames = ['', '']; + if ( + !memoizedTradePath.inputAmount || + !memoizedTradePath.outputAmount || + memoizedTradePath?.path[0] !== memoizedTradePath?.inputAmount?.currency?.contract || + memoizedTradePath.path[1] !== memoizedTradePath?.outputAmount?.currency?.contract + ) { + pathtokenNames = await getTradedCurrencies(memoizedTradePath?.path); + } else { + pathtokenNames = [memoizedTradePath?.inputAmount?.currency?.code, memoizedTradePath.outputAmount?.currency?.code as string]; + } + + setPathArray(pathtokenNames); setPathTokensIsLoading(false); - } else if (trade.platform == PlatformType.STELLAR_CLASSIC && trade.path) { - setPathTokensIsLoading(true); - const codes = trade.path.map((address) => { + } else if (memoizedTradePath.platform == PlatformType.STELLAR_CLASSIC && memoizedTradePath.path) { + const codes = memoizedTradePath.path.map((address) => { if (address == 'native') return 'XLM'; return address.split(':')[0]; }); setPathArray(codes); setPathTokensIsLoading(false); - } else if (trade.platform == PlatformType.AGGREGATOR) { - if (!trade?.distribution) return; + } else if (memoizedTradePath.platform == PlatformType.AGGREGATOR) { + if (!memoizedTradePath?.distribution) return; + let tempDistributionArray: distributionData[] = []; - setPathTokensIsLoading(!pathTokensIsLoading); - for (let distribution of trade?.distribution) { - const promises = distribution.path.map(async (contract) => { - const asset = await findToken(contract, tokensAsMap, sorobanContext); - const code = asset?.code == 'native' ? 'XLM' : asset?.code; - return code; - }); - const results = await Promise.allSettled(promises); - const fulfilledValues = results - .filter((result) => result.status === 'fulfilled' && result.value) - .map((result) => (result.status === 'fulfilled' && result.value ? result.value : '')); + for (let distribution of memoizedTradePath?.distribution) { + const fulfilledValues = await getAggregatorPathCurrencyCodes( + distribution.path, + tokensAsMap, + sorobanContext, + ); + tempDistributionArray.push({ path: fulfilledValues, parts: distribution.parts, @@ -111,9 +151,11 @@ function SwapPathComponent({ trade }: { trade: InterfaceTrade | undefined }) { setTotalParts(tempDistributionArray.reduce((acc, curr) => acc + curr.parts, 0)); } setPathTokensIsLoading(false); + } else { + setPathTokensIsLoading(false); } })(); - }, [trade?.path, isLoading, sorobanContext]); + }, [trade?.path, memoizedTradePath, isLoading, sorobanContext]); return ( @@ -124,15 +166,15 @@ function SwapPathComponent({ trade }: { trade: InterfaceTrade | undefined }) { `} > - {trade?.platform == PlatformType.AGGREGATOR && distributionArray.length > 1 + {memoizedTradePath?.platform == PlatformType.AGGREGATOR && distributionArray.length > 1 ? 'Paths:' : 'Path'} - {(trade?.platform == PlatformType.ROUTER || - trade?.platform == PlatformType.STELLAR_CLASSIC) && ( + {(memoizedTradePath?.platform == PlatformType.ROUTER || + memoizedTradePath?.platform == PlatformType.STELLAR_CLASSIC) && ( {pathArray?.map((contract, index) => ( @@ -144,7 +186,7 @@ function SwapPathComponent({ trade }: { trade: InterfaceTrade | undefined }) { )} - {trade?.platform == PlatformType.AGGREGATOR && ( + {memoizedTradePath?.platform == PlatformType.AGGREGATOR && ( {distributionArray.map((distribution, index) => ( diff --git a/src/functions/getPairAddress.tsx b/src/functions/getPairAddress.tsx index 0da2943f..b68dd119 100644 --- a/src/functions/getPairAddress.tsx +++ b/src/functions/getPairAddress.tsx @@ -10,13 +10,13 @@ export async function getPairAddress( address_1: string | undefined, sorobanContext: SorobanContextType, ) { + if (!address_0 || !address_1) return ''; const { activeNetwork } = sorobanContext; const activeChain = passphraseToBackendNetworkName[activeNetwork!].toLowerCase(); const factory = await fetchFactory(activeChain); - const response = await contractInvoke({ contractAddress: factory.address, method: 'get_pair', diff --git a/src/hooks/tokens/useApiTokens.ts b/src/hooks/tokens/useApiTokens.ts index 7d1034b8..76ec143e 100644 --- a/src/hooks/tokens/useApiTokens.ts +++ b/src/hooks/tokens/useApiTokens.ts @@ -1,6 +1,6 @@ import { useSorobanReact } from 'stellar-react'; import { TokenMapType, TokenType, tokensResponse } from 'interfaces'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import { fetchTokens } from 'services/tokens'; import useSWRImmutable from 'swr/immutable'; import { tokensToMap } from './utils'; @@ -17,6 +17,7 @@ export const useApiTokens = () => { setActiveChain(activeNetworkId); } }, [activeNetwork]); + const { data, mutate, isLoading, error } = useSWRImmutable( ['tokens', activeChain], () => fetchTokens(activeChain), diff --git a/src/hooks/tokens/useToken.ts b/src/hooks/tokens/useToken.ts index f0d74eb5..1f6f610e 100644 --- a/src/hooks/tokens/useToken.ts +++ b/src/hooks/tokens/useToken.ts @@ -12,7 +12,7 @@ import { Asset } from '@stellar/stellar-sdk'; export const findToken = async ( tokenAddress: string | undefined, tokensAsMap: TokenMapType, - sorobanContext: SorobanContextType, + sorobanContext: SorobanContextType ) => { if (!tokenAddress || tokenAddress === '') return undefined; @@ -22,10 +22,11 @@ export const findToken = async ( if (!formattedAddress) return undefined; const fromMap = tokensAsMap[formattedAddress]; - + if (fromMap) return fromMap; const token = await getToken(sorobanContext, formattedAddress); + // const token: TokenType = { // contract: formattedAddress, // name: name as string, @@ -74,6 +75,7 @@ export function useToken(tokenAddress: string | undefined) { ([key, tokenAddress, sorobanContext]) => getTokenName(tokenAddress!, sorobanContext), ); + const { mutate } = useSWRConfig(); const { data, isLoading, error } = useSWRImmutable( ['token', tokenAddress, tokensAsMap, sorobanContext], @@ -81,6 +83,7 @@ export function useToken(tokenAddress: string | undefined) { findToken(tokenAddress, tokensAsMap, sorobanContext), ); + const handleTokenRefresh = () => { mutate((key: any) => revalidateKeysCondition(key), undefined, { revalidate: true }); }; diff --git a/src/hooks/tokens/useUserAddedTokens.ts b/src/hooks/tokens/useUserAddedTokens.ts index ee73cbee..f8aa59cf 100644 --- a/src/hooks/tokens/useUserAddedTokens.ts +++ b/src/hooks/tokens/useUserAddedTokens.ts @@ -1,6 +1,6 @@ import { useSorobanReact } from 'stellar-react'; import { TokenMapType, TokenType, tokensResponse } from 'interfaces'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import { tokensToMap } from './utils'; import { passphraseToBackendNetworkName } from 'services/pairs'; @@ -8,26 +8,25 @@ import { passphraseToBackendNetworkName } from 'services/pairs'; export const useUserAddedTokens = () => { const sorobanContext = useSorobanReact(); + const [userAddedTokens, setUserAddedTokens] = useState([]); - const [tokensFromActiveChain, setTokensFromActiveChain] = useState([]); - const [tokensAsMap, setTokensAsMap] = useState({}); const userAddedTokensLocalStorage = localStorage.getItem(`userAddedTokens`); useEffect(() => { const userAddedTokensStr = localStorage.getItem(`userAddedTokens`) || '[]'; const userAddedTokens = (JSON.parse(userAddedTokensStr) ?? []) as tokensResponse[]; - const activeNetwork = sorobanContext.activeNetwork; - const activeChain = passphraseToBackendNetworkName[activeNetwork!].toLowerCase(); - const tokensFromActiveChain = - userAddedTokens?.find((item: tokensResponse) => item?.network === activeChain)?.tokens ?? []; - const tokensAsMap = tokensToMap(tokensFromActiveChain); - setUserAddedTokens(userAddedTokens); + }, [userAddedTokensLocalStorage]); // Depend only on localStorage string + + const tokensFromActiveChain = useMemo(() => { + const activeNetwork = sorobanContext.activeNetwork; + if (!activeNetwork) return []; + const activeChain = passphraseToBackendNetworkName[activeNetwork].toLowerCase(); + return userAddedTokens?.find((item: tokensResponse) => item?.network === activeChain)?.tokens ?? []; + }, [userAddedTokens, sorobanContext]); // Depend on userAddedTokens and sorobanContext - setTokensFromActiveChain(tokensFromActiveChain); - setTokensAsMap(tokensAsMap); - }, [sorobanContext, userAddedTokensLocalStorage]); + const tokensAsMap = useMemo(() => tokensToMap(tokensFromActiveChain), [tokensFromActiveChain]); // Depend on tokensFromActiveChain return { allUserAddedTokens: userAddedTokens, diff --git a/src/hooks/tokens/utils.ts b/src/hooks/tokens/utils.ts index d07c899a..53124e74 100644 --- a/src/hooks/tokens/utils.ts +++ b/src/hooks/tokens/utils.ts @@ -54,7 +54,7 @@ export const addUserToken = (token: TokenType, sorobanContext: SorobanContextTyp export async function getToken( sorobanContext: SorobanContextType, - tokenAddress?: string | undefined, + tokenAddress?: string | undefined ): Promise { if (!tokenAddress || tokenAddress === '' || !sorobanContext.activeNetwork) return undefined; diff --git a/src/hooks/useBalances.tsx b/src/hooks/useBalances.tsx index 3dfb16f6..a3732191 100644 --- a/src/hooks/useBalances.tsx +++ b/src/hooks/useBalances.tsx @@ -30,7 +30,7 @@ export interface tokenBalancesType { export async function tokenBalance( tokenAddress: string, userAddress: string, - sorobanContext: SorobanContextType, + sorobanContext: SorobanContextType ) { const user = accountToScVal(userAddress); diff --git a/src/hooks/useBestTrade.ts b/src/hooks/useBestTrade.ts index 42b7e8dd..0b6cdff7 100644 --- a/src/hooks/useBestTrade.ts +++ b/src/hooks/useBestTrade.ts @@ -56,6 +56,7 @@ export function useBestTrade( isLoading: isLoadingSWR, isValidating, } = useSWR( + amountSpecified && otherCurrency ? [amountSpecified, otherCurrency, tradeType, amountSpecified.value, maxHops, protocolsStatus] : null, @@ -69,10 +70,11 @@ export function useBestTrade( }), { revalidateIfStale: true, - revalidateOnFocus: true, + revalidateOnFocus: false, refreshInterval: 0, }, ); + const isLoading = isLoadingSWR || isValidating; //Define the input and output currency based on the trade type diff --git a/src/hooks/useGetMyBalances.ts b/src/hooks/useGetMyBalances.ts index 1aa4d950..77d8531c 100644 --- a/src/hooks/useGetMyBalances.ts +++ b/src/hooks/useGetMyBalances.ts @@ -17,15 +17,14 @@ interface FetchBalancesProps { account: AccountResponse | undefined; } -const fetchBalances = async ({ address, tokens, sorobanContext, account }: FetchBalancesProps) => { +const fetchBalances = async ({ address, tokens, sorobanContext, account }: FetchBalancesProps, ) => { if (!address || !tokens || !sorobanContext || !account) return null; - + const response = await tokenBalances(address, tokens, sorobanContext, account, true); - return response; }; -function calculateAvailableBalance( +export function calculateAvailableBalance( balance?: string | number | BigNumber | null, networkFees?: string | number | BigNumber | null, subentryCount?: number, diff --git a/src/hooks/useRouterCallback.tsx b/src/hooks/useRouterCallback.tsx index 9e953265..3c10253a 100644 --- a/src/hooks/useRouterCallback.tsx +++ b/src/hooks/useRouterCallback.tsx @@ -48,6 +48,8 @@ export function useRouterCallback() { return useCallback( async (method: RouterMethod, args?: StellarSdk.xdr.ScVal[], signAndSend?: boolean) => { + + let result = (await contractInvoke({ contractAddress: router_address as string, method: method, diff --git a/src/hooks/useSwapNetworkFees.ts b/src/hooks/useSwapNetworkFees.ts index 1e260f27..8512f0c6 100644 --- a/src/hooks/useSwapNetworkFees.ts +++ b/src/hooks/useSwapNetworkFees.ts @@ -18,7 +18,7 @@ const fetchNetworkFees = async ( trade: InterfaceTrade | undefined, sorobanContext: SorobanContextType, ) => { - if (!trade || !sorobanContext) return 0; + if (!trade || !sorobanContext) return 0; const fees = await calculateSwapFees(sorobanContext, trade); return fees ? Number(fees) / 10 ** 7 : 0; }; diff --git a/src/services/swapService.ts b/src/services/swapService.ts new file mode 100644 index 00000000..1b85b9ac --- /dev/null +++ b/src/services/swapService.ts @@ -0,0 +1,390 @@ +import { SorobanContextType } from 'stellar-react'; +import { SwapState } from 'state/swap/reducer'; +import { Field } from 'state/swap/actions'; +import { TokenType, TokenMapType, CurrencyAmount } from 'interfaces'; // Import CurrencyAmount +import { relevantTokensType, tokenBalancesType } from 'hooks'; // Import tokenBalancesType +import { InterfaceTrade, TradeState, TradeType } from 'state/routing/types'; // Import TradeType +import { ReactNode } from 'react'; +import { findTokenService } from './tokenService'; // Import findTokenService +import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount'; // Import tryParseCurrencyAmount +import { tokenBalances } from 'hooks'; // Import tokenBalances +import { useBestTrade } from 'hooks/useBestTrade'; // Import useBestTrade +import { useUserSlippageToleranceWithDefault } from 'state/user/hooks'; // Import useUserSlippageToleranceWithDefault +import { DEFAULT_SLIPPAGE_INPUT_VALUE } from 'components/Settings/MaxSlippageSettings'; // Import DEFAULT_SLIPPAGE_INPUT_VALUE +import { isAddress } from 'helpers/address'; // Import isAddress +import { AccountResponse } from '@stellar/stellar-sdk/lib/horizon'; // Import AccountResponse +import BigNumber from 'bignumber.js'; + + +export type SwapInfoService = { + currencies: { [field in Field]?: TokenType | null }; + currencyBalances: { [field in Field]?: relevantTokensType | string | undefined }; //{ [field in Field]?: CurrencyAmount } + parsedAmount?: any; //CurrencyAmount + inputError?: ReactNode; + trade: { + trade?: any; //InterfaceTrade + state: any; //TradeState + uniswapXGasUseEstimateUSD?: number; + error?: any; + }; + allowedSlippage: any; //Percent + autoSlippage: any; //Percent +}; + +// Import necessary types and functions for calculateBestRouteService +import { + BuildSplitTradeReturn, + BuildTradeReturn, + PlatformType, + Protocol, + SwapRouteRequest, + SwapRouteSplitRequest, + ExactInBuildTradeReturn, + ExactInSplitBuildTradeReturn, + ExactOutBuildTradeReturn, + ExactOutSplitBuildTradeReturn, +} from 'state/routing/types'; +import { CurrencyAmount as AmountAsset } from 'interfaces'; +import { getPairAddress } from 'functions/getPairAddress'; +import { reservesBNWithTokens } from 'hooks/useReserves'; //NOTE: This is a hook, need to check if it can be used or replaced +import { getExpectedAmount } from 'functions/getExpectedAmount'; +import { Networks } from '@stellar/stellar-sdk'; +import { getSwapRoute, getSwapSplitRoute } from 'services/soroswapApi'; +import { passphraseToBackendNetworkName } from 'services/pairs'; +import { getBestPath, getHorizonBestPath } from 'helpers/horizon/getHorizonPath'; +import { ProtocolsStatus } from 'contexts'; + +export interface CalculateRouteServiceProps { + amountAsset: AmountAsset; + quoteAsset: TokenType; + amount: string; + tradeType: TradeType; + currentProtocolsStatus: ProtocolsStatus[]; + sorobanContext: SorobanContextType; + factory: string | undefined; // Assuming factory address is a string + maxHops: number; + protocolsStatus: ProtocolsStatus[]; + isAggregator: boolean; + isExactIn: boolean; + allowedSlippage: number +} + +const shouldUseDirectPath = process.env.NEXT_PUBLIC_DIRECT_PATH_ENABLED === 'true'; + +export const calculateBestRouteService = async ({ + amountAsset, + quoteAsset, + amount, + tradeType, + currentProtocolsStatus, + sorobanContext, + factory, + maxHops, + protocolsStatus, + isAggregator, + isExactIn, + allowedSlippage +}: CalculateRouteServiceProps): Promise => { + + if (shouldUseDirectPath) { + try { + // get pair address from factory + const pairAddress = await getPairAddress( + isExactIn ? amountAsset.currency.contract : quoteAsset.contract, + isExactIn ? quoteAsset.contract : amountAsset.currency.contract, + sorobanContext, + ); + + // Get reserves from pair + // reservesBNWithTokens is a hook, need to find an alternative + const reserves = await reservesBNWithTokens(pairAddress, sorobanContext); + if (!reserves?.reserve0 || !reserves?.reserve1) { + throw new Error('Reserves not found'); + } + + + // Get amountOut or amountIn from reserves and tradeType + // getExpectedAmount is a function, can be used directly + let quoteAmount = await getExpectedAmount( + isExactIn ? amountAsset.currency : quoteAsset, + isExactIn ? quoteAsset : amountAsset.currency, + new BigNumber(amount), + sorobanContext, + tradeType, + ); + + // Convert from lumens to stroops (multiply by 10^7) + const quoteAmountInteger = quoteAmount.integerValue(); + + const toReturn: BuildTradeReturn = + tradeType === TradeType.EXACT_INPUT + ? { + assetIn: amountAsset.currency.contract, + assetOut: quoteAsset.contract, + priceImpact: { + numerator: 0, + denominator: 1, + }, + platform: PlatformType.ROUTER, + tradeType: TradeType.EXACT_INPUT, + trade: { + amountIn: BigInt(amountAsset.value), + amountOutMin: BigInt(quoteAmountInteger.toString()), + path: [amountAsset.currency.contract, quoteAsset.contract], + } + } + : { + assetIn: amountAsset.currency.contract, + assetOut: quoteAsset.contract, + priceImpact: { + numerator: 0, + denominator: 1, + }, + platform: PlatformType.ROUTER, + tradeType: TradeType.EXACT_OUTPUT, + trade: { + amountOut: BigInt(amount), + amountInMax: BigInt(quoteAmountInteger.toString()), + path: [amountAsset.currency.contract, quoteAsset.contract ], + }, + }; + + return toReturn; + } catch (error) { + console.error('Error generating direct path:', error); + throw error; + } + } + if (!factory) throw new Error('Factory address not found'); + + const isHorizonEnabled = currentProtocolsStatus.find( + (p) => p.key === PlatformType.STELLAR_CLASSIC, + )?.value; + + const isSoroswapEnabled = currentProtocolsStatus.find((p) => p.key === Protocol.SOROSWAP) + ?.value; + + const horizonProps = { + assetFrom: isExactIn ? amountAsset.currency : quoteAsset, + assetTo: isExactIn ? quoteAsset : amountAsset.currency, + amount, + tradeType, + }; + + let horizonPath: BuildTradeReturn | undefined; + if (isHorizonEnabled) { + horizonPath = (await getHorizonBestPath(horizonProps, sorobanContext)) as BuildTradeReturn; + } + + let sorobanPath: BuildTradeReturn | BuildSplitTradeReturn | undefined; + if (isAggregator) { + + const swapSplitRequest: SwapRouteSplitRequest = { + assetIn: amountAsset.currency.contract, + assetOut: quoteAsset.contract, + amount: amount, + tradeType: tradeType, + protocols: protocolsStatus.filter(p => p.key !== PlatformType.STELLAR_CLASSIC && p.value).map(p => p.key as Protocol), + parts: 10, + slippageTolerance: Math.floor(Number(allowedSlippage) * 100).toString(), + assetList: ['SOROSWAP'], // TODO: Use actual asset list, + maxHops: maxHops, + }; + + try { + const networkName = passphraseToBackendNetworkName[sorobanContext.activeNetwork ?? Networks.TESTNET]; + const response = await getSwapSplitRoute(networkName, swapSplitRequest); + sorobanPath = response; + } catch (error) { + console.error('Error getting soroban split path:', error); + return undefined; + } + + } else if (isSoroswapEnabled) { + //NOTE: Asset changed to defaultm without reverse + const swapRequest: SwapRouteRequest = { + assetIn: amountAsset.currency.contract, + assetOut: quoteAsset.contract, + amount: amount, + tradeType: tradeType, + slippageTolerance: Math.floor(Number(allowedSlippage) * 100).toString(), + assetList: ['SOROSWAP'], // TODO: Use actual asset list, + maxHops: maxHops, + }; + + try { + const networkName = passphraseToBackendNetworkName[sorobanContext.activeNetwork ?? Networks.TESTNET]; + const response = await getSwapRoute(networkName, swapRequest); + sorobanPath = response; + } catch (error) { + console.error('Error getting soroban path:', error); + return undefined; + } + } + const bestPath = getBestPath(horizonPath, sorobanPath, tradeType); + return bestPath; +}; + +export const getDerivedSwapInfoService = async ( + state: SwapState, + sorobanContext: SorobanContextType, + account: string | undefined | null, + tokensAsMap: TokenMapType, + horizonAccount: AccountResponse | undefined, // Accept AccountResponse as argument + allowedSlippage: number, // Accept allowedSlippage as argument + // Add arguments for calculateBestRouteService dependencies + factory: string | undefined, + maxHops: number, + protocolsStatus: ProtocolsStatus[], + isAggregator: boolean, +): Promise => { + + const { + independentField, + typedValue, + [Field.INPUT]: { currencyId: inputCurrencyId }, + [Field.OUTPUT]: { currencyId: outputCurrencyId }, + recipient, + } = state; + + // Fetch input and output tokens using findTokenService + const inputCurrency = await findTokenService(inputCurrencyId ?? undefined, tokensAsMap, sorobanContext); + const outputCurrency = await findTokenService(outputCurrencyId ?? undefined, tokensAsMap, sorobanContext); + + const tokensArray = inputCurrency && outputCurrency ? [inputCurrency, outputCurrency] : undefined; + + // Fetch relevant token balances + let relevantTokenBalances: { [field in Field]?: relevantTokensType | string | undefined } | undefined; + if (account && tokensArray && horizonAccount) { // Use passed horizonAccount + const balancesResult = await tokenBalances(account, tokensArray, sorobanContext, horizonAccount); + // Transform the balances array into an object with INPUT and OUTPUT fields + relevantTokenBalances = { + [Field.INPUT]: balancesResult?.balances?.[0] ?? '', // Assuming the order is always input then output + [Field.OUTPUT]: balancesResult?.balances?.[1] ?? '', + }; + } + + const isExactIn: boolean = independentField === Field.INPUT; + const parsedAmount = tryParseCurrencyAmount(typedValue, inputCurrency ?? undefined); + + // Fetch best trade using the new service function + let trade: any = { trade: undefined, state: TradeState.STALE }; // Initial state + + if (parsedAmount && inputCurrency && outputCurrency) { + trade.state = TradeState.LOADING; // Set loading state before the async call + try { + const route = await calculateBestRouteService({ + amountAsset: parsedAmount, + quoteAsset: outputCurrency, + amount: parsedAmount.value, // Use typedValue for the amount + tradeType: isExactIn ? TradeType.EXACT_INPUT : TradeType.EXACT_OUTPUT, + currentProtocolsStatus: protocolsStatus, // Pass protocolsStatus + sorobanContext, + factory, // Pass factory + maxHops, // Pass maxHops + protocolsStatus, // Pass protocolsStatus again? Check if needed twice + isAggregator, // Pass isAggregator + isExactIn: isExactIn, + allowedSlippage + }); + + // Map the route result to the trade structure expected by SwapInfoService + let inputAmount: CurrencyAmount | undefined; + let outputAmount: CurrencyAmount | undefined; + + if (route) { + // Use type guards to narrow down the type of the route and access trade details + if (route.tradeType === TradeType.EXACT_INPUT) { + const exactInRoute = route as ExactInBuildTradeReturn | ExactInSplitBuildTradeReturn; + const tradeDetails = exactInRoute.trade; + inputAmount = { currency: inputCurrency, value: tradeDetails.amountIn.toString() }; + outputAmount = { currency: outputCurrency, value: tradeDetails.amountOutMin.toString() }; + } else if (route.tradeType === TradeType.EXACT_OUTPUT) { // TradeType.EXACT_OUTPUT + const exactOutRoute = route as ExactOutBuildTradeReturn | ExactOutSplitBuildTradeReturn; + const tradeDetails = exactOutRoute.trade; + inputAmount = { currency: inputCurrency, value: tradeDetails.amountInMax.toString() }; + outputAmount = { currency: outputCurrency, value: tradeDetails.amountOut.toString() }; + } + } + + trade = { + trade: { + inputAmount: inputAmount, + outputAmount: outputAmount, + state: route ? TradeState.VALID : TradeState.NO_ROUTE_FOUND, + path: (route?.trade as any)?.path, + distribution: (route?.trade as any)?.distribution, + expectedAmount: route?.tradeType === TradeType.EXACT_INPUT ? (route?.trade as any)?.amountOutMin?.toString() : (route?.trade as any)?.amountInMax?.toString(), + assetIn: route?.assetIn, + assetOut: route?.assetOut, + priceImpact: route?.priceImpact, + platform: route?.platform, + tradeType: route?.tradeType, + }, + }; + + } catch (error) { + console.error("Error calculating best route:", error); + trade.state = TradeState.INVALID; // Set state to invalid on error + // Optionally set an error message in trade.error + } + } + + + // Calculate input error + let inputError: ReactNode | undefined; + + if (!account) { + inputError = 'Connect Wallet'; + } + + if (!inputCurrency || !outputCurrency) { + inputError = inputError ?? 'Select a token'; + } + + if (!parsedAmount) { + inputError = inputError ?? 'Enter an amount'; + } + + const formattedTo = isAddress(recipient ?? ''); + if (!recipient || !formattedTo) { + inputError = inputError ?? 'Enter a recipient'; + } + + // Check for insufficient liquidity + if ( + trade.trade?.outputAmount?.value?.includes('Infinity') || + trade.trade?.outputAmount?.value?.includes('-') + ) { + inputError = 'Insufficient liquidity for this trade.'; + } + + // Check for insufficient balance + const balanceIn: string | relevantTokensType | undefined = relevantTokenBalances ? relevantTokenBalances[Field.INPUT] : undefined; // Get the balance for the input currency from the object + const maxAmountIn: string | number = trade.trade?.inputAmount?.value ?? 0; + + if ( + balanceIn && + typeof balanceIn !== 'string' && + BigNumber(balanceIn.balance!).isLessThan(BigNumber(maxAmountIn)) // Use isLessThan for strict comparison + ) { + inputError = `Insufficient ${balanceIn.code} balance`; + } + + + return { + currencies: { + [Field.INPUT]: inputCurrency, + [Field.OUTPUT]: outputCurrency, + }, + currencyBalances: relevantTokenBalances ?? {}, // Use the fetched balances, provide empty object if undefined + parsedAmount: parsedAmount, // Use the parsed amount + inputError: inputError, // Use the calculated input error + trade: trade, // Use the fetched trade + allowedSlippage: allowedSlippage, // Use the calculated slippage + autoSlippage: undefined, // Auto slippage is not directly used in the return type + }; +}; + + + diff --git a/src/services/tokenService.ts b/src/services/tokenService.ts new file mode 100644 index 00000000..adeec7da --- /dev/null +++ b/src/services/tokenService.ts @@ -0,0 +1,103 @@ +import { SorobanContextType, WalletNetwork } from 'stellar-react'; +import { getClassicAssetSorobanAddress } from 'functions/getClassicAssetSorobanAddress'; +import { getClassicStellarAsset, isAddress } from 'helpers/address'; +import { getTokenName } from 'helpers/soroban'; +import { TokenMapType, TokenType, tokensResponse } from 'interfaces'; +import { getToken, isClassicStellarAsset, tokensToMap } from 'hooks/tokens/utils'; +import { Asset } from '@stellar/stellar-sdk'; +import { fetchTokens } from 'services/tokens'; +import { passphraseToBackendNetworkName } from 'services/pairs'; + +export const findTokenService = async ( + tokenAddress: string | undefined, + tokensAsMap: TokenMapType, + sorobanContext: SorobanContextType +): Promise => { + if (!tokenAddress || tokenAddress === '') return undefined; + + const classicAssetSearch = getClassicAssetSorobanAddress(tokenAddress!, sorobanContext); + + const formattedAddress = isAddress(classicAssetSearch ? classicAssetSearch : tokenAddress); + if (!formattedAddress) return undefined; + + const fromMap = tokensAsMap[formattedAddress]; + + if (fromMap) return fromMap; + + const token = await getToken(sorobanContext, formattedAddress); + + if (!token?.name || !token?.code) return undefined; + // Here from token.name we will try to understand if this is a classic asset (even if we got a soroban contracta as address). + const stellarAsset = getClassicStellarAsset(token.name); + + /* + TODO: Here we might have a situation where a Soroban Contract has as name a CODE:ISSUER + We need to have a beter way to know if a soroban contract its for sure a stellar classic asset, and which. + */ + if (stellarAsset && typeof stellarAsset !== 'boolean') { + return { + issuer: stellarAsset.issuer, + contract: token.contract, + name: stellarAsset.asset, + code: stellarAsset.assetCode, + decimals: 7, + icon: '', + }; + } + else { + return token + }; +}; + +// Helper function to check if a token is a classic stellar asset +export const isClassicStellarAssetService = async ( + tokenAddress: string | undefined, + sorobanContext: SorobanContextType +): Promise => { + if (!tokenAddress) return undefined; + return isClassicStellarAsset(tokenAddress, sorobanContext); +}; + +// Helper function to get token name +export const getTokenNameService = async ( + tokenAddress: string | undefined, + sorobanContext: SorobanContextType, + +): Promise => { + if (!tokenAddress) return undefined; + return getTokenName(tokenAddress, sorobanContext); +}; + +export const getAllTokensService = async ( + sorobanContext: SorobanContextType, + +): Promise<{ tokens: TokenType[]; tokensAsMap: TokenMapType }> => { + const { activeNetwork } = sorobanContext; + if (!activeNetwork) { + return { tokens: [], tokensAsMap: {} }; + } + + const activeChain = passphraseToBackendNetworkName[activeNetwork].toLowerCase(); + + // Fetch API tokens + const apiTokensData = await fetchTokens(activeChain); + + // If test net: apiTokens has strucuture [{network: 'mainnet': assets: []...}], + // for mainNet: {network: 'mainnet', assets: []} + const apiTokens = (apiTokensData && apiTokensData?.length) ? + apiTokensData?.find((tkn: { network: string; }) => tkn && tkn. + network === activeChain + )?.assets : apiTokensData?.assets; + // Get user-added tokens from localStorage + const userAddedTokensStr = localStorage.getItem(`userAddedTokens`) || '[]'; + const userAddedTokens = (JSON.parse(userAddedTokensStr) ?? []) as tokensResponse[]; + const userTokensFromActiveChain = userAddedTokens?.find( + (item: tokensResponse) => item?.network === activeChain, + )?.tokens ?? []; + + // Combine tokens + const allTokens = [...apiTokens, ...userTokensFromActiveChain]; + const allTokensAsMap = tokensToMap(allTokens); + + return { tokens: allTokens, tokensAsMap: allTokensAsMap }; +}; \ No newline at end of file diff --git a/src/services/tokens.ts b/src/services/tokens.ts index 63955aaf..e7d91a84 100644 --- a/src/services/tokens.ts +++ b/src/services/tokens.ts @@ -23,3 +23,4 @@ export const fetchTokens = async (network: string) => { return tokensList; }; + \ No newline at end of file diff --git a/src/state/swap/hooks.tsx b/src/state/swap/hooks.tsx index 168fde32..3056a399 100644 --- a/src/state/swap/hooks.tsx +++ b/src/state/swap/hooks.tsx @@ -5,7 +5,6 @@ import { relevantTokensType, tokenBalances } from 'hooks'; import { useToken } from 'hooks/tokens/useToken'; import { useBestTrade } from 'hooks/useBestTrade'; import useHorizonLoadAccount from 'hooks/useHorizonLoadAccount'; -import { TokenType } from 'interfaces'; import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount'; import { ParsedQs } from 'qs'; import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; @@ -15,6 +14,7 @@ import { useUserSlippageToleranceWithDefault } from 'state/user/hooks'; import { Field, selectCurrency, setRecipient, switchCurrencies, typeInput } from './actions'; import { SwapState } from './reducer'; import { DEFAULT_SLIPPAGE_INPUT_VALUE } from 'components/Settings/MaxSlippageSettings'; +import { TokenType } from 'interfaces'; export function useSwapActionHandlers(dispatch: React.Dispatch): { onCurrencySelection: (field: Field, currency: TokenType) => void; @@ -90,14 +90,16 @@ export function useDerivedSwapInfo(state: SwapState) { } = state; const { token: inputCurrency } = useToken(inputCurrencyId!); + // const { token: outputCurrency } = useToken(outputCurrencyId!); + const recipientLookup = { address: '' }; //TODO: Use ENS useENS(recipient ?? undefined) // const to: string | null | undefined = account; //recipient === null ? account : recipientLookup.contract) ?? null const tokensArray = useMemo(() => { - return inputCurrency && outputCurrency ? [inputCurrency, outputCurrency] : undefined; + return inputCurrency && outputCurrency?.code ? [inputCurrency, outputCurrency] : undefined; }, [inputCurrency, outputCurrency]); const [relevantTokenBalances, setRelevantTokenBalances] = useState< @@ -105,6 +107,7 @@ export function useDerivedSwapInfo(state: SwapState) { >(); const { account: horizonAccount } = useHorizonLoadAccount(); + useEffect(() => { if (account) { tokenBalances(account, tokensArray, sorobanContext, horizonAccount).then((res) => { @@ -316,3 +319,4 @@ export function queryParametersToSwapState(parsedQs: ParsedQs): SwapState { // return parsedSwapState // } +