Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export class PendulumToMoonbeamXCMPhaseHandler extends BasePhaseHandler {
return balance.gte(expectedOutputAmountRaw);
};

const waitForMoonbeamArrival = async (timeoutMs: number = 120000): Promise<boolean> => {
const waitForMoonbeamArrival = async (timeoutMs = 120000): Promise<boolean> => {
const startTime = Date.now();
const pollIntervalMs = 5000;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/api/workers/cleanup.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
}
Expand Down
27 changes: 21 additions & 6 deletions apps/frontend/src/components/ListItem/index.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 (
<button
className={`btn w-full justify-start gap-4 rounded-lg border-gray-200 px-3 text-left transition-transform hover:bg-gray-100 active:scale-[0.98] ${
Expand Down Expand Up @@ -61,9 +74,11 @@ export const ListItem = memo(function ListItem({ token, isSelected, onSelect }:
)}
</span>
</span>
<span className="text-base">
{!isFiat && <UserBalance className="font-bold" token={token.details as OnChainTokenDetails} />}
</span>
{formattedBalance && (
<span className="font-bold text-base">
{formattedBalance} {token.assetSymbol}
</span>
)}
</div>
</button>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,63 @@
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<string, { balance: string; balanceUsd: string }>
): 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();
const searchFilter = useSearchFilter();
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<HTMLDivElement>(null);

const shouldVirtualize = currentDefinitions.length > MIN_ITEMS_FOR_VIRTUALIZATION;
Expand All @@ -49,14 +83,20 @@ 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 (
<div
className="absolute left-0 w-full"
key={virtualItem.key}
style={{ height: ROW_HEIGHT, transform: `translateY(${virtualItem.start}px)` }}
>
<ListItem isSelected={isSelected} onSelect={tokenType => handleTokenSelect(tokenType, token)} token={token} />
<ListItem
balance={tokenBalance?.balance}
isSelected={isSelected}
onSelect={tokenType => handleTokenSelect(tokenType, token)}
token={token}
/>
</div>
);
})}
Expand All @@ -65,8 +105,10 @@ export const SelectionTokenList = () => {
<div className="flex flex-col gap-2">
{currentDefinitions.map(token => {
const isSelected = selectedToken === token.type && selectedNetwork === token.network;
const tokenBalance = balances.get(getBalanceKey(token.network, token.assetSymbol));
return (
<ListItem
balance={tokenBalance?.balance}
isSelected={isSelected}
key={`${token.type}-${token.network}`}
onSelect={tokenType => handleTokenSelect(tokenType, token)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(value: T[]): T[] {
const ref = useRef<T[]>(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,
Expand Down Expand Up @@ -108,19 +89,35 @@ function getOnChainTokensDefinitionsForNetwork(selectedNetwork: Networks): Exten
}
}

let cachedOnChainTokens: ExtendedTokenDefinition[] | null = null;
let cachedEvmConfigRef: ReturnType<typeof getEvmTokenConfig> | 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
Expand Down
23 changes: 23 additions & 0 deletions apps/frontend/src/hooks/useInitTokenBalances.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
19 changes: 19 additions & 0 deletions apps/frontend/src/hooks/useMaintenanceStatus.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
16 changes: 7 additions & 9 deletions apps/frontend/src/hooks/useOnchainTokenBalance.ts
Original file line number Diff line number Diff line change
@@ -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";
}
Loading