diff --git a/apps/api/src/api/services/phases/handlers/pendulum-to-moonbeam-xcm-handler.ts b/apps/api/src/api/services/phases/handlers/pendulum-to-moonbeam-xcm-handler.ts index ea0a7fc0b..1d48c4b0f 100644 --- a/apps/api/src/api/services/phases/handlers/pendulum-to-moonbeam-xcm-handler.ts +++ b/apps/api/src/api/services/phases/handlers/pendulum-to-moonbeam-xcm-handler.ts @@ -85,7 +85,7 @@ export class PendulumToMoonbeamXCMPhaseHandler extends BasePhaseHandler { return balance.gte(expectedOutputAmountRaw); }; - const waitForMoonbeamArrival = async (timeoutMs: number = 120000): Promise => { + const waitForMoonbeamArrival = async (timeoutMs = 120000): Promise => { const startTime = Date.now(); const pollIntervalMs = 5000; diff --git a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts index 4f06d46a3..41aec032b 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts @@ -226,7 +226,7 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { isExecuted = true; break; } else if (!payTxHash) { - logger.info(`SquidRouterPayPhaseHandler: Bridge transaction detected on Axelar. Proceeding to fund gas.`); + logger.info("SquidRouterPayPhaseHandler: Bridge transaction detected on Axelar. Proceeding to fund gas."); const nativeToFundRaw = this.calculateGasFeeInUnits(axelarScanStatus.fees, DEFAULT_SQUIDROUTER_GAS_ESTIMATE); const logIndex = Number(axelarScanStatus.id.split("_")[2]); @@ -249,7 +249,7 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { }); } } else { - logger.info(`SquidRouterPayPhaseHandler: Same-chain transaction detected. Skipping Axelar check.`); + logger.info("SquidRouterPayPhaseHandler: Same-chain transaction detected. Skipping Axelar check."); } } catch (error) { logger.error(`SquidRouterPayPhaseHandler: Error in bridge status loop for ${swapHash}:`, error); diff --git a/apps/api/src/api/workers/cleanup.worker.ts b/apps/api/src/api/workers/cleanup.worker.ts index 5dc2d4692..9d2a245f9 100644 --- a/apps/api/src/api/workers/cleanup.worker.ts +++ b/apps/api/src/api/workers/cleanup.worker.ts @@ -176,7 +176,7 @@ class CleanupWorker { await this.processCleanup(state); return { stateId: state.id, status: "fulfilled" }; } catch (error) { - logger.error(`Error processing cleanup:`, error); + logger.error("Error processing cleanup:", error); // Don't update the state here, processCleanup handles its own updates return { reason: error, stateId: state.id, status: "rejected" }; } diff --git a/apps/frontend/src/components/ListItem/index.tsx b/apps/frontend/src/components/ListItem/index.tsx index 57b913178..a7d81b366 100644 --- a/apps/frontend/src/components/ListItem/index.tsx +++ b/apps/frontend/src/components/ListItem/index.tsx @@ -1,20 +1,31 @@ import { CheckIcon } from "@heroicons/react/20/solid"; -import { FiatToken, isFiatToken, OnChainToken, OnChainTokenDetails } from "@vortexfi/shared"; +import { FiatToken, isFiatToken, OnChainToken, stringifyBigWithSignificantDecimals } from "@vortexfi/shared"; +import Big from "big.js"; import { memo } from "react"; import { useTranslation } from "react-i18next"; import { getTokenDisabledReason, isFiatTokenDisabled } from "../../config/tokenAvailability"; import { useTokenIcon } from "../../hooks/useTokenIcon"; import { TokenIconWithNetwork } from "../TokenIconWithNetwork"; import { ExtendedTokenDefinition } from "../TokenSelection/TokenSelectionList/hooks/useTokenSelection"; -import { UserBalance } from "../UserBalance"; interface ListItemProps { isSelected?: boolean; onSelect: (tokenType: OnChainToken | FiatToken) => void; token: ExtendedTokenDefinition; + balance?: string; } -export const ListItem = memo(function ListItem({ token, isSelected, onSelect }: ListItemProps) { +function formatBalance(balance: string): string { + try { + const big = new Big(balance); + if (big.eq(0)) return ""; + return stringifyBigWithSignificantDecimals(big, 2); + } catch { + return ""; + } +} + +export const ListItem = memo(function ListItem({ token, isSelected, onSelect, balance }: ListItemProps) { const { t } = useTranslation(); const isFiat = isFiatToken(token.type); // Use assetIcon for fiat lookup, with network for on-chain tokens @@ -24,6 +35,8 @@ export const ListItem = memo(function ListItem({ token, isSelected, onSelect }: const isDisabled = isFiat && isFiatTokenDisabled(token.type as FiatToken); const disabledReason = isFiat && isDisabled ? t(getTokenDisabledReason(token.type as FiatToken)) : undefined; + const formattedBalance = balance ? formatBalance(balance) : ""; + return ( ); diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx index 0bd1bf4e6..a35090db1 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx @@ -1,17 +1,47 @@ import { useVirtualizer } from "@tanstack/react-virtual"; -import { useRef } from "react"; +import { useMemo, useRef } from "react"; import { useNetwork } from "../../../../contexts/network"; import { cn } from "../../../../helpers/cn"; -import { useTokensSortedByBalance } from "../../../../hooks/useTokensSortedByBalance"; +import { useTokenBalances } from "../../../../stores/tokenBalanceStore"; import { useIsNetworkDropdownOpen, useSearchFilter, useSelectedNetworkFilter } from "../../../../stores/tokenSelectionStore"; import { ListItem } from "../../../ListItem"; import { useIsFiatDirection, useTokenDefinitions } from "../helpers"; -import { useTokenSelection } from "../hooks/useTokenSelection"; +import { ExtendedTokenDefinition, useTokenSelection } from "../hooks/useTokenSelection"; const ROW_HEIGHT = 56; const OVERSCAN = 10; const MIN_ITEMS_FOR_VIRTUALIZATION = 20; +function getBalanceKey(network: string, symbol: string): string { + return `${network}-${symbol}`; +} + +function sortByBalance( + definitions: ExtendedTokenDefinition[], + balances: Map +): ExtendedTokenDefinition[] { + return [...definitions].sort((a, b) => { + const balanceA = balances.get(getBalanceKey(a.network, a.assetSymbol)); + const balanceB = balances.get(getBalanceKey(b.network, b.assetSymbol)); + const usdA = parseFloat(balanceA?.balanceUsd ?? "0"); + const usdB = parseFloat(balanceB?.balanceUsd ?? "0"); + + if (usdA !== usdB) { + return usdB - usdA; + } + + // When USD values are equal (e.g., both 0), sort by raw balance + const rawBalanceA = parseFloat(balanceA?.balance ?? "0"); + const rawBalanceB = parseFloat(balanceB?.balance ?? "0"); + + if (rawBalanceA !== rawBalanceB) { + return rawBalanceB - rawBalanceA; + } + + return a.assetSymbol.localeCompare(b.assetSymbol); + }); +} + export const SelectionTokenList = () => { const isFiatDirection = useIsFiatDirection(); const isNetworkDropdownOpen = useIsNetworkDropdownOpen(); @@ -19,11 +49,15 @@ export const SelectionTokenList = () => { const selectedNetworkFilter = useSelectedNetworkFilter(); const { selectedNetwork } = useNetwork(); const { filteredDefinitions } = useTokenDefinitions(searchFilter, selectedNetworkFilter); - - const sortedDefinitions = useTokensSortedByBalance(filteredDefinitions); - const currentDefinitions = isFiatDirection ? filteredDefinitions : sortedDefinitions; const { handleTokenSelect, selectedToken } = useTokenSelection(); + const balances = useTokenBalances(); + + const currentDefinitions = useMemo( + () => (isFiatDirection ? filteredDefinitions : sortByBalance(filteredDefinitions, balances)), + [isFiatDirection, filteredDefinitions, balances] + ); + const parentRef = useRef(null); const shouldVirtualize = currentDefinitions.length > MIN_ITEMS_FOR_VIRTUALIZATION; @@ -49,6 +83,7 @@ export const SelectionTokenList = () => { {rowVirtualizer.getVirtualItems().map(virtualItem => { const token = currentDefinitions[virtualItem.index]; const isSelected = selectedToken === token.type && selectedNetwork === token.network; + const tokenBalance = balances.get(getBalanceKey(token.network, token.assetSymbol)); return (
{ key={virtualItem.key} style={{ height: ROW_HEIGHT, transform: `translateY(${virtualItem.start}px)` }} > - handleTokenSelect(tokenType, token)} token={token} /> + handleTokenSelect(tokenType, token)} + token={token} + />
); })} @@ -65,8 +105,10 @@ export const SelectionTokenList = () => {
{currentDefinitions.map(token => { const isSelected = selectedToken === token.type && selectedNetwork === token.network; + const tokenBalance = balances.get(getBalanceKey(token.network, token.assetSymbol)); return ( handleTokenSelect(tokenType, token)} diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx index 0dd9dda4f..27ae49229 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx @@ -14,63 +14,44 @@ import { RampDirection, stellarTokenConfig } from "@vortexfi/shared"; -import { useMemo, useRef } from "react"; +import { useMemo } from "react"; import { getEvmTokenConfig } from "../../../services/tokens"; import { useRampDirection } from "../../../stores/rampDirectionStore"; import { useTokenSelectionState } from "../../../stores/tokenSelectionStore"; import { ExtendedTokenDefinition } from "./hooks/useTokenSelection"; -function useDeepStableReference(value: T[]): T[] { - const ref = useRef(value); - - const isChanged = useMemo(() => { - if (ref.current.length !== value.length) return true; - - return JSON.stringify(ref.current) !== JSON.stringify(value); - }, [value]); - - if (isChanged) { - ref.current = value; - } - - return ref.current; -} - export function useTokenDefinitions(filter: string, selectedNetworkFilter: Networks | "all") { const { tokenSelectModalType } = useTokenSelectionState(); const rampDirection = useRampDirection(); - const rawDefinitions = useMemo( + const allDefinitions = useMemo( () => getAllSupportedTokenDefinitions(tokenSelectModalType, rampDirection), [tokenSelectModalType, rampDirection] ); - const allDefinitions = useDeepStableReference(rawDefinitions); - const availableNetworks = useMemo(() => { const networks = new Set(allDefinitions.map(token => token.network)); return Array.from(networks).sort(); }, [allDefinitions]); - const rawNetworkFiltered = useMemo(() => { + const networkFiltered = useMemo(() => { if (selectedNetworkFilter === "all") { return allDefinitions; } return allDefinitions.filter(token => token.network === selectedNetworkFilter); }, [allDefinitions, selectedNetworkFilter]); - const networkFilteredDefinitions = useDeepStableReference(rawNetworkFiltered); - const filteredDefinitions = useMemo(() => { - const searchTerm = filter.toLowerCase(); + if (!filter) return networkFiltered; - return networkFilteredDefinitions.filter( + const searchTerm = filter.toLowerCase(); + return networkFiltered.filter( ({ assetSymbol, name, networkDisplayName }) => assetSymbol.toLowerCase().includes(searchTerm) || (name && name.toLowerCase().includes(searchTerm)) || networkDisplayName.toLowerCase().includes(searchTerm) ); - }, [networkFilteredDefinitions, filter]); + }, [networkFiltered, filter]); return { availableNetworks, @@ -108,19 +89,35 @@ function getOnChainTokensDefinitionsForNetwork(selectedNetwork: Networks): Exten } } +let cachedOnChainTokens: ExtendedTokenDefinition[] | null = null; +let cachedEvmConfigRef: ReturnType | null = null; + function getAllOnChainTokens(): ExtendedTokenDefinition[] { + const currentEvmConfig = getEvmTokenConfig(); + + if (cachedOnChainTokens && cachedEvmConfigRef === currentEvmConfig) { + return cachedOnChainTokens; + } + const allTokens: ExtendedTokenDefinition[] = []; allTokens.push(...getOnChainTokensDefinitionsForNetwork(Networks.AssetHub)); const evmNetworks = Object.values(Networks).filter(isNetworkEVM).filter(doesNetworkSupportRamp) as EvmNetworks[]; - const evmConfig = getEvmTokenConfig(); for (const network of evmNetworks) { - if (evmConfig[network]) { + if (currentEvmConfig[network]) { allTokens.push(...getOnChainTokensDefinitionsForNetwork(network)); } } + + cachedOnChainTokens = allTokens; + cachedEvmConfigRef = currentEvmConfig; return allTokens; } +export function invalidateOnChainTokensCache(): void { + cachedOnChainTokens = null; + cachedEvmConfigRef = null; +} + function getFiatTokens(filterEurcOnly = false): ExtendedTokenDefinition[] { const moonbeamEntries = Object.entries(moonbeamTokenConfig); const stellarEntries = filterEurcOnly diff --git a/apps/frontend/src/hooks/useInitTokenBalances.ts b/apps/frontend/src/hooks/useInitTokenBalances.ts new file mode 100644 index 000000000..f39913462 --- /dev/null +++ b/apps/frontend/src/hooks/useInitTokenBalances.ts @@ -0,0 +1,23 @@ +import { useEffect } from "react"; +import { useAssetHubNode } from "../contexts/polkadotNode"; +import { useTokenBalanceActions } from "../stores/tokenBalanceStore"; +import { useVortexAccount } from "./useVortexAccount"; + +/** + * Hook to initialize token balances when wallet connects. + * Should be called once at the app level (e.g., in App.tsx or a layout component). + */ +export function useInitTokenBalances(): void { + const { evmAddress, substrateAddress } = useVortexAccount(); + const { apiComponents: assethubNode } = useAssetHubNode(); + const { fetchBalances, clearBalances } = useTokenBalanceActions(); + + useEffect(() => { + if (!evmAddress && !substrateAddress) { + clearBalances(); + return; + } + + fetchBalances(evmAddress ?? null, substrateAddress ?? null, assethubNode?.api); + }, [evmAddress, substrateAddress, assethubNode?.api, fetchBalances, clearBalances]); +} diff --git a/apps/frontend/src/hooks/useMaintenanceStatus.ts b/apps/frontend/src/hooks/useMaintenanceStatus.ts new file mode 100644 index 000000000..ba3a36c70 --- /dev/null +++ b/apps/frontend/src/hooks/useMaintenanceStatus.ts @@ -0,0 +1,19 @@ +import { useEffect } from "react"; +import { useFetchMaintenanceStatus } from "../stores/maintenanceStore"; + +export function useMaintenanceStatus() { + const fetchMaintenanceStatus = useFetchMaintenanceStatus(); + + useEffect(() => { + fetchMaintenanceStatus(); + + const interval = setInterval( + () => { + fetchMaintenanceStatus(); + }, + 5 * 60 * 1000 + ); + + return () => clearInterval(interval); + }, [fetchMaintenanceStatus]); +} diff --git a/apps/frontend/src/hooks/useOnchainTokenBalance.ts b/apps/frontend/src/hooks/useOnchainTokenBalance.ts index 3260456c8..fdc53a323 100644 --- a/apps/frontend/src/hooks/useOnchainTokenBalance.ts +++ b/apps/frontend/src/hooks/useOnchainTokenBalance.ts @@ -1,14 +1,12 @@ import { OnChainTokenDetails, OnChainTokenDetailsWithBalance } from "@vortexfi/shared"; -import { useMemo } from "react"; -import { useOnchainTokenBalances } from "./useOnchainTokenBalances"; +import { useTokenBalance } from "../stores/tokenBalanceStore"; export const useOnchainTokenBalance = ({ token }: { token: OnChainTokenDetails }): OnChainTokenDetailsWithBalance => { - const tokens = useMemo(() => [token], [token]); - const balances = useOnchainTokenBalances(tokens); + const balance = useTokenBalance(token.network, token.assetSymbol); - return balances[0]; + return { + ...token, + balance: balance?.balance ?? "0.00", + balanceUsd: balance?.balanceUsd ?? "0.00" + }; }; - -export function getOnchainTokenBalance(token?: OnChainTokenDetailsWithBalance): string { - return token?.balance ?? "0"; -} diff --git a/apps/frontend/src/hooks/useTokensSortedByBalance.ts b/apps/frontend/src/hooks/useTokensSortedByBalance.ts deleted file mode 100644 index b264684c6..000000000 --- a/apps/frontend/src/hooks/useTokensSortedByBalance.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { isOnChainTokenDetails, OnChainTokenDetails } from "@vortexfi/shared"; -import { useMemo } from "react"; -import { ExtendedTokenDefinition } from "../components/TokenSelection/TokenSelectionList/hooks/useTokenSelection"; -import { useOnchainTokenBalancesSorted } from "./useOnchainTokenBalancesSorted"; - -function sortDefinitionsByBalanceOrder( - definitions: ExtendedTokenDefinition[], - balanceSortedDetails: OnChainTokenDetails[] -): ExtendedTokenDefinition[] { - const sortOrderMap = new Map(); - balanceSortedDetails.forEach((details, index) => { - sortOrderMap.set(`${details.network}-${details.assetSymbol}`, index); - }); - - return [...definitions].sort((a, b) => { - const keyA = `${a.network}-${a.assetSymbol}`; - const keyB = `${b.network}-${b.assetSymbol}`; - const orderA = sortOrderMap.get(keyA) ?? Number.MAX_SAFE_INTEGER; - const orderB = sortOrderMap.get(keyB) ?? Number.MAX_SAFE_INTEGER; - return orderA - orderB; - }); -} - -export const useTokensSortedByBalance = (tokenDefinitions: ExtendedTokenDefinition[]): ExtendedTokenDefinition[] => { - const onChainTokenDetails = useMemo( - () => - tokenDefinitions - .filter(token => token.details && isOnChainTokenDetails(token.details)) - .map(token => token.details as OnChainTokenDetails), - [tokenDefinitions] - ); - - const sortedTokensWithBalances = useOnchainTokenBalancesSorted(onChainTokenDetails); - - return useMemo( - () => sortDefinitionsByBalanceOrder(tokenDefinitions, sortedTokensWithBalances), - [tokenDefinitions, sortedTokensWithBalances] - ); -}; diff --git a/apps/frontend/src/layouts/index.tsx b/apps/frontend/src/layouts/index.tsx index 3d03362c9..31e888472 100644 --- a/apps/frontend/src/layouts/index.tsx +++ b/apps/frontend/src/layouts/index.tsx @@ -4,6 +4,7 @@ import { MaintenanceBanner } from "../components/MaintenanceBanner"; import { Navbar } from "../components/Navbar"; import Stepper from "../components/Stepper"; import { useIsQuoteComponentDisplayed } from "../hooks/ramp/useIsQuoteComponentDisplayed"; +import { useInitTokenBalances } from "../hooks/useInitTokenBalances"; import { useStepper } from "../hooks/useStepper"; import { useWidgetMode } from "../hooks/useWidgetMode"; import { useFetchMaintenanceStatus } from "../stores/maintenanceStore"; @@ -15,24 +16,12 @@ interface BaseLayoutProps { export const BaseLayout: FC = ({ main, modals }) => { const isWidgetMode = useWidgetMode(); - const fetchMaintenanceStatus = useFetchMaintenanceStatus(); + const { steps } = useStepper(); const isQuoteComponentDisplayed = useIsQuoteComponentDisplayed(); - // Fetch maintenance status when the app loads - useEffect(() => { - fetchMaintenanceStatus(); - - // Set up periodic refresh every 5 minutes - const interval = setInterval( - () => { - fetchMaintenanceStatus(); - }, - 5 * 60 * 1000 - ); - - return () => clearInterval(interval); - }, [fetchMaintenanceStatus]); + useInitTokenBalances(); + useFetchMaintenanceStatus(); const isStepperHidden = isWidgetMode && isQuoteComponentDisplayed; diff --git a/apps/frontend/src/services/balances/assetHubBalanceFetcher.ts b/apps/frontend/src/services/balances/assetHubBalanceFetcher.ts new file mode 100644 index 000000000..d9ce0733b --- /dev/null +++ b/apps/frontend/src/services/balances/assetHubBalanceFetcher.ts @@ -0,0 +1,101 @@ +import { ApiPromise } from "@polkadot/api"; +import { AssetHubTokenDetails, assetHubTokenConfig, getTokenUsdPrice, Networks, nativeToDecimal } from "@vortexfi/shared"; +import Big from "big.js"; +import { BalanceMap, getBalanceKey, TokenBalance } from "./types"; + +function calculateBalanceUsd(balance: string, symbol: string): string { + const usdPrice = getTokenUsdPrice(symbol); + if (!usdPrice || usdPrice === 0) { + return "0.00"; + } + return Big(balance).times(usdPrice).toFixed(2, 0); +} + +function getAllSupportedAssetHubTokens(): AssetHubTokenDetails[] { + return Object.values(assetHubTokenConfig); +} + +async function fetchNativeBalance( + api: ApiPromise, + substrateAddress: string, + nativeToken: AssetHubTokenDetails +): Promise { + try { + const accountInfo = await api.query.system.account(substrateAddress); + const accountData = accountInfo.toJSON() as { data: { free: number } }; + const freeBalance = accountData.data.free || 0; + const balance = nativeToDecimal(freeBalance, nativeToken.decimals).toFixed(4, 0).toString(); + + return { balance, balanceUsd: calculateBalanceUsd(balance, nativeToken.assetSymbol) }; + } catch (error) { + console.error("Error fetching AssetHub native balance:", error); + return { balance: "0.00", balanceUsd: "0.00" }; + } +} + +async function fetchAssetBalances( + api: ApiPromise, + substrateAddress: string, + assetTokens: AssetHubTokenDetails[] +): Promise { + const balances = new Map(); + + if (assetTokens.length === 0) { + return balances; + } + + try { + const assetIds = [...new Set(assetTokens.map(t => t.foreignAssetId).filter(Boolean))]; + const assetInfos = await api.query.assets.asset.multi(assetIds); + const accountQueries = assetIds.map(id => [id, substrateAddress]); + const accountInfos = await api.query.assets.account.multi(accountQueries); + + const assetInfoMap = new Map(); + const accountInfoMap = new Map(); + assetIds.forEach((id, i) => { + assetInfoMap.set(id, assetInfos[i]); + accountInfoMap.set(id, accountInfos[i]); + }); + + for (const token of assetTokens) { + const assetId = token.foreignAssetId; + let balance = "0.00"; + + if (assetId != null) { + const assetInfo = assetInfoMap.get(assetId); + const accountInfo = accountInfoMap.get(assetId); + const rawMinBalance = assetInfo ? (assetInfo.toJSON()?.minBalance ?? 0) : 0; + const rawBalance = accountInfo ? (accountInfo.toJSON()?.balance ?? 0) : 0; + const offrampableBalance = rawBalance > 0 ? rawBalance - rawMinBalance : 0; + balance = nativeToDecimal(offrampableBalance, token.decimals).toFixed(2, 0).toString(); + } + + balances.set(getBalanceKey(Networks.AssetHub, token.assetSymbol), { + balance, + balanceUsd: calculateBalanceUsd(balance, token.assetSymbol) + }); + } + } catch (error) { + console.error("Error fetching AssetHub asset balances:", error); + } + + return balances; +} + +export async function fetchAssetHubBalances(api: ApiPromise, substrateAddress: string): Promise { + const balances = new Map(); + const assetHubTokens = getAllSupportedAssetHubTokens(); + + const assetTokens = assetHubTokens.filter(t => !t.isNative); + const nativeToken = assetHubTokens.find(t => t.isNative); + + if (nativeToken) { + const nativeBalance = await fetchNativeBalance(api, substrateAddress, nativeToken); + balances.set(getBalanceKey(Networks.AssetHub, nativeToken.assetSymbol), nativeBalance); + } + + const assetBalances = await fetchAssetBalances(api, substrateAddress, assetTokens); + assetBalances.forEach((value, key) => balances.set(key, value)); + + return balances; +} diff --git a/apps/frontend/src/services/balances/evmBalanceFetcher.ts b/apps/frontend/src/services/balances/evmBalanceFetcher.ts new file mode 100644 index 000000000..a72003ec2 --- /dev/null +++ b/apps/frontend/src/services/balances/evmBalanceFetcher.ts @@ -0,0 +1,119 @@ +import { + ALCHEMY_API_KEY, + doesNetworkSupportRamp, + EvmNetworks, + EvmTokenDetails, + getAllEvmTokens, + isNetworkEVM, + Networks +} from "@vortexfi/shared"; +import Big from "big.js"; +import { hexToBigInt } from "viem"; +import { multiplyByPowerOfTen } from "../../helpers/contracts"; +import { getEvmTokenConfig } from "../tokens"; +import { BalanceMap, getBalanceKey, TokenBalance } from "./types"; + +const ALCHEMY_NETWORK_MAP: Partial> = { + [Networks.Arbitrum]: "arb-mainnet", + [Networks.Avalanche]: "avax-mainnet", + [Networks.Base]: "base-mainnet", + [Networks.BSC]: "bsc-mainnet", + [Networks.Ethereum]: "eth-mainnet", + [Networks.Moonbeam]: "moonbeam-mainnet", + [Networks.Polygon]: "polygon-mainnet" +}; + +async function fetchAlchemyBalances(address: string, network: Networks): Promise> { + const networkName = ALCHEMY_NETWORK_MAP[network]; + if (!networkName || !ALCHEMY_API_KEY) { + return new Map(); + } + + try { + const response = await fetch(`https://api.g.alchemy.com/data/v1/${ALCHEMY_API_KEY}/assets/tokens/balances/by-address`, { + body: JSON.stringify({ + addresses: [{ address, networks: [networkName] }], + includeErc20Tokens: true, + includeNativeTokens: true + }), + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + + const data = await response.json(); + const balanceMap = new Map(); + + if (data.data?.tokens) { + for (const token of data.data.tokens) { + const tokenAddress = token.tokenAddress || "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + const key = `${network}-${tokenAddress.toLowerCase()}`; + const decimalBalance = hexToBigInt(token.tokenBalance as `0x${string}`).toString(); + balanceMap.set(key, decimalBalance); + } + } + + return balanceMap; + } catch (error) { + console.error(`Error fetching Alchemy balances for ${network}:`, error); + return new Map(); + } +} + +function getAllSupportedEvmTokens(): EvmTokenDetails[] { + const evmConfig = getEvmTokenConfig(); + const tokens: EvmTokenDetails[] = []; + const evmNetworks = Object.values(Networks).filter(isNetworkEVM).filter(doesNetworkSupportRamp) as EvmNetworks[]; + + for (const network of evmNetworks) { + const networkConfig = evmConfig[network]; + if (networkConfig) { + const networkTokens = Object.values(networkConfig).filter((t): t is EvmTokenDetails => t !== undefined); + tokens.push(...networkTokens); + } + } + + return tokens; +} + +function buildEvmTokenLookup(): Map { + const allEvmTokens = getAllEvmTokens(); + const map = new Map(); + for (const token of allEvmTokens) { + if (token.erc20AddressSourceChain) { + const key = `${token.network}-${token.erc20AddressSourceChain.toLowerCase()}`; + map.set(key, token); + } + } + return map; +} + +export async function fetchEvmBalances(evmAddress: string): Promise { + const newBalances = new Map(); + const evmTokenLookup = buildEvmTokenLookup(); + const evmTokens = getAllSupportedEvmTokens(); + + const evmNetworks = [...new Set(evmTokens.map(t => t.network))].filter(isNetworkEVM) as Networks[]; + const networkResults = await Promise.all(evmNetworks.map(network => fetchAlchemyBalances(evmAddress, network))); + + const allEvmBalances = new Map(); + for (const result of networkResults) { + result.forEach((value, key) => allEvmBalances.set(key, value)); + } + + for (const token of evmTokens) { + const addressKey = `${token.network}-${token.erc20AddressSourceChain?.toLowerCase()}`; + const rawBalance = allEvmBalances.get(addressKey); + + const showDecimals = token.assetSymbol.toLowerCase().includes("usd") ? 2 : 6; + const balance = rawBalance ? multiplyByPowerOfTen(Big(rawBalance), -token.decimals).toFixed(showDecimals, 0) : "0.00"; + + const matchingToken = evmTokenLookup.get(addressKey); + const usdPrice = matchingToken?.usdPrice ?? 0; + const balanceUsd = usdPrice > 0 ? Big(balance).times(usdPrice).toFixed(2, 0) : "0.00"; + + const balanceKey = getBalanceKey(token.network, token.assetSymbol); + newBalances.set(balanceKey, { balance, balanceUsd }); + } + + return newBalances; +} diff --git a/apps/frontend/src/services/balances/index.ts b/apps/frontend/src/services/balances/index.ts new file mode 100644 index 000000000..2a3d3bad5 --- /dev/null +++ b/apps/frontend/src/services/balances/index.ts @@ -0,0 +1,29 @@ +import { fetchAssetHubBalances } from "./assetHubBalanceFetcher"; +import { fetchEvmBalances } from "./evmBalanceFetcher"; +import { BalanceMap, FetchBalancesParams, mergeBalanceMaps } from "./types"; + +export async function fetchAllBalances({ + evmAddress, + substrateAddress, + assethubApi +}: FetchBalancesParams): Promise { + const results: BalanceMap[] = []; + + const promises: Promise[] = []; + + if (evmAddress) { + promises.push(fetchEvmBalances(evmAddress)); + } + + if (substrateAddress && assethubApi) { + promises.push(fetchAssetHubBalances(assethubApi, substrateAddress)); + } + + const fetchedResults = await Promise.all(promises); + results.push(...fetchedResults); + + return mergeBalanceMaps(...results); +} + +export type { BalanceMap, FetchBalancesParams, TokenBalance } from "./types"; +export { getBalanceKey } from "./types"; diff --git a/apps/frontend/src/services/balances/types.ts b/apps/frontend/src/services/balances/types.ts new file mode 100644 index 000000000..dd940a485 --- /dev/null +++ b/apps/frontend/src/services/balances/types.ts @@ -0,0 +1,26 @@ +import { ApiPromise } from "@polkadot/api"; + +export interface TokenBalance { + balance: string; + balanceUsd: string; +} + +export type BalanceMap = Map; + +export function getBalanceKey(network: string, symbol: string): string { + return `${network}-${symbol}`; +} + +export function mergeBalanceMaps(...maps: BalanceMap[]): BalanceMap { + const merged = new Map(); + for (const map of maps) { + map.forEach((value, key) => merged.set(key, value)); + } + return merged; +} + +export interface FetchBalancesParams { + evmAddress: string | null; + substrateAddress: string | null; + assethubApi?: ApiPromise; +} diff --git a/apps/frontend/src/stores/tokenBalanceStore.ts b/apps/frontend/src/stores/tokenBalanceStore.ts new file mode 100644 index 000000000..9f6da320d --- /dev/null +++ b/apps/frontend/src/stores/tokenBalanceStore.ts @@ -0,0 +1,82 @@ +import { ApiPromise } from "@polkadot/api"; +import { create } from "zustand"; +import { BalanceMap, fetchAllBalances, getBalanceKey, TokenBalance } from "../services/balances"; + +interface TokenBalanceState { + balances: BalanceMap; + isLoading: boolean; + lastEvmAddress: string | null; + lastSubstrateAddress: string | null; + error: string | null; +} + +interface TokenBalanceActions { + fetchBalances: (evmAddress: string | null, substrateAddress: string | null, assethubApi?: ApiPromise) => Promise; + getBalance: (network: string, symbol: string) => TokenBalance | undefined; + clearBalances: () => void; +} + +interface TokenBalanceStore { + state: TokenBalanceState; + actions: TokenBalanceActions; +} + +const initialState: TokenBalanceState = { + balances: new Map(), + error: null, + isLoading: false, + lastEvmAddress: null, + lastSubstrateAddress: null +}; + +export const useTokenBalanceStore = create((set, get) => ({ + actions: { + clearBalances: () => set({ state: { ...initialState } }), + + fetchBalances: async (evmAddress, substrateAddress, assethubApi) => { + const currentState = get().state; + + if (currentState.lastEvmAddress === evmAddress && currentState.lastSubstrateAddress === substrateAddress) { + return; + } + + set({ state: { ...currentState, error: null, isLoading: true } }); + + try { + const balances = await fetchAllBalances({ assethubApi, evmAddress, substrateAddress }); + + set({ + state: { + balances, + error: null, + isLoading: false, + lastEvmAddress: evmAddress, + lastSubstrateAddress: substrateAddress + } + }); + } catch (error) { + console.error("Error fetching token balances:", error); + set({ + state: { + ...get().state, + error: error instanceof Error ? error.message : "Failed to fetch balances", + isLoading: false + } + }); + } + }, + + getBalance: (network, symbol) => { + return get().state.balances.get(getBalanceKey(network, symbol)); + } + }, + state: initialState +})); + +export const useTokenBalances = () => useTokenBalanceStore(state => state.state.balances); +export const useTokenBalancesLoading = () => useTokenBalanceStore(state => state.state.isLoading); +export const useTokenBalanceActions = () => useTokenBalanceStore(state => state.actions); + +export const useTokenBalance = (network: string, symbol: string): TokenBalance | undefined => { + return useTokenBalanceStore(state => state.state.balances.get(getBalanceKey(network, symbol))); +}; diff --git a/packages/shared/src/tokens/index.ts b/packages/shared/src/tokens/index.ts index 9e69bc526..e072299a8 100644 --- a/packages/shared/src/tokens/index.ts +++ b/packages/shared/src/tokens/index.ts @@ -8,6 +8,8 @@ export * from "./constants/misc"; // Constants // Configurations export * from "./evm/config"; +// Dynamic tokens - must be exported AFTER all dependencies (config, pendulum/config, etc.) +export * from "./evm/dynamicEvmTokens"; export * from "./moonbeam/config"; export * from "./pendulum/config"; export * from "./stellar/config"; @@ -24,6 +26,4 @@ export * from "./utils/helpers"; export * from "./utils/normalization"; // Utils export * from "./utils/typeGuards"; -// Dynamic tokens - must be exported AFTER all dependencies (config, pendulum/config, etc.) -export * from "./evm/dynamicEvmTokens"; /* prettier-ignore-end */