From c80d51ee3e579a0516bb9cb10ba3b833662d5cdf Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Tue, 3 Feb 2026 13:38:02 +0100 Subject: [PATCH 1/6] backend lint fixes --- .../phases/handlers/pendulum-to-moonbeam-xcm-handler.ts | 2 +- .../phases/handlers/squid-router-pay-phase-handler.ts | 4 ++-- apps/api/src/api/workers/cleanup.worker.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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" }; } From fb1b15472875cdb01666b151ceb7894bb6d4ad7b Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Tue, 3 Feb 2026 16:46:04 +0100 Subject: [PATCH 2/6] Add centralized token balance service and store --- .../src/hooks/useInitTokenBalances.ts | 23 ++++ .../balances/assetHubBalanceFetcher.ts | 92 ++++++++++++++ .../services/balances/evmBalanceFetcher.ts | 119 ++++++++++++++++++ apps/frontend/src/services/balances/index.ts | 29 +++++ apps/frontend/src/services/balances/types.ts | 26 ++++ apps/frontend/src/stores/tokenBalanceStore.ts | 82 ++++++++++++ 6 files changed, 371 insertions(+) create mode 100644 apps/frontend/src/hooks/useInitTokenBalances.ts create mode 100644 apps/frontend/src/services/balances/assetHubBalanceFetcher.ts create mode 100644 apps/frontend/src/services/balances/evmBalanceFetcher.ts create mode 100644 apps/frontend/src/services/balances/index.ts create mode 100644 apps/frontend/src/services/balances/types.ts create mode 100644 apps/frontend/src/stores/tokenBalanceStore.ts 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/services/balances/assetHubBalanceFetcher.ts b/apps/frontend/src/services/balances/assetHubBalanceFetcher.ts new file mode 100644 index 000000000..460f9170f --- /dev/null +++ b/apps/frontend/src/services/balances/assetHubBalanceFetcher.ts @@ -0,0 +1,92 @@ +import { ApiPromise } from "@polkadot/api"; +import { AssetHubTokenDetails, assetHubTokenConfig, Networks, nativeToDecimal } from "@vortexfi/shared"; +import { BalanceMap, getBalanceKey, TokenBalance } from "./types"; + +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: "0.00" }; + } 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: "0.00" + }); + } + } 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))); +}; From 12fabca39ecbb33ece46bc7fd81108ed14c4c290 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Tue, 3 Feb 2026 17:41:44 +0100 Subject: [PATCH 3/6] Refactor token selection to use balance store --- .../src/components/ListItem/index.tsx | 28 +- .../components/SelectionTokenList.tsx | 47 ++- .../TokenSelectionList/helpers.tsx | 53 ++- .../src/hooks/useOnchainTokenBalance.ts | 16 +- .../src/hooks/useOnchainTokenBalances.ts | 395 ------------------ .../hooks/useOnchainTokenBalancesSorted.ts | 22 - .../src/hooks/useTokensSortedByBalance.ts | 39 -- 7 files changed, 94 insertions(+), 506 deletions(-) delete mode 100644 apps/frontend/src/hooks/useOnchainTokenBalances.ts delete mode 100644 apps/frontend/src/hooks/useOnchainTokenBalancesSorted.ts delete mode 100644 apps/frontend/src/hooks/useTokensSortedByBalance.ts diff --git a/apps/frontend/src/components/ListItem/index.tsx b/apps/frontend/src/components/ListItem/index.tsx index 525326fdb..cb28423a9 100644 --- a/apps/frontend/src/components/ListItem/index.tsx +++ b/apps/frontend/src/components/ListItem/index.tsx @@ -1,20 +1,32 @@ import { CheckIcon } from "@heroicons/react/20/solid"; -import { FiatToken, isFiatToken, OnChainToken, OnChainTokenDetails } from "@vortexfi/shared"; +import { FiatToken, isFiatToken, OnChainToken } from "@vortexfi/shared"; +import Big from "big.js"; import { memo } from "react"; import { useTranslation } from "react-i18next"; import { getTokenDisabledReason, isFiatTokenDisabled } from "../../config/tokenAvailability"; +import { stringifyBigWithSignificantDecimals } from "../../helpers/contracts"; import { useGetAssetIcon } from "../../hooks/useGetAssetIcon"; 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 fiatIcon = useGetAssetIcon(token.assetIcon); const tokenIcon = token.logoURI ?? fiatIcon; @@ -23,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..d654b1446 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx @@ -1,17 +1,38 @@ 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; + } + return a.assetSymbol.localeCompare(b.assetSymbol); + }); +} + export const SelectionTokenList = () => { const isFiatDirection = useIsFiatDirection(); const isNetworkDropdownOpen = useIsNetworkDropdownOpen(); @@ -19,11 +40,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 +74,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 +96,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/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/useOnchainTokenBalances.ts b/apps/frontend/src/hooks/useOnchainTokenBalances.ts deleted file mode 100644 index 4a5caeb4c..000000000 --- a/apps/frontend/src/hooks/useOnchainTokenBalances.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { - ALCHEMY_API_KEY, - AssetHubTokenDetails, - AssetHubTokenDetailsWithBalance, - assetHubTokenConfig, - EvmTokenDetails, - EvmTokenDetailsWithBalance, - getAllEvmTokens, - getNetworkId, - isAssetHubTokenDetails, - isEvmTokenDetails, - isNetworkEVM, - Networks, - nativeToDecimal, - OnChainTokenDetails, - OnChainTokenDetailsWithBalance -} from "@vortexfi/shared"; -import Big from "big.js"; -import { useEffect, useMemo, useState } from "react"; -import { Abi, hexToBigInt } from "viem"; - -// Global cache to persist balances across hook instances and app lifecycle -const globalBalanceCache = new Map>(); - -import { useBalance, useReadContracts } from "wagmi"; -import { useNetwork } from "../contexts/network"; -import { useAssetHubNode } from "../contexts/polkadotNode"; -import erc20ABI from "../contracts/ERC20"; -import { multiplyByPowerOfTen } from "../helpers/contracts"; -import { getEvmTokensForNetwork } from "../services/tokens"; -import { useVortexAccount } from "./useVortexAccount"; - -interface AlchemyTokenBalancesResponse { - data: { - tokens: { - network: string; - address: string; - tokenAddress: string | null; - tokenBalance: string; - }[]; - pageKey?: string; - }; -} - -const getAlchemyNetworkName = (network: Networks): string | null => { - if (!ALCHEMY_API_KEY) return null; - - const networkMap: 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" - }; - - const networkName = networkMap[network]; - return networkName || null; -}; - -const fetchAlchemyTokenBalances = async (address: string, network: Networks): Promise> => { - const networkName = getAlchemyNetworkName(network); - if (!networkName) { - return new Map(); - } - - try { - const endpoint = `https://api.g.alchemy.com/data/v1/${ALCHEMY_API_KEY}/assets/tokens/balances/by-address`; - - const response = await fetch(endpoint, { - body: JSON.stringify({ - addresses: [ - { - address, - networks: [networkName] - } - ], - includeErc20Tokens: true, - includeNativeTokens: true - }), - headers: { - "Content-Type": "application/json" - }, - method: "POST" - }); - - const data: AlchemyTokenBalancesResponse = await response.json(); - - const balanceMap = new Map(); - if (data.data?.tokens) { - data.data.tokens.forEach(token => { - const tokenAddress = token.tokenAddress || "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; - const key = `${network}-${tokenAddress.toLowerCase()}`; - // Convert hex balance to decimal string using Big.js - const decimalBalance = hexToBigInt(token.tokenBalance as `0x${string}`).toString(); - balanceMap.set(key, decimalBalance); - }); - } - - return balanceMap; - } catch (error) { - console.error(`Error fetching balances for ${network}:`, error); - return new Map(); - } -}; - -export const useEvmNativeBalance = (): EvmTokenDetailsWithBalance | null => { - const { evmAddress: address } = useVortexAccount(); - const { selectedNetwork } = useNetwork(); - const chainId = getNetworkId(selectedNetwork); - - const tokensForNetwork: EvmTokenDetails[] = useMemo(() => { - if (isNetworkEVM(selectedNetwork)) { - return getEvmTokensForNetwork(selectedNetwork); - } else return []; - }, [selectedNetwork]); - - const nativeToken = useMemo(() => tokensForNetwork.find(t => t.isNative), [tokensForNetwork.find]); - - const { data: balance } = useBalance({ - address: address as `0x${string}`, - chainId: isNetworkEVM(selectedNetwork) ? chainId : undefined, - query: { - enabled: !!nativeToken && !!address && isNetworkEVM(selectedNetwork) - } - }); - - return useMemo(() => { - if (!nativeToken || !balance || !isNetworkEVM(selectedNetwork)) return null; - - const formattedBalance = multiplyByPowerOfTen(Big(balance.value.toString()), -balance.decimals).toFixed(4, 0); - - // Calculate balanceUsd by finding matching token in getAllEvmTokens by address and network - const allEvmTokens = getAllEvmTokens(); - const matchingToken = allEvmTokens.find( - token => - token.erc20AddressSourceChain?.toLowerCase() === nativeToken.erc20AddressSourceChain?.toLowerCase() && - token.network === nativeToken.network - ); - const usdPrice = matchingToken?.usdPrice ?? 0; - const balanceUsd = usdPrice > 0 ? Big(formattedBalance).times(usdPrice).toFixed(2, 0) : "0.00"; - - return { - ...nativeToken, - balance: formattedBalance, - balanceUsd - }; - }, [balance, selectedNetwork, nativeToken]); -}; - -export const useAssetHubNativeBalance = (): AssetHubTokenDetailsWithBalance | null => { - const [nativeBalance, setNativeBalance] = useState(null); - const { substrateAddress } = useVortexAccount(); - const { apiComponents: assethubNode } = useAssetHubNode(); - const { selectedNetwork } = useNetwork(); - - const nativeToken = useMemo(() => { - const assethubTokens = Object.values(assetHubTokenConfig); - return assethubTokens.find(token => token.isNative); - }, []); - - useEffect(() => { - if (!nativeToken || selectedNetwork !== Networks.AssetHub) { - setNativeBalance(null); - return; - } - - // If substrate wallet is not connected or node is not available, - // still show the token with zero balance - if (!substrateAddress || !assethubNode) { - setNativeBalance({ - ...nativeToken, - balance: "0.0000", - balanceUsd: "0.00" - }); - return; - } - - const getNativeBalance = async () => { - try { - const { api } = assethubNode; - const accountInfo = await api.query.system.account(substrateAddress); - const accountData = accountInfo.toJSON() as { - data: { - free: number; - reserved: number; - frozen: number; - }; - }; - - const freeBalance = accountData.data.free || 0; - const formattedBalance = nativeToDecimal(freeBalance, nativeToken.decimals).toFixed(4, 0).toString(); - - setNativeBalance({ - ...nativeToken, - balance: formattedBalance, - balanceUsd: "0.00" - }); - } catch (error) { - console.error("Error fetching AssetHub native balance:", error); - setNativeBalance(null); - } - }; - - getNativeBalance(); - }, [assethubNode, substrateAddress, selectedNetwork, nativeToken]); - - return nativeBalance; -}; - -const groupTokensByNetwork = (tokens: EvmTokenDetails[]): Record => { - return tokens.reduce>((acc, token) => { - if (!acc[token.network]) { - acc[token.network] = []; - } - acc[token.network].push(token); - return acc; - }, {}); -}; - -export const useEvmBalances = (tokens: EvmTokenDetails[]): EvmTokenDetailsWithBalance[] => { - const { evmAddress: address } = useVortexAccount(); - const [balanceMap, setBalanceMap] = useState>(new Map()); - - const tokensByNetwork = useMemo(() => groupTokensByNetwork(tokens), [tokens]); - - // Fetch balances from Alchemy for all EVM networks once on component mount - useEffect(() => { - if (!address) return; - - const fetchAllBalances = async () => { - // Get all EVM networks from the tokens passed - const evmNetworks = Object.keys(tokensByNetwork).filter(network => isNetworkEVM(network as Networks)) as Networks[]; - - const allBalances = new Map(); - - for (const network of evmNetworks) { - const networkTokens = tokensByNetwork[network]; - if (!networkTokens?.length) continue; - - const cacheKey = `${address}-${network}`; - let balances: Map; - - if (globalBalanceCache.has(cacheKey)) { - balances = globalBalanceCache.get(cacheKey)!; - } else { - try { - balances = await fetchAlchemyTokenBalances(address, network); - console.log(`[${network}] Balances:`, Object.fromEntries(balances)); - - globalBalanceCache.set(cacheKey, balances); - } catch (error) { - console.error(`Failed to fetch ${network} balances:`, error); - balances = new Map(); - } - } - - balances.forEach((value, key) => { - allBalances.set(key, value); - }); - } - - setBalanceMap(allBalances); - }; - - fetchAllBalances(); - }, [address, tokensByNetwork]); // - run when address or tokens change - - // Create a flat list of all tokens with their balances - const tokensWithBalances = tokens.reduce>((prev, curr, index) => { - const key = `${curr.network}-${curr.erc20AddressSourceChain?.toLowerCase()}`; - const tokenBalance = balanceMap.get(key); // If we are dealing with a stablecoin, we show 2 decimals, otherwise 6 - const showDecimals = curr.assetSymbol.toLowerCase().includes("usd") ? 2 : 6; - - const balance = tokenBalance ? multiplyByPowerOfTen(Big(tokenBalance), -curr.decimals).toFixed(showDecimals, 0) : "0.00"; - - // Calculate balanceUsd by finding matching token in getAllEvmTokens by address and network - const allEvmTokens = getAllEvmTokens(); - const matchingToken = allEvmTokens.find( - token => - token.erc20AddressSourceChain?.toLowerCase() === curr.erc20AddressSourceChain?.toLowerCase() && - token.network === curr.network - ); - const usdPrice = matchingToken?.usdPrice ?? 0; - const balanceUsd = usdPrice > 0 ? Big(balance).times(usdPrice).toFixed(2, 0) : "0.00"; - - prev.push({ - ...curr, - balance, - balanceUsd - }); - - return prev; - }, []); - - return tokensWithBalances; -}; - -export const useAssetHubBalances = (tokens: AssetHubTokenDetails[]): AssetHubTokenDetailsWithBalance[] => { - const [balances, setBalances] = useState>([]); - const { substrateAddress } = useVortexAccount(); - const { apiComponents: assethubNode } = useAssetHubNode(); - - useEffect(() => { - // Only process non-native asset tokens here — native token handled by useAssetHubNativeBalance - if (tokens.length === 0) return; - - const assetTokens = tokens.filter(t => !t.isNative); - if (assetTokens.length === 0) { - setBalances([]); - return; - } - - // If substrate wallet is not connected or node is not available, - // still show the tokens with zero balances - if (!substrateAddress || !assethubNode) { - setBalances(assetTokens.map(token => ({ ...token, balance: "0.00", balanceUsd: "0.00" }))); - return; - } - - const getBalances = async () => { - const { api } = assethubNode; - - // Use unique list of assetIds and preserve mapping - const assetIds = Array.from(new Set(assetTokens.map(t => t.foreignAssetId).filter(id => id != null))); - const assetInfos = await api.query.assets.asset.multi(assetIds); - const accountQueries = assetIds.map(assetId => [assetId, substrateAddress]); - const accountInfos = await api.query.assets.account.multi(accountQueries); - - // Build maps by assetId - const assetInfoMap = new Map(); - assetIds.forEach((id, i) => assetInfoMap.set(id, assetInfos[i])); - - const accountInfoMap = new Map(); - assetIds.forEach((id, i) => accountInfoMap.set(id, accountInfos[i])); - - const tokensWithBalances = assetTokens.map(token => { - const assetId = token.foreignAssetId; - let balance: string; - - // assetId should exist for asset tokens; if missing, fallback to zero - if (assetId == null) { - balance = "0.00"; - } else { - const assetInfo = assetInfoMap.get(assetId); - const accountInfo = accountInfoMap.get(assetId); - - const rawMinBalance = assetInfo ? ((assetInfo.toJSON() as any).minBalance ?? 0) : 0; - const rawBalance = accountInfo ? ((accountInfo.toJSON() as any).balance ?? 0) : 0; - const offrampableBalance = rawBalance > 0 ? rawBalance - rawMinBalance : 0; - balance = nativeToDecimal(offrampableBalance, token.decimals).toFixed(2, 0).toString(); - } - - return { ...token, balance, balanceUsd: "0.00" }; - }); - - setBalances(tokensWithBalances); - }; - - getBalances(); - }, [assethubNode, tokens, substrateAddress]); - - return balances; -}; - -export const useOnchainTokenBalances = (tokens: OnChainTokenDetails[]): OnChainTokenDetailsWithBalance[] => { - const evmTokens = useMemo(() => tokens.filter(isEvmTokenDetails) as EvmTokenDetailsWithBalance[], [tokens]); - const substrateTokens = useMemo(() => tokens.filter(isAssetHubTokenDetails) as AssetHubTokenDetailsWithBalance[], [tokens]); - - const evmBalances = useEvmBalances(evmTokens); - const substrateBalances = useAssetHubBalances(substrateTokens); - const evmNativeBalance = useEvmNativeBalance(); - const assethubNativeBalance = useAssetHubNativeBalance(); - - return useMemo(() => { - // Combine all token balances - const allTokens = [...evmBalances, ...substrateBalances, assethubNativeBalance, evmNativeBalance].filter(Boolean); - - // Deduplicate tokens by network-symbol and type (native vs asset) - const uniqueTokens = new Map(); - allTokens.forEach(token => { - if (token) { - const isNative = "isNative" in token ? token.isNative : false; - const assetId = "foreignAssetId" in token ? token.foreignAssetId : null; - const key = `${token.network}-${token.assetSymbol}-${isNative}-${assetId}`; - if (!uniqueTokens.has(key)) { - uniqueTokens.set(key, token); - } - } - }); - - return Array.from(uniqueTokens.values()) as OnChainTokenDetailsWithBalance[]; - }, [assethubNativeBalance, evmBalances, substrateBalances, evmNativeBalance]); -}; diff --git a/apps/frontend/src/hooks/useOnchainTokenBalancesSorted.ts b/apps/frontend/src/hooks/useOnchainTokenBalancesSorted.ts deleted file mode 100644 index f1ef30b2e..000000000 --- a/apps/frontend/src/hooks/useOnchainTokenBalancesSorted.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { OnChainTokenDetails, OnChainTokenDetailsWithBalance } from "@vortexfi/shared"; -import { useMemo } from "react"; -import { useOnchainTokenBalances } from "./useOnchainTokenBalances"; - -export const useOnchainTokenBalancesSorted = (tokens: OnChainTokenDetails[]): OnChainTokenDetailsWithBalance[] => { - const tokenBalances = useOnchainTokenBalances(tokens); - - return useMemo(() => { - // Sort by balance (highest to lowest), then by symbol (alphabetically) - return [...tokenBalances].sort((a, b) => { - const aBalance = parseFloat(a.balanceUsd ?? "0"); - const bBalance = parseFloat(b.balanceUsd ?? "0"); - - // Primary sort: balance descending (highest to lowest) - if (aBalance !== bBalance) { - return bBalance - aBalance; - } - - return 1; - }); - }, [tokenBalances]); -}; 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] - ); -}; From 94c8a6f05e99eebe039388c18651dd3042216e63 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Tue, 3 Feb 2026 17:42:25 +0100 Subject: [PATCH 4/6] Extract maintenance status logic and init balances in layout --- .../src/hooks/useMaintenanceStatus.ts | 19 +++++++++++++++++++ apps/frontend/src/layouts/index.tsx | 19 ++++--------------- 2 files changed, 23 insertions(+), 15 deletions(-) create mode 100644 apps/frontend/src/hooks/useMaintenanceStatus.ts 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/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; From 66652f345a34ac55bf5b13a6102730b3241c1f72 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Tue, 3 Feb 2026 18:17:23 +0100 Subject: [PATCH 5/6] fetch assethub tokens usd prices --- .../components/SelectionTokenList.tsx | 9 +++++++++ .../services/balances/assetHubBalanceFetcher.ts | 15 ++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx index d654b1446..a35090db1 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx @@ -29,6 +29,15 @@ function sortByBalance( 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); }); } diff --git a/apps/frontend/src/services/balances/assetHubBalanceFetcher.ts b/apps/frontend/src/services/balances/assetHubBalanceFetcher.ts index 460f9170f..d9ce0733b 100644 --- a/apps/frontend/src/services/balances/assetHubBalanceFetcher.ts +++ b/apps/frontend/src/services/balances/assetHubBalanceFetcher.ts @@ -1,7 +1,16 @@ import { ApiPromise } from "@polkadot/api"; -import { AssetHubTokenDetails, assetHubTokenConfig, Networks, nativeToDecimal } from "@vortexfi/shared"; +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); } @@ -17,7 +26,7 @@ async function fetchNativeBalance( const freeBalance = accountData.data.free || 0; const balance = nativeToDecimal(freeBalance, nativeToken.decimals).toFixed(4, 0).toString(); - return { balance, balanceUsd: "0.00" }; + return { balance, balanceUsd: calculateBalanceUsd(balance, nativeToken.assetSymbol) }; } catch (error) { console.error("Error fetching AssetHub native balance:", error); return { balance: "0.00", balanceUsd: "0.00" }; @@ -63,7 +72,7 @@ async function fetchAssetBalances( balances.set(getBalanceKey(Networks.AssetHub, token.assetSymbol), { balance, - balanceUsd: "0.00" + balanceUsd: calculateBalanceUsd(balance, token.assetSymbol) }); } } catch (error) { From a26f7c378bb5132a4879c6728f7d658dd7612c6e Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Tue, 3 Feb 2026 19:36:33 +0100 Subject: [PATCH 6/6] bring back merging hardcoded evm tokens over the dynamic ones --- .../shared/src/tokens/evm/dynamicEvmTokens.ts | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/tokens/evm/dynamicEvmTokens.ts b/packages/shared/src/tokens/evm/dynamicEvmTokens.ts index bc0ad365a..dac8a69f1 100644 --- a/packages/shared/src/tokens/evm/dynamicEvmTokens.ts +++ b/packages/shared/src/tokens/evm/dynamicEvmTokens.ts @@ -134,22 +134,40 @@ function groupTokensByNetwork(tokens: EvmTokenDetails[]): Record { const evmTokens = squidTokens .map(mapSquidTokenToEvmTokenDetails) .filter((token): token is EvmTokenDetails => token !== null); - //.slice(0, 500); // TODO TESTING Limit to first 500 tokens to avoid overload state.tokens = evmTokens; state.tokensByNetwork = groupTokensByNetwork(evmTokens);