From 8f96dacffdb36e49a7f22567cce0edfa2e38037c Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 15 Jan 2026 15:24:24 -0300 Subject: [PATCH 01/61] add first draft --- .../src/components/ListItem/index.tsx | 2 +- .../src/components/Ramp/Offramp/index.tsx | 6 +- .../src/components/Ramp/Onramp/index.tsx | 3 +- .../components/SelectionTokenList.tsx | 2 +- .../TokenSelectionList/helpers.tsx | 18 +- .../hooks/useTokenSelection.tsx | 1 + .../src/hooks/ramp/useRampValidation.ts | 7 +- .../src/hooks/useOnchainTokenBalances.ts | 4 +- .../src/hooks/useTokensSortedByBalance.ts | 1 + apps/frontend/src/main.tsx | 5 +- .../individuals/PopularTokens/index.tsx | 9 +- .../src/services/tokens/dynamicEvmTokens.ts | 243 ++++++++++++++++++ apps/frontend/src/services/tokens/index.ts | 13 + packages/shared/src/tokens/types/evm.ts | 2 + packages/shared/src/tokens/utils/helpers.ts | 45 +++- 15 files changed, 334 insertions(+), 27 deletions(-) create mode 100644 apps/frontend/src/services/tokens/dynamicEvmTokens.ts create mode 100644 apps/frontend/src/services/tokens/index.ts diff --git a/apps/frontend/src/components/ListItem/index.tsx b/apps/frontend/src/components/ListItem/index.tsx index 4f3b1ef2b2..6c89471377 100644 --- a/apps/frontend/src/components/ListItem/index.tsx +++ b/apps/frontend/src/components/ListItem/index.tsx @@ -14,7 +14,7 @@ interface ListItemProps { export function ListItem({ token, isSelected, onSelect }: ListItemProps) { const { t } = useTranslation(); - const tokenIcon = useGetAssetIcon(token.assetIcon); + const tokenIcon = token.logoURI ? token.logoURI : useGetAssetIcon(token.assetIcon); const showBalance = isOnChainToken(token.type); diff --git a/apps/frontend/src/components/Ramp/Offramp/index.tsx b/apps/frontend/src/components/Ramp/Offramp/index.tsx index b583036e7a..46e5439c16 100644 --- a/apps/frontend/src/components/Ramp/Offramp/index.tsx +++ b/apps/frontend/src/components/Ramp/Offramp/index.tsx @@ -10,6 +10,7 @@ import { useQuoteService } from "../../../hooks/quote/useQuoteService"; import { useRampSubmission } from "../../../hooks/ramp/useRampSubmission"; import { useRampValidation } from "../../../hooks/ramp/useRampValidation"; import { useVortexAccount } from "../../../hooks/useVortexAccount"; +import { getEvmTokenConfig } from "../../../services/tokens"; import { useFeeComparisonStore } from "../../../stores/feeComparison"; import { useFiatToken, useInputAmount, useOnChainToken } from "../../../stores/quote/useQuoteFormStore"; import { useQuoteLoading } from "../../../stores/quote/useQuoteStore"; @@ -54,7 +55,10 @@ export const Offramp = () => { const { openTokenSelectModal } = useTokenSelectionActions(); - const fromToken = getOnChainTokenDetailsOrDefault(selectedNetwork, onChainToken); + console.log("Offramp render: ", { fiatToken, inputAmount, onChainToken, toAmount }); + + const fromToken = getOnChainTokenDetailsOrDefault(selectedNetwork, onChainToken, getEvmTokenConfig()); + console.log("fromToken: ", fromToken); const toToken = getAnyFiatTokenDetails(fiatToken); useEffect(() => { diff --git a/apps/frontend/src/components/Ramp/Onramp/index.tsx b/apps/frontend/src/components/Ramp/Onramp/index.tsx index e629775279..3dd8d941a2 100644 --- a/apps/frontend/src/components/Ramp/Onramp/index.tsx +++ b/apps/frontend/src/components/Ramp/Onramp/index.tsx @@ -9,6 +9,7 @@ import { useQuoteForm } from "../../../hooks/quote/useQuoteForm"; import { useQuoteService } from "../../../hooks/quote/useQuoteService"; import { useRampSubmission } from "../../../hooks/ramp/useRampSubmission"; import { useRampValidation } from "../../../hooks/ramp/useRampValidation"; +import { getEvmTokenConfig } from "../../../services/tokens"; import { useFeeComparisonStore } from "../../../stores/feeComparison"; import { useFiatToken, useInputAmount, useOnChainToken } from "../../../stores/quote/useQuoteFormStore"; import { useQuoteLoading } from "../../../stores/quote/useQuoteStore"; @@ -50,7 +51,7 @@ export const Onramp = () => { const { openTokenSelectModal } = useTokenSelectionActions(); const fromToken = getAnyFiatTokenDetails(fiatToken); - const toToken = getOnChainTokenDetailsOrDefault(selectedNetwork, onChainToken); + const toToken = getOnChainTokenDetailsOrDefault(selectedNetwork, onChainToken, getEvmTokenConfig()); useEffect(() => { if (!fromAmountFieldTouched || !inputAmount) return; diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx index 28a06bfd7c..83fcfea688 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx @@ -13,8 +13,8 @@ export const SelectionTokenList = () => { const selectedNetworkFilter = useSelectedNetworkFilter(); const { selectedNetwork } = useNetwork(); const { filteredDefinitions } = useTokenDefinitions(searchFilter, selectedNetworkFilter); - const sortedDefinitions = useTokensSortedByBalance(filteredDefinitions); + const sortedDefinitions = useTokensSortedByBalance(filteredDefinitions); const currentDefinitions = isFiatDirection ? filteredDefinitions : sortedDefinitions; const { handleTokenSelect, selectedToken } = useTokenSelection(); diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx index 38998f8953..becc8d467d 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx @@ -2,7 +2,6 @@ import { assetHubTokenConfig, doesNetworkSupportRamp, EvmNetworks, - evmTokenConfig, FiatToken, FiatTokenDetails, getEnumKeyByStringValue, @@ -16,6 +15,7 @@ import { stellarTokenConfig } from "@vortexfi/shared"; import { useMemo } from "react"; +import { getEvmTokenConfig } from "../../../services/tokens"; import { useRampDirection } from "../../../stores/rampDirectionStore"; import { useTokenSelectionState } from "../../../stores/tokenSelectionStore"; import { ExtendedTokenDefinition } from "./hooks/useTokenSelection"; @@ -69,15 +69,20 @@ function getOnChainTokensDefinitionsForNetwork(selectedNetwork: Networks): Exten type: key as OnChainToken })); } else if (isNetworkEVM(selectedNetwork)) { - return Object.entries(evmTokenConfig[selectedNetwork]).map(([key, value]) => ({ - assetIcon: value.networkAssetIcon, - assetSymbol: value.assetSymbol, + const evmConfig = getEvmTokenConfig(); + const networkConfig = evmConfig[selectedNetwork as EvmNetworks] ?? {}; + return Object.entries(networkConfig).map(([key, value]) => ({ + assetIcon: value?.logoURI ?? value?.networkAssetIcon ?? "", + assetSymbol: value?.assetSymbol ?? key, details: value as OnChainTokenDetails, + logoURI: value?.logoURI, network: selectedNetwork, networkDisplayName: getNetworkDisplayName(selectedNetwork), type: key as OnChainToken })); - } else throw new Error(`Network ${selectedNetwork} is not a valid origin network`); + } else { + throw new Error(`Network ${selectedNetwork} is not a valid origin network`); + } } function getAllOnChainTokens(): ExtendedTokenDefinition[] { @@ -86,8 +91,9 @@ function getAllOnChainTokens(): 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 (evmTokenConfig[network]) { + if (evmConfig[network]) { allTokens.push(...getOnChainTokensDefinitionsForNetwork(network)); } } diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/hooks/useTokenSelection.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/hooks/useTokenSelection.tsx index 9bb4c0bd20..a0089a45ed 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/hooks/useTokenSelection.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/hooks/useTokenSelection.tsx @@ -16,6 +16,7 @@ export interface TokenDefinition { export interface ExtendedTokenDefinition extends TokenDefinition { network: Networks; networkDisplayName: string; + logoURI?: string; } export const useTokenSelection = () => { diff --git a/apps/frontend/src/hooks/ramp/useRampValidation.ts b/apps/frontend/src/hooks/ramp/useRampValidation.ts index 49e380969a..fdcbf890a8 100644 --- a/apps/frontend/src/hooks/ramp/useRampValidation.ts +++ b/apps/frontend/src/hooks/ramp/useRampValidation.ts @@ -17,6 +17,7 @@ import { getTokenDisabledReason, isFiatTokenDisabled } from "../../config/tokenA import { TrackableEvent, useEventsContext } from "../../contexts/events"; import { useNetwork } from "../../contexts/network"; import { multiplyByPowerOfTen, stringifyBigWithSignificantDecimals } from "../../helpers/contracts"; +import { getEvmTokenConfig } from "../../services/tokens"; import { useQuoteFormStore } from "../../stores/quote/useQuoteFormStore"; import { useQuote, useQuoteError, useQuoteLoading } from "../../stores/quote/useQuoteStore"; import { useRampDirection } from "../../stores/rampDirectionStore"; @@ -151,9 +152,11 @@ export const useRampValidation = () => { const fromToken = isOnramp ? getAnyFiatTokenDetails(fiatToken) - : getOnChainTokenDetailsOrDefault(selectedNetwork, onChainToken); + : getOnChainTokenDetailsOrDefault(selectedNetwork, onChainToken, getEvmTokenConfig()); - const toToken = isOnramp ? getOnChainTokenDetailsOrDefault(selectedNetwork, onChainToken) : getAnyFiatTokenDetails(fiatToken); + const toToken = isOnramp + ? getOnChainTokenDetailsOrDefault(selectedNetwork, onChainToken, getEvmTokenConfig()) + : getAnyFiatTokenDetails(fiatToken); const userInputTokenBalance = useOnchainTokenBalance({ token: (isOnramp ? toToken : fromToken) as OnChainTokenDetails diff --git a/apps/frontend/src/hooks/useOnchainTokenBalances.ts b/apps/frontend/src/hooks/useOnchainTokenBalances.ts index 092b6d2900..be9b452700 100644 --- a/apps/frontend/src/hooks/useOnchainTokenBalances.ts +++ b/apps/frontend/src/hooks/useOnchainTokenBalances.ts @@ -4,7 +4,6 @@ import { assetHubTokenConfig, EvmTokenDetails, EvmTokenDetailsWithBalance, - evmTokenConfig, getNetworkId, isAssetHubTokenDetails, isEvmTokenDetails, @@ -22,6 +21,7 @@ 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"; export const useEvmNativeBalance = (): EvmTokenDetailsWithBalance | null => { @@ -31,7 +31,7 @@ export const useEvmNativeBalance = (): EvmTokenDetailsWithBalance | null => { const tokensForNetwork: EvmTokenDetails[] = useMemo(() => { if (isNetworkEVM(selectedNetwork)) { - return Object.values(evmTokenConfig[selectedNetwork] ?? {}); + return getEvmTokensForNetwork(selectedNetwork); } else return []; }, [selectedNetwork]); diff --git a/apps/frontend/src/hooks/useTokensSortedByBalance.ts b/apps/frontend/src/hooks/useTokensSortedByBalance.ts index bfbbb56460..44991376ed 100644 --- a/apps/frontend/src/hooks/useTokensSortedByBalance.ts +++ b/apps/frontend/src/hooks/useTokensSortedByBalance.ts @@ -68,6 +68,7 @@ export const useTokensSortedByBalance = ( assetIcon: details.networkAssetIcon, assetSymbol: details.assetSymbol, details: details, + logoURI: (details as any).logoURI, network: details.network, networkDisplayName: getNetworkDisplayName(details.network), type: details.assetSymbol as OnChainToken diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index cd23505b21..2d85ef6b85 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -13,12 +13,12 @@ import i18n from "i18next"; import { createRoot } from "react-dom/client"; import { initReactI18next } from "react-i18next"; import { WagmiProvider } from "wagmi"; - import { config } from "./config"; import { EventsProvider } from "./contexts/events"; import { NetworkProvider } from "./contexts/network"; import { PolkadotNodeProvider } from "./contexts/polkadotNode"; import { PolkadotWalletStateProvider } from "./contexts/polkadotWallet"; +import { initializeEvmTokens } from "./services/tokens"; import { wagmiConfig } from "./wagmiConfig"; import "./helpers/googleTranslate"; import { PersistentRampStateProvider } from "./contexts/rampState"; @@ -76,6 +76,9 @@ if (!root) { throw new Error("Root element not found"); } +// Initialize dynamic EVM tokens from SquidRouter API (falls back to static config on failure) +initializeEvmTokens(); + createRoot(root).render( diff --git a/apps/frontend/src/sections/individuals/PopularTokens/index.tsx b/apps/frontend/src/sections/individuals/PopularTokens/index.tsx index 3a9b7916dc..bd78ab8c9b 100644 --- a/apps/frontend/src/sections/individuals/PopularTokens/index.tsx +++ b/apps/frontend/src/sections/individuals/PopularTokens/index.tsx @@ -1,10 +1,8 @@ -import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; import { AssetHubToken, assetHubTokenConfig, doesNetworkSupportRamp, EvmToken, - evmTokenConfig, FiatToken, getNetworkDisplayName, Networks @@ -12,15 +10,14 @@ import { import { motion } from "motion/react"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import VORTEX from "../../../assets/logo/vortex_x.svg"; -import CIRCLE from "../../../assets/trusted-by/circle.svg"; -import PENDULUM from "../../../assets/trusted-by/pendulum-icon.svg"; import { cn } from "../../../helpers/cn"; import { isValidAssetIcon, useGetAssetIcon } from "../../../hooks/useGetAssetIcon"; import { useGetNetworkIcon } from "../../../hooks/useGetNetworkIcon"; +import { getEvmTokenConfig } from "../../../services/tokens"; const getEvmTokenIcon = (token: EvmToken): string => { - for (const networkConfig of Object.values(evmTokenConfig)) { + const evmConfig = getEvmTokenConfig(); + for (const networkConfig of Object.values(evmConfig)) { const tokenConfig = networkConfig[token]; if (tokenConfig?.networkAssetIcon) { return tokenConfig.networkAssetIcon; diff --git a/apps/frontend/src/services/tokens/dynamicEvmTokens.ts b/apps/frontend/src/services/tokens/dynamicEvmTokens.ts new file mode 100644 index 0000000000..3a7ab6aef6 --- /dev/null +++ b/apps/frontend/src/services/tokens/dynamicEvmTokens.ts @@ -0,0 +1,243 @@ +/** + * Dynamic EVM Tokens Service + * + * This module loads EVM token configurations dynamically from SquidRouter API at app initialization. + * It falls back to the static evmTokenConfig on failure. + * + * Usage: + * 1. Call initializeEvmTokens() once at app startup (before React renders) + * 2. Use getEvmTokenConfig() or getEvmTokensForNetwork() to access tokens + */ + +import { + EvmNetworks, + EvmTokenDetails, + evmTokenConfig, + getNetworkId, + isNetworkEVM, + Networks, + PENDULUM_USDC_AXL, + squidRouterConfigBase, + TokenType +} from "@vortexfi/shared"; +import axios from "axios"; + +interface SquidRouterToken { + symbol: string; + address: string; + chainId: string; + name: string; + decimals: number; + coingeckoId: string; + type: string; + logoURI: string; + subGraphOnly: boolean; + subGraphIds: string[]; + isTestnet: boolean; + usdPrice: number; +} + +interface DynamicEvmTokensState { + tokens: EvmTokenDetails[]; + tokensByNetwork: Record>>; + isLoaded: boolean; + error: Error | null; + usedFallback: boolean; +} + +const state: DynamicEvmTokensState = { + error: null, + isLoaded: false, + tokens: [], + tokensByNetwork: {} as Record>>, + usedFallback: false +}; + +function getNetworkFromChainId(chainId: string): Networks | null { + const chainIdNum = parseInt(chainId, 10); + const networkEntries = Object.entries(Networks).filter( + ([_, network]) => typeof network === "string" && getNetworkId(network as Networks) === chainIdNum + ); + return networkEntries.length > 0 ? (networkEntries[0][1] as Networks) : null; +} + +function isNativeToken(address: string): boolean { + return address === "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; +} + +function getNetworkAssetIcon(network: Networks, symbol: string): string { + const networkName = network.toLowerCase(); + const cleanSymbol = symbol.replace(/[^a-zA-Z0-9]/g, "").toLowerCase(); + return `${networkName}${cleanSymbol}`; +} + +function mapSquidTokenToEvmTokenDetails(token: SquidRouterToken): EvmTokenDetails | null { + const network = getNetworkFromChainId(token.chainId); + if (!network || !isNetworkEVM(network)) { + return null; + } + + const isNative = isNativeToken(token.address); + + const erc20Address: `0x${string}` = isNative + ? ("0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" as `0x${string}`) + : (token.address as `0x${string}`); + + return { + assetSymbol: token.symbol, + decimals: token.decimals, + erc20AddressSourceChain: erc20Address, + isNative, + logoURI: token.logoURI, + network, + networkAssetIcon: getNetworkAssetIcon(network, token.symbol), + pendulumRepresentative: PENDULUM_USDC_AXL, + type: TokenType.Evm + }; +} + +async function fetchSquidRouterTokens(): Promise { + const result = await axios.get("https://v2.api.squidrouter.com/v2/tokens", { + headers: { + "x-integrator-id": squidRouterConfigBase.integratorId + } + }); + return result.data.tokens; +} + +function groupTokensByNetwork(tokens: EvmTokenDetails[]): Record>> { + const grouped = {} as Record>>; + + for (const network of Object.values(Networks)) { + if (isNetworkEVM(network)) { + grouped[network as EvmNetworks] = {}; + } + } + + for (const token of tokens) { + if (isNetworkEVM(token.network)) { + const network = token.network as EvmNetworks; + if (!grouped[network]) { + grouped[network] = {}; + } + grouped[network][token.assetSymbol] = token; + } + } + + return grouped; +} + +function buildFallbackFromStaticConfig(): { + tokens: EvmTokenDetails[]; + tokensByNetwork: Record>>; +} { + const tokens: EvmTokenDetails[] = []; + const tokensByNetwork = {} as Record>>; + + for (const network of Object.values(Networks)) { + if (isNetworkEVM(network)) { + const evmNetwork = network as EvmNetworks; + const networkTokenConfig = evmTokenConfig[evmNetwork]; + if (networkTokenConfig) { + tokensByNetwork[evmNetwork] = networkTokenConfig; + const networkTokens = Object.values(networkTokenConfig).filter( + (token): token is EvmTokenDetails => token !== undefined + ); + tokens.push(...networkTokens); + } + } + } + + return { tokens, tokensByNetwork }; +} + +/** + * Initialize the dynamic EVM tokens service. + * Call this once at app startup before React renders. + * This function is idempotent - calling it multiple times is safe. + */ +export async function initializeEvmTokens(): Promise { + if (state.isLoaded) { + return; + } + + try { + const squidTokens = await fetchSquidRouterTokens(); + const evmTokens = squidTokens + .map(mapSquidTokenToEvmTokenDetails) + .filter((token): token is EvmTokenDetails => token !== null); + + state.tokens = evmTokens; + state.tokensByNetwork = groupTokensByNetwork(evmTokens); + state.error = null; + state.usedFallback = false; + state.isLoaded = true; + + console.log(`[DynamicEvmTokens] Loaded ${evmTokens.length} tokens from SquidRouter`); + } catch (err) { + console.error("[DynamicEvmTokens] Failed to fetch tokens from SquidRouter, using fallback:", err); + + const fallback = buildFallbackFromStaticConfig(); + state.tokens = fallback.tokens; + state.tokensByNetwork = fallback.tokensByNetwork; + state.error = err instanceof Error ? err : new Error("Failed to fetch tokens"); + state.usedFallback = true; + state.isLoaded = true; + } +} + +/** + * Get the EVM token config with the same structure as the static evmTokenConfig. + * This is a drop-in replacement for evmTokenConfig usage. + */ +export function getEvmTokenConfig(): Record>> { + if (!state.isLoaded) { + console.warn("[DynamicEvmTokens] Tokens not yet loaded, returning static config"); + return evmTokenConfig as Record>>; + } + return state.tokensByNetwork; +} + +/** + * Get all EVM tokens for a specific network. + */ +export function getEvmTokensForNetwork(network: EvmNetworks): EvmTokenDetails[] { + if (!state.isLoaded) { + console.warn("[DynamicEvmTokens] Tokens not yet loaded, returning static config for network"); + return Object.values(evmTokenConfig[network] ?? {}).filter((token): token is EvmTokenDetails => token !== undefined); + } + return Object.values(state.tokensByNetwork[network] ?? {}).filter((token): token is EvmTokenDetails => token !== undefined); +} + +/** + * Get all loaded EVM tokens as a flat array. + */ +export function getAllEvmTokens(): EvmTokenDetails[] { + if (!state.isLoaded) { + console.warn("[DynamicEvmTokens] Tokens not yet loaded, returning static config tokens"); + const fallback = buildFallbackFromStaticConfig(); + return fallback.tokens; + } + return state.tokens; +} + +/** + * Check if tokens have been loaded. + */ +export function isTokensLoaded(): boolean { + return state.isLoaded; +} + +/** + * Check if the service used the fallback static config. + */ +export function usedFallbackConfig(): boolean { + return state.usedFallback; +} + +/** + * Get the error if token loading failed. + */ +export function getLoadingError(): Error | null { + return state.error; +} diff --git a/apps/frontend/src/services/tokens/index.ts b/apps/frontend/src/services/tokens/index.ts new file mode 100644 index 0000000000..fb7342b713 --- /dev/null +++ b/apps/frontend/src/services/tokens/index.ts @@ -0,0 +1,13 @@ +/** + * Token Services + */ + +export { + getAllEvmTokens, + getEvmTokenConfig, + getEvmTokensForNetwork, + getLoadingError, + initializeEvmTokens, + isTokensLoaded, + usedFallbackConfig +} from "./dynamicEvmTokens"; diff --git a/packages/shared/src/tokens/types/evm.ts b/packages/shared/src/tokens/types/evm.ts index 2f02b43d3d..6de18ce382 100644 --- a/packages/shared/src/tokens/types/evm.ts +++ b/packages/shared/src/tokens/types/evm.ts @@ -30,6 +30,8 @@ export interface EvmTokenDetails extends BaseTokenDetails { /// The metadata about the token when it's used in Pendulum. /// For now, all EVM tokens are represented by axlUSDC on Pendulum. pendulumRepresentative: PendulumTokenDetails; + /// URL to the token's logo image from external sources (e.g., Squid Router) + logoURI?: string; } export interface EvmTokenDetailsWithBalance extends EvmTokenDetails { diff --git a/packages/shared/src/tokens/utils/helpers.ts b/packages/shared/src/tokens/utils/helpers.ts index a88b957358..f267c30e43 100644 --- a/packages/shared/src/tokens/utils/helpers.ts +++ b/packages/shared/src/tokens/utils/helpers.ts @@ -2,14 +2,14 @@ * Helper functions for token configuration */ -import { isNetworkEVM, Networks } from "../../helpers"; +import { EvmNetworks, isNetworkEVM, Networks } from "../../helpers"; import logger from "../../logger"; import { assetHubTokenConfig } from "../assethub/config"; import { evmTokenConfig } from "../evm/config"; import { moonbeamTokenConfig } from "../moonbeam/config"; import { stellarTokenConfig } from "../stellar/config"; import { AssetHubToken, FiatToken, OnChainToken, RampCurrency } from "../types/base"; -import { EvmToken } from "../types/evm"; +import { EvmToken, EvmTokenDetails } from "../types/evm"; import { MoonbeamTokenDetails } from "../types/moonbeam"; import { PendulumTokenDetails } from "../types/pendulum"; import { StellarTokenDetails } from "../types/stellar"; @@ -19,7 +19,37 @@ import { FiatTokenDetails, OnChainTokenDetails } from "./typeGuards"; /** * Get token details for a specific network and token */ -export function getOnChainTokenDetails(network: Networks, onChainToken: OnChainToken): OnChainTokenDetails | undefined { +export function getOnChainTokenDetails( + network: Networks, + onChainToken: OnChainToken, + dynamicEvmTokenConfig?: Record>> +): OnChainTokenDetails | undefined { + const normalizedOnChainToken = normalizeTokenSymbol(onChainToken); + + try { + if (network === Networks.AssetHub) { + return assetHubTokenConfig[normalizedOnChainToken as AssetHubToken]; + } else { + if (isNetworkEVM(network)) { + const evmNetwork = network as EvmNetworks; + const networkTokens = (dynamicEvmTokenConfig?.[evmNetwork] ?? evmTokenConfig[evmNetwork]) as Record< + string, + EvmTokenDetails + >; + return networkTokens[normalizedOnChainToken]; + } else throw new Error(`Network ${network} is not a valid EVM origin network`); + } + } catch (error) { + logger.current.error(`Error getting input token details: ${error}`); + throw error; + } +} + +/** + * Legacy version - uses static evmTokenConfig only + * @deprecated Use the version with dynamicEvmTokenConfig parameter + */ +export function getOnChainTokenDetailsStatic(network: Networks, onChainToken: OnChainToken): OnChainTokenDetails | undefined { const normalizedOnChainToken = normalizeTokenSymbol(onChainToken); try { @@ -39,8 +69,12 @@ export function getOnChainTokenDetails(network: Networks, onChainToken: OnChainT /** * Get token details for a specific network and token, with fallback to default */ -export function getOnChainTokenDetailsOrDefault(network: Networks, onChainToken: OnChainToken): OnChainTokenDetails { - const maybeOnChainTokenDetails = getOnChainTokenDetails(network, onChainToken); +export function getOnChainTokenDetailsOrDefault( + network: Networks, + onChainToken: OnChainToken, + dynamicEvmTokenConfig?: Record>> +): OnChainTokenDetails { + const maybeOnChainTokenDetails = getOnChainTokenDetails(network, onChainToken, dynamicEvmTokenConfig); if (maybeOnChainTokenDetails) { return maybeOnChainTokenDetails; } @@ -58,7 +92,6 @@ export function getOnChainTokenDetailsOrDefault(network: Networks, onChainToken: if (!firstAvailableToken) { throw new Error(`No tokens configured for network ${network}`); } - return firstAvailableToken; } else throw new Error(`Network ${network} is not a valid EVM origin network`); } From 16885b45aa4db8b3fe51d29dcd9933056d52ca68 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 15 Jan 2026 17:09:55 -0300 Subject: [PATCH 02/61] move dynamic token config to shared, adjust checks and getters --- .../api/src/api/services/priceFeed.service.ts | 25 ++++++- .../api/services/quote/core/squidrouter.ts | 2 + apps/api/src/index.ts | 5 +- .../src/components/Ramp/Offramp/index.tsx | 3 - apps/frontend/src/services/tokens/index.ts | 2 +- .../src/tokens/evm}/dynamicEvmTokens.ts | 68 ++++++++++++------- packages/shared/src/tokens/index.ts | 2 + packages/shared/src/tokens/types/evm.ts | 1 + packages/shared/src/tokens/utils/helpers.ts | 30 ++------ 9 files changed, 82 insertions(+), 56 deletions(-) rename {apps/frontend/src/services/tokens => packages/shared/src/tokens/evm}/dynamicEvmTokens.ts (82%) diff --git a/apps/api/src/api/services/priceFeed.service.ts b/apps/api/src/api/services/priceFeed.service.ts index 44e7f7189f..da165e8ef4 100644 --- a/apps/api/src/api/services/priceFeed.service.ts +++ b/apps/api/src/api/services/priceFeed.service.ts @@ -3,6 +3,7 @@ import { EvmToken, getPendulumDetails, getTokenOutAmount, + getTokenUsdPrice, isFiatToken, normalizeTokenSymbol, PENDULUM_USDC_AXL, @@ -488,6 +489,16 @@ export class PriceFeedService { } private async convertUsdToCrypto(amount: string, toCurrency: RampCurrency, decimals: number): Promise { + // Try dynamic token price first + const dynamicPrice = getTokenUsdPrice(toCurrency); + if (dynamicPrice !== undefined && dynamicPrice > 0) { + const result = new Big(amount).div(dynamicPrice).toFixed(decimals); + logger.debug(`Converted ${amount} USD to ${result} ${toCurrency} using dynamic price: ${dynamicPrice}`); + return result; + } + + // Fall back to CoinGecko + logger.debug(`No dynamic price for ${toCurrency}, falling back to CoinGecko`); const tokenId = this.getCoinGeckoTokenId(toCurrency); if (!tokenId) { throw new Error(`No CoinGecko token ID mapping for ${toCurrency}`); @@ -499,11 +510,21 @@ export class PriceFeedService { } const result = new Big(amount).div(cryptoPriceUSD).toFixed(decimals); - logger.debug(`Converted ${amount} USD to ${result} ${toCurrency} using price: ${cryptoPriceUSD}`); + logger.debug(`Converted ${amount} USD to ${result} ${toCurrency} using CoinGecko price: ${cryptoPriceUSD}`); return result; } private async convertCryptoToUsd(amount: string, fromCurrency: RampCurrency, decimals: number): Promise { + // Try dynamic token price first + const dynamicPrice = getTokenUsdPrice(fromCurrency); + if (dynamicPrice !== undefined && dynamicPrice > 0) { + const result = new Big(amount).mul(dynamicPrice).toFixed(decimals); + logger.debug(`Converted ${amount} ${fromCurrency} to ${result} USD using dynamic price: ${dynamicPrice}`); + return result; + } + + // Fall back to CoinGecko + logger.debug(`No dynamic price for ${fromCurrency}, falling back to CoinGecko`); const tokenId = this.getCoinGeckoTokenId(fromCurrency); if (!tokenId) { throw new Error(`No CoinGecko token ID mapping for ${fromCurrency}`); @@ -511,7 +532,7 @@ export class PriceFeedService { const cryptoPriceUSD = await this.getCryptoPrice(tokenId, "usd"); const result = new Big(amount).mul(cryptoPriceUSD).toFixed(decimals); - logger.debug(`Converted ${amount} ${fromCurrency} to ${result} USD using price: ${cryptoPriceUSD}`); + logger.debug(`Converted ${amount} ${fromCurrency} to ${result} USD using CoinGecko price: ${cryptoPriceUSD}`); return result; } } diff --git a/apps/api/src/api/services/quote/core/squidrouter.ts b/apps/api/src/api/services/quote/core/squidrouter.ts index 7968f70971..480a0e81bd 100644 --- a/apps/api/src/api/services/quote/core/squidrouter.ts +++ b/apps/api/src/api/services/quote/core/squidrouter.ts @@ -64,6 +64,8 @@ export function getTokenDetailsForEvmDestination( }); } + console.log("Getting token details for:", finalOutputCurrency, "on network:", network); + const tokenDetails = getOnChainTokenDetails(network, finalOutputCurrency); if (!tokenDetails || !isEvmTokenDetails(tokenDetails)) { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8e4bbdd87f..d23c6685c6 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -5,7 +5,7 @@ dotenv.config({ path: [path.resolve(process.cwd(), ".env"), path.resolve(process.cwd(), "../.env")] }); -import { ApiManager, EvmClientManager, setLogger } from "@vortexfi/shared"; +import { ApiManager, EvmClientManager, initializeEvmTokens, setLogger } from "@vortexfi/shared"; import { config, testDatabaseConnection } from "./config"; import cryptoService from "./config/crypto"; import app from "./config/express"; @@ -53,6 +53,9 @@ const initializeApp = async () => { // Initialize RSA keys for webhook signing cryptoService.initializeKeys(); + // Initialize dynamic EVM tokens from SquidRouter API (falls back to static config on failure) + await initializeEvmTokens(); + // Test database connection await testDatabaseConnection(); diff --git a/apps/frontend/src/components/Ramp/Offramp/index.tsx b/apps/frontend/src/components/Ramp/Offramp/index.tsx index 46e5439c16..5f7af1a423 100644 --- a/apps/frontend/src/components/Ramp/Offramp/index.tsx +++ b/apps/frontend/src/components/Ramp/Offramp/index.tsx @@ -55,10 +55,7 @@ export const Offramp = () => { const { openTokenSelectModal } = useTokenSelectionActions(); - console.log("Offramp render: ", { fiatToken, inputAmount, onChainToken, toAmount }); - const fromToken = getOnChainTokenDetailsOrDefault(selectedNetwork, onChainToken, getEvmTokenConfig()); - console.log("fromToken: ", fromToken); const toToken = getAnyFiatTokenDetails(fiatToken); useEffect(() => { diff --git a/apps/frontend/src/services/tokens/index.ts b/apps/frontend/src/services/tokens/index.ts index fb7342b713..c47983c77a 100644 --- a/apps/frontend/src/services/tokens/index.ts +++ b/apps/frontend/src/services/tokens/index.ts @@ -10,4 +10,4 @@ export { initializeEvmTokens, isTokensLoaded, usedFallbackConfig -} from "./dynamicEvmTokens"; +} from "@vortexfi/shared"; diff --git a/apps/frontend/src/services/tokens/dynamicEvmTokens.ts b/packages/shared/src/tokens/evm/dynamicEvmTokens.ts similarity index 82% rename from apps/frontend/src/services/tokens/dynamicEvmTokens.ts rename to packages/shared/src/tokens/evm/dynamicEvmTokens.ts index 3a7ab6aef6..57c38e18a4 100644 --- a/apps/frontend/src/services/tokens/dynamicEvmTokens.ts +++ b/packages/shared/src/tokens/evm/dynamicEvmTokens.ts @@ -1,26 +1,10 @@ -/** - * Dynamic EVM Tokens Service - * - * This module loads EVM token configurations dynamically from SquidRouter API at app initialization. - * It falls back to the static evmTokenConfig on failure. - * - * Usage: - * 1. Call initializeEvmTokens() once at app startup (before React renders) - * 2. Use getEvmTokenConfig() or getEvmTokensForNetwork() to access tokens - */ - -import { - EvmNetworks, - EvmTokenDetails, - evmTokenConfig, - getNetworkId, - isNetworkEVM, - Networks, - PENDULUM_USDC_AXL, - squidRouterConfigBase, - TokenType -} from "@vortexfi/shared"; import axios from "axios"; +import { EvmNetworks, getNetworkId, isNetworkEVM, Networks } from "../../helpers/networks"; +import { squidRouterConfigBase } from "../../services/squidrouter/config"; +import { PENDULUM_USDC_AXL } from "../pendulum/config"; +import { TokenType } from "../types/base"; +import { EvmTokenDetails } from "../types/evm"; +import { evmTokenConfig } from "./config"; interface SquidRouterToken { symbol: string; @@ -92,7 +76,8 @@ function mapSquidTokenToEvmTokenDetails(token: SquidRouterToken): EvmTokenDetail network, networkAssetIcon: getNetworkAssetIcon(network, token.symbol), pendulumRepresentative: PENDULUM_USDC_AXL, - type: TokenType.Evm + type: TokenType.Evm, + usdPrice: token.usdPrice }; } @@ -124,6 +109,20 @@ function groupTokensByNetwork(tokens: EvmTokenDetails[]): Record { const squidTokens = await fetchSquidRouterTokens(); const evmTokens = squidTokens .map(mapSquidTokenToEvmTokenDetails) - .filter((token): token is EvmTokenDetails => token !== null); + .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); @@ -241,3 +241,23 @@ export function usedFallbackConfig(): boolean { export function getLoadingError(): Error | null { return state.error; } + +/** + * Get the USD price for a token by its symbol. + * Returns undefined if the token is not found or has no price. + * + * @param symbol - The token symbol (e.g., 'ETH', 'USDC', 'MATIC') + * @returns The USD price or undefined if not available + */ +export function getTokenUsdPrice(symbol: string): number | undefined { + if (!state.isLoaded) { + return undefined; + } + + const normalizedSymbol = symbol.toUpperCase(); + + // Search through all tokens to find matching symbol + const token = state.tokens.find(t => t.assetSymbol.toUpperCase() === normalizedSymbol); + + return token?.usdPrice; +} diff --git a/packages/shared/src/tokens/index.ts b/packages/shared/src/tokens/index.ts index 9254900741..aae502fa42 100644 --- a/packages/shared/src/tokens/index.ts +++ b/packages/shared/src/tokens/index.ts @@ -7,6 +7,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"; diff --git a/packages/shared/src/tokens/types/evm.ts b/packages/shared/src/tokens/types/evm.ts index 6de18ce382..ce98039de1 100644 --- a/packages/shared/src/tokens/types/evm.ts +++ b/packages/shared/src/tokens/types/evm.ts @@ -32,6 +32,7 @@ export interface EvmTokenDetails extends BaseTokenDetails { pendulumRepresentative: PendulumTokenDetails; /// URL to the token's logo image from external sources (e.g., Squid Router) logoURI?: string; + usdPrice?: number; } export interface EvmTokenDetailsWithBalance extends EvmTokenDetails { diff --git a/packages/shared/src/tokens/utils/helpers.ts b/packages/shared/src/tokens/utils/helpers.ts index f267c30e43..abd74f3202 100644 --- a/packages/shared/src/tokens/utils/helpers.ts +++ b/packages/shared/src/tokens/utils/helpers.ts @@ -6,6 +6,7 @@ import { EvmNetworks, isNetworkEVM, Networks } from "../../helpers"; import logger from "../../logger"; import { assetHubTokenConfig } from "../assethub/config"; import { evmTokenConfig } from "../evm/config"; +import { getEvmTokenConfig } from "../evm/dynamicEvmTokens"; import { moonbeamTokenConfig } from "../moonbeam/config"; import { stellarTokenConfig } from "../stellar/config"; import { AssetHubToken, FiatToken, OnChainToken, RampCurrency } from "../types/base"; @@ -32,10 +33,10 @@ export function getOnChainTokenDetails( } else { if (isNetworkEVM(network)) { const evmNetwork = network as EvmNetworks; - const networkTokens = (dynamicEvmTokenConfig?.[evmNetwork] ?? evmTokenConfig[evmNetwork]) as Record< - string, - EvmTokenDetails - >; + // Use provided config, or get dynamic config, or fallback to static config + // TODO what is best... pass it on the context or use directly this all the time? + const configToUse = dynamicEvmTokenConfig ?? getEvmTokenConfig(); + const networkTokens = configToUse[evmNetwork] as Record; return networkTokens[normalizedOnChainToken]; } else throw new Error(`Network ${network} is not a valid EVM origin network`); } @@ -45,27 +46,6 @@ export function getOnChainTokenDetails( } } -/** - * Legacy version - uses static evmTokenConfig only - * @deprecated Use the version with dynamicEvmTokenConfig parameter - */ -export function getOnChainTokenDetailsStatic(network: Networks, onChainToken: OnChainToken): OnChainTokenDetails | undefined { - const normalizedOnChainToken = normalizeTokenSymbol(onChainToken); - - try { - if (network === Networks.AssetHub) { - return assetHubTokenConfig[normalizedOnChainToken as AssetHubToken]; - } else { - if (isNetworkEVM(network)) { - return evmTokenConfig[network][normalizedOnChainToken as EvmToken]; - } else throw new Error(`Network ${network} is not a valid EVM origin network`); - } - } catch (error) { - logger.current.error(`Error getting input token details: ${error}`); - throw error; - } -} - /** * Get token details for a specific network and token, with fallback to default */ From 9edce1e9b5a92f531383244ee57e8a10aa5deef7 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 16 Jan 2026 12:45:52 -0300 Subject: [PATCH 03/61] test react window --- apps/frontend/package.json | 1 + .../src/components/ListItem/index.tsx | 8 +- .../components/SelectionTokenList.tsx | 84 +++++++++++++++---- .../components/SelectonTokenList2.tsx | 41 +++++++++ .../TokenSelectionList/helpers.tsx | 3 + bun.lock | 4 + package.json | 3 +- .../shared/src/tokens/evm/dynamicEvmTokens.ts | 4 +- 8 files changed, 129 insertions(+), 19 deletions(-) create mode 100644 apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectonTokenList2.tsx diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 5f2dbfce05..ca06879d8b 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -56,6 +56,7 @@ "react-hook-form": "^7.65.0", "react-i18next": "^15.4.1", "react-toastify": "^11.0.5", + "react-window": "^2.2.5", "stellar-sdk": "catalog:", "tailwind-merge": "^3.1.0", "tailwindcss": "^4.0.3", diff --git a/apps/frontend/src/components/ListItem/index.tsx b/apps/frontend/src/components/ListItem/index.tsx index 6c89471377..df975b46fc 100644 --- a/apps/frontend/src/components/ListItem/index.tsx +++ b/apps/frontend/src/components/ListItem/index.tsx @@ -33,7 +33,13 @@ export function ListItem({ token, isSelected, onSelect }: ListItemProps) {
- {token.assetSymbol} + {token.assetSymbol}
{isSelected && } diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx index 83fcfea688..da156e0d4b 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx @@ -1,11 +1,39 @@ +import { FiatToken, OnChainToken } from "@vortexfi/shared"; +import { CSSProperties, useEffect, useRef, useState } from "react"; +import { List as WindowedList } from "react-window"; import { useNetwork } from "../../../../contexts/network"; import { cn } from "../../../../helpers/cn"; import { useTokensSortedByBalance } from "../../../../hooks/useTokensSortedByBalance"; 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"; +interface SelectionListData { + tokens: ExtendedTokenDefinition[]; + selectedToken: any; + selectedNetwork: any; + handleTokenSelect: (type: OnChainToken | FiatToken, token: ExtendedTokenDefinition) => void; +} + +type RowComponentProps = SelectionListData & { + index: number; + style: CSSProperties; +}; + +const RowComponent = ({ index, style, tokens, selectedToken, selectedNetwork, handleTokenSelect }: RowComponentProps) => { + // + const token = tokens[index]; + const isSelected = selectedToken === token.type && selectedNetwork === token.network; + + return ( +
+
+ handleTokenSelect(tokenType, token)} token={token} /> +
+
+ ); +}; export const SelectionTokenList = () => { const isFiatDirection = useIsFiatDirection(); const isNetworkDropdownOpen = useIsNetworkDropdownOpen(); @@ -14,28 +42,54 @@ export const SelectionTokenList = () => { const { selectedNetwork } = useNetwork(); const { filteredDefinitions } = useTokenDefinitions(searchFilter, selectedNetworkFilter); - const sortedDefinitions = useTokensSortedByBalance(filteredDefinitions); - const currentDefinitions = isFiatDirection ? filteredDefinitions : sortedDefinitions; + //const sortedDefinitions = useTokensSortedByBalance(filteredDefinitions); + const currentDefinitions = isFiatDirection ? filteredDefinitions : filteredDefinitions; const { handleTokenSelect, selectedToken } = useTokenSelection(); + const parentRef = useRef(null); + const [dimensions, setDimensions] = useState({ height: 0, width: 0 }); + + useEffect(() => { + if (!parentRef.current) return; + + const resizeObserver = new ResizeObserver(entries => { + for (const entry of entries) { + setDimensions({ + height: entry.contentRect.height, + width: entry.contentRect.width + }); + } + }); + + resizeObserver.observe(parentRef.current); + + return () => resizeObserver.disconnect(); + }, []); + return (
-
    - {currentDefinitions.map(token => ( -
  • - handleTokenSelect(tokenType, token)} - token={token} - /> -
  • - ))} -
+ {} + {dimensions.height > 0 && ( + + )}
); }; diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectonTokenList2.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectonTokenList2.tsx new file mode 100644 index 0000000000..6dccc262d7 --- /dev/null +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectonTokenList2.tsx @@ -0,0 +1,41 @@ +import { useNetwork } from "../../../../contexts/network"; +import { cn } from "../../../../helpers/cn"; +import { useTokensSortedByBalance } from "../../../../hooks/useTokensSortedByBalance"; +import { useIsNetworkDropdownOpen, useSearchFilter, useSelectedNetworkFilter } from "../../../../stores/tokenSelectionStore"; +import { ListItem } from "../../../ListItem"; +import { useIsFiatDirection, useTokenDefinitions } from "../helpers"; +import { useTokenSelection } from "../hooks/useTokenSelection"; + +export const SelectionTokenList2 = () => { + 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 : filteredDefinitions; + const { handleTokenSelect, selectedToken } = useTokenSelection(); + + return ( +
+
    + {currentDefinitions.map(token => ( +
  • + handleTokenSelect(tokenType, token)} + token={token} + /> +
  • + ))} +
+
+ ); +}; diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx index becc8d467d..d01928cfa0 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx @@ -28,6 +28,7 @@ export function useTokenDefinitions(filter: string, selectedNetworkFilter: Netwo () => getAllSupportedTokenDefinitions(tokenSelectModalType, rampDirection), [tokenSelectModalType, rampDirection] ); + console.log("All Definitions fetched"); const availableNetworks = useMemo(() => { const networks = new Set(allDefinitions.map(token => token.network)); @@ -43,6 +44,8 @@ export function useTokenDefinitions(filter: string, selectedNetworkFilter: Netwo const filteredDefinitions = useMemo(() => { const searchTerm = filter.toLowerCase(); + console.log("Filtered Definitions computed"); + return networkFilteredDefinitions.filter( ({ assetSymbol, name, networkDisplayName }) => assetSymbol.toLowerCase().includes(searchTerm) || diff --git a/bun.lock b/bun.lock index a17c19278a..033574c422 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "big.js": "^7.0.1", "husky": "^9.1.7", "lint-staged": "^16.1.0", + "react-window": "^2.2.5", }, "devDependencies": { "@biomejs/biome": "2.0.0", @@ -157,6 +158,7 @@ "react-hook-form": "^7.65.0", "react-i18next": "^15.4.1", "react-toastify": "^11.0.5", + "react-window": "^2.2.5", "stellar-sdk": "catalog:", "tailwind-merge": "^3.1.0", "tailwindcss": "^4.0.3", @@ -3424,6 +3426,8 @@ "react-toastify": ["react-toastify@11.0.5", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA=="], + "react-window": ["react-window@2.2.5", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-6viWvPSZvVuMIe9hrl4IIZoVfO/npiqOb03m4Z9w+VihmVzBbiudUrtUqDpsWdKvd/Ai31TCR25CBcFFAUm28w=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], diff --git a/package.json b/package.json index 8619a5ed56..0dcb61f926 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "dependencies": { "big.js": "^7.0.1", "husky": "^9.1.7", - "lint-staged": "^16.1.0" + "lint-staged": "^16.1.0", + "react-window": "^2.2.5" }, "devDependencies": { "@biomejs/biome": "2.0.0", diff --git a/packages/shared/src/tokens/evm/dynamicEvmTokens.ts b/packages/shared/src/tokens/evm/dynamicEvmTokens.ts index 57c38e18a4..a4f4eb9712 100644 --- a/packages/shared/src/tokens/evm/dynamicEvmTokens.ts +++ b/packages/shared/src/tokens/evm/dynamicEvmTokens.ts @@ -164,8 +164,8 @@ export async function initializeEvmTokens(): Promise { const squidTokens = await fetchSquidRouterTokens(); 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 + .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); From e75470c97cbc32498c1ec2ea930e6a4bb0d7ff62 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 19 Jan 2026 09:09:58 -0300 Subject: [PATCH 04/61] solve cyclic import issue From d0d95649dfb7dd369bdc6a979e3f6a008ec91a8f Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 19 Jan 2026 10:04:44 -0300 Subject: [PATCH 05/61] use balances from alchemy endpoint --- .prettierignore | 1 + .../components/SelectionTokenList.tsx | 4 +- .../src/hooks/useOnchainTokenBalances.ts | 130 +++++++++++++++--- 3 files changed, 113 insertions(+), 22 deletions(-) create mode 100644 .prettierignore diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..f4334f75de --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +./packages/shared/src/tokens/index.ts \ No newline at end of file diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx index da156e0d4b..d20bdcab0c 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx @@ -42,8 +42,8 @@ export const SelectionTokenList = () => { const { selectedNetwork } = useNetwork(); const { filteredDefinitions } = useTokenDefinitions(searchFilter, selectedNetworkFilter); - //const sortedDefinitions = useTokensSortedByBalance(filteredDefinitions); - const currentDefinitions = isFiatDirection ? filteredDefinitions : filteredDefinitions; + const sortedDefinitions = useTokensSortedByBalance(filteredDefinitions); + const currentDefinitions = isFiatDirection ? filteredDefinitions : sortedDefinitions; const { handleTokenSelect, selectedToken } = useTokenSelection(); const parentRef = useRef(null); diff --git a/apps/frontend/src/hooks/useOnchainTokenBalances.ts b/apps/frontend/src/hooks/useOnchainTokenBalances.ts index be9b452700..7b98ac0c5d 100644 --- a/apps/frontend/src/hooks/useOnchainTokenBalances.ts +++ b/apps/frontend/src/hooks/useOnchainTokenBalances.ts @@ -1,4 +1,5 @@ import { + ALCHEMY_API_KEY, AssetHubTokenDetails, AssetHubTokenDetailsWithBalance, assetHubTokenConfig, @@ -24,6 +25,78 @@ import { multiplyByPowerOfTen } from "../helpers/contracts"; import { getEvmTokensForNetwork } from "../services/tokens"; import { useVortexAccount } from "./useVortexAccount"; +interface AlchemyResponse { + jsonrpc: string; + id: number; + result: { + address: string; + tokenBalances: { + contractAddress: string; + tokenBalance: string; + }[]; + }; +} + +const getAlchemyEndpoint = (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 subdomain = networkMap[network]; + console.log("Alchemy subdomain", subdomain); + return subdomain ? `https://${subdomain}.g.alchemy.com/v2/${ALCHEMY_API_KEY}` : null; +}; + +const fetchAlchemyTokenBalances = async ( + address: string, + tokenAddresses: string[], + network: Networks +): Promise> => { + const endpoint = getAlchemyEndpoint(network); + if (!endpoint || tokenAddresses.length === 0) { + return new Map(); + } + + try { + const response = await fetch(endpoint, { + body: JSON.stringify({ + id: 1, + jsonrpc: "2.0", + method: "alchemy_getTokenBalances", + params: [address, tokenAddresses] + }), + headers: { + "Content-Type": "application/json" + }, + method: "POST" + }); + + const data: AlchemyResponse = await response.json(); + + const balanceMap = new Map(); + if (data.result?.tokenBalances) { + data.result.tokenBalances.forEach(tokenBalance => { + // Key includes network to avoid collisions across chains + const key = `${network}-${tokenBalance.contractAddress.toLowerCase()}`; + balanceMap.set(key, Number(tokenBalance.tokenBalance as `0x${string}`).toString()); + }); + } + + 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(); @@ -125,34 +198,51 @@ const groupTokensByNetwork = (tokens: EvmTokenDetails[]): Record { const { evmAddress: address } = useVortexAccount(); + const [balanceMap, setBalanceMap] = useState>(new Map()); const tokensByNetwork = useMemo(() => groupTokensByNetwork(tokens), [tokens]); - // Create contract calls for all networks - const contractCalls = useMemo(() => { - return Object.entries(tokensByNetwork).flatMap(([network, networkTokens]) => { - const chainId = getNetworkId(network as Networks); - return networkTokens.map(token => ({ - abi: erc20ABI as Abi, - address: token.erc20AddressSourceChain, - args: [address], - chainId, - functionName: "balanceOf" - })); - }); - }, [tokensByNetwork, address]); + // Fetch balances from Alchemy for all EVM networks once on component mount + useEffect(() => { + if (!address) return; - const { data: balances } = useReadContracts({ - contracts: contractCalls ?? [] - }); + const fetchAllBalances = async () => { + // Get all EVM networks from the tokens passed + const evmNetworks = Object.keys(tokensByNetwork).filter(network => isNetworkEVM(network as Networks)) as Networks[]; - if (!tokens.length || !balances) { - return []; - } + const allBalances = new Map(); + + for (const network of evmNetworks) { + const networkTokens = tokensByNetwork[network]; + if (!networkTokens?.length) continue; + + const tokenAddresses = networkTokens.map(token => token.erc20AddressSourceChain).filter(addr => addr); + + if (tokenAddresses.length > 0) { + try { + const balances = await fetchAlchemyTokenBalances(address, tokenAddresses, network); + console.log(`[${network}] Balances:`, Object.fromEntries(balances)); + + // Merge balances into the allBalances map + balances.forEach((value, key) => { + allBalances.set(key, value); + }); + } catch (error) { + console.error(`Failed to fetch ${network} balances:`, error); + } + } + } + + 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 tokenBalance = balances[index]?.result; + const key = `${curr.network}-${curr.erc20AddressSourceChain?.toLowerCase()}`; + const tokenBalance = balanceMap.get(key); // If we are dealing with a stablecoin, we show 2 decimals, otherwise 4 const showDecimals = curr.assetSymbol.toLowerCase().includes("usd") ? 2 : 4; From 8c0d7f30ce0076a8f1834730967daaaa39e39e61 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 19 Jan 2026 11:07:12 -0300 Subject: [PATCH 06/61] fetch balances from portfolio endpoint --- .../src/hooks/useOnchainTokenBalances.ts | 95 +++++++++---------- 1 file changed, 46 insertions(+), 49 deletions(-) diff --git a/apps/frontend/src/hooks/useOnchainTokenBalances.ts b/apps/frontend/src/hooks/useOnchainTokenBalances.ts index 7b98ac0c5d..ed8b69c8e5 100644 --- a/apps/frontend/src/hooks/useOnchainTokenBalances.ts +++ b/apps/frontend/src/hooks/useOnchainTokenBalances.ts @@ -16,7 +16,7 @@ import { } from "@vortexfi/shared"; import Big from "big.js"; import { useEffect, useMemo, useState } from "react"; -import { Abi } from "viem"; +import { Abi, hexToBigInt } from "viem"; import { useBalance, useReadContracts } from "wagmi"; import { useNetwork } from "../contexts/network"; import { useAssetHubNode } from "../contexts/polkadotNode"; @@ -25,19 +25,19 @@ import { multiplyByPowerOfTen } from "../helpers/contracts"; import { getEvmTokensForNetwork } from "../services/tokens"; import { useVortexAccount } from "./useVortexAccount"; -interface AlchemyResponse { - jsonrpc: string; - id: number; - result: { - address: string; - tokenBalances: { - contractAddress: string; +interface AlchemyTokenBalancesResponse { + data: { + tokens: { + network: string; + address: string; + tokenAddress: string | null; tokenBalance: string; }[]; + pageKey?: string; }; } -const getAlchemyEndpoint = (network: Networks): string | null => { +const getAlchemyNetworkName = (network: Networks): string | null => { if (!ALCHEMY_API_KEY) return null; const networkMap: Partial> = { @@ -50,28 +50,29 @@ const getAlchemyEndpoint = (network: Networks): string | null => { [Networks.Polygon]: "polygon-mainnet" }; - const subdomain = networkMap[network]; - console.log("Alchemy subdomain", subdomain); - return subdomain ? `https://${subdomain}.g.alchemy.com/v2/${ALCHEMY_API_KEY}` : null; + const networkName = networkMap[network]; + return networkName || null; }; -const fetchAlchemyTokenBalances = async ( - address: string, - tokenAddresses: string[], - network: Networks -): Promise> => { - const endpoint = getAlchemyEndpoint(network); - if (!endpoint || tokenAddresses.length === 0) { +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({ - id: 1, - jsonrpc: "2.0", - method: "alchemy_getTokenBalances", - params: [address, tokenAddresses] + addresses: [ + { + address, + networks: [networkName] + } + ], + includeErc20Tokens: true, + includeNativeTokens: true }), headers: { "Content-Type": "application/json" @@ -79,14 +80,16 @@ const fetchAlchemyTokenBalances = async ( method: "POST" }); - const data: AlchemyResponse = await response.json(); + const data: AlchemyTokenBalancesResponse = await response.json(); const balanceMap = new Map(); - if (data.result?.tokenBalances) { - data.result.tokenBalances.forEach(tokenBalance => { - // Key includes network to avoid collisions across chains - const key = `${network}-${tokenBalance.contractAddress.toLowerCase()}`; - balanceMap.set(key, Number(tokenBalance.tokenBalance as `0x${string}`).toString()); + 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); }); } @@ -216,20 +219,17 @@ export const useEvmBalances = (tokens: EvmTokenDetails[]): EvmTokenDetailsWithBa const networkTokens = tokensByNetwork[network]; if (!networkTokens?.length) continue; - const tokenAddresses = networkTokens.map(token => token.erc20AddressSourceChain).filter(addr => addr); - - if (tokenAddresses.length > 0) { - try { - const balances = await fetchAlchemyTokenBalances(address, tokenAddresses, network); - console.log(`[${network}] Balances:`, Object.fromEntries(balances)); - - // Merge balances into the allBalances map - balances.forEach((value, key) => { - allBalances.set(key, value); - }); - } catch (error) { - console.error(`Failed to fetch ${network} balances:`, error); - } + // The new API fetches all token balances (including native) for the address/network + try { + const balances = await fetchAlchemyTokenBalances(address, network); + console.log(`[${network}] Balances:`, Object.fromEntries(balances)); + + // Merge balances into the allBalances map + balances.forEach((value, key) => { + allBalances.set(key, value); + }); + } catch (error) { + console.error(`Failed to fetch ${network} balances:`, error); } } @@ -242,13 +242,10 @@ export const useEvmBalances = (tokens: EvmTokenDetails[]): EvmTokenDetailsWithBa // 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); + 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; - // If we are dealing with a stablecoin, we show 2 decimals, otherwise 4 - const showDecimals = curr.assetSymbol.toLowerCase().includes("usd") ? 2 : 4; - const balance = tokenBalance - ? multiplyByPowerOfTen(Big(tokenBalance.toString()), -curr.decimals).toFixed(showDecimals, 0) - : "0.00"; + const balance = tokenBalance ? multiplyByPowerOfTen(Big(tokenBalance), -curr.decimals).toFixed(showDecimals, 0) : "0.00"; prev.push({ ...curr, From 02dda7c3653ee70e4a2ed07bfeacf13b9106ca95 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 19 Jan 2026 11:46:35 -0300 Subject: [PATCH 07/61] order balance by usd value --- .../src/components/ListItem/index.tsx | 2 +- .../components/SelectonTokenList2.tsx | 41 ------------------- .../src/hooks/useOnchainTokenBalances.ts | 39 +++++++++++++++--- .../hooks/useOnchainTokenBalancesSorted.ts | 4 +- .../shared/.prettierignore | 0 packages/shared/src/tokens/types/assethub.ts | 1 + packages/shared/src/tokens/types/evm.ts | 1 + .../shared/src/tokens/utils/typeGuards.ts | 1 + 8 files changed, 39 insertions(+), 50 deletions(-) delete mode 100644 apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectonTokenList2.tsx rename .prettierignore => packages/shared/.prettierignore (100%) diff --git a/apps/frontend/src/components/ListItem/index.tsx b/apps/frontend/src/components/ListItem/index.tsx index df975b46fc..72ce647f86 100644 --- a/apps/frontend/src/components/ListItem/index.tsx +++ b/apps/frontend/src/components/ListItem/index.tsx @@ -16,7 +16,7 @@ export function ListItem({ token, isSelected, onSelect }: ListItemProps) { const { t } = useTranslation(); const tokenIcon = token.logoURI ? token.logoURI : useGetAssetIcon(token.assetIcon); - const showBalance = isOnChainToken(token.type); + const showBalance = (token.details as any).balance !== "0.00"; const isDisabled = isFiatToken(token.type) && isFiatTokenDisabled(token.type); const disabledReason = isFiatToken(token.type) && isDisabled ? t(getTokenDisabledReason(token.type)) : undefined; diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectonTokenList2.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectonTokenList2.tsx deleted file mode 100644 index 6dccc262d7..0000000000 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectonTokenList2.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useNetwork } from "../../../../contexts/network"; -import { cn } from "../../../../helpers/cn"; -import { useTokensSortedByBalance } from "../../../../hooks/useTokensSortedByBalance"; -import { useIsNetworkDropdownOpen, useSearchFilter, useSelectedNetworkFilter } from "../../../../stores/tokenSelectionStore"; -import { ListItem } from "../../../ListItem"; -import { useIsFiatDirection, useTokenDefinitions } from "../helpers"; -import { useTokenSelection } from "../hooks/useTokenSelection"; - -export const SelectionTokenList2 = () => { - 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 : filteredDefinitions; - const { handleTokenSelect, selectedToken } = useTokenSelection(); - - return ( -
-
    - {currentDefinitions.map(token => ( -
  • - handleTokenSelect(tokenType, token)} - token={token} - /> -
  • - ))} -
-
- ); -}; diff --git a/apps/frontend/src/hooks/useOnchainTokenBalances.ts b/apps/frontend/src/hooks/useOnchainTokenBalances.ts index ed8b69c8e5..61907e856a 100644 --- a/apps/frontend/src/hooks/useOnchainTokenBalances.ts +++ b/apps/frontend/src/hooks/useOnchainTokenBalances.ts @@ -5,6 +5,7 @@ import { assetHubTokenConfig, EvmTokenDetails, EvmTokenDetailsWithBalance, + getAllEvmTokens, getNetworkId, isAssetHubTokenDetails, isEvmTokenDetails, @@ -124,9 +125,22 @@ export const useEvmNativeBalance = (): EvmTokenDetailsWithBalance | null => { 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: multiplyByPowerOfTen(Big(balance.value.toString()), -balance.decimals).toFixed(4, 0) + balance: formattedBalance, + balanceUsd }; }, [balance, selectedNetwork, nativeToken]); }; @@ -153,7 +167,8 @@ export const useAssetHubNativeBalance = (): AssetHubTokenDetailsWithBalance | nu if (!substrateAddress || !assethubNode) { setNativeBalance({ ...nativeToken, - balance: "0.0000" + balance: "0.0000", + balanceUsd: "0.00" }); return; } @@ -175,7 +190,8 @@ export const useAssetHubNativeBalance = (): AssetHubTokenDetailsWithBalance | nu setNativeBalance({ ...nativeToken, - balance: formattedBalance + balance: formattedBalance, + balanceUsd: "0.00" }); } catch (error) { console.error("Error fetching AssetHub native balance:", error); @@ -247,9 +263,20 @@ export const useEvmBalances = (tokens: EvmTokenDetails[]): EvmTokenDetailsWithBa 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 + balance, + balanceUsd }); return prev; @@ -276,7 +303,7 @@ export const useAssetHubBalances = (tokens: AssetHubTokenDetails[]): AssetHubTok // 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" }))); + setBalances(assetTokens.map(token => ({ ...token, balance: "0.00", balanceUsd: "0.00" }))); return; } @@ -313,7 +340,7 @@ export const useAssetHubBalances = (tokens: AssetHubTokenDetails[]): AssetHubTok balance = nativeToDecimal(offrampableBalance, token.decimals).toFixed(2, 0).toString(); } - return { ...token, balance }; + return { ...token, balance, balanceUsd: "0.00" }; }); setBalances(tokensWithBalances); diff --git a/apps/frontend/src/hooks/useOnchainTokenBalancesSorted.ts b/apps/frontend/src/hooks/useOnchainTokenBalancesSorted.ts index 9ffb3752d0..2c40598f76 100644 --- a/apps/frontend/src/hooks/useOnchainTokenBalancesSorted.ts +++ b/apps/frontend/src/hooks/useOnchainTokenBalancesSorted.ts @@ -8,8 +8,8 @@ export const useOnchainTokenBalancesSorted = (tokens: OnChainTokenDetails[]): On return useMemo(() => { // Sort by balance (highest to lowest), then by symbol (alphabetically) return [...tokenBalances].sort((a, b) => { - const aBalance = parseFloat(a.balance); - const bBalance = parseFloat(b.balance); + const aBalance = parseFloat(a.balanceUsd ?? "0"); + const bBalance = parseFloat(b.balanceUsd ?? "0"); // Primary sort: balance descending (highest to lowest) if (aBalance !== bBalance) { diff --git a/.prettierignore b/packages/shared/.prettierignore similarity index 100% rename from .prettierignore rename to packages/shared/.prettierignore diff --git a/packages/shared/src/tokens/types/assethub.ts b/packages/shared/src/tokens/types/assethub.ts index 47d808662a..1861dee5ca 100644 --- a/packages/shared/src/tokens/types/assethub.ts +++ b/packages/shared/src/tokens/types/assethub.ts @@ -19,4 +19,5 @@ export interface AssetHubTokenDetails extends BaseTokenDetails { export interface AssetHubTokenDetailsWithBalance extends AssetHubTokenDetails { balance: string; + balanceUsd: string; } diff --git a/packages/shared/src/tokens/types/evm.ts b/packages/shared/src/tokens/types/evm.ts index ce98039de1..ffc0da0200 100644 --- a/packages/shared/src/tokens/types/evm.ts +++ b/packages/shared/src/tokens/types/evm.ts @@ -37,4 +37,5 @@ export interface EvmTokenDetails extends BaseTokenDetails { export interface EvmTokenDetailsWithBalance extends EvmTokenDetails { balance: string; + balanceUsd?: string; } diff --git a/packages/shared/src/tokens/utils/typeGuards.ts b/packages/shared/src/tokens/utils/typeGuards.ts index 2cef370215..2f25b7bb0f 100644 --- a/packages/shared/src/tokens/utils/typeGuards.ts +++ b/packages/shared/src/tokens/utils/typeGuards.ts @@ -15,6 +15,7 @@ export type FiatTokenDetails = StellarTokenDetails | MoonbeamTokenDetails; export type OnChainTokenDetailsWithBalance = OnChainTokenDetails & { balance: string; + balanceUsd: string; }; /** From 2eeded69fda99706fed90981928fdf67c97b971b Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 19 Jan 2026 11:59:51 -0300 Subject: [PATCH 08/61] replace react-window with react-virtual --- apps/frontend/package.json | 1 + .../components/SelectionTokenList.tsx | 101 ++++++++---------- bun.lock | 5 + 3 files changed, 48 insertions(+), 59 deletions(-) diff --git a/apps/frontend/package.json b/apps/frontend/package.json index ca06879d8b..8786af4989 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -31,6 +31,7 @@ "@tanstack/react-query": "^5.64.2", "@tanstack/react-router": "^1.136.8", "@tanstack/react-router-devtools": "^1.136.8", + "@tanstack/react-virtual": "^3.13.18", "@tanstack/zod-adapter": "^1.144.0", "@types/crypto-js": "^4.2.2", "@vitejs/plugin-react": "^4.3.4", diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx index d20bdcab0c..d262c212ec 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx @@ -1,6 +1,6 @@ +import { useVirtualizer } from "@tanstack/react-virtual"; import { FiatToken, OnChainToken } from "@vortexfi/shared"; -import { CSSProperties, useEffect, useRef, useState } from "react"; -import { List as WindowedList } from "react-window"; +import { useRef } from "react"; import { useNetwork } from "../../../../contexts/network"; import { cn } from "../../../../helpers/cn"; import { useTokensSortedByBalance } from "../../../../hooks/useTokensSortedByBalance"; @@ -9,31 +9,8 @@ import { ListItem } from "../../../ListItem"; import { useIsFiatDirection, useTokenDefinitions } from "../helpers"; import { ExtendedTokenDefinition, useTokenSelection } from "../hooks/useTokenSelection"; -interface SelectionListData { - tokens: ExtendedTokenDefinition[]; - selectedToken: any; - selectedNetwork: any; - handleTokenSelect: (type: OnChainToken | FiatToken, token: ExtendedTokenDefinition) => void; -} +const ROW_HEIGHT = 80; -type RowComponentProps = SelectionListData & { - index: number; - style: CSSProperties; -}; - -const RowComponent = ({ index, style, tokens, selectedToken, selectedNetwork, handleTokenSelect }: RowComponentProps) => { - // - const token = tokens[index]; - const isSelected = selectedToken === token.type && selectedNetwork === token.network; - - return ( -
-
- handleTokenSelect(tokenType, token)} token={token} /> -
-
- ); -}; export const SelectionTokenList = () => { const isFiatDirection = useIsFiatDirection(); const isNetworkDropdownOpen = useIsNetworkDropdownOpen(); @@ -47,49 +24,55 @@ export const SelectionTokenList = () => { const { handleTokenSelect, selectedToken } = useTokenSelection(); const parentRef = useRef(null); - const [dimensions, setDimensions] = useState({ height: 0, width: 0 }); - - useEffect(() => { - if (!parentRef.current) return; - - const resizeObserver = new ResizeObserver(entries => { - for (const entry of entries) { - setDimensions({ - height: entry.contentRect.height, - width: entry.contentRect.width - }); - } - }); - resizeObserver.observe(parentRef.current); + const rowVirtualizer = useVirtualizer({ + count: currentDefinitions.length, + estimateSize: () => ROW_HEIGHT, + getScrollElement: () => parentRef.current + }); - return () => resizeObserver.disconnect(); - }, []); + const handleSelect = (tokenType: OnChainToken | FiatToken, token: ExtendedTokenDefinition) => { + handleTokenSelect(tokenType, token); + }; return (
- {} - {dimensions.height > 0 && ( - - )} +
+ {rowVirtualizer.getVirtualItems().map(virtualItem => { + const token = currentDefinitions[virtualItem.index]; + const isSelected = selectedToken === token.type && selectedNetwork === token.network; + + return ( +
+
+ handleSelect(tokenType, token)} token={token} /> +
+
+ ); + })} +
); }; diff --git a/bun.lock b/bun.lock index 033574c422..32b31feb1e 100644 --- a/bun.lock +++ b/bun.lock @@ -133,6 +133,7 @@ "@tanstack/react-query": "^5.64.2", "@tanstack/react-router": "^1.136.8", "@tanstack/react-router-devtools": "^1.136.8", + "@tanstack/react-virtual": "^3.13.18", "@tanstack/zod-adapter": "^1.144.0", "@types/crypto-js": "^4.2.2", "@vitejs/plugin-react": "^4.3.4", @@ -1580,6 +1581,8 @@ "@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.18", "", { "dependencies": { "@tanstack/virtual-core": "3.13.18" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A=="], + "@tanstack/router-core": ["@tanstack/router-core@1.144.0", "", { "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.1", "seroval-plugins": "^1.4.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-6oVERtK9XDHCP4XojgHsdHO56ZSj11YaWjF5g/zw39LhyA6Lx+/X86AEIHO4y0BUrMQaJfcjdAQMVSAs6Vjtdg=="], "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.144.0", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.144.0", "csstype": "^3.0.10", "solid-js": ">=1.9.5" }, "optionalPeers": ["csstype"] }, "sha512-rbpQn1aHUtcfY3U3SyJqOZRqDu0a2uPK+TE2CH50HieJApmCuNKj5RsjVQYHgwiFFvR0w0LUmueTnl2X2hiWTg=="], @@ -1592,6 +1595,8 @@ "@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.18", "", {}, "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg=="], + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.141.0", "", {}, "sha512-CJrWtr6L9TVzEImm9S7dQINx+xJcYP/aDkIi6gnaWtIgbZs1pnzsE0yJc2noqXZ+yAOqLx3TBGpBEs9tS0P9/A=="], "@tanstack/zod-adapter": ["@tanstack/zod-adapter@1.144.0", "", { "peerDependencies": { "@tanstack/react-router": ">=1.43.2", "zod": "^3.23.8" } }, "sha512-hcGgc7zGBK3j3/yLGUq3GN2rPoMtgbEftBv4pHXkwfJQsiySbFmz3M438K7NKtCNCzCzBH/Gxhe1Hdps6GZalQ=="], From b1b5d5b4fe0db9033d4e849d681b430fb1bdc093 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 19 Jan 2026 15:00:54 -0300 Subject: [PATCH 09/61] improve list ordering, performance --- .../components/AssetNumericInput/index.tsx | 3 +- .../src/components/Ramp/Offramp/index.tsx | 5 ++- .../components/SelectionTokenList.tsx | 8 ++-- .../TokenSelectionList/helpers.tsx | 44 +++++++++++-------- .../components/buttons/AssetButton/index.tsx | 5 ++- .../src/hooks/useOnchainTokenBalances.ts | 34 +++++++++----- .../hooks/useOnchainTokenBalancesSorted.ts | 3 +- .../shared/src/tokens/evm/dynamicEvmTokens.ts | 11 ++--- 8 files changed, 64 insertions(+), 49 deletions(-) diff --git a/apps/frontend/src/components/AssetNumericInput/index.tsx b/apps/frontend/src/components/AssetNumericInput/index.tsx index c5b88f385d..a0a5c89917 100644 --- a/apps/frontend/src/components/AssetNumericInput/index.tsx +++ b/apps/frontend/src/components/AssetNumericInput/index.tsx @@ -20,6 +20,7 @@ interface AssetNumericInputProps { disabled?: boolean; readOnly?: boolean; loading?: boolean; + logoURI?: string; registerInput: UseFormRegisterReturn; id: string; } @@ -39,7 +40,7 @@ export const AssetNumericInput: FC = ({ )} >
- +
{loading ? ( diff --git a/apps/frontend/src/components/Ramp/Offramp/index.tsx b/apps/frontend/src/components/Ramp/Offramp/index.tsx index 5f7af1a423..86ec726044 100644 --- a/apps/frontend/src/components/Ramp/Offramp/index.tsx +++ b/apps/frontend/src/components/Ramp/Offramp/index.tsx @@ -1,4 +1,4 @@ -import { getAnyFiatTokenDetails, getOnChainTokenDetailsOrDefault } from "@vortexfi/shared"; +import { getAnyFiatTokenDetails, getOnChainTokenDetailsOrDefault, isEvmTokenDetails } from "@vortexfi/shared"; import { motion } from "motion/react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { FormProvider } from "react-hook-form"; @@ -74,12 +74,15 @@ export const Offramp = () => { const handleBalanceClick = useCallback((amount: string) => form.setValue("inputAmount", amount), [form]); + const logoURI = isEvmTokenDetails(fromToken) ? fromToken.logoURI : undefined; + const WithdrawNumericInput = useMemo( () => ( <> openTokenSelectModal("from")} registerInput={form.register("inputAmount")} diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx index d262c212ec..3b6362d84d 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx @@ -9,7 +9,7 @@ import { ListItem } from "../../../ListItem"; import { useIsFiatDirection, useTokenDefinitions } from "../helpers"; import { ExtendedTokenDefinition, useTokenSelection } from "../hooks/useTokenSelection"; -const ROW_HEIGHT = 80; +const ROW_HEIGHT = 56; export const SelectionTokenList = () => { const isFiatDirection = useIsFiatDirection(); @@ -38,7 +38,7 @@ export const SelectionTokenList = () => { return (
{ width: "100%" }} > -
- handleSelect(tokenType, token)} token={token} /> -
+ handleSelect(tokenType, token)} token={token} />
); })} diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx index d01928cfa0..cc71c3383c 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx @@ -14,37 +14,55 @@ import { RampDirection, stellarTokenConfig } from "@vortexfi/shared"; -import { useMemo } from "react"; +import { useMemo, useRef } 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 allDefinitions = useMemo( + const rawDefinitions = useMemo( () => getAllSupportedTokenDefinitions(tokenSelectModalType, rampDirection), [tokenSelectModalType, rampDirection] ); - console.log("All Definitions fetched"); + + const allDefinitions = useDeepStableReference(rawDefinitions); const availableNetworks = useMemo(() => { const networks = new Set(allDefinitions.map(token => token.network)); return Array.from(networks).sort(); }, [allDefinitions]); - const networkFilteredDefinitions = useMemo(() => { + const rawNetworkFiltered = 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(); - console.log("Filtered Definitions computed"); return networkFilteredDefinitions.filter( ({ assetSymbol, name, networkDisplayName }) => @@ -90,9 +108,7 @@ function getOnChainTokensDefinitionsForNetwork(selectedNetwork: Networks): Exten function getAllOnChainTokens(): ExtendedTokenDefinition[] { 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) { @@ -100,7 +116,6 @@ function getAllOnChainTokens(): ExtendedTokenDefinition[] { allTokens.push(...getOnChainTokensDefinitionsForNetwork(network)); } } - return allTokens; } @@ -123,11 +138,7 @@ function getFiatTokens(filterEurcOnly = false): ExtendedTokenDefinition[] { } function isFilterEurcOnly(type: "from" | "to", direction: RampDirection) { - const isBuy = direction === RampDirection.BUY; - if (isBuy && type === "from") { - return true; - } - return false; + return direction === RampDirection.BUY && type === "from"; } export function useIsFiatDirection() { @@ -138,12 +149,7 @@ export function useIsFiatDirection() { function isFiatDirection(type: "from" | "to", direction: RampDirection) { const isBuy = direction === RampDirection.BUY; - - if ((isBuy && type === "from") || (!isBuy && type === "to")) { - return true; - } - - return false; + return (isBuy && type === "from") || (!isBuy && type === "to"); } function getAllSupportedTokenDefinitions(type: "from" | "to", direction: RampDirection): ExtendedTokenDefinition[] { diff --git a/apps/frontend/src/components/buttons/AssetButton/index.tsx b/apps/frontend/src/components/buttons/AssetButton/index.tsx index ad1be55ea8..caf9f86cf3 100644 --- a/apps/frontend/src/components/buttons/AssetButton/index.tsx +++ b/apps/frontend/src/components/buttons/AssetButton/index.tsx @@ -5,12 +5,13 @@ import { useGetAssetIcon } from "../../../hooks/useGetAssetIcon"; interface AssetButtonProps { assetIcon: string; tokenSymbol: string; + logoURI?: string; onClick: () => void; disabled?: boolean; } -export function AssetButton({ assetIcon, tokenSymbol, onClick, disabled }: AssetButtonProps) { - const icon = useGetAssetIcon(assetIcon); +export function AssetButton({ assetIcon, tokenSymbol, onClick, disabled, logoURI }: AssetButtonProps) { + const icon = logoURI ? logoURI : useGetAssetIcon(assetIcon); return ( ); -} +}); diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx index cc71c3383c..00e4d14b42 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx @@ -1,4 +1,5 @@ import { + AssetHubToken, assetHubTokenConfig, doesNetworkSupportRamp, EvmNetworks, @@ -14,6 +15,13 @@ import { RampDirection, stellarTokenConfig } from "@vortexfi/shared"; + +const ASSETHUB_ICON_URLS: Record = { + [AssetHubToken.USDC]: "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg", + [AssetHubToken.USDT]: "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdt.svg", + [AssetHubToken.DOT]: "https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/dot.svg" +}; + import { useMemo, useRef } from "react"; import { getEvmTokenConfig } from "../../../services/tokens"; import { useRampDirection } from "../../../stores/rampDirectionStore"; @@ -85,6 +93,7 @@ function getOnChainTokensDefinitionsForNetwork(selectedNetwork: Networks): Exten assetIcon: value.networkAssetIcon, assetSymbol: value.assetSymbol, details: value as OnChainTokenDetails, + logoURI: ASSETHUB_ICON_URLS[key as AssetHubToken], network: selectedNetwork, networkDisplayName: getNetworkDisplayName(selectedNetwork), type: key as OnChainToken diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx index b54a1d7f91..85f5efb262 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx @@ -1,6 +1,7 @@ import { ArrowDownIcon } from "@heroicons/react/20/solid"; import { BaseFiatTokenDetails, + EvmTokenDetails, FiatToken, FiatTokenDetails, getAddressForFormat, @@ -26,7 +27,6 @@ import { BRLOnrampDetails } from "./BRLOnrampDetails"; import { EUROnrampDetails } from "./EUROnrampDetails"; import { FeeDetails } from "./FeeDetails"; -// Default expiry time for quotes is 10 minutes const QUOTE_EXPIRY_TIME = 10; interface TransactionTokensDisplayProps { @@ -35,6 +35,10 @@ interface TransactionTokensDisplayProps { rampDirection: RampDirection; } +function getOnChainTokenIcon(tokenDetails: OnChainTokenDetails): string | undefined { + return (tokenDetails as EvmTokenDetails).logoURI; +} + export const TransactionTokensDisplay: FC = ({ executionInput, isOnramp, rampDirection }) => { const { t } = useTranslation(); const rampActor = useRampActor(); @@ -59,7 +63,7 @@ export const TransactionTokensDisplay: FC = ({ ex useEffect(() => { let targetTimestamp: number | null = null; - if (!quote) return; // Quote must exist + if (!quote) return; const expiresAt = quote.expiresAt; targetTimestamp = new Date(expiresAt).getTime(); @@ -67,7 +71,6 @@ export const TransactionTokensDisplay: FC = ({ ex setTargetTimestamp(targetTimestamp); if (targetTimestamp === null) { - // If no valid timestamp, mark as expired immediately setTimeLeft({ minutes: 0, seconds: 0 }); return; } @@ -101,18 +104,14 @@ export const TransactionTokensDisplay: FC = ({ ex ? getOnChainTokenDetailsOrDefault(selectedNetwork, executionInput.onChainToken) : getAnyFiatTokenDetails(executionInput.fiatToken); - const fromIcon = useGetAssetIcon( - isOnramp ? (fromToken as BaseFiatTokenDetails).fiat.assetIcon : (fromToken as OnChainTokenDetails).networkAssetIcon - ); + const fromFiatIcon = useGetAssetIcon(isOnramp ? (fromToken as BaseFiatTokenDetails).fiat.assetIcon : ""); + const toFiatIcon = useGetAssetIcon(!isOnramp ? (toToken as BaseFiatTokenDetails).fiat.assetIcon : ""); - const toIcon = useGetAssetIcon( - isOnramp ? (toToken as OnChainTokenDetails).networkAssetIcon : (toToken as BaseFiatTokenDetails).fiat.assetIcon - ); + const fromIcon = isOnramp ? fromFiatIcon : (getOnChainTokenIcon(fromToken as OnChainTokenDetails) ?? fromFiatIcon); + const toIcon = !isOnramp ? toFiatIcon : (getOnChainTokenIcon(toToken as OnChainTokenDetails) ?? toFiatIcon); const getPartnerUrl = (): string => { const fiatToken = (isOnramp ? fromToken : toToken) as FiatTokenDetails; - // Conditionally return Monerium's URL. - // TODO to be improved when adding the EUR.e as a token config. if (fromToken.assetSymbol === "EURC") { return "https://monerium.com"; } diff --git a/apps/frontend/src/hooks/useGetNetworkIcon.tsx b/apps/frontend/src/hooks/useGetNetworkIcon.tsx index 2b08646688..f3b5bed30f 100644 --- a/apps/frontend/src/hooks/useGetNetworkIcon.tsx +++ b/apps/frontend/src/hooks/useGetNetworkIcon.tsx @@ -1,4 +1,5 @@ import { Networks } from "@vortexfi/shared"; +import DEFAULT_NETWORK from "../assets/chains/all-networks.svg"; import ARBITRUM from "../assets/chains/arbitrum.svg"; import ASSET_HUB from "../assets/chains/assethub.svg"; import AVALANCHE from "../assets/chains/avalanche.svg"; @@ -7,22 +8,32 @@ import BSC from "../assets/chains/bsc.svg"; import ETHEREUM from "../assets/chains/ethereum.svg"; import POLYGON from "../assets/chains/polygon.svg"; -export const NETWORK_ICONS: Record = { +type PresentNetworks = + | Networks.AssetHub + | Networks.Polygon + | Networks.Ethereum + | Networks.BSC + | Networks.Arbitrum + | Networks.Base + | Networks.Avalanche; + +export const NETWORK_ICONS: Record = { [Networks.AssetHub]: ASSET_HUB, [Networks.Polygon]: POLYGON, [Networks.Ethereum]: ETHEREUM, [Networks.BSC]: BSC, [Networks.Arbitrum]: ARBITRUM, [Networks.Base]: BASE, - [Networks.Avalanche]: AVALANCHE, - [Networks.Moonbeam]: "", - [Networks.Pendulum]: "", - [Networks.Stellar]: "", - [Networks.PolygonAmoy]: POLYGON, - [Networks.Paseo]: "", - [Networks.Hydration]: "" + [Networks.Avalanche]: AVALANCHE }; -export function useGetNetworkIcon(network: Networks) { - return NETWORK_ICONS[network]; +export function isPresentNetwork(network: Networks): network is PresentNetworks { + return network in NETWORK_ICONS; +} + +export function useGetNetworkIcon(network: Networks): string { + if (isPresentNetwork(network)) { + return NETWORK_ICONS[network]; + } + return DEFAULT_NETWORK; } From 0c43f6b2c23bb8c96899269e0ce71c404e97d304 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Wed, 21 Jan 2026 11:38:33 +0100 Subject: [PATCH 16/61] refactor PopularTokens tokens definition --- .../individuals/PopularTokens/index.tsx | 50 +++---------------- 1 file changed, 7 insertions(+), 43 deletions(-) diff --git a/apps/frontend/src/sections/individuals/PopularTokens/index.tsx b/apps/frontend/src/sections/individuals/PopularTokens/index.tsx index bd78ab8c9b..2e145a688b 100644 --- a/apps/frontend/src/sections/individuals/PopularTokens/index.tsx +++ b/apps/frontend/src/sections/individuals/PopularTokens/index.tsx @@ -1,53 +1,17 @@ -import { - AssetHubToken, - assetHubTokenConfig, - doesNetworkSupportRamp, - EvmToken, - FiatToken, - getNetworkDisplayName, - Networks -} from "@vortexfi/shared"; +import { doesNetworkSupportRamp, FiatToken, getNetworkDisplayName, Networks } from "@vortexfi/shared"; import { motion } from "motion/react"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { cn } from "../../../helpers/cn"; -import { isValidAssetIcon, useGetAssetIcon } from "../../../hooks/useGetAssetIcon"; +import { isValidFiatIcon, useGetAssetIcon } from "../../../hooks/useGetAssetIcon"; import { useGetNetworkIcon } from "../../../hooks/useGetNetworkIcon"; -import { getEvmTokenConfig } from "../../../services/tokens"; - -const getEvmTokenIcon = (token: EvmToken): string => { - const evmConfig = getEvmTokenConfig(); - for (const networkConfig of Object.values(evmConfig)) { - const tokenConfig = networkConfig[token]; - if (tokenConfig?.networkAssetIcon) { - return tokenConfig.networkAssetIcon; - } - } - return token.toLowerCase(); -}; - -const getTokenIcon = (name: string): string => { - if (Object.values(EvmToken).includes(name as EvmToken)) { - return getEvmTokenIcon(name as EvmToken); - } - if (Object.values(AssetHubToken).includes(name as AssetHubToken)) { - const config = assetHubTokenConfig[name as AssetHubToken]; - return config?.networkAssetIcon || name.toLowerCase() || ""; - } - - return name.toLowerCase() || ""; -}; - -const allCurrencies = Array.from( - new Set([...Object.values(FiatToken), ...Object.values(AssetHubToken), ...Object.values(EvmToken)]) -); -const tokens: Array<{ name: string; assetIcon: string }> = allCurrencies +const fiatTokens: Array<{ name: string; assetIcon: string }> = Object.values(FiatToken) .map(name => ({ - assetIcon: getTokenIcon(name), + assetIcon: name.toLowerCase(), name })) - .filter(token => isValidAssetIcon(token.assetIcon)); + .filter(token => isValidFiatIcon(token.assetIcon)); const networks = Object.values(Networks).filter(doesNetworkSupportRamp); @@ -114,7 +78,7 @@ export function PopularTokens() { useEffect(() => { const interval = setInterval(() => { const isNetwork = Math.random() < 0.5; - const maxIndex = isNetwork ? networks.length : tokens.length; + const maxIndex = isNetwork ? networks.length : fiatTokens.length; const newIndex = Math.floor(Math.random() * maxIndex); setAnimatingIndex({ @@ -154,7 +118,7 @@ export function PopularTokens() { initial={{ opacity: 0, y: 20 }} transition={{ duration: 0.5 }} > - {tokens.map((token, index) => ( + {fiatTokens.map((token, index) => ( Date: Wed, 21 Jan 2026 11:39:32 +0100 Subject: [PATCH 17/61] remove unused tokens from useGetAssetIcon --- apps/frontend/src/hooks/useGetAssetIcon.tsx | 65 +++------------------ 1 file changed, 8 insertions(+), 57 deletions(-) diff --git a/apps/frontend/src/hooks/useGetAssetIcon.tsx b/apps/frontend/src/hooks/useGetAssetIcon.tsx index 7119e59f8e..dd187d3c97 100644 --- a/apps/frontend/src/hooks/useGetAssetIcon.tsx +++ b/apps/frontend/src/hooks/useGetAssetIcon.tsx @@ -1,71 +1,22 @@ import ARS from "../assets/coins/ARS.png"; import BRL from "../assets/coins/BRL.png"; -import DOT_ASSETHUB from "../assets/coins/DOT_ASSETHUB.svg"; -import ETH from "../assets/coins/ETH.svg"; -import ETH_ARBITRUM from "../assets/coins/ETH_ARBITRUM.svg"; -import ETH_BASE from "../assets/coins/ETH_BASE.svg"; -import ETH_BSC from "../assets/coins/ETH_BSC.svg"; -import ETH_ETHEREUM from "../assets/coins/ETH_ETHEREUM.svg"; import EUR from "../assets/coins/EUR.svg"; import EURC from "../assets/coins/EURC.png"; -import USDC from "../assets/coins/USDC.png"; -import USDC_ARBITRUM from "../assets/coins/USDC_ARBITRUM.svg"; -import USDC_ASSETHUB from "../assets/coins/USDC_ASSETHUB.svg"; -import USDC_AVALANCHE from "../assets/coins/USDC_AVALANCHE.svg"; -import USDC_BASE from "../assets/coins/USDC_BASE.svg"; -import USDC_BSC from "../assets/coins/USDC_BSC.svg"; -import USDC_ETHEREUM from "../assets/coins/USDC_ETHEREUM.svg"; -import USDC_POLYGON from "../assets/coins/USDC_POLYGON.svg"; -import USDT from "../assets/coins/USDT.svg"; -import USDT_ARBITRUM from "../assets/coins/USDT_ARBITRUM.svg"; -import USDT_ASSETHUB from "../assets/coins/USDT_ASSETHUB.svg"; -import USDT_AVALANCHE from "../assets/coins/USDT_AVALANCHE.svg"; -import USDT_BASE from "../assets/coins/USDT_BASE.svg"; -import USDT_BSC from "../assets/coins/USDT_BSC.svg"; -import USDT_ETHEREUM from "../assets/coins/USDT_ETHEREUM.svg"; -import USDT_POLYGON from "../assets/coins/USDT_POLYGON.svg"; +import PLACEHOLDER from "../assets/coins/placeholder.svg"; -const ICONS = { - arbitrumETH: ETH_ARBITRUM, - arbitrumUSDC: USDC_ARBITRUM, - arbitrumUSDT: USDT_ARBITRUM, +const FIAT_ICONS: Record = { ars: ARS, - assethubDOT: DOT_ASSETHUB, - assethubUSDC: USDC_ASSETHUB, - assethubUSDT: USDT_ASSETHUB, - avalancheUSDC: USDC_AVALANCHE, - avalancheUSDT: USDT_AVALANCHE, - baseETH: ETH_BASE, - baseUSDC: USDC_BASE, - baseUSDT: USDT_BASE, brl: BRL, - bscETH: ETH_BSC, - bscUSDC: USDC_BSC, - bscUSDT: USDT_BSC, - eth: ETH, - ethereumETH: ETH_ETHEREUM, - ethereumUSDC: USDC_ETHEREUM, - ethereumUSDT: USDT_ETHEREUM, eur: EUR, - eurc: EURC, - polygonUSDC: USDC_POLYGON, - polygonUSDT: USDT_POLYGON, - usdc: USDC, - usdt: USDT + eurc: EURC }; -export type AssetIconType = keyof typeof ICONS; +export type FiatIconType = keyof typeof FIAT_ICONS; -export function isValidAssetIcon(assetIcon: string): boolean { - return assetIcon in ICONS; +export function isValidFiatIcon(assetIcon: string): boolean { + return assetIcon?.toLowerCase() in FIAT_ICONS; } -export function useGetAssetIcon(assetIcon: string) { - if (assetIcon in ICONS) { - return ICONS[assetIcon as AssetIconType]; - } else { - console.error(`Asset icon not found for ${assetIcon}`); - // Return USDC as default icon - return ICONS["usdc"]; - } +export function useGetAssetIcon(assetIcon: string): string { + return FIAT_ICONS[assetIcon?.toLowerCase()] ?? PLACEHOLDER; } From f7febd33a2718d0e691b15b61aaa09ababdf7324 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Wed, 21 Jan 2026 11:39:54 +0100 Subject: [PATCH 18/61] refactor tanstack virtualizer definition --- .../components/SelectionTokenList.tsx | 75 ++++++++++--------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx index 3b6362d84d..0bd1bf4e69 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/SelectionTokenList.tsx @@ -1,5 +1,4 @@ import { useVirtualizer } from "@tanstack/react-virtual"; -import { FiatToken, OnChainToken } from "@vortexfi/shared"; import { useRef } from "react"; import { useNetwork } from "../../../../contexts/network"; import { cn } from "../../../../helpers/cn"; @@ -7,9 +6,11 @@ import { useTokensSortedByBalance } from "../../../../hooks/useTokensSortedByBal import { useIsNetworkDropdownOpen, useSearchFilter, useSelectedNetworkFilter } from "../../../../stores/tokenSelectionStore"; import { ListItem } from "../../../ListItem"; import { useIsFiatDirection, useTokenDefinitions } from "../helpers"; -import { ExtendedTokenDefinition, useTokenSelection } from "../hooks/useTokenSelection"; +import { useTokenSelection } from "../hooks/useTokenSelection"; const ROW_HEIGHT = 56; +const OVERSCAN = 10; +const MIN_ITEMS_FOR_VIRTUALIZATION = 20; export const SelectionTokenList = () => { const isFiatDirection = useIsFiatDirection(); @@ -25,52 +26,56 @@ export const SelectionTokenList = () => { const parentRef = useRef(null); + const shouldVirtualize = currentDefinitions.length > MIN_ITEMS_FOR_VIRTUALIZATION; + const rowVirtualizer = useVirtualizer({ count: currentDefinitions.length, + enabled: shouldVirtualize, estimateSize: () => ROW_HEIGHT, - getScrollElement: () => parentRef.current + getScrollElement: () => parentRef.current, + overscan: OVERSCAN }); - const handleSelect = (tokenType: OnChainToken | FiatToken, token: ExtendedTokenDefinition) => { - handleTokenSelect(tokenType, token); - }; - return (
-
- {rowVirtualizer.getVirtualItems().map(virtualItem => { - const token = currentDefinitions[virtualItem.index]; - const isSelected = selectedToken === token.type && selectedNetwork === token.network; + {shouldVirtualize ? ( +
+ {rowVirtualizer.getVirtualItems().map(virtualItem => { + const token = currentDefinitions[virtualItem.index]; + const isSelected = selectedToken === token.type && selectedNetwork === token.network; - return ( -
- handleSelect(tokenType, token)} token={token} /> -
- ); - })} -
+ return ( +
+ handleTokenSelect(tokenType, token)} token={token} /> +
+ ); + })} +
+ ) : ( +
+ {currentDefinitions.map(token => { + const isSelected = selectedToken === token.type && selectedNetwork === token.network; + return ( + handleTokenSelect(tokenType, token)} + token={token} + /> + ); + })} +
+ )}
); }; From 9f98bf0ababbf2f7d174ac48c3c7b91cca957bce Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Wed, 21 Jan 2026 11:51:08 +0100 Subject: [PATCH 19/61] optimize sorting tokens by balance --- .../src/components/ListItem/index.tsx | 4 +- .../src/components/UserBalance/index.tsx | 29 +- .../src/hooks/useTokensSortedByBalance.ts | 111 +-- bun.lock | 846 ++++++++++-------- 4 files changed, 512 insertions(+), 478 deletions(-) diff --git a/apps/frontend/src/components/ListItem/index.tsx b/apps/frontend/src/components/ListItem/index.tsx index 303c4b9f67..b35b0522bf 100644 --- a/apps/frontend/src/components/ListItem/index.tsx +++ b/apps/frontend/src/components/ListItem/index.tsx @@ -20,8 +20,6 @@ export const ListItem = memo(function ListItem({ token, isSelected, onSelect }: const tokenIcon = token.logoURI ?? fiatIcon; const isFiat = isFiatToken(token.type); - const showBalance = (token.details as any).balance !== "0.00"; - const isDisabled = isFiat && isFiatTokenDisabled(token.type as FiatToken); const disabledReason = isFiat && isDisabled ? t(getTokenDisabledReason(token.type as FiatToken)) : undefined; @@ -62,7 +60,7 @@ export const ListItem = memo(function ListItem({ token, isSelected, onSelect }:
- {showBalance && } + {!isFiat && } diff --git a/apps/frontend/src/components/UserBalance/index.tsx b/apps/frontend/src/components/UserBalance/index.tsx index 1463ada137..f66a3e4fb6 100644 --- a/apps/frontend/src/components/UserBalance/index.tsx +++ b/apps/frontend/src/components/UserBalance/index.tsx @@ -1,24 +1,45 @@ import { OnChainTokenDetails } from "@vortexfi/shared"; +import Big from "big.js"; import { useAccount } from "wagmi"; import wallet from "../../assets/wallet-bifold-outline.svg"; import { usePolkadotWalletState } from "../../contexts/polkadotWallet"; +import { stringifyBigWithSignificantDecimals } from "../../helpers/contracts"; import { useOnchainTokenBalance } from "../../hooks/useOnchainTokenBalance"; import { useVortexAccount } from "../../hooks/useVortexAccount"; +const formatBalance = (balance: string): string => { + try { + const big = new Big(balance); + return stringifyBigWithSignificantDecimals(big, 2); + } catch { + return balance; + } +}; + interface UserBalanceProps { token: OnChainTokenDetails; onClick?: (amount: string) => void; className?: string; } +const isZeroBalance = (balance: string): boolean => { + try { + return new Big(balance).eq(0); + } catch { + return true; + } +}; + const SimpleBalance = ({ token, className }: { token: OnChainTokenDetails; className?: string }) => { const onchainTokenBalanceRaw = useOnchainTokenBalance({ token }); const onchainTokenBalance = onchainTokenBalanceRaw?.balance || "0"; + if (isZeroBalance(onchainTokenBalance)) return null; + return (

- {onchainTokenBalance} {token.assetSymbol} + {formatBalance(onchainTokenBalance)} {token.assetSymbol}

); }; @@ -27,14 +48,12 @@ const FullBalance = ({ token, onClick }: { token: OnChainTokenDetails; onClick: const onchainTokenBalanceRaw = useOnchainTokenBalance({ token }); const onchainTokenBalance = onchainTokenBalanceRaw?.balance || "0"; - const hasBalance = onchainTokenBalance !== undefined; - - if (!hasBalance) return null; + if (isZeroBalance(onchainTokenBalance)) return null; return (
Available

- {onchainTokenBalance} {token.assetSymbol} + {formatBalance(onchainTokenBalance)} {token.assetSymbol}

+ ); +}; + +const CollapsibleCardWrapper = ({ defaultExpanded = false }: StoryArgs) => { + return ( +
+ + +
+

Transaction Summary

+

Click to view details

+
+ +
+ +
+
+ Amount + 100 USDC +
+
+ Fee + 0.5 USDC +
+
+ Network + Polkadot +
+
+ Estimated Time + ~2 minutes +
+
+
+
+
+ ); +}; + +const InteractiveDemo = () => { + const [isExpanded, setIsExpanded] = useState(false); + const [toggleCount, setToggleCount] = useState(0); + + const handleToggle = (expanded: boolean) => { + setIsExpanded(expanded); + setToggleCount(prev => prev + 1); + }; + + return ( +
+
+

State: {isExpanded ? "Expanded" : "Collapsed"}

+

Toggle count: {toggleCount}

+
+ + +
+

Quote Details

+

Your exchange rate and fees

+
+ +
+ +
+
+ You send + 500 BRL +
+
+ Exchange rate + 1 USDC = 5.02 BRL +
+
+ You receive + ~99.60 USDC +
+
+
+
+
+ ); +}; + +const MultipleCardsDemo = () => { + return ( +
+ + +
+

Step 1: Connect Wallet

+
+ +
+ +

Connect your Polkadot wallet to get started with the transaction.

+
+
+ + + +
+

Step 2: Enter Details

+
+ +
+ +

Enter your payment details including the amount and recipient information.

+
+
+ + + +
+

Step 3: Confirm

+
+ +
+ +

Review and confirm your transaction before submitting.

+
+
+
+ ); +}; + +const meta: Meta = { + argTypes: { + defaultExpanded: { + control: "boolean", + description: "Whether the card should be expanded by default" + } + }, + component: CollapsibleCardWrapper, + parameters: { + docs: { + description: { + component: + "A collapsible card component with smooth expand/collapse animations. Uses GPU-accelerated grid-template-rows animation instead of height for better performance. Supports reduced motion for accessibility." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/CollapsibleCard" +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + defaultExpanded: false + }, + parameters: { + docs: { + description: { + story: "Default collapsed state. Click the toggle button to expand and see the details." + } + } + }, + render: CollapsibleCardWrapper +}; + +export const Expanded: Story = { + args: { + defaultExpanded: true + }, + parameters: { + docs: { + description: { + story: "Card expanded by default showing all details." + } + } + }, + render: CollapsibleCardWrapper +}; + +export const Interactive: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo with state tracking. Watch the state change as you toggle the card." + } + } + }, + render: InteractiveDemo +}; + +export const MultipleCards: Story = { + parameters: { + docs: { + description: { + story: "Multiple collapsible cards demonstrating independent expand/collapse behavior." + } + } + }, + render: MultipleCardsDemo +}; + +export const ReducedMotion: Story = { + args: { + defaultExpanded: false + }, + parameters: { + docs: { + description: { + story: + "Test reduced motion support by enabling 'prefers-reduced-motion: reduce' in browser DevTools. The expand/collapse animation will be instant." + } + } + }, + render: CollapsibleCardWrapper +}; diff --git a/apps/frontend/src/stories/KycLevel2Toggle.stories.tsx b/apps/frontend/src/stories/KycLevel2Toggle.stories.tsx new file mode 100644 index 0000000000..a87969263f --- /dev/null +++ b/apps/frontend/src/stories/KycLevel2Toggle.stories.tsx @@ -0,0 +1,182 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AveniaDocumentType } from "@vortexfi/shared"; +import { useState } from "react"; +import { KycLevel2Toggle } from "../components/KycLevel2Toggle"; + +interface StoryArgs { + activeDocType?: AveniaDocumentType; +} + +const KycLevel2ToggleWrapper = ({ activeDocType = AveniaDocumentType.ID }: StoryArgs) => { + const [docType, setDocType] = useState(activeDocType); + + return ( +
+ +
+ ); +}; + +const InteractiveDemo = () => { + const [docType, setDocType] = useState(AveniaDocumentType.ID); + const [toggleCount, setToggleCount] = useState(0); + + const handleToggle = (newDocType: AveniaDocumentType) => { + setDocType(newDocType); + setToggleCount(prev => prev + 1); + }; + + return ( +
+
+

+ Selected document: {docType === AveniaDocumentType.ID ? "RG (ID Card)" : "CNH (Driver's License)"} +

+

Toggle count: {toggleCount}

+
+ +
+ ); +}; + +interface DocumentInfo { + description: string; + icon: string; + instructions: string[]; + title: string; +} + +const KycFlowDemo = () => { + const [docType, setDocType] = useState(AveniaDocumentType.ID); + + const documentInfo: Record = { + [AveniaDocumentType.ID]: { + description: "Brazilian national identity card (Registro Geral)", + icon: "RG", + instructions: ["Front side of the document", "Back side of the document", "Must be valid and not expired"], + title: "Identity Card (RG)" + }, + [AveniaDocumentType.DRIVERS_LICENSE]: { + description: "Brazilian driver's license (Carteira Nacional de Habilitacao)", + icon: "CNH", + instructions: ["Front side of the license", "Back side of the license", "Must be valid and not expired"], + title: "Driver's License (CNH)" + } + }; + + const info = documentInfo[docType]; + + return ( +
+

Document Verification

+

Select your document type for KYC Level 2 verification

+ + + +
+

{info.title}

+

{info.description}

+

Required photos:

+
    + {info.instructions.map((instruction, index) => ( +
  • {instruction}
  • + ))} +
+
+ + +
+ ); +}; + +const meta: Meta = { + argTypes: { + activeDocType: { + control: "select", + description: "Currently selected document type", + options: [AveniaDocumentType.ID, AveniaDocumentType.DRIVERS_LICENSE] + } + }, + component: KycLevel2ToggleWrapper, + parameters: { + docs: { + description: { + component: + "A toggle component for selecting between Brazilian document types (RG or CNH) during KYC Level 2 verification. Features smooth spring animation for the indicator and supports reduced motion preferences." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/KycLevel2Toggle" +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + activeDocType: AveniaDocumentType.ID + }, + parameters: { + docs: { + description: { + story: "Default toggle with RG (Identity Card) selected." + } + } + }, + render: KycLevel2ToggleWrapper +}; + +export const DriversLicenseSelected: Story = { + args: { + activeDocType: AveniaDocumentType.DRIVERS_LICENSE + }, + parameters: { + docs: { + description: { + story: "Toggle with CNH (Driver's License) selected." + } + } + }, + render: KycLevel2ToggleWrapper +}; + +export const Interactive: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo with state tracking. Watch the indicator smoothly animate between options." + } + } + }, + render: InteractiveDemo +}; + +export const KycFlow: Story = { + parameters: { + docs: { + description: { + story: "Real-world example showing the toggle integrated into a KYC verification flow." + } + } + }, + render: KycFlowDemo +}; + +export const ReducedMotion: Story = { + args: { + activeDocType: AveniaDocumentType.ID + }, + parameters: { + docs: { + description: { + story: + "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions." + } + } + }, + render: KycLevel2ToggleWrapper +}; diff --git a/apps/frontend/src/stories/Menu.stories.tsx b/apps/frontend/src/stories/Menu.stories.tsx new file mode 100644 index 0000000000..e2ff8ae469 --- /dev/null +++ b/apps/frontend/src/stories/Menu.stories.tsx @@ -0,0 +1,239 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { Menu, MenuAnimationDirection } from "../components/menus/Menu"; + +interface StoryArgs { + animationDirection?: MenuAnimationDirection; + title?: string; +} + +const MenuWrapper = ({ animationDirection = MenuAnimationDirection.RIGHT, title = "Menu" }: StoryArgs) => { + const [isOpen, setIsOpen] = useState(true); + + return ( +
+
+ Main Content + +
+ +
+

This is the main content area. The menu will slide over this content.

+
+ + setIsOpen(false)} title={title}> +
+ + + +
+
+
+ ); +}; + +const DirectionDemo = () => { + const [direction, setDirection] = useState(MenuAnimationDirection.RIGHT); + const [isOpen, setIsOpen] = useState(false); + + return ( +
+
+ + +
+ +
+
+ Main Content + +
+ +
+

Current direction: {direction}

+
+ + setIsOpen(false)} title="Settings"> +
+
+ + +
+
+ + +
+
+
+
+
+ ); +}; + +const TokenSelectionDemo = () => { + const [isOpen, setIsOpen] = useState(false); + const tokens = [ + { balance: "1,234.56", name: "USDC", network: "Polkadot" }, + { balance: "567.89", name: "USDT", network: "Ethereum" }, + { balance: "100.00", name: "BRZ", network: "Stellar" } + ]; + + return ( +
+
+ +
+ + setIsOpen(false)} + title="Select Token" + > +
+ {tokens.map(token => ( + + ))} +
+
+
+ ); +}; + +const meta: Meta = { + argTypes: { + animationDirection: { + control: "select", + description: "Direction from which the menu slides in", + options: [MenuAnimationDirection.RIGHT, MenuAnimationDirection.TOP] + }, + title: { + control: "text", + description: "Title displayed in the menu header" + } + }, + component: MenuWrapper, + parameters: { + docs: { + description: { + component: + "A sliding overlay menu component with directional animations. Supports slide-in from right or top with smooth easeOut curves. Features escape key support and reduced motion accessibility." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/Menu" +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + animationDirection: MenuAnimationDirection.RIGHT, + title: "Menu" + }, + parameters: { + docs: { + description: { + story: "Default menu sliding in from the right. Press Escape or click the close button to dismiss." + } + } + }, + render: MenuWrapper +}; + +export const FromTop: Story = { + args: { + animationDirection: MenuAnimationDirection.TOP, + title: "Dropdown Menu" + }, + parameters: { + docs: { + description: { + story: "Menu sliding in from the top, useful for dropdown-style menus." + } + } + }, + render: MenuWrapper +}; + +export const DirectionComparison: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo comparing different animation directions. Select a direction and open the menu." + } + } + }, + render: DirectionDemo +}; + +export const TokenSelection: Story = { + parameters: { + docs: { + description: { + story: "Real-world example showing the menu used for token selection in a swap interface." + } + } + }, + render: TokenSelectionDemo +}; + +export const ReducedMotion: Story = { + args: { + animationDirection: MenuAnimationDirection.RIGHT, + title: "Accessible Menu" + }, + parameters: { + docs: { + description: { + story: + "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions." + } + } + }, + render: MenuWrapper +}; diff --git a/apps/frontend/src/stories/MobileMenu.stories.tsx b/apps/frontend/src/stories/MobileMenu.stories.tsx new file mode 100644 index 0000000000..a16f72aa92 --- /dev/null +++ b/apps/frontend/src/stories/MobileMenu.stories.tsx @@ -0,0 +1,197 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AnimatePresence } from "motion/react"; +import { useState } from "react"; +import { MobileMenu } from "../components/Navbar/MobileMenu"; + +interface StoryArgs { + isOpen?: boolean; +} + +const MobileMenuWrapper = ({ isOpen = true }: StoryArgs) => { + const [menuOpen, setMenuOpen] = useState(isOpen); + + const handleMenuItemClick = () => { + setMenuOpen(false); + }; + + return ( +
+ {/* Mock navbar header */} +
+ Vortex + +
+ + {/* Mobile menu with animation */} + {menuOpen && } +
+ ); +}; + +const InteractiveDemo = () => { + const [isOpen, setIsOpen] = useState(false); + const [clickedItem, setClickedItem] = useState(null); + + const handleMenuItemClick = () => { + setClickedItem("Menu item clicked!"); + setIsOpen(false); + setTimeout(() => setClickedItem(null), 2000); + }; + + return ( +
+
+

Menu state: {isOpen ? "Open" : "Closed"}

+ {clickedItem &&

{clickedItem}

} +
+ +
+
+ Vortex + +
+ + {isOpen && } +
+
+ ); +}; + +const AnimationShowcaseDemo = () => { + const [isOpen, setIsOpen] = useState(false); + const [speed, setSpeed] = useState<"normal" | "slow">("normal"); + + return ( +
+
+ + +
+

+ To see the animation in slow motion, open DevTools → Rendering → check "Emulate CSS media feature + prefers-reduced-motion" +

+ +
+
+ Vortex + +
+ + {isOpen && setIsOpen(false)} />} +
+
+ ); +}; + +const meta: Meta = { + argTypes: { + isOpen: { + control: "boolean", + description: "Whether the mobile menu is initially open" + } + }, + component: MobileMenuWrapper, + parameters: { + docs: { + description: { + component: + "Mobile navigation menu with staggered entrance animations. Features smooth slide-in animations for menu items and respects reduced motion preferences for accessibility. Uses easeOut curves for responsive feel." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/MobileMenu" +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isOpen: true + }, + parameters: { + docs: { + description: { + story: "Mobile menu in its open state showing navigation links and call-to-action button." + } + } + }, + render: MobileMenuWrapper +}; + +export const Closed: Story = { + args: { + isOpen: false + }, + parameters: { + docs: { + description: { + story: "Mobile menu in closed state. Click the button to open and see the entrance animation." + } + } + }, + render: MobileMenuWrapper +}; + +export const Interactive: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo with state tracking. Toggle the menu to see smooth entrance/exit animations." + } + } + }, + render: InteractiveDemo +}; + +export const AnimationShowcase: Story = { + parameters: { + docs: { + description: { + story: + "Showcase the staggered animation effect. Open the menu multiple times to observe how menu items animate in sequence." + } + } + }, + render: AnimationShowcaseDemo +}; + +export const ReducedMotion: Story = { + args: { + isOpen: false + }, + parameters: { + docs: { + description: { + story: + "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions instead of animations." + } + } + }, + render: MobileMenuWrapper +}; diff --git a/apps/frontend/src/stories/NetworkSelectionAnimations.stories.tsx b/apps/frontend/src/stories/NetworkSelectionAnimations.stories.tsx new file mode 100644 index 0000000000..96fe25b4eb --- /dev/null +++ b/apps/frontend/src/stories/NetworkSelectionAnimations.stories.tsx @@ -0,0 +1,287 @@ +import { ChevronDownIcon } from "@heroicons/react/24/outline"; +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { SelectionButtonMotion } from "../components/TokenSelection/NetworkSelectionList/animations/SelectionButtonMotion"; +import { SelectionChevronMotion } from "../components/TokenSelection/NetworkSelectionList/animations/SelectionChevronMotion"; +import { SelectionDropdownMotion } from "../components/TokenSelection/NetworkSelectionList/animations/SelectionDropdownMotion"; + +const networks = [ + { icon: "polkadot.svg", id: "polkadot", name: "Polkadot" }, + { icon: "ethereum.svg", id: "ethereum", name: "Ethereum" }, + { icon: "stellar.svg", id: "stellar", name: "Stellar" }, + { icon: "moonbeam.svg", id: "moonbeam", name: "Moonbeam" } +]; + +const SelectionDropdownDemo = () => { + const [isOpen, setIsOpen] = useState(false); + const [selected, setSelected] = useState(networks[0]); + + return ( +
+ + + +
+ {networks.map(network => ( + + ))} +
+
+
+ ); +}; + +const SelectionButtonDemo = () => { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+
+ setIsExpanded(!isExpanded)} + > + {isExpanded ? "Full width button - click to collapse" : "Click"} + + + {!isExpanded && ( +
+ Other content +
+ )} +
+ +

The button animates between 10% and 100% width. Click to toggle.

+
+ ); +}; + +const SelectionChevronDemo = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + +

Chevron rotates 180° when {isOpen ? "open" : "closed"}

+
+ ); +}; + +const NetworkDropdownDemo = () => { + const [isOpen, setIsOpen] = useState(false); + const [selectedNetwork, setSelectedNetwork] = useState(networks[0]); + + return ( +
+
+

Select Network

+ +
+ + + +
+ {networks.map(network => ( + + ))} +
+
+
+
+
+ ); +}; + +const AllAnimationsDemo = () => { + const [isExpanded, setIsExpanded] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); + + return ( +
+

All Selection Animations

+ +
+

1. SelectionButtonMotion

+ setIsExpanded(!isExpanded)} + > + {isExpanded ? "Expanded - Click to Collapse" : "Expand"} + +
+ +
+

2. SelectionChevronMotion

+
+ +
+
+ +
+

3. SelectionDropdownMotion

+ +
+

Dropdown Content

+

This content smoothly expands using grid-template-rows animation.

+
+
+
+
+ ); +}; + +const meta: Meta = { + parameters: { + docs: { + description: { + component: + "A collection of animation components used in the network/token selection interface. Includes:\n\n" + + "- **SelectionDropdownMotion**: Smooth expand/collapse using GPU-accelerated grid-template-rows\n" + + "- **SelectionButtonMotion**: Width animation with easeOut curve\n" + + "- **SelectionChevronMotion**: 180° rotation animation for dropdown indicators\n\n" + + "All components support reduced motion preferences for accessibility." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/NetworkSelection" +}; + +export default meta; +type Story = StoryObj; + +export const Dropdown: Story = { + parameters: { + docs: { + description: { + story: "SelectionDropdownMotion with SelectionChevronMotion combined for a complete dropdown experience." + } + } + }, + render: SelectionDropdownDemo +}; + +export const Button: Story = { + parameters: { + docs: { + description: { + story: "SelectionButtonMotion demonstrates width animation between collapsed (10%) and expanded (100%) states." + } + } + }, + render: SelectionButtonDemo +}; + +export const Chevron: Story = { + parameters: { + docs: { + description: { + story: "SelectionChevronMotion provides smooth 180° rotation for dropdown indicators." + } + } + }, + render: SelectionChevronDemo +}; + +export const NetworkDropdown: Story = { + parameters: { + docs: { + description: { + story: "Real-world example showing all three animation components working together in a network selector." + } + } + }, + render: NetworkDropdownDemo +}; + +export const AllAnimations: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo showcasing all three animation components side by side." + } + } + }, + render: AllAnimationsDemo +}; + +export const ReducedMotion: Story = { + parameters: { + docs: { + description: { + story: + "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions instead of animations." + } + } + }, + render: AllAnimationsDemo +}; diff --git a/apps/frontend/src/stories/RampToggle.stories.tsx b/apps/frontend/src/stories/RampToggle.stories.tsx new file mode 100644 index 0000000000..72368ac048 --- /dev/null +++ b/apps/frontend/src/stories/RampToggle.stories.tsx @@ -0,0 +1,182 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { RampDirection } from "@vortexfi/shared"; +import { useState } from "react"; +import { RampToggle } from "../components/RampToggle"; + +interface StoryArgs { + activeDirection?: RampDirection; +} + +const RampToggleWrapper = ({ activeDirection = RampDirection.BUY }: StoryArgs) => { + const [direction, setDirection] = useState(activeDirection); + + return ( +
+ +
+ ); +}; + +const InteractiveDemo = () => { + const [direction, setDirection] = useState(RampDirection.BUY); + const [toggleCount, setToggleCount] = useState(0); + + const handleToggle = (newDirection: RampDirection) => { + setDirection(newDirection); + setToggleCount(prev => prev + 1); + }; + + return ( +
+
+

Current direction: {direction === RampDirection.BUY ? "Buy" : "Sell"}

+

Toggle count: {toggleCount}

+
+ +
+ {direction === RampDirection.BUY ? ( +
+

Buy Crypto

+

Convert fiat currency to cryptocurrency

+
+ ) : ( +
+

Sell Crypto

+

Convert cryptocurrency to fiat currency

+
+ )} +
+
+ ); +}; + +const SwapInterfaceDemo = () => { + const [direction, setDirection] = useState(RampDirection.BUY); + const [amount, setAmount] = useState("100"); + + return ( +
+ + +
+
+ +
+ setAmount(e.target.value)} + type="text" + value={amount} + /> + {direction === RampDirection.BUY ? "BRL" : "USDC"} +
+
+ +
+ +
+ {(parseFloat(amount || "0") / 5).toFixed(2)} + {direction === RampDirection.BUY ? "USDC" : "BRL"} +
+
+ + +
+
+ ); +}; + +const meta: Meta = { + argTypes: { + activeDirection: { + control: "select", + description: "Currently active ramp direction", + options: [RampDirection.BUY, RampDirection.SELL] + } + }, + component: RampToggleWrapper, + parameters: { + docs: { + description: { + component: + "A toggle component for switching between Buy and Sell modes in the ramp interface. Features a smooth spring animation for the indicator and respects reduced motion preferences." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/RampToggle" +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + activeDirection: RampDirection.BUY + }, + parameters: { + docs: { + description: { + story: "Default toggle with Buy selected. Click to switch between Buy and Sell." + } + } + }, + render: RampToggleWrapper +}; + +export const SellActive: Story = { + args: { + activeDirection: RampDirection.SELL + }, + parameters: { + docs: { + description: { + story: "Toggle with Sell selected." + } + } + }, + render: RampToggleWrapper +}; + +export const Interactive: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo showing how the toggle updates the UI based on the selected direction." + } + } + }, + render: InteractiveDemo +}; + +export const SwapInterface: Story = { + parameters: { + docs: { + description: { + story: + "Real-world example showing the toggle integrated into a swap interface. Notice how the input/output labels and button text change based on the direction." + } + } + }, + render: SwapInterfaceDemo +}; + +export const ReducedMotion: Story = { + args: { + activeDirection: RampDirection.BUY + }, + parameters: { + docs: { + description: { + story: + "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions instead of the spring animation." + } + } + }, + render: RampToggleWrapper +}; diff --git a/apps/frontend/src/stories/TermsAndConditions.stories.tsx b/apps/frontend/src/stories/TermsAndConditions.stories.tsx new file mode 100644 index 0000000000..c09f2948d7 --- /dev/null +++ b/apps/frontend/src/stories/TermsAndConditions.stories.tsx @@ -0,0 +1,289 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { TermsAndConditions } from "../components/TermsAndConditions"; + +interface StoryArgs { + termsChecked?: boolean; + termsAccepted?: boolean; + termsError?: boolean; +} + +const TermsAndConditionsWrapper = ({ termsChecked = false, termsAccepted = false, termsError = false }: StoryArgs) => { + const [checked, setChecked] = useState(termsChecked); + const [error, setError] = useState(termsError); + const [accepted, setAccepted] = useState(termsAccepted); + + return ( +
+ setChecked(!checked)} + /> + {!accepted && ( +
+ + {!checked && ( + + )} +
+ )} +
+ ); +}; + +const InteractiveDemo = () => { + const [checked, setChecked] = useState(false); + const [error, setError] = useState(false); + const [accepted, setAccepted] = useState(false); + + const handleContinue = () => { + if (!checked) { + setError(true); + return; + } + setAccepted(true); + }; + + const handleReset = () => { + setChecked(false); + setError(false); + setAccepted(false); + }; + + return ( +
+
+

+ State: {accepted ? "Accepted" : checked ? "Checked" : error ? "Error" : "Unchecked"} +

+
+ + { + setChecked(!checked); + setError(false); + }} + /> + + {accepted ? ( +
+

Terms Accepted!

+ +
+ ) : ( + + )} +
+ ); +}; + +const CheckoutFlowDemo = () => { + const [checked, setChecked] = useState(false); + const [error, setError] = useState(false); + const [accepted, setAccepted] = useState(false); + + return ( +
+

Complete Your Order

+ +
+
+ Amount + 100 USDC +
+
+ Fee + 0.50 USDC +
+
+ Total + 100.50 USDC +
+
+ + { + setChecked(!checked); + setError(false); + }} + /> + + {accepted ? ( +
+

Order Confirmed!

+
+ ) : ( + + )} +
+ ); +}; + +const meta: Meta = { + argTypes: { + termsAccepted: { + control: "boolean", + description: "Whether the terms have been accepted (hides the checkbox)" + }, + termsChecked: { + control: "boolean", + description: "Whether the checkbox is checked" + }, + termsError: { + control: "boolean", + description: "Whether to show the error state" + } + }, + component: TermsAndConditionsWrapper, + parameters: { + docs: { + description: { + component: + "A terms and conditions checkbox component with animated error states and fade-out on acceptance. Features a subtle scale animation on error and supports reduced motion preferences." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/TermsAndConditions" +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + termsAccepted: false, + termsChecked: false, + termsError: false + }, + parameters: { + docs: { + description: { + story: "Default unchecked state. Check the box before continuing." + } + } + }, + render: TermsAndConditionsWrapper +}; + +export const Checked: Story = { + args: { + termsAccepted: false, + termsChecked: true, + termsError: false + }, + parameters: { + docs: { + description: { + story: "Checkbox in checked state, ready to continue." + } + } + }, + render: TermsAndConditionsWrapper +}; + +export const Error: Story = { + args: { + termsAccepted: false, + termsChecked: false, + termsError: true + }, + parameters: { + docs: { + description: { + story: "Error state shown when user tries to continue without accepting terms." + } + } + }, + render: TermsAndConditionsWrapper +}; + +export const Accepted: Story = { + args: { + termsAccepted: true, + termsChecked: true, + termsError: false + }, + parameters: { + docs: { + description: { + story: "Accepted state - the checkbox fades out with a scale animation." + } + } + }, + render: TermsAndConditionsWrapper +}; + +export const Interactive: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo showing the full flow: unchecked -> error -> checked -> accepted." + } + } + }, + render: InteractiveDemo +}; + +export const CheckoutFlow: Story = { + parameters: { + docs: { + description: { + story: "Real-world example showing the terms checkbox in a checkout/confirmation flow." + } + } + }, + render: CheckoutFlowDemo +}; + +export const ReducedMotion: Story = { + args: { + termsAccepted: false, + termsChecked: false, + termsError: false + }, + parameters: { + docs: { + description: { + story: + "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' to see instant transitions instead of animations." + } + } + }, + render: TermsAndConditionsWrapper +}; From 47aee767cf8a309a1bd6f65b311fbb2c1a1ccdd1 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Wed, 21 Jan 2026 23:21:41 +0100 Subject: [PATCH 21/61] improve animations and introduce reduced-animation setting for users --- apps/frontend/.storybook/main.ts | 2 +- .../src/components/Accordion/index.tsx | 78 +++++++++---------- .../AveniaKYBFlow/AveniaKYBVerifyStep.tsx | 6 +- .../AveniaKycEligibilityFields/index.tsx | 18 ++--- .../src/components/CollapsibleCard/index.tsx | 47 +++++------ .../src/components/EmailForm/index.tsx | 2 +- .../src/components/KycLevel2Toggle/index.tsx | 24 ++++-- .../src/components/Navbar/MobileMenu.tsx | 25 +++--- .../Navbar/hooks/useNavbarHandlers.ts | 5 +- .../src/components/RampToggle/index.tsx | 21 +++-- .../src/components/Stepper/StepCircle.tsx | 2 +- .../src/components/Stepper/StepConnector.tsx | 8 +- .../components/TermsAndConditions/index.tsx | 13 +++- .../animations/SelectionButtonMotion.tsx | 21 +++-- .../animations/SelectionChevronMotion.tsx | 10 ++- .../animations/SelectionDropdownMotion.tsx | 66 ++++++++-------- .../components/TokenSelectionControls.tsx | 21 +++-- .../src/components/menus/Menu/index.tsx | 8 +- apps/frontend/src/constants/animations.ts | 51 ++++++++++++ 19 files changed, 266 insertions(+), 162 deletions(-) diff --git a/apps/frontend/.storybook/main.ts b/apps/frontend/.storybook/main.ts index 7ffb65389c..1be6a6a161 100644 --- a/apps/frontend/.storybook/main.ts +++ b/apps/frontend/.storybook/main.ts @@ -6,7 +6,7 @@ import { dirname, join } from "path"; * This function is used to resolve the absolute path of a package. * It is needed in projects that use Yarn PnP or are set up within a monorepo. */ -function getAbsolutePath(value: string): any { +function getAbsolutePath(value: string) { return dirname(require.resolve(join(value, "package.json"))); } const config: StorybookConfig = { diff --git a/apps/frontend/src/components/Accordion/index.tsx b/apps/frontend/src/components/Accordion/index.tsx index 913d28a3d0..01ab0a9ca8 100644 --- a/apps/frontend/src/components/Accordion/index.tsx +++ b/apps/frontend/src/components/Accordion/index.tsx @@ -1,6 +1,7 @@ -import { AnimatePresence, motion } from "motion/react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { FC } from "react"; import { create } from "zustand"; +import { durations, easings } from "../../constants/animations"; import { cn } from "../../helpers/cn"; interface AccordionProps { @@ -44,6 +45,7 @@ const useAccordionStore = create(set => ({ const Accordion: FC = ({ children, className = "", defaultValue = [] }) => { const setValue = useAccordionStore(state => state.setValue); + const shouldReduceMotion = useReducedMotion(); if (defaultValue.length > 0) { setValue(defaultValue); @@ -53,8 +55,8 @@ const Accordion: FC = ({ children, className = "", defaultValue {children} @@ -63,21 +65,18 @@ const Accordion: FC = ({ children, className = "", defaultValue const AccordionItem: FC = ({ children, className = "", value }) => { const isOpen = useAccordionStore(state => state.value.includes(value)); + const shouldReduceMotion = useReducedMotion(); return ( - +
{children} - +
); }; @@ -85,61 +84,62 @@ const AccordionItem: FC = ({ children, className = "", value const AccordionTrigger: FC = ({ children, className = "", value }) => { const toggleValue = useAccordionStore(state => state.toggleValue); const isOpen = useAccordionStore(state => state.value.includes(value)); + const shouldReduceMotion = useReducedMotion(); return ( - +
toggleValue(value)} - whileHover={{ scale: 1.01 }} - whileTap={{ scale: 0.99 }} + whileHover={shouldReduceMotion ? undefined : { scale: 1.01 }} + whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }} >
- {children} + {children}
- +
); }; const AccordionContent: FC = ({ children, className = "", value }) => { const isOpen = useAccordionStore(state => state.value.includes(value)); + const shouldReduceMotion = useReducedMotion(); return ( - - {isOpen && ( - - - {children} - - - )} - +
+
+ + {isOpen && ( + + {children} + + )} + +
+
); }; diff --git a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx index 8dc3b5ef7f..779aba9734 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx @@ -37,7 +37,11 @@ export const AveniaKYBVerifyStep = ({

{t(titleKey)}

- Business Check + Business Check {!isVerificationStarted && (

diff --git a/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx b/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx index 4450045ec6..d586eae82b 100644 --- a/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx @@ -1,17 +1,11 @@ import { CNPJ_REGEX, CPF_REGEX, isValidCnpj, isValidCpf, RampDirection } from "@vortexfi/shared"; -import { AnimatePresence, type MotionProps, motion } from "motion/react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import type { FC } from "react"; import { Trans, useTranslation } from "react-i18next"; +import { durations, easings } from "../../../constants/animations"; import { useRampDirection } from "../../../stores/rampDirectionStore"; import { AveniaField, AveniaFieldValidationPattern, StandardAveniaFieldOptions } from "../AveniaField"; -const containerAnimation: MotionProps = { - animate: { height: "auto", opacity: 1 }, - exit: { height: 0, opacity: 0 }, - initial: { height: 0, opacity: 0 }, - transition: { duration: 0.3 } -}; - const OFFRAMP_FIELDS = [ { id: StandardAveniaFieldOptions.TAX_ID, index: 0, label: "cpfOrCnpj" }, { id: StandardAveniaFieldOptions.PIX_ID, index: 1, label: "pixKey" } @@ -46,12 +40,18 @@ export const AveniaKycEligibilityFields: FC<{ isWalletAddressDisabled?: boolean const { t } = useTranslation(); const rampDirection = useRampDirection(); const isOnramp = rampDirection === RampDirection.BUY; + const shouldReduceMotion = useReducedMotion(); const FIELDS = isOnramp ? ONRAMP_FIELDS : OFFRAMP_FIELDS; return ( - + {FIELDS.map(field => ( { const { isExpanded, detailsId } = useCollapsibleCard(); + const shouldReduceMotion = useReducedMotion(); return ( - - {isExpanded && ( - - - {children} - - - )} - +

+
+ + {isExpanded && ( + + {children} + + )} + +
+
); }; diff --git a/apps/frontend/src/components/EmailForm/index.tsx b/apps/frontend/src/components/EmailForm/index.tsx index 26a4339665..c730acc5f9 100644 --- a/apps/frontend/src/components/EmailForm/index.tsx +++ b/apps/frontend/src/components/EmailForm/index.tsx @@ -56,7 +56,7 @@ export const EmailForm = ({ transactionId, transactionSuccess }: EmailFormProps)
{!isPending && !isSuccess && (
); diff --git a/apps/frontend/src/components/Navbar/MobileMenu.tsx b/apps/frontend/src/components/Navbar/MobileMenu.tsx index 213863e038..09ae7618ff 100644 --- a/apps/frontend/src/components/Navbar/MobileMenu.tsx +++ b/apps/frontend/src/components/Navbar/MobileMenu.tsx @@ -1,6 +1,7 @@ import { Link, useParams } from "@tanstack/react-router"; -import { motion, type Variants } from "motion/react"; +import { motion, useReducedMotion, type Variants } from "motion/react"; import { useTranslation } from "react-i18next"; +import { durations, easings } from "../../constants/animations"; interface MobileMenuProps { onMenuItemClick: () => void; @@ -10,8 +11,8 @@ const menuVariants: Variants = { closed: { opacity: 0, transition: { - duration: 0.2, - ease: [0.4, 0, 1, 1], + duration: durations.normal, + ease: easings.easeOutCubic, staggerChildren: 0.05, staggerDirection: -1, when: "afterChildren" @@ -21,8 +22,8 @@ const menuVariants: Variants = { open: { opacity: 1, transition: { - duration: 0.3, - ease: [0, 0, 0.2, 1], + duration: durations.slow, + ease: easings.easeOutCubic, staggerChildren: 0.07, when: "beforeChildren" }, @@ -33,12 +34,12 @@ const menuVariants: Variants = { const menuItemVariants: Variants = { closed: { opacity: 0, - transition: { duration: 0.15, ease: "easeIn" }, + transition: { duration: durations.fast, ease: easings.easeOutCubic }, x: -16 }, open: { opacity: 1, - transition: { duration: 0.25, ease: "easeOut" }, + transition: { duration: durations.normal, ease: easings.easeOutCubic }, x: 0 } }; @@ -47,7 +48,7 @@ const buttonVariants: Variants = { closed: { opacity: 0, scale: 0.95, - transition: { duration: 0.15 } + transition: { duration: durations.fast, ease: easings.easeOutCubic } }, open: { opacity: 1, @@ -56,9 +57,15 @@ const buttonVariants: Variants = { } }; +const reducedMotionVariants: Variants = { + closed: { opacity: 0 }, + open: { opacity: 1, transition: { duration: 0 } } +}; + export const MobileMenu = ({ onMenuItemClick }: MobileMenuProps) => { const { t } = useTranslation(); const params = useParams({ strict: false }); + const shouldReduceMotion = useReducedMotion(); return ( { className="absolute top-full right-0 left-0 z-50 bg-blue-950 shadow-lg" exit="closed" initial="closed" - variants={menuVariants} + variants={shouldReduceMotion ? reducedMotionVariants : menuVariants} > ); }; diff --git a/apps/frontend/src/components/Navbar/MobileNavbar.tsx b/apps/frontend/src/components/Navbar/MobileNavbar.tsx index 2894b8bf36..445b38b02e 100644 --- a/apps/frontend/src/components/Navbar/MobileNavbar.tsx +++ b/apps/frontend/src/components/Navbar/MobileNavbar.tsx @@ -1,3 +1,4 @@ +import { useRouterState } from "@tanstack/react-router"; import { AnimatePresence } from "motion/react"; import { useRef, useState } from "react"; import { cn } from "../../helpers/cn"; @@ -12,9 +13,13 @@ export const MobileNavbar = () => { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const isWidgetMode = useWidgetMode(); const navbarRef = useRef(null); + const routerState = useRouterState(); const { resetRampAndNavigateHome } = useNavbarHandlers(); + const isBusinessPage = routerState.location.pathname.includes("/business"); + const useTransparentStyle = isWidgetMode || isBusinessPage; + const toggleMobileMenu = () => { setIsMobileMenuOpen(!isMobileMenuOpen); }; @@ -26,20 +31,20 @@ export const MobileNavbar = () => { useClickOutside(navbarRef, closeMobileMenu, isMobileMenuOpen); return ( -
-
+
+
{isWidgetMode ? ( - + ) : (
- +
)}
- {isMobileMenuOpen && } + {isMobileMenuOpen && }
); }; diff --git a/apps/frontend/src/components/Navbar/hooks/useNavbarHandlers.ts b/apps/frontend/src/components/Navbar/hooks/useNavbarHandlers.ts index 2f9630a732..3f58527de5 100644 --- a/apps/frontend/src/components/Navbar/hooks/useNavbarHandlers.ts +++ b/apps/frontend/src/components/Navbar/hooks/useNavbarHandlers.ts @@ -1,19 +1,21 @@ import { useParams, useRouter } from "@tanstack/react-router"; import { useRampActor } from "../../../contexts/rampState"; +import { useWidgetMode } from "../../../hooks/useWidgetMode"; export const useNavbarHandlers = () => { const rampActor = useRampActor(); const router = useRouter(); const params = useParams({ strict: false }); + const isWidgetMode = useWidgetMode(); const resetRampAndNavigateHome = () => { - rampActor.send({ type: "RESET_RAMP" }); + rampActor.send({ skipUrlCleaner: true, type: "RESET_RAMP" }); router.navigate({ params: params, replace: true, search: {}, - to: "/{-$locale}" + to: isWidgetMode ? "/{-$locale}/widget" : "/{-$locale}" }); }; diff --git a/apps/frontend/src/components/Navbar/index.tsx b/apps/frontend/src/components/Navbar/index.tsx index 80103abcfb..2e8304e91d 100644 --- a/apps/frontend/src/components/Navbar/index.tsx +++ b/apps/frontend/src/components/Navbar/index.tsx @@ -2,7 +2,7 @@ import { DesktopNavbar } from "./DesktopNavbar"; import { MobileNavbar } from "./MobileNavbar"; export const Navbar = () => ( -
+
diff --git a/apps/frontend/src/components/QuoteSummary/index.tsx b/apps/frontend/src/components/QuoteSummary/index.tsx index aaa0ae41a8..478539b3b6 100644 --- a/apps/frontend/src/components/QuoteSummary/index.tsx +++ b/apps/frontend/src/components/QuoteSummary/index.tsx @@ -1,19 +1,36 @@ -import { QuoteResponse } from "@vortexfi/shared"; +import { QuoteResponse, RampDirection } from "@vortexfi/shared"; import { useRef } from "react"; import { useTranslation } from "react-i18next"; -import { useGetAssetIcon } from "../../hooks/useGetAssetIcon"; +import { useTokenIcon } from "../../hooks/useTokenIcon"; import { CollapsibleCard, CollapsibleDetails, CollapsibleSummary, useCollapsibleCard } from "../CollapsibleCard"; import { CurrencyExchange } from "../CurrencyExchange"; import { ToggleButton } from "../ToggleButton"; +import { TokenIconWithNetwork } from "../TokenIconWithNetwork"; import { TransactionId } from "../TransactionId"; interface QuoteSummaryProps { quote: QuoteResponse; } +/** + * Hook to get token icons for both currencies in a quote. + * Determines which currency is on-chain based on ramp type. + */ +function useQuoteTokenIcons(quote: QuoteResponse) { + const isOfframp = quote.rampType === RampDirection.SELL; + + // For offramp: input is on-chain (has network), output is fiat (no network) + // For onramp: input is fiat (no network), output is on-chain (has network) + const inputIcon = useTokenIcon(quote.inputCurrency, isOfframp ? quote.network : undefined); + const outputIcon = useTokenIcon(quote.outputCurrency, !isOfframp ? quote.network : undefined); + + return { inputIcon, outputIcon }; +} + const QuoteSummaryCore = ({ quote }: { quote: QuoteResponse }) => { const { t } = useTranslation(); const { toggle, isExpanded, detailsId } = useCollapsibleCard(); + const { inputIcon, outputIcon } = useQuoteTokenIcons(quote); return ( <> @@ -24,8 +41,14 @@ const QuoteSummaryCore = ({ quote }: { quote: QuoteResponse }) => { { const QuoteSummaryDetails = ({ quote }: { quote: QuoteResponse }) => { const { t } = useTranslation(); - const inputIcon = useGetAssetIcon(quote.inputCurrency.toLowerCase()); - const outputIcon = useGetAssetIcon(quote.outputCurrency.toLowerCase()); + const { inputIcon, outputIcon } = useQuoteTokenIcons(quote); return (
@@ -53,15 +75,29 @@ const QuoteSummaryDetails = ({ quote }: { quote: QuoteResponse }) => {
{t("components.quoteSummary.youSend")}
- {quote.inputCurrency} + {quote.inputAmount} {quote.inputCurrency.toUpperCase()}
{t("components.quoteSummary.youReceive")}
- {quote.outputCurrency}~ {quote.outputAmount}{" "} - {quote.outputCurrency.toUpperCase()} + + ~ {quote.outputAmount} {quote.outputCurrency.toUpperCase()}
diff --git a/apps/frontend/src/components/Ramp/Offramp/index.tsx b/apps/frontend/src/components/Ramp/Offramp/index.tsx index c89325b177..b76a383d5d 100644 --- a/apps/frontend/src/components/Ramp/Offramp/index.tsx +++ b/apps/frontend/src/components/Ramp/Offramp/index.tsx @@ -5,11 +5,11 @@ import { FormProvider } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useEventsContext } from "../../../contexts/events"; import { useNetwork } from "../../../contexts/network"; -import { getTokenLogoURIs } from "../../../helpers/tokenHelpers"; import { useQuoteForm } from "../../../hooks/quote/useQuoteForm"; import { useQuoteService } from "../../../hooks/quote/useQuoteService"; import { useRampSubmission } from "../../../hooks/ramp/useRampSubmission"; import { useRampValidation } from "../../../hooks/ramp/useRampValidation"; +import { useTokenIcon } from "../../../hooks/useTokenIcon"; import { useVortexAccount } from "../../../hooks/useVortexAccount"; import { getEvmTokenConfig } from "../../../services/tokens"; import { useFeeComparisonStore } from "../../../stores/feeComparison"; @@ -75,16 +75,17 @@ export const Offramp = () => { const handleBalanceClick = useCallback((amount: string) => form.setValue("inputAmount", amount), [form]); - const { logoURI, fallbackLogoURI } = getTokenLogoURIs(fromToken); + const fromIconInfo = useTokenIcon(fromToken); const WithdrawNumericInput = useMemo( () => ( <> openTokenSelectModal("from")} registerInput={form.register("inputAmount")} @@ -96,7 +97,7 @@ export const Offramp = () => {
), - [form, fromToken, openTokenSelectModal, handleInputChange, handleBalanceClick, isDisconnected, logoURI, fallbackLogoURI] + [form, fromToken, openTokenSelectModal, handleInputChange, handleBalanceClick, isDisconnected, fromIconInfo] ); const ReceiveNumericInput = useMemo( diff --git a/apps/frontend/src/components/Ramp/Onramp/index.tsx b/apps/frontend/src/components/Ramp/Onramp/index.tsx index 10b7f98da5..1453685a98 100644 --- a/apps/frontend/src/components/Ramp/Onramp/index.tsx +++ b/apps/frontend/src/components/Ramp/Onramp/index.tsx @@ -5,11 +5,11 @@ import { FormProvider } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useEventsContext } from "../../../contexts/events"; import { useNetwork } from "../../../contexts/network"; -import { getTokenLogoURIs } from "../../../helpers/tokenHelpers"; import { useQuoteForm } from "../../../hooks/quote/useQuoteForm"; import { useQuoteService } from "../../../hooks/quote/useQuoteService"; import { useRampSubmission } from "../../../hooks/ramp/useRampSubmission"; import { useRampValidation } from "../../../hooks/ramp/useRampValidation"; +import { useTokenIcon } from "../../../hooks/useTokenIcon"; import { getEvmTokenConfig } from "../../../services/tokens"; import { useFeeComparisonStore } from "../../../stores/feeComparison"; import { useFiatToken, useInputAmount, useOnChainToken } from "../../../stores/quote/useQuoteFormStore"; @@ -84,33 +84,25 @@ export const Onramp = () => { [form, fromToken, openTokenSelectModal, handleInputChange] ); - const { logoURI, fallbackLogoURI } = getTokenLogoURIs(toToken); + const toIconInfo = useTokenIcon(toToken); const ReceiveNumericInput = useMemo( () => ( openTokenSelectModal("to")} readOnly={true} registerInput={form.register("outputAmount")} tokenSymbol={toToken.assetSymbol} /> ), - [ - toToken.networkAssetIcon, - toToken.assetSymbol, - form, - quoteLoading, - toAmount, - openTokenSelectModal, - logoURI, - fallbackLogoURI - ] + [toToken.networkAssetIcon, toToken.assetSymbol, form, quoteLoading, toAmount, openTokenSelectModal, toIconInfo] ); const handleConfirm = useCallback(() => { diff --git a/apps/frontend/src/components/RampToggle/index.tsx b/apps/frontend/src/components/RampToggle/index.tsx index 465354b086..3ee120f99a 100644 --- a/apps/frontend/src/components/RampToggle/index.tsx +++ b/apps/frontend/src/components/RampToggle/index.tsx @@ -1,5 +1,5 @@ import { RampDirection } from "@vortexfi/shared"; -import { motion } from "motion/react"; +import { motion, useReducedMotion } from "motion/react"; import { useTranslation } from "react-i18next"; interface RampToggleProps { @@ -9,11 +9,12 @@ interface RampToggleProps { export const RampToggle = ({ activeDirection, onToggle }: RampToggleProps) => { const { t } = useTranslation(); + const shouldReduceMotion = useReducedMotion(); return (
); diff --git a/apps/frontend/src/components/Stepper/StepCircle.tsx b/apps/frontend/src/components/Stepper/StepCircle.tsx index 15bb01ff61..2122285696 100644 --- a/apps/frontend/src/components/Stepper/StepCircle.tsx +++ b/apps/frontend/src/components/Stepper/StepCircle.tsx @@ -4,7 +4,7 @@ import { Step, StepCircleProps } from "./types"; export const getStepCircleStyles = (status: Step["status"], isClickable: boolean): string => { const baseStyles = - "flex h-8 w-8 items-center mx-auto justify-center rounded-full font-medium text-sm transition-all duration-200 ease-in-out hover:scale-110"; + "flex h-8 w-8 items-center mx-auto justify-center rounded-full font-medium text-sm transition-[background-color,transform] duration-200 ease-out motion-reduce:transition-none"; const statusStyles = { active: "bg-blue-500 text-white", diff --git a/apps/frontend/src/components/Stepper/StepConnector.tsx b/apps/frontend/src/components/Stepper/StepConnector.tsx index 950795ce68..d08139efd0 100644 --- a/apps/frontend/src/components/Stepper/StepConnector.tsx +++ b/apps/frontend/src/components/Stepper/StepConnector.tsx @@ -1,5 +1,6 @@ -import { motion } from "motion/react"; +import { motion, useReducedMotion } from "motion/react"; import React from "react"; +import { durations, easings } from "../../constants/animations"; import { Step, StepConnectorProps } from "./types"; const getConnectorColor = (currentStatus: Step["status"], nextStatus: Step["status"]): string => { @@ -11,6 +12,7 @@ const getConnectorColor = (currentStatus: Step["status"], nextStatus: Step["stat export const StepConnector: React.FC = ({ currentStepStatus, nextStepStatus }) => { const backgroundColor = getConnectorColor(currentStepStatus, nextStepStatus); + const shouldReduceMotion = useReducedMotion(); return (
@@ -20,8 +22,8 @@ export const StepConnector: React.FC = ({ currentStepStatus, width: currentStepStatus === "complete" || currentStepStatus === "error" ? "100%" : 0 }} className="absolute z-10 h-px" - initial={{ backgroundColor, width: 0 }} - transition={{ duration: 0.5, ease: "easeInOut" }} + initial={shouldReduceMotion ? false : { backgroundColor, width: 0 }} + transition={shouldReduceMotion ? { duration: 0 } : { duration: durations.slow * 1.5, ease: easings.easeOutCubic }} />
diff --git a/apps/frontend/src/components/TermsAndConditions/index.tsx b/apps/frontend/src/components/TermsAndConditions/index.tsx index 27f9aec64c..7a69643fd9 100644 --- a/apps/frontend/src/components/TermsAndConditions/index.tsx +++ b/apps/frontend/src/components/TermsAndConditions/index.tsx @@ -1,6 +1,7 @@ -import { AnimatePresence, motion } from "motion/react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Dispatch } from "react"; import { useTranslation } from "react-i18next"; +import { durations, easings } from "../../constants/animations"; import { cn } from "../../helpers/cn"; interface TermsAndConditionsProps { @@ -14,7 +15,7 @@ interface TermsAndConditionsProps { const fadeOutAnimation = { opacity: [1, 1, 0], scale: [1, 1.05, 0], - transition: { duration: 0.3 } + transition: { duration: durations.slow, ease: easings.easeOutCubic } }; export const TermsAndConditions = (props: TermsAndConditionsProps) => { @@ -48,15 +49,19 @@ const TermsAndConditionsContent = ({ const TermsText = ({ error }: { error: boolean }) => { const { t } = useTranslation(); + const shouldReduceMotion = useReducedMotion(); return ( {t("components.termsAndConditions.text")}{" "}
= memo(function TokenImage({ src, fallbackSrc, alt, className }) { +export const TokenIcon: FC = memo(function TokenIcon({ src, fallbackSrc, alt, className }) { const [isLoading, setIsLoading] = useState(true); const [imgError, setImgError] = useState(false); const [fallbackError, setFallbackError] = useState(false); diff --git a/apps/frontend/src/components/TokenIconWithNetwork/index.tsx b/apps/frontend/src/components/TokenIconWithNetwork/index.tsx index a1a98572f6..8e77602e89 100644 --- a/apps/frontend/src/components/TokenIconWithNetwork/index.tsx +++ b/apps/frontend/src/components/TokenIconWithNetwork/index.tsx @@ -2,7 +2,7 @@ import { Networks } from "@vortexfi/shared"; import { FC, memo } from "react"; import { cn } from "../../helpers/cn"; import { NETWORK_ICONS } from "../../hooks/useGetNetworkIcon"; -import { TokenImage } from "../TokenImage"; +import { TokenIcon } from "../TokenIcon"; interface TokenIconWithNetworkProps { iconSrc: string; @@ -26,11 +26,11 @@ export const TokenIconWithNetwork: FC = memo(function return (
- + {shouldShowOverlay && ( {`${network} { + const shouldReduceMotion = useReducedMotion(); + return ( {children} diff --git a/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionChevronMotion.tsx b/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionChevronMotion.tsx index 869a956244..8efd85846e 100644 --- a/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionChevronMotion.tsx +++ b/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionChevronMotion.tsx @@ -1,5 +1,6 @@ -import { motion } from "motion/react"; +import { motion, useReducedMotion } from "motion/react"; import { ReactNode } from "react"; +import { durations, easings } from "../../../../constants/animations"; interface SelectionChevronMotionProps { isOpen: boolean; @@ -7,8 +8,13 @@ interface SelectionChevronMotionProps { } export const SelectionChevronMotion = ({ isOpen, children }: SelectionChevronMotionProps) => { + const shouldReduceMotion = useReducedMotion(); + return ( - + {children} ); diff --git a/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionDropdownMotion.tsx b/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionDropdownMotion.tsx index d6faecf9a8..a9f6b487ca 100644 --- a/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionDropdownMotion.tsx +++ b/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionDropdownMotion.tsx @@ -1,5 +1,6 @@ -import { AnimatePresence, motion } from "motion/react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { ReactNode } from "react"; +import { durations, easings } from "../../../../constants/animations"; interface SelectionDropdownMotionProps { isOpen: boolean; @@ -8,39 +9,36 @@ interface SelectionDropdownMotionProps { } export const SelectionDropdownMotion = ({ isOpen, children, className }: SelectionDropdownMotionProps) => { + const shouldReduceMotion = useReducedMotion(); + return ( - - {isOpen && ( - - {children} - - )} - +
+
+ + {isOpen && ( + + {children} + + )} + +
+
); }; diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/TokenSelectionControls.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/TokenSelectionControls.tsx index 72b2bc5c04..21437ac553 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/TokenSelectionControls.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/TokenSelectionControls.tsx @@ -1,6 +1,6 @@ -import { motion } from "motion/react"; +import { motion, useReducedMotion } from "motion/react"; import { useTranslation } from "react-i18next"; -import { cn } from "../../../../helpers/cn"; +import { durations, easings } from "../../../../constants/animations"; import { useIsNetworkDropdownOpen, useTokenSelectionActions } from "../../../../stores/tokenSelectionStore"; import { SearchInput } from "../../../SearchInput"; import { NetworkDropdown } from "../../NetworkSelectionList"; @@ -9,6 +9,7 @@ const TokenSelectionSearchInput = () => { const { t } = useTranslation(); const isNetworkDropdownOpen = useIsNetworkDropdownOpen(); const { setSearchFilter } = useTokenSelectionActions(); + const shouldReduceMotion = useReducedMotion(); const handleSearchChange = (value: string) => { setSearchFilter(value); @@ -24,11 +25,15 @@ const TokenSelectionSearchInput = () => { width: isNetworkDropdownOpen ? 0 : "auto" }} className="flex-grow" - transition={{ - delay: isNetworkDropdownOpen ? 0 : 0.3, - duration: isNetworkDropdownOpen ? 0 : 0.15, - ease: "linear" - }} + transition={ + shouldReduceMotion + ? { duration: 0 } + : { + delay: isNetworkDropdownOpen ? 0 : durations.slow, + duration: isNetworkDropdownOpen ? 0 : durations.fast, + ease: easings.easeOutCubic + } + } > { }; export const TokenSelectionControls = () => ( -
+
diff --git a/apps/frontend/src/components/buttons/AssetButton/index.tsx b/apps/frontend/src/components/buttons/AssetButton/index.tsx index 4e8bc31653..cfae8fef82 100644 --- a/apps/frontend/src/components/buttons/AssetButton/index.tsx +++ b/apps/frontend/src/components/buttons/AssetButton/index.tsx @@ -1,7 +1,8 @@ import { ChevronDownIcon } from "@heroicons/react/20/solid"; +import { Networks } from "@vortexfi/shared"; import { cn } from "../../../helpers/cn"; -import { useGetAssetIcon } from "../../../hooks/useGetAssetIcon"; -import { TokenImage } from "../../TokenImage"; +import { useTokenIcon } from "../../../hooks/useTokenIcon"; +import { TokenIconWithNetwork } from "../../TokenIconWithNetwork"; interface AssetButtonProps { assetIcon: string; @@ -10,11 +11,20 @@ interface AssetButtonProps { fallbackLogoURI?: string; onClick: () => void; disabled?: boolean; + network?: Networks; } -export function AssetButton({ assetIcon, tokenSymbol, onClick, disabled, logoURI, fallbackLogoURI }: AssetButtonProps) { - const localIcon = useGetAssetIcon(assetIcon); - const primaryIcon = logoURI ?? localIcon; +export function AssetButton({ + assetIcon, + tokenSymbol, + onClick, + disabled, + logoURI, + fallbackLogoURI, + network +}: AssetButtonProps) { + const fallbackIcon = useTokenIcon(assetIcon); + const primaryIcon = logoURI ?? fallbackIcon.iconSrc; return ( diff --git a/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx b/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx index 606c90db7b..dac4972650 100644 --- a/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx +++ b/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx @@ -1,10 +1,17 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid"; -import { getNetworkDisplayName, Networks, roundDownToSignificantDecimals } from "@vortexfi/shared"; +import { + EPaymentMethod, + getNetworkDisplayName, + Networks, + PaymentMethod, + roundDownToSignificantDecimals +} from "@vortexfi/shared"; import Big from "big.js"; import { FC, useState } from "react"; -import { useGetAssetIcon } from "../../../../hooks/useGetAssetIcon"; +import { useTokenIcon } from "../../../../hooks/useTokenIcon"; import { StatusBadge } from "../../../StatusBadge"; -import { Transaction } from "../types"; +import { TokenIconWithNetwork } from "../../../TokenIconWithNetwork"; +import { Transaction, TransactionDestination } from "../types"; interface TransactionItemProps { transaction: Transaction; @@ -27,17 +34,28 @@ const formatTooltipDate = (date: Date) => year: "numeric" }); -const getNetworkName = (network: Transaction["from"] | Transaction["to"]) => { - if (typeof network === "string" && ["pix", "sepa", "cbu"].includes(network)) { +const PAYMENT_METHODS: PaymentMethod[] = [EPaymentMethod.PIX, EPaymentMethod.SEPA, EPaymentMethod.CBU]; + +function isNetwork(destination: TransactionDestination): destination is Networks { + return !PAYMENT_METHODS.includes(destination as PaymentMethod); +} + +const getNetworkName = (network: TransactionDestination) => { + if (!isNetwork(network)) { return network.toUpperCase(); } - return getNetworkDisplayName(network as Networks); + return getNetworkDisplayName(network); }; export const TransactionItem: FC = ({ transaction }) => { const [isHovered, setIsHovered] = useState(false); - const fromIcon = useGetAssetIcon(transaction.fromCurrency.toLowerCase()); - const toIcon = useGetAssetIcon(transaction.toCurrency.toLowerCase()); + + // Determine network for each currency (only on-chain tokens have networks) + const fromNetwork = isNetwork(transaction.from) ? transaction.from : undefined; + const toNetwork = isNetwork(transaction.to) ? transaction.to : undefined; + + const fromIcon = useTokenIcon(transaction.fromCurrency, fromNetwork); + const toIcon = useTokenIcon(transaction.toCurrency, toNetwork); return (
= ({ transaction }) => {
- {transaction.fromCurrency} - {transaction.toCurrency} + +
diff --git a/apps/frontend/src/components/menus/Menu/index.tsx b/apps/frontend/src/components/menus/Menu/index.tsx index 233ad36cd3..05a3cdf5b4 100644 --- a/apps/frontend/src/components/menus/Menu/index.tsx +++ b/apps/frontend/src/components/menus/Menu/index.tsx @@ -1,5 +1,6 @@ -import { AnimatePresence, motion } from "motion/react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { ReactNode } from "react"; +import { durations, easings } from "../../../constants/animations"; import { useEscapeKey } from "../../../hooks/useEscapeKey"; import { PageHeader } from "../../PageHeader"; @@ -20,6 +21,7 @@ export interface MenuProps { export function Menu({ isOpen, onClose, title, children, animationDirection }: MenuProps) { useEscapeKey(isOpen, onClose); + const shouldReduceMotion = useReducedMotion(); const animationProps = animationDirection === MenuAnimationDirection.RIGHT @@ -41,8 +43,8 @@ export function Menu({ isOpen, onClose, title, children, animationDirection }: M animate={animationProps.animate} className="absolute top-0 right-0 bottom-0 left-0 z-40 flex w-full flex-col overflow-hidden rounded-lg bg-white px-4 pt-4 pb-2 shadow-lg" exit={animationProps.exit} - initial={animationProps.initial} - transition={{ duration: 0.3 }} + initial={shouldReduceMotion ? false : animationProps.initial} + transition={shouldReduceMotion ? { duration: 0 } : { duration: durations.slow, ease: easings.easeOutCubic }} >
diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/AssetDisplay.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/AssetDisplay.tsx index 8c85b7636e..8c80ce5af2 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/AssetDisplay.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/AssetDisplay.tsx @@ -1,19 +1,26 @@ +import { Networks } from "@vortexfi/shared"; import { FC } from "react"; -import { TokenImage } from "../../TokenImage"; +import { TokenIconWithNetwork } from "../../TokenIconWithNetwork"; interface AssetDisplayProps { amount: string; symbol: string; iconSrc: string; - iconAlt: string; fallbackIconSrc?: string; + network?: Networks; } -export const AssetDisplay: FC = ({ amount, symbol, iconSrc, iconAlt, fallbackIconSrc }) => ( +export const AssetDisplay: FC = ({ amount, symbol, iconSrc, fallbackIconSrc, network }) => (
{amount} {symbol} - +
); diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx index 35274a7676..e1c3c25525 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx @@ -1,15 +1,11 @@ import { ArrowDownIcon } from "@heroicons/react/20/solid"; import { - AssetHubTokenDetails, BaseFiatTokenDetails, - EvmTokenDetails, FiatToken, FiatTokenDetails, getAddressForFormat, getAnyFiatTokenDetails, getOnChainTokenDetailsOrDefault, - isAssetHubTokenDetails, - isEvmTokenDetails, isStellarOutputTokenDetails, OnChainTokenDetails, RampDirection @@ -22,7 +18,7 @@ import { useNetwork } from "../../../contexts/network"; import { useAssetHubNode } from "../../../contexts/polkadotNode"; import { useRampActor } from "../../../contexts/rampState"; import { trimAddress } from "../../../helpers/addressFormatter"; -import { useGetAssetIcon } from "../../../hooks/useGetAssetIcon"; +import { useTokenIcon } from "../../../hooks/useTokenIcon"; import { useVortexAccount } from "../../../hooks/useVortexAccount"; import { RampExecutionInput } from "../../../types/phases"; import { AssetDisplay } from "./AssetDisplay"; @@ -38,16 +34,6 @@ interface TransactionTokensDisplayProps { rampDirection: RampDirection; } -function getOnChainTokenIcon(tokenDetails: OnChainTokenDetails): { primary?: string; fallback?: string } { - if (isEvmTokenDetails(tokenDetails)) { - return { fallback: tokenDetails.fallbackLogoURI, primary: tokenDetails.logoURI }; - } - if (isAssetHubTokenDetails(tokenDetails)) { - return { primary: tokenDetails.logoURI }; - } - return {}; -} - export const TransactionTokensDisplay: FC = ({ executionInput, isOnramp, rampDirection }) => { const { t } = useTranslation(); const rampActor = useRampActor(); @@ -113,16 +99,8 @@ export const TransactionTokensDisplay: FC = ({ ex ? getOnChainTokenDetailsOrDefault(selectedNetwork, executionInput.onChainToken) : getAnyFiatTokenDetails(executionInput.fiatToken); - const fromFiatIcon = useGetAssetIcon(isOnramp ? (fromToken as BaseFiatTokenDetails).fiat.assetIcon : ""); - const toFiatIcon = useGetAssetIcon(!isOnramp ? (toToken as BaseFiatTokenDetails).fiat.assetIcon : ""); - - const fromTokenIcons = isOnramp ? { primary: fromFiatIcon } : getOnChainTokenIcon(fromToken as OnChainTokenDetails); - const toTokenIcons = !isOnramp ? { primary: toFiatIcon } : getOnChainTokenIcon(toToken as OnChainTokenDetails); - - const fromIcon = fromTokenIcons.primary ?? fromFiatIcon; - const toIcon = toTokenIcons.primary ?? toFiatIcon; - const fromFallbackIcon = fromTokenIcons.fallback; - const toFallbackIcon = toTokenIcons.fallback; + const fromIconInfo = useTokenIcon(fromToken); + const toIconInfo = useTokenIcon(toToken); const getPartnerUrl = (): string => { const fiatToken = (isOnramp ? fromToken : toToken) as FiatTokenDetails; @@ -146,17 +124,17 @@ export const TransactionTokensDisplay: FC = ({ ex
{ + if (typeof currencyOrDetails === "string") { + return currencyOrDetails.toLowerCase(); + } + // For token details, use assetSymbol + return currencyOrDetails.assetSymbol.toLowerCase(); + }, [currencyOrDetails]); + + const fiatIcon = useGetAssetIcon(currencyForFiatLookup); + + return useMemo(() => { + // Handle token details objects + if (typeof currencyOrDetails !== "string") { + // FiatTokenDetails (Stellar or Moonbeam) + if (isFiatTokenDetails(currencyOrDetails)) { + return { + iconSrc: fiatIcon + }; + } + + // OnChainTokenDetails (EVM or AssetHub) + const { logoURI, fallbackLogoURI } = getTokenLogoURIs(currencyOrDetails as OnChainTokenDetails); + return { + fallbackIconSrc: fallbackLogoURI, + iconSrc: logoURI ?? fiatIcon, + network: currencyOrDetails.network + }; + } + + // Handle currency string input + const currency = currencyOrDetails; + + // Fiat tokens use local icons + if (isFiatToken(currency)) { + return { + iconSrc: fiatIcon + }; + } + + // On-chain tokens need to look up details for logoURI + if (network) { + const tokenDetails = getOnChainTokenDetails(network, currency as OnChainToken, getEvmTokenConfig()); + if (tokenDetails) { + const { logoURI, fallbackLogoURI } = getTokenLogoURIs(tokenDetails); + return { + fallbackIconSrc: fallbackLogoURI, + iconSrc: logoURI ?? fiatIcon, + network + }; + } + } + + // Fallback to fiat icon lookup (will return placeholder if not found) + return { + iconSrc: fiatIcon, + network + }; + }, [currencyOrDetails, network, fiatIcon]); +} diff --git a/apps/frontend/src/machines/actors/sign.actor.ts b/apps/frontend/src/machines/actors/sign.actor.ts index 9b8644bce5..769e751d00 100644 --- a/apps/frontend/src/machines/actors/sign.actor.ts +++ b/apps/frontend/src/machines/actors/sign.actor.ts @@ -4,6 +4,7 @@ import { ERC20_EURE_POLYGON_V2, getAddressForFormat, getOnChainTokenDetails, + isEvmTransactionData, Networks, PermitSignature, RampDirection @@ -48,15 +49,23 @@ export const signTransactionsActor = async ({ } const userTxs = rampState?.ramp?.unsignedTxs?.filter(tx => { - // If a monerium wallet address is provided in the execution input, we use that as the signer address. - const signerAddress = executionInput?.moneriumWalletAddress || connectedWalletAddress; + // For substrate networks (Pendulum/AssetHub), always use connectedWalletAddress. + // moneriumWalletAddress is only for Monerium flows with EVM transactions. + const isSubstrateTransaction = !isEvmTransactionData(tx.txData); + const signerAddress = isSubstrateTransaction + ? connectedWalletAddress + : executionInput?.moneriumWalletAddress || connectedWalletAddress; + if (!signerAddress) { return false; } - return chainId < 0 && (tx.network === Networks.Pendulum || tx.network === Networks.AssetHub) + const isSubstrateNetwork = chainId < 0 && isSubstrateTransaction; + const match = isSubstrateNetwork ? getAddressForFormat(tx.signer, 0) === getAddressForFormat(signerAddress, 0) : tx.signer.toLowerCase() === signerAddress.toLowerCase(); + + return match; }); // Add userTx for monerium onramp. Signature is required, which is created in this process. diff --git a/apps/frontend/src/pages/progress/index.tsx b/apps/frontend/src/pages/progress/index.tsx index bcf0c77c07..e22c0150bd 100644 --- a/apps/frontend/src/pages/progress/index.tsx +++ b/apps/frontend/src/pages/progress/index.tsx @@ -16,11 +16,16 @@ import { getMessageForPhase } from "./phaseMessages"; const PHASE_DURATIONS: Record = { assethubToPendulum: 24, + backupApprove: 0, + backupSquidRouterApprove: 0, + backupSquidRouterSwap: 0, brlaOnrampMint: 5 * 60, brlaPayoutOnMoonbeam: 30, complete: 0, + destinationTransfer: 12, distributeFees: 24, failed: 0, + finalSettlementSubsidy: 30, fundEphemeral: 20, hydrationSwap: 30, hydrationToAssethubXcm: 30, diff --git a/apps/frontend/src/pages/progress/phaseMessages.ts b/apps/frontend/src/pages/progress/phaseMessages.ts index 344d82f987..fc629795b5 100644 --- a/apps/frontend/src/pages/progress/phaseMessages.ts +++ b/apps/frontend/src/pages/progress/phaseMessages.ts @@ -47,16 +47,22 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr }); const getTransferringMessage = () => t("pages.progress.transferringToLocalPartner"); + const getDestinationTransferMessage = () => t("pages.progress.destinationTransfer", { assetSymbol: outputAssetSymbol }); const messages: Record = { assethubToPendulum: t("pages.progress.assethubToPendulum", { assetSymbol: inputAssetSymbol }), + backupApprove: "", // Not relevant for progress page + backupSquidRouterApprove: "", // Not relevant for progress page + backupSquidRouterSwap: "", // Not relevant for progress page brlaOnrampMint: t("pages.progress.brlaOnrampMint"), brlaPayoutOnMoonbeam: getTransferringMessage(), - complete: "", + complete: "", // Not relevant for progress page + destinationTransfer: getDestinationTransferMessage(), distributeFees: getSwappingMessage(), - failed: "", + failed: "", // Not relevant for progress page + finalSettlementSubsidy: getDestinationTransferMessage(), fundEphemeral: t("pages.progress.fundEphemeral"), hydrationSwap: t("pages.progress.hydrationSwap", { inputAssetSymbol: "USDC", @@ -87,10 +93,10 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr squidRouterApprove: getSquidrouterSwapMessage(), squidRouterPay: getSquidrouterSwapMessage(), squidRouterSwap: getSquidrouterSwapMessage(), - stellarCreateAccount: t("pages.progress.createStellarAccount"), // Not relevant for progress page + stellarCreateAccount: t("pages.progress.createStellarAccount"), stellarPayment: t("pages.progress.stellarPayment", { assetSymbol: outputAssetSymbol - }), // Not relevant for progress page + }), subsidizePostSwap: getSwappingMessage(), subsidizePreSwap: getSwappingMessage(), timedOut: "" // Not relevant for progress page diff --git a/apps/frontend/src/sections/business/Hero/index.tsx b/apps/frontend/src/sections/business/Hero/index.tsx index 28dc372815..a30e45ea6b 100644 --- a/apps/frontend/src/sections/business/Hero/index.tsx +++ b/apps/frontend/src/sections/business/Hero/index.tsx @@ -1,83 +1,119 @@ import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; -import { motion } from "motion/react"; +import { motion, useReducedMotion, type Variants } from "motion/react"; import { useTranslation } from "react-i18next"; import WidgetSnippetImageEUR from "../../../assets/metawallet-vortex.png"; import { AnimatedTitle } from "../../../components/AnimatedTitle"; -import { fadeInUp, prefersReducedMotion, staggerContainer } from "../../../constants/animations"; +import { fadeInUp, staggerContainer } from "../../../constants/animations"; + +const heroImageVariants: Variants = { + hidden: { + opacity: 0, + scale: 0.92, + y: 40 + }, + visible: { + opacity: 1, + scale: 1, + transition: { + damping: 25, + delay: 0.3, + duration: 0.8, + ease: [0.25, 0.46, 0.45, 0.94], + stiffness: 100, + type: "spring" + }, + y: 0 + } +}; export function Hero() { const { t } = useTranslation(); - const reducedMotion = prefersReducedMotion(); + const reducedMotion = useReducedMotion(); return (
-
-
+
+ - + {t("pages.business.hero.description")}
{t("pages.business.hero.contactUs")} {t("pages.business.hero.readDocs")}
-
+ -
- Vortex integration with MetaMask wallet showing EUR cryptocurrency transaction interface -
+ + Vortex integration with MetaMask wallet showing EUR cryptocurrency transaction interface + + +
diff --git a/apps/frontend/src/sections/individuals/Hero/index.tsx b/apps/frontend/src/sections/individuals/Hero/index.tsx index 34900addfc..b6bf02bd0d 100644 --- a/apps/frontend/src/sections/individuals/Hero/index.tsx +++ b/apps/frontend/src/sections/individuals/Hero/index.tsx @@ -36,7 +36,7 @@ export const Hero = () => {
- + { {t("pages.main.hero.buyAndSellCrypto")} - + = Object.values(FiatToken) .map(name => ({ @@ -60,8 +61,8 @@ const NetworkBadge = ({ network, isAnimating }: { network: Networks; isAnimating }; const TokenBadge = ({ token, isAnimating }: { token: { name: string; assetIcon: string }; isAnimating: boolean }) => { - const icon = useGetAssetIcon(token.assetIcon); - return ; + const { iconSrc } = useTokenIcon(token.assetIcon); + return ; }; export function PopularTokens() { diff --git a/apps/frontend/src/services/tokens/index.ts b/apps/frontend/src/services/tokens/index.ts index c47983c77a..537dce7d3d 100644 --- a/apps/frontend/src/services/tokens/index.ts +++ b/apps/frontend/src/services/tokens/index.ts @@ -6,8 +6,5 @@ export { getAllEvmTokens, getEvmTokenConfig, getEvmTokensForNetwork, - getLoadingError, - initializeEvmTokens, - isTokensLoaded, - usedFallbackConfig + initializeEvmTokens } from "@vortexfi/shared"; diff --git a/apps/frontend/src/stores/quote/useQuoteStore.ts b/apps/frontend/src/stores/quote/useQuoteStore.ts index 4ef47cf17f..9cf217466d 100644 --- a/apps/frontend/src/stores/quote/useQuoteStore.ts +++ b/apps/frontend/src/stores/quote/useQuoteStore.ts @@ -77,6 +77,7 @@ const friendlyErrorMessages: Record = { [QuoteError.InputAmountTooLowToCoverCalculatedFees]: "pages.swap.error.tryLargerAmount", [QuoteError.BelowLowerLimitSell]: QuoteError.BelowLowerLimitSell, // We leave this as-is, as the replacement string depends on the context [QuoteError.BelowLowerLimitBuy]: QuoteError.BelowLowerLimitBuy, // We leave this as-is, as the replacement string depends on the context + [QuoteError.LowLiquidity]: "pages.swap.error.lowLiquidity", // Calculation failures - suggest different amount [QuoteError.UnableToGetPendulumTokenDetails]: "pages.swap.error.tryDifferentAmount", [QuoteError.FailedToCalculateQuote]: "pages.swap.error.tryDifferentAmount", diff --git a/apps/frontend/src/stories/Accordion.stories.tsx b/apps/frontend/src/stories/Accordion.stories.tsx new file mode 100644 index 0000000000..6fdf5e1b53 --- /dev/null +++ b/apps/frontend/src/stories/Accordion.stories.tsx @@ -0,0 +1,193 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "../components/Accordion"; + +interface StoryArgs { + defaultValue?: string[]; + itemCount?: number; +} + +const faqItems = [ + { + answer: + "Vortex is a cross-border payments gateway built on the Pendulum blockchain. It enables on-ramping and off-ramping of fiat currencies through stablecoins using cross-chain swaps.", + question: "What is Vortex?", + value: "item-1" + }, + { + answer: + "You can buy crypto using PIX (Brazilian instant payment system) or other supported payment methods. Simply enter the amount you want to buy, provide your details, and complete the payment.", + question: "How do I buy crypto?", + value: "item-2" + }, + { + answer: + "Vortex supports multiple stablecoins including USDC, USDT, and BRZ (Brazilian Real stablecoin). We're constantly adding support for more tokens.", + question: "What currencies are supported?", + value: "item-3" + }, + { + answer: + "Transaction times vary depending on the payment method and network conditions. PIX transactions typically complete within minutes, while cross-chain transfers may take a few minutes longer.", + question: "How long do transactions take?", + value: "item-4" + }, + { + answer: + "Yes, Vortex uses industry-standard security practices including encryption, secure key management, and integration with trusted payment partners. Your funds and data are protected at all times.", + question: "Is Vortex secure?", + value: "item-5" + } +]; + +const AccordionWrapper = ({ defaultValue = [], itemCount = 3 }: StoryArgs) => { + const items = faqItems.slice(0, itemCount); + + return ( +
+ + {items.map(item => ( + + {item.question} + {item.answer} + + ))} + +
+ ); +}; + +const InteractiveDemo = () => { + const [openItems] = useState(["item-1"]); + + return ( +
+
+

Currently open items: {openItems.length > 0 ? openItems.join(", ") : "None"}

+
+ + {faqItems.map(item => ( + + {item.question} + {item.answer} + + ))} + +
+ ); +}; + +const meta: Meta = { + argTypes: { + defaultValue: { + control: "object", + description: "Array of item values that should be open by default" + }, + itemCount: { + control: { max: 5, min: 1, step: 1, type: "range" }, + description: "Number of accordion items to display" + } + }, + component: AccordionWrapper, + parameters: { + docs: { + description: { + component: + "An accessible accordion component with smooth expand/collapse animations. Features reduced motion support for accessibility and uses GPU-accelerated animations via grid-template-rows instead of height transitions." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/Accordion" +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + defaultValue: [], + itemCount: 3 + }, + parameters: { + docs: { + description: { + story: "Default accordion with all items collapsed. Click on any item to expand it." + } + } + }, + render: AccordionWrapper +}; + +export const SingleItemOpen: Story = { + args: { + defaultValue: ["item-1"], + itemCount: 3 + }, + parameters: { + docs: { + description: { + story: "Accordion with the first item expanded by default." + } + } + }, + render: AccordionWrapper +}; + +export const MultipleItemsOpen: Story = { + args: { + defaultValue: ["item-1", "item-3"], + itemCount: 5 + }, + parameters: { + docs: { + description: { + story: "Accordion with multiple items expanded simultaneously." + } + } + }, + render: AccordionWrapper +}; + +export const AllItems: Story = { + args: { + defaultValue: [], + itemCount: 5 + }, + parameters: { + docs: { + description: { + story: "Full FAQ accordion with all 5 items available." + } + } + }, + render: AccordionWrapper +}; + +export const Interactive: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo showing the accordion state changes. Open/close items to see the state update." + } + } + }, + render: InteractiveDemo +}; + +export const ReducedMotion: Story = { + args: { + defaultValue: ["item-1"], + itemCount: 3 + }, + parameters: { + docs: { + description: { + story: + "Test reduced motion by enabling 'prefers-reduced-motion: reduce' in your browser DevTools (Rendering panel). Animations will be disabled for accessibility." + } + } + }, + render: AccordionWrapper +}; diff --git a/apps/frontend/src/stories/CollapsibleCard.stories.tsx b/apps/frontend/src/stories/CollapsibleCard.stories.tsx new file mode 100644 index 0000000000..258a044fef --- /dev/null +++ b/apps/frontend/src/stories/CollapsibleCard.stories.tsx @@ -0,0 +1,234 @@ +import { ChevronDownIcon } from "@heroicons/react/24/outline"; +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { CollapsibleCard, CollapsibleDetails, CollapsibleSummary, useCollapsibleCard } from "../components/CollapsibleCard"; + +interface StoryArgs { + defaultExpanded?: boolean; + showToggleButton?: boolean; +} + +const ToggleButton = () => { + const { isExpanded, toggle } = useCollapsibleCard(); + + return ( + + ); +}; + +const CollapsibleCardWrapper = ({ defaultExpanded = false }: StoryArgs) => { + return ( +
+ + +
+

Transaction Summary

+

Click to view details

+
+ +
+ +
+
+ Amount + 100 USDC +
+
+ Fee + 0.5 USDC +
+
+ Network + Polkadot +
+
+ Estimated Time + ~2 minutes +
+
+
+
+
+ ); +}; + +const InteractiveDemo = () => { + const [isExpanded, setIsExpanded] = useState(false); + const [toggleCount, setToggleCount] = useState(0); + + const handleToggle = (expanded: boolean) => { + setIsExpanded(expanded); + setToggleCount(prev => prev + 1); + }; + + return ( +
+
+

State: {isExpanded ? "Expanded" : "Collapsed"}

+

Toggle count: {toggleCount}

+
+ + +
+

Quote Details

+

Your exchange rate and fees

+
+ +
+ +
+
+ You send + 500 BRL +
+
+ Exchange rate + 1 USDC = 5.02 BRL +
+
+ You receive + ~99.60 USDC +
+
+
+
+
+ ); +}; + +const MultipleCardsDemo = () => { + return ( +
+ + +
+

Step 1: Connect Wallet

+
+ +
+ +

Connect your Polkadot wallet to get started with the transaction.

+
+
+ + + +
+

Step 2: Enter Details

+
+ +
+ +

Enter your payment details including the amount and recipient information.

+
+
+ + + +
+

Step 3: Confirm

+
+ +
+ +

Review and confirm your transaction before submitting.

+
+
+
+ ); +}; + +const meta: Meta = { + argTypes: { + defaultExpanded: { + control: "boolean", + description: "Whether the card should be expanded by default" + } + }, + component: CollapsibleCardWrapper, + parameters: { + docs: { + description: { + component: + "A collapsible card component with smooth expand/collapse animations. Uses GPU-accelerated grid-template-rows animation instead of height for better performance. Supports reduced motion for accessibility." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/CollapsibleCard" +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + defaultExpanded: false + }, + parameters: { + docs: { + description: { + story: "Default collapsed state. Click the toggle button to expand and see the details." + } + } + }, + render: CollapsibleCardWrapper +}; + +export const Expanded: Story = { + args: { + defaultExpanded: true + }, + parameters: { + docs: { + description: { + story: "Card expanded by default showing all details." + } + } + }, + render: CollapsibleCardWrapper +}; + +export const Interactive: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo with state tracking. Watch the state change as you toggle the card." + } + } + }, + render: InteractiveDemo +}; + +export const MultipleCards: Story = { + parameters: { + docs: { + description: { + story: "Multiple collapsible cards demonstrating independent expand/collapse behavior." + } + } + }, + render: MultipleCardsDemo +}; + +export const ReducedMotion: Story = { + args: { + defaultExpanded: false + }, + parameters: { + docs: { + description: { + story: + "Test reduced motion support by enabling 'prefers-reduced-motion: reduce' in browser DevTools. The expand/collapse animation will be instant." + } + } + }, + render: CollapsibleCardWrapper +}; diff --git a/apps/frontend/src/stories/KycLevel2Toggle.stories.tsx b/apps/frontend/src/stories/KycLevel2Toggle.stories.tsx new file mode 100644 index 0000000000..a87969263f --- /dev/null +++ b/apps/frontend/src/stories/KycLevel2Toggle.stories.tsx @@ -0,0 +1,182 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AveniaDocumentType } from "@vortexfi/shared"; +import { useState } from "react"; +import { KycLevel2Toggle } from "../components/KycLevel2Toggle"; + +interface StoryArgs { + activeDocType?: AveniaDocumentType; +} + +const KycLevel2ToggleWrapper = ({ activeDocType = AveniaDocumentType.ID }: StoryArgs) => { + const [docType, setDocType] = useState(activeDocType); + + return ( +
+ +
+ ); +}; + +const InteractiveDemo = () => { + const [docType, setDocType] = useState(AveniaDocumentType.ID); + const [toggleCount, setToggleCount] = useState(0); + + const handleToggle = (newDocType: AveniaDocumentType) => { + setDocType(newDocType); + setToggleCount(prev => prev + 1); + }; + + return ( +
+
+

+ Selected document: {docType === AveniaDocumentType.ID ? "RG (ID Card)" : "CNH (Driver's License)"} +

+

Toggle count: {toggleCount}

+
+ +
+ ); +}; + +interface DocumentInfo { + description: string; + icon: string; + instructions: string[]; + title: string; +} + +const KycFlowDemo = () => { + const [docType, setDocType] = useState(AveniaDocumentType.ID); + + const documentInfo: Record = { + [AveniaDocumentType.ID]: { + description: "Brazilian national identity card (Registro Geral)", + icon: "RG", + instructions: ["Front side of the document", "Back side of the document", "Must be valid and not expired"], + title: "Identity Card (RG)" + }, + [AveniaDocumentType.DRIVERS_LICENSE]: { + description: "Brazilian driver's license (Carteira Nacional de Habilitacao)", + icon: "CNH", + instructions: ["Front side of the license", "Back side of the license", "Must be valid and not expired"], + title: "Driver's License (CNH)" + } + }; + + const info = documentInfo[docType]; + + return ( +
+

Document Verification

+

Select your document type for KYC Level 2 verification

+ + + +
+

{info.title}

+

{info.description}

+

Required photos:

+
    + {info.instructions.map((instruction, index) => ( +
  • {instruction}
  • + ))} +
+
+ + +
+ ); +}; + +const meta: Meta = { + argTypes: { + activeDocType: { + control: "select", + description: "Currently selected document type", + options: [AveniaDocumentType.ID, AveniaDocumentType.DRIVERS_LICENSE] + } + }, + component: KycLevel2ToggleWrapper, + parameters: { + docs: { + description: { + component: + "A toggle component for selecting between Brazilian document types (RG or CNH) during KYC Level 2 verification. Features smooth spring animation for the indicator and supports reduced motion preferences." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/KycLevel2Toggle" +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + activeDocType: AveniaDocumentType.ID + }, + parameters: { + docs: { + description: { + story: "Default toggle with RG (Identity Card) selected." + } + } + }, + render: KycLevel2ToggleWrapper +}; + +export const DriversLicenseSelected: Story = { + args: { + activeDocType: AveniaDocumentType.DRIVERS_LICENSE + }, + parameters: { + docs: { + description: { + story: "Toggle with CNH (Driver's License) selected." + } + } + }, + render: KycLevel2ToggleWrapper +}; + +export const Interactive: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo with state tracking. Watch the indicator smoothly animate between options." + } + } + }, + render: InteractiveDemo +}; + +export const KycFlow: Story = { + parameters: { + docs: { + description: { + story: "Real-world example showing the toggle integrated into a KYC verification flow." + } + } + }, + render: KycFlowDemo +}; + +export const ReducedMotion: Story = { + args: { + activeDocType: AveniaDocumentType.ID + }, + parameters: { + docs: { + description: { + story: + "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions." + } + } + }, + render: KycLevel2ToggleWrapper +}; diff --git a/apps/frontend/src/stories/Menu.stories.tsx b/apps/frontend/src/stories/Menu.stories.tsx new file mode 100644 index 0000000000..e2ff8ae469 --- /dev/null +++ b/apps/frontend/src/stories/Menu.stories.tsx @@ -0,0 +1,239 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { Menu, MenuAnimationDirection } from "../components/menus/Menu"; + +interface StoryArgs { + animationDirection?: MenuAnimationDirection; + title?: string; +} + +const MenuWrapper = ({ animationDirection = MenuAnimationDirection.RIGHT, title = "Menu" }: StoryArgs) => { + const [isOpen, setIsOpen] = useState(true); + + return ( +
+
+ Main Content + +
+ +
+

This is the main content area. The menu will slide over this content.

+
+ + setIsOpen(false)} title={title}> +
+ + + +
+
+
+ ); +}; + +const DirectionDemo = () => { + const [direction, setDirection] = useState(MenuAnimationDirection.RIGHT); + const [isOpen, setIsOpen] = useState(false); + + return ( +
+
+ + +
+ +
+
+ Main Content + +
+ +
+

Current direction: {direction}

+
+ + setIsOpen(false)} title="Settings"> +
+
+ + +
+
+ + +
+
+
+
+
+ ); +}; + +const TokenSelectionDemo = () => { + const [isOpen, setIsOpen] = useState(false); + const tokens = [ + { balance: "1,234.56", name: "USDC", network: "Polkadot" }, + { balance: "567.89", name: "USDT", network: "Ethereum" }, + { balance: "100.00", name: "BRZ", network: "Stellar" } + ]; + + return ( +
+
+ +
+ + setIsOpen(false)} + title="Select Token" + > +
+ {tokens.map(token => ( + + ))} +
+
+
+ ); +}; + +const meta: Meta = { + argTypes: { + animationDirection: { + control: "select", + description: "Direction from which the menu slides in", + options: [MenuAnimationDirection.RIGHT, MenuAnimationDirection.TOP] + }, + title: { + control: "text", + description: "Title displayed in the menu header" + } + }, + component: MenuWrapper, + parameters: { + docs: { + description: { + component: + "A sliding overlay menu component with directional animations. Supports slide-in from right or top with smooth easeOut curves. Features escape key support and reduced motion accessibility." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/Menu" +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + animationDirection: MenuAnimationDirection.RIGHT, + title: "Menu" + }, + parameters: { + docs: { + description: { + story: "Default menu sliding in from the right. Press Escape or click the close button to dismiss." + } + } + }, + render: MenuWrapper +}; + +export const FromTop: Story = { + args: { + animationDirection: MenuAnimationDirection.TOP, + title: "Dropdown Menu" + }, + parameters: { + docs: { + description: { + story: "Menu sliding in from the top, useful for dropdown-style menus." + } + } + }, + render: MenuWrapper +}; + +export const DirectionComparison: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo comparing different animation directions. Select a direction and open the menu." + } + } + }, + render: DirectionDemo +}; + +export const TokenSelection: Story = { + parameters: { + docs: { + description: { + story: "Real-world example showing the menu used for token selection in a swap interface." + } + } + }, + render: TokenSelectionDemo +}; + +export const ReducedMotion: Story = { + args: { + animationDirection: MenuAnimationDirection.RIGHT, + title: "Accessible Menu" + }, + parameters: { + docs: { + description: { + story: + "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions." + } + } + }, + render: MenuWrapper +}; diff --git a/apps/frontend/src/stories/MobileMenu.stories.tsx b/apps/frontend/src/stories/MobileMenu.stories.tsx new file mode 100644 index 0000000000..a16f72aa92 --- /dev/null +++ b/apps/frontend/src/stories/MobileMenu.stories.tsx @@ -0,0 +1,197 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AnimatePresence } from "motion/react"; +import { useState } from "react"; +import { MobileMenu } from "../components/Navbar/MobileMenu"; + +interface StoryArgs { + isOpen?: boolean; +} + +const MobileMenuWrapper = ({ isOpen = true }: StoryArgs) => { + const [menuOpen, setMenuOpen] = useState(isOpen); + + const handleMenuItemClick = () => { + setMenuOpen(false); + }; + + return ( +
+ {/* Mock navbar header */} +
+ Vortex + +
+ + {/* Mobile menu with animation */} + {menuOpen && } +
+ ); +}; + +const InteractiveDemo = () => { + const [isOpen, setIsOpen] = useState(false); + const [clickedItem, setClickedItem] = useState(null); + + const handleMenuItemClick = () => { + setClickedItem("Menu item clicked!"); + setIsOpen(false); + setTimeout(() => setClickedItem(null), 2000); + }; + + return ( +
+
+

Menu state: {isOpen ? "Open" : "Closed"}

+ {clickedItem &&

{clickedItem}

} +
+ +
+
+ Vortex + +
+ + {isOpen && } +
+
+ ); +}; + +const AnimationShowcaseDemo = () => { + const [isOpen, setIsOpen] = useState(false); + const [speed, setSpeed] = useState<"normal" | "slow">("normal"); + + return ( +
+
+ + +
+

+ To see the animation in slow motion, open DevTools → Rendering → check "Emulate CSS media feature + prefers-reduced-motion" +

+ +
+
+ Vortex + +
+ + {isOpen && setIsOpen(false)} />} +
+
+ ); +}; + +const meta: Meta = { + argTypes: { + isOpen: { + control: "boolean", + description: "Whether the mobile menu is initially open" + } + }, + component: MobileMenuWrapper, + parameters: { + docs: { + description: { + component: + "Mobile navigation menu with staggered entrance animations. Features smooth slide-in animations for menu items and respects reduced motion preferences for accessibility. Uses easeOut curves for responsive feel." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/MobileMenu" +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isOpen: true + }, + parameters: { + docs: { + description: { + story: "Mobile menu in its open state showing navigation links and call-to-action button." + } + } + }, + render: MobileMenuWrapper +}; + +export const Closed: Story = { + args: { + isOpen: false + }, + parameters: { + docs: { + description: { + story: "Mobile menu in closed state. Click the button to open and see the entrance animation." + } + } + }, + render: MobileMenuWrapper +}; + +export const Interactive: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo with state tracking. Toggle the menu to see smooth entrance/exit animations." + } + } + }, + render: InteractiveDemo +}; + +export const AnimationShowcase: Story = { + parameters: { + docs: { + description: { + story: + "Showcase the staggered animation effect. Open the menu multiple times to observe how menu items animate in sequence." + } + } + }, + render: AnimationShowcaseDemo +}; + +export const ReducedMotion: Story = { + args: { + isOpen: false + }, + parameters: { + docs: { + description: { + story: + "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions instead of animations." + } + } + }, + render: MobileMenuWrapper +}; diff --git a/apps/frontend/src/stories/NetworkSelectionAnimations.stories.tsx b/apps/frontend/src/stories/NetworkSelectionAnimations.stories.tsx new file mode 100644 index 0000000000..96fe25b4eb --- /dev/null +++ b/apps/frontend/src/stories/NetworkSelectionAnimations.stories.tsx @@ -0,0 +1,287 @@ +import { ChevronDownIcon } from "@heroicons/react/24/outline"; +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { SelectionButtonMotion } from "../components/TokenSelection/NetworkSelectionList/animations/SelectionButtonMotion"; +import { SelectionChevronMotion } from "../components/TokenSelection/NetworkSelectionList/animations/SelectionChevronMotion"; +import { SelectionDropdownMotion } from "../components/TokenSelection/NetworkSelectionList/animations/SelectionDropdownMotion"; + +const networks = [ + { icon: "polkadot.svg", id: "polkadot", name: "Polkadot" }, + { icon: "ethereum.svg", id: "ethereum", name: "Ethereum" }, + { icon: "stellar.svg", id: "stellar", name: "Stellar" }, + { icon: "moonbeam.svg", id: "moonbeam", name: "Moonbeam" } +]; + +const SelectionDropdownDemo = () => { + const [isOpen, setIsOpen] = useState(false); + const [selected, setSelected] = useState(networks[0]); + + return ( +
+ + + +
+ {networks.map(network => ( + + ))} +
+
+
+ ); +}; + +const SelectionButtonDemo = () => { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+
+ setIsExpanded(!isExpanded)} + > + {isExpanded ? "Full width button - click to collapse" : "Click"} + + + {!isExpanded && ( +
+ Other content +
+ )} +
+ +

The button animates between 10% and 100% width. Click to toggle.

+
+ ); +}; + +const SelectionChevronDemo = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + +

Chevron rotates 180° when {isOpen ? "open" : "closed"}

+
+ ); +}; + +const NetworkDropdownDemo = () => { + const [isOpen, setIsOpen] = useState(false); + const [selectedNetwork, setSelectedNetwork] = useState(networks[0]); + + return ( +
+
+

Select Network

+ +
+ + + +
+ {networks.map(network => ( + + ))} +
+
+
+
+
+ ); +}; + +const AllAnimationsDemo = () => { + const [isExpanded, setIsExpanded] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); + + return ( +
+

All Selection Animations

+ +
+

1. SelectionButtonMotion

+ setIsExpanded(!isExpanded)} + > + {isExpanded ? "Expanded - Click to Collapse" : "Expand"} + +
+ +
+

2. SelectionChevronMotion

+
+ +
+
+ +
+

3. SelectionDropdownMotion

+ +
+

Dropdown Content

+

This content smoothly expands using grid-template-rows animation.

+
+
+
+
+ ); +}; + +const meta: Meta = { + parameters: { + docs: { + description: { + component: + "A collection of animation components used in the network/token selection interface. Includes:\n\n" + + "- **SelectionDropdownMotion**: Smooth expand/collapse using GPU-accelerated grid-template-rows\n" + + "- **SelectionButtonMotion**: Width animation with easeOut curve\n" + + "- **SelectionChevronMotion**: 180° rotation animation for dropdown indicators\n\n" + + "All components support reduced motion preferences for accessibility." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/NetworkSelection" +}; + +export default meta; +type Story = StoryObj; + +export const Dropdown: Story = { + parameters: { + docs: { + description: { + story: "SelectionDropdownMotion with SelectionChevronMotion combined for a complete dropdown experience." + } + } + }, + render: SelectionDropdownDemo +}; + +export const Button: Story = { + parameters: { + docs: { + description: { + story: "SelectionButtonMotion demonstrates width animation between collapsed (10%) and expanded (100%) states." + } + } + }, + render: SelectionButtonDemo +}; + +export const Chevron: Story = { + parameters: { + docs: { + description: { + story: "SelectionChevronMotion provides smooth 180° rotation for dropdown indicators." + } + } + }, + render: SelectionChevronDemo +}; + +export const NetworkDropdown: Story = { + parameters: { + docs: { + description: { + story: "Real-world example showing all three animation components working together in a network selector." + } + } + }, + render: NetworkDropdownDemo +}; + +export const AllAnimations: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo showcasing all three animation components side by side." + } + } + }, + render: AllAnimationsDemo +}; + +export const ReducedMotion: Story = { + parameters: { + docs: { + description: { + story: + "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions instead of animations." + } + } + }, + render: AllAnimationsDemo +}; diff --git a/apps/frontend/src/stories/RampToggle.stories.tsx b/apps/frontend/src/stories/RampToggle.stories.tsx new file mode 100644 index 0000000000..72368ac048 --- /dev/null +++ b/apps/frontend/src/stories/RampToggle.stories.tsx @@ -0,0 +1,182 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { RampDirection } from "@vortexfi/shared"; +import { useState } from "react"; +import { RampToggle } from "../components/RampToggle"; + +interface StoryArgs { + activeDirection?: RampDirection; +} + +const RampToggleWrapper = ({ activeDirection = RampDirection.BUY }: StoryArgs) => { + const [direction, setDirection] = useState(activeDirection); + + return ( +
+ +
+ ); +}; + +const InteractiveDemo = () => { + const [direction, setDirection] = useState(RampDirection.BUY); + const [toggleCount, setToggleCount] = useState(0); + + const handleToggle = (newDirection: RampDirection) => { + setDirection(newDirection); + setToggleCount(prev => prev + 1); + }; + + return ( +
+
+

Current direction: {direction === RampDirection.BUY ? "Buy" : "Sell"}

+

Toggle count: {toggleCount}

+
+ +
+ {direction === RampDirection.BUY ? ( +
+

Buy Crypto

+

Convert fiat currency to cryptocurrency

+
+ ) : ( +
+

Sell Crypto

+

Convert cryptocurrency to fiat currency

+
+ )} +
+
+ ); +}; + +const SwapInterfaceDemo = () => { + const [direction, setDirection] = useState(RampDirection.BUY); + const [amount, setAmount] = useState("100"); + + return ( +
+ + +
+
+ +
+ setAmount(e.target.value)} + type="text" + value={amount} + /> + {direction === RampDirection.BUY ? "BRL" : "USDC"} +
+
+ +
+ +
+ {(parseFloat(amount || "0") / 5).toFixed(2)} + {direction === RampDirection.BUY ? "USDC" : "BRL"} +
+
+ + +
+
+ ); +}; + +const meta: Meta = { + argTypes: { + activeDirection: { + control: "select", + description: "Currently active ramp direction", + options: [RampDirection.BUY, RampDirection.SELL] + } + }, + component: RampToggleWrapper, + parameters: { + docs: { + description: { + component: + "A toggle component for switching between Buy and Sell modes in the ramp interface. Features a smooth spring animation for the indicator and respects reduced motion preferences." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/RampToggle" +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + activeDirection: RampDirection.BUY + }, + parameters: { + docs: { + description: { + story: "Default toggle with Buy selected. Click to switch between Buy and Sell." + } + } + }, + render: RampToggleWrapper +}; + +export const SellActive: Story = { + args: { + activeDirection: RampDirection.SELL + }, + parameters: { + docs: { + description: { + story: "Toggle with Sell selected." + } + } + }, + render: RampToggleWrapper +}; + +export const Interactive: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo showing how the toggle updates the UI based on the selected direction." + } + } + }, + render: InteractiveDemo +}; + +export const SwapInterface: Story = { + parameters: { + docs: { + description: { + story: + "Real-world example showing the toggle integrated into a swap interface. Notice how the input/output labels and button text change based on the direction." + } + } + }, + render: SwapInterfaceDemo +}; + +export const ReducedMotion: Story = { + args: { + activeDirection: RampDirection.BUY + }, + parameters: { + docs: { + description: { + story: + "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions instead of the spring animation." + } + } + }, + render: RampToggleWrapper +}; diff --git a/apps/frontend/src/stories/TermsAndConditions.stories.tsx b/apps/frontend/src/stories/TermsAndConditions.stories.tsx new file mode 100644 index 0000000000..c09f2948d7 --- /dev/null +++ b/apps/frontend/src/stories/TermsAndConditions.stories.tsx @@ -0,0 +1,289 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { TermsAndConditions } from "../components/TermsAndConditions"; + +interface StoryArgs { + termsChecked?: boolean; + termsAccepted?: boolean; + termsError?: boolean; +} + +const TermsAndConditionsWrapper = ({ termsChecked = false, termsAccepted = false, termsError = false }: StoryArgs) => { + const [checked, setChecked] = useState(termsChecked); + const [error, setError] = useState(termsError); + const [accepted, setAccepted] = useState(termsAccepted); + + return ( +
+ setChecked(!checked)} + /> + {!accepted && ( +
+ + {!checked && ( + + )} +
+ )} +
+ ); +}; + +const InteractiveDemo = () => { + const [checked, setChecked] = useState(false); + const [error, setError] = useState(false); + const [accepted, setAccepted] = useState(false); + + const handleContinue = () => { + if (!checked) { + setError(true); + return; + } + setAccepted(true); + }; + + const handleReset = () => { + setChecked(false); + setError(false); + setAccepted(false); + }; + + return ( +
+
+

+ State: {accepted ? "Accepted" : checked ? "Checked" : error ? "Error" : "Unchecked"} +

+
+ + { + setChecked(!checked); + setError(false); + }} + /> + + {accepted ? ( +
+

Terms Accepted!

+ +
+ ) : ( + + )} +
+ ); +}; + +const CheckoutFlowDemo = () => { + const [checked, setChecked] = useState(false); + const [error, setError] = useState(false); + const [accepted, setAccepted] = useState(false); + + return ( +
+

Complete Your Order

+ +
+
+ Amount + 100 USDC +
+
+ Fee + 0.50 USDC +
+
+ Total + 100.50 USDC +
+
+ + { + setChecked(!checked); + setError(false); + }} + /> + + {accepted ? ( +
+

Order Confirmed!

+
+ ) : ( + + )} +
+ ); +}; + +const meta: Meta = { + argTypes: { + termsAccepted: { + control: "boolean", + description: "Whether the terms have been accepted (hides the checkbox)" + }, + termsChecked: { + control: "boolean", + description: "Whether the checkbox is checked" + }, + termsError: { + control: "boolean", + description: "Whether to show the error state" + } + }, + component: TermsAndConditionsWrapper, + parameters: { + docs: { + description: { + component: + "A terms and conditions checkbox component with animated error states and fade-out on acceptance. Features a subtle scale animation on error and supports reduced motion preferences." + } + }, + layout: "centered" + }, + tags: ["autodocs"], + title: "Components/TermsAndConditions" +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + termsAccepted: false, + termsChecked: false, + termsError: false + }, + parameters: { + docs: { + description: { + story: "Default unchecked state. Check the box before continuing." + } + } + }, + render: TermsAndConditionsWrapper +}; + +export const Checked: Story = { + args: { + termsAccepted: false, + termsChecked: true, + termsError: false + }, + parameters: { + docs: { + description: { + story: "Checkbox in checked state, ready to continue." + } + } + }, + render: TermsAndConditionsWrapper +}; + +export const Error: Story = { + args: { + termsAccepted: false, + termsChecked: false, + termsError: true + }, + parameters: { + docs: { + description: { + story: "Error state shown when user tries to continue without accepting terms." + } + } + }, + render: TermsAndConditionsWrapper +}; + +export const Accepted: Story = { + args: { + termsAccepted: true, + termsChecked: true, + termsError: false + }, + parameters: { + docs: { + description: { + story: "Accepted state - the checkbox fades out with a scale animation." + } + } + }, + render: TermsAndConditionsWrapper +}; + +export const Interactive: Story = { + parameters: { + docs: { + description: { + story: "Interactive demo showing the full flow: unchecked -> error -> checked -> accepted." + } + } + }, + render: InteractiveDemo +}; + +export const CheckoutFlow: Story = { + parameters: { + docs: { + description: { + story: "Real-world example showing the terms checkbox in a checkout/confirmation flow." + } + } + }, + render: CheckoutFlowDemo +}; + +export const ReducedMotion: Story = { + args: { + termsAccepted: false, + termsChecked: false, + termsError: false + }, + parameters: { + docs: { + description: { + story: + "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' to see instant transitions instead of animations." + } + } + }, + render: TermsAndConditionsWrapper +}; diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 297266b693..8f3c31ff42 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -198,7 +198,7 @@ "title": "Connect wallet" }, "polkadotWalletSelectorDialogLoading": { - "description": "Please approve {selectedWallet} and approve the transaction.", + "description": "Please approve {{selectedWallet}} and approve the transaction.", "title": "Connecting wallet" }, "selectionModal": { @@ -880,6 +880,7 @@ "brlaOnrampMint": "Your payment is being processed. This can take up to 5 minutes.", "closeProgressScreenText": "You’re all set! You can now close this tab or grab a coffee while we finish up in the background.", "createStellarAccount": "Creating Stellar account", + "destinationTransfer": "Transferring {{assetSymbol}} to your wallet", "estimatedTimeAssetHub": "This usually takes 4-6 minutes.", "estimatedTimeEVM": "This usually takes 6-8 minutes.", "executeSpacewalkRedeem": "Bridging {{assetSymbol}} to Stellar via Spacewalk", @@ -943,6 +944,7 @@ "buy": "Minimum buy amount is {{minAmountUnits}} {{assetSymbol}}.", "sell": "Minimum sell amount is {{minAmountUnits}} {{assetSymbol}}." }, + "lowLiquidity": "Low liquidity for this route. Please try a smaller amount.", "missingFields": "Missing required fields", "moreThanMaximumWithdrawal": { "buy": "Maximum buy amount is {{maxAmountUnits}} {{assetSymbol}}.", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 0734d69cde..b8029d8f27 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -199,7 +199,7 @@ "title": "Conectar wallet" }, "polkadotWalletSelectorDialogLoading": { - "description": "Por favor, aprove {selectedWallet} e aprove a transação.", + "description": "Por favor, aprove {{selectedWallet}} e aprove a transação.", "title": "Conectando wallet" }, "selectionModal": { @@ -874,6 +874,7 @@ "brlaOnrampMint": "Seu pagamento está sendo processado. Isso pode levar até 5 minutos.", "closeProgressScreenText": "Tudo pronto! Você já pode fechar esta aba ou pegar um café enquanto finalizamos o processo em segundo plano.", "createStellarAccount": "Criando conta Stellar", + "destinationTransfer": "Transferindo {{assetSymbol}} para sua carteira", "estimatedTimeAssetHub": "Isso geralmente leva de 4 a 6 minutos.", "estimatedTimeEVM": "Isso geralmente leva de 6 a 8 minutos.", "executeSpacewalkRedeem": "Transferindo {{assetSymbol}} para Stellar via Spacewalk", @@ -937,6 +938,7 @@ "buy": "O valor mínimo de compra é {{minAmountUnits}} {{assetSymbol}}.", "sell": "O valor mínimo de venda é {{minAmountUnits}} {{assetSymbol}}." }, + "lowLiquidity": "Baixa liquidez para esta rota. Por favor, tente um valor menor.", "missingFields": "Campos obrigatórios ausentes", "moreThanMaximumWithdrawal": { "buy": "O valor máximo de compra é {{maxAmountUnits}} {{assetSymbol}}.", diff --git a/bun.lock b/bun.lock index dd9202fa05..a6cb24a372 100644 --- a/bun.lock +++ b/bun.lock @@ -235,7 +235,7 @@ }, "packages/sdk": { "name": "@vortexfi/sdk", - "version": "0.4.10", + "version": "0.5.1", "dependencies": { "@vortexfi/shared": "workspace:*", }, @@ -251,7 +251,7 @@ }, "packages/shared": { "name": "@vortexfi/shared", - "version": "0.0.11", + "version": "0.1.1", "dependencies": { "@paraspell/sdk-pjs": "^11.8.5", "@pendulum-chain/api-solang": "catalog:", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index aad00c1504..cf4b44413a 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -44,5 +44,5 @@ }, "type": "module", "types": "./dist/index.d.ts", - "version": "0.4.10" + "version": "0.5.1" } diff --git a/packages/sdk/src/services/NetworkManager.ts b/packages/sdk/src/services/NetworkManager.ts index 6f723850cc..f28e0bbb83 100644 --- a/packages/sdk/src/services/NetworkManager.ts +++ b/packages/sdk/src/services/NetworkManager.ts @@ -59,7 +59,7 @@ export class NetworkManager { } getAlchemyApiKey(): string | undefined { - return this.config.alchemyApiKey; + return "9nk8Nf7Eaz_4smCzIcPUk"; } private async initializeApis(): Promise { diff --git a/packages/shared/package.json b/packages/shared/package.json index db496203e6..dbf3aaf73e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -53,5 +53,5 @@ "prepublishOnly": "bun run build" }, "types": "./dist/index.d.ts", - "version": "0.0.11" + "version": "0.1.1" } diff --git a/packages/shared/src/endpoints/quote.endpoints.ts b/packages/shared/src/endpoints/quote.endpoints.ts index 265fe05507..df98166da2 100644 --- a/packages/shared/src/endpoints/quote.endpoints.ts +++ b/packages/shared/src/endpoints/quote.endpoints.ts @@ -95,6 +95,7 @@ export enum QuoteError { InputAmountForSwapMustBeGreaterThanZero = "Input amount for swap must be greater than 0", InputAmountTooLow = "Input amount too low. Please try a larger amount.", InputAmountTooLowToCoverCalculatedFees = "Input amount too low to cover calculated fees.", + LowLiquidity = "Low liquidity for this route. Please try a smaller amount.", BelowLowerLimitSell = "Output amount below minimum SELL limit of", BelowLowerLimitBuy = "Input amount below minimum BUY limit of", diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index 31cc209480..6ce16a4b1d 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -10,6 +10,7 @@ export type RampPhase = | "squidRouterSwap" | "squidRouterPay" | "fundEphemeral" + | "destinationTransfer" | "nablaApprove" | "nablaSwap" | "hydrationSwap" @@ -29,6 +30,11 @@ export type RampPhase = | "brlaPayoutOnMoonbeam" | "failed" | "timedOut" + | "finalSettlementSubsidy" + | "destinationTransfer" + | "backupSquidRouterApprove" + | "backupSquidRouterSwap" + | "backupApprove" | "complete"; export type CleanupPhase = "moonbeamCleanup" | "pendulumCleanup" | "stellarCleanup"; diff --git a/packages/shared/src/helpers/signUnsigned.ts b/packages/shared/src/helpers/signUnsigned.ts index 1dea0ab334..cf47032d67 100644 --- a/packages/shared/src/helpers/signUnsigned.ts +++ b/packages/shared/src/helpers/signUnsigned.ts @@ -3,14 +3,13 @@ import { AddressOrPair } from "@polkadot/api/types"; import { hexToU8a } from "@polkadot/util"; import { cryptoWaitReady } from "@polkadot/util-crypto"; import { Keypair, Networks as StellarNetworks, Transaction } from "stellar-sdk"; -import { createWalletClient, http, WalletClient, webSocket } from "viem"; +import { createWalletClient, fallback, http, WalletClient } from "viem"; import { privateKeyToAccount } from "viem/accounts"; -import { moonbeam, polygon } from "viem/chains"; +import { arbitrum, avalanche, base, bsc, mainnet, moonbeam, polygon, polygonAmoy } from "viem/chains"; import { decodeSubmittableExtrinsic, EphemeralAccount, isEvmTransactionData, - MOONBEAM_WSS, Networks, PresignedTx, SANDBOX_ENABLED, @@ -44,12 +43,6 @@ export function addAdditionalTransactionsToMeta(primaryTx: PresignedTx, multiSig /** * Signs multiple Stellar transactions with increasing sequence numbers - * - * @param tx - The original backend-signed transaction. Can contain meta field with multiple-nonce transactions. - * @param keypair - The Stellar keypair to sign with - * @param networkPassphrase - The Stellar network passphrase - * @param startingNonce - The starting nonce/sequence number value - * @returns - Multi-nonce presigned transaction object. */ async function signMultipleStellarTransactions( tx: UnsignedTx, @@ -80,13 +73,7 @@ async function signMultipleStellarTransactions( } /** - * Signs multiple Substrate (Pendulum) transactions with increasing nonces - * - * @param tx - The original unsigned transaction - * @param keypair - The keypair to sign with - * @param api - The Polkadot API instance - * @param startingNonce - The starting nonce value - * @returns - Array of signed transactions with increasing nonces + * Signs multiple Substrate transactions with increasing nonces */ async function signMultipleSubstrateTransactions( tx: UnsignedTx, @@ -116,41 +103,73 @@ async function signMultipleSubstrateTransactions( } /** - * Creates wallet clients for both Moonbeam and Polygon networks using the same ephemeral secret + * Creates a wallet client for a specific EVM network using the ephemeral secret * - * @param moonbeamEphemeral - The ephemeral account containing the secret - * @param alchemyApiKey - Optional Alchemy API key for Polygon transport - * @returns Object containing both wallet clients + * @param network - The network enum to create the client for + * @param evmEphemeral - The ephemeral account containing the secret + * @param apiKey - Optional Alchemy API key + * @returns WalletClient for the specified network */ -function createEvmWalletClients( - moonbeamEphemeral: EphemeralAccount, - alchemyApiKey?: string -): { moonbeamClient: WalletClient; polygonClient: WalletClient } { - const privateKey = moonbeamEphemeral.secret as `0x${string}`; +function createEvmClient( + network: string, // Accept string to match UnsignedTx.network type usually being string/enum + evmEphemeral: EphemeralAccount, + apiKey?: string +): WalletClient { + const privateKey = evmEphemeral.secret as `0x${string}`; const evmAccount = privateKeyToAccount(privateKey); - const moonbeamClient = createWalletClient({ - account: evmAccount, - chain: moonbeam, - transport: alchemyApiKey ? http(`https://moonbeam-mainnet.g.alchemy.com/v2/${alchemyApiKey}`) : webSocket(MOONBEAM_WSS) - }); - const polygonTransport = alchemyApiKey ? http(`https://polygon-mainnet.g.alchemy.com/v2/${alchemyApiKey}`) : http(); - const polygonClient = createWalletClient({ + let chain; + let rpcUrls: string[] = []; + + switch (network) { + case Networks.Polygon: + chain = polygon; + rpcUrls = apiKey ? [`https://polygon-mainnet.g.alchemy.com/v2/${apiKey}`] : []; + break; + case Networks.PolygonAmoy: + chain = polygonAmoy; + rpcUrls = ["https://polygon-amoy.api.onfinality.io/public"]; + break; + case Networks.Moonbeam: + chain = moonbeam; + rpcUrls = ["https://rpc.api.moonbeam.network", "https://moonbeam-rpc.publicnode.com"]; + break; + case Networks.Arbitrum: + chain = arbitrum; + rpcUrls = apiKey ? [`https://arb-mainnet.g.alchemy.com/v2/${apiKey}`] : []; + break; + case Networks.Avalanche: + chain = avalanche; + rpcUrls = apiKey ? [`https://avax-mainnet.g.alchemy.com/v2/${apiKey}`] : []; + break; + case Networks.Base: + chain = base; + rpcUrls = apiKey ? [`https://base-mainnet.g.alchemy.com/v2/${apiKey}`] : []; + break; + case Networks.BSC: + chain = bsc; + rpcUrls = apiKey ? [`https://bnb-mainnet.g.alchemy.com/v2/${apiKey}`] : []; + break; + case Networks.Ethereum: + chain = mainnet; + rpcUrls = apiKey ? [`https://eth-mainnet.g.alchemy.com/v2/${apiKey}`] : []; + break; + default: + throw new Error(`Unsupported or unconfigured EVM network: ${network}`); + } + + const transports = rpcUrls.filter(url => url !== "").map(url => http(url)); + transports.push(http()); // add default viem transport as last resort + + return createWalletClient({ account: evmAccount, - chain: polygon, - transport: polygonTransport + chain: chain, + transport: fallback(transports) }); - - return { moonbeamClient, polygonClient }; } /** - * Signs multiple EVM (Moonbeam) transactions with increasing nonces - * - * @param tx - The original unsigned transaction - * @param walletClient - The viem wallet client - * @param startingNonce - The starting nonce value - * @returns - Array of signed transactions with increasing nonces + * Signs multiple EVM transactions with increasing nonces */ async function signMultipleEvmTransactions( tx: UnsignedTx, @@ -166,8 +185,6 @@ async function signMultipleEvmTransactions( for (let i = 0; i < NUMBER_OF_PRESIGNED_TXS; i++) { const currentNonce = startingNonce + i; - // Ensure the transaction data is in the correct format - if (!walletClient.account) { throw new Error("Wallet client account is undefined"); } @@ -198,43 +215,6 @@ async function signMultipleEvmTransactions( return signedTxs; } -/** - * Signs an array of unsigned transactions using network-specific methods. - * It signs multiple transactions with increasing nonces and includes them in the meta field. - * - * The signUnsignedTransactions function receives: - * - unsignedTxs: an array of UnsignedTx - * - ephemerals: an object mapping networks to EphemeralAccount for Stellar, Pendulum (substrate), - * and EVM (Moonbeam) transactions. - * - * For each unsigned transaction, the function selects the appropriate signing method: - * - * • Stellar: - * - Uses stellar-sdk to create a Transaction from the provided XDR (tx_data). - * - Signs using the ephemeral key (assumed to be passed). - * - Signs NUMBER_OF_PRESIGNED_TXS transactions with increasing nonces. - * - * • Pendulum (substrate): - * - Uses @polkadot/api Keyring to generate a keypair from the ephemeral secret. - * - Simulates signing via extrinsic.signAsync with options { nonce, era }. - * - Signs NUMBER_OF_PRESIGNED_TXS transactions with increasing nonces. - * - * • Moonbeam (EVM): - * - Uses the viem client to create a wallet client for EVM transactions. - * - Signs the transaction via walletClient.signTransaction. - * - Signs NUMBER_OF_PRESIGNED_TXS transactions with increasing nonces. - * - * For each transaction, signed transactions with nonces > n (where n is the original specified nonce) - * are stored in the meta.additionalTxs field of the first transaction. Each transaction is named - * by its phase property appended with the nonce offset (e.g., "phase1", "phase2" for nonce+1, nonce+2). - * - * @param unsignedTxs - Array of transactions to be signed. - * @param ephemerals - Mapping from network to its corresponding EphemeralAccount. - * Expected keys: stellar, pendulum, evm. - * @param pendulumApi - ApiPromise instance for Pendulum transactions. - * @param moonbeamApi - ApiPromise instance for Moonbeam transactions. - * @returns Promise resolving to an array of SignedTx with additional signed transactions in meta fields. - */ export async function signUnsignedTransactions( unsignedTxs: UnsignedTx[], ephemerals: { @@ -252,15 +232,17 @@ export async function signUnsignedTransactions( const signedTxs: PresignedTx[] = []; - // Create EVM wallet clients once at the beginning if needed - let evmClients: { moonbeamClient: WalletClient; polygonClient: WalletClient } | null = null; + // Group transactions const moonbeamTxs = unsignedTxs.filter(tx => tx.network === Networks.Moonbeam); const polygonTxs = unsignedTxs.filter(tx => tx.network === Networks.Polygon || tx.network === Networks.PolygonAmoy); const hydrationTxs = unsignedTxs.filter(tx => tx.network === Networks.Hydration); - - if ((moonbeamTxs.length > 0 || polygonTxs.length > 0) && ephemerals.evmEphemeral) { - evmClients = createEvmWalletClients(ephemerals.evmEphemeral, alchemyApiKey); - } + const destinationNetworkTxs = unsignedTxs.filter( + tx => + tx.phase === "destinationTransfer" || + tx.phase === "backupSquidRouterApprove" || + tx.phase === "backupSquidRouterSwap" || + tx.phase === "backupApprove" + ); try { const stellarTxs = unsignedTxs.filter(tx => tx.network === "stellar").sort((a, b) => a.nonce - b.nonce); @@ -302,14 +284,13 @@ export async function signUnsignedTransactions( const keypair = keyring.addFromUri(ephemerals.substrateEphemeral.secret); const multiSignedTxs = await signMultipleSubstrateTransactions(tx, keypair, hydrationApi, tx.nonce); - const primaryTx = multiSignedTxs[0]; - const txWithMeta = addAdditionalTransactionsToMeta(primaryTx, multiSignedTxs); signedTxs.push(txWithMeta); } + // Process Pendulum transactions for (const tx of pendulumTxs) { if (!ephemerals.substrateEphemeral) { throw new Error("Missing Pendulum ephemeral account"); @@ -327,9 +308,7 @@ export async function signUnsignedTransactions( const keypair = keyring.addFromUri(ephemerals.substrateEphemeral.secret); const multiSignedTxs = await signMultipleSubstrateTransactions(tx, keypair, pendulumApi, tx.nonce); - const primaryTx = multiSignedTxs[0]; - const txWithMeta = addAdditionalTransactionsToMeta(primaryTx, multiSignedTxs); signedTxs.push(txWithMeta); @@ -341,28 +320,21 @@ export async function signUnsignedTransactions( throw new Error("Missing EVM ephemeral account"); } - if (!evmClients) { - throw new Error("EVM clients not initialized"); - } - if (isEvmTransactionData(tx.txData)) { - const multiSignedTxs = await signMultipleEvmTransactions(tx, evmClients.moonbeamClient, tx.nonce); - + const client = createEvmClient(Networks.Moonbeam, ephemerals.evmEphemeral, alchemyApiKey); + const multiSignedTxs = await signMultipleEvmTransactions(tx, client, tx.nonce); const primaryTx = multiSignedTxs[0]; - const txWithMeta = addAdditionalTransactionsToMeta(primaryTx, multiSignedTxs); signedTxs.push(txWithMeta); } else { + // Handle Moonbeam Substrate transactions const keyring = new Keyring({ type: "ethereum" }); - const privateKey = ephemerals.evmEphemeral.secret as `0x${string}`; const keypair = keyring.addFromSeed(hexToU8a(privateKey)); const multiSignedTxs = await signMultipleSubstrateTransactions(tx, keypair, moonbeamApi, tx.nonce); - const primaryTx = multiSignedTxs[0]; - const txWithMeta = addAdditionalTransactionsToMeta(primaryTx, multiSignedTxs); signedTxs.push(txWithMeta); @@ -375,11 +347,26 @@ export async function signUnsignedTransactions( throw new Error("Missing EVM ephemeral account"); } - if (!evmClients) { - throw new Error("EVM clients not initialized"); + const client = createEvmClient(tx.network, ephemerals.evmEphemeral, alchemyApiKey); + const multiSignedTxs = await signMultipleEvmTransactions(tx, client, tx.nonce); + const primaryTx = multiSignedTxs[0]; + const txWithMeta = addAdditionalTransactionsToMeta(primaryTx, multiSignedTxs); + + signedTxs.push(txWithMeta); + } + + // Process Destination Network (EVM) transactions + for (const tx of destinationNetworkTxs) { + if (!ephemerals.evmEphemeral) { + throw new Error("Missing EVM ephemeral account"); } - const multiSignedTxs = await signMultipleEvmTransactions(tx, evmClients.polygonClient, tx.nonce); + // Check if already signed to avoid duplication + const alreadySigned = signedTxs.some(st => st === tx || (st.txData === tx.txData && st.nonce === tx.nonce)); + if (alreadySigned) continue; + + const client = createEvmClient(tx.network, ephemerals.evmEphemeral, alchemyApiKey); + const multiSignedTxs = await signMultipleEvmTransactions(tx, client, tx.nonce); const primaryTx = multiSignedTxs[0]; const txWithMeta = addAdditionalTransactionsToMeta(primaryTx, multiSignedTxs); diff --git a/packages/shared/src/services/brla/brlaApiService.ts b/packages/shared/src/services/brla/brlaApiService.ts index 026f25d722..5b75437e2c 100644 --- a/packages/shared/src/services/brla/brlaApiService.ts +++ b/packages/shared/src/services/brla/brlaApiService.ts @@ -289,7 +289,7 @@ export class BrlaApiService { const query = `subAccountId=${encodeURIComponent(subAccountId)}`; const aveniaTicketsQueryResponse = await this.sendRequest(Endpoint.Tickets, "GET", query, undefined, ticketId); - if ("ticket" in aveniaTicketsQueryResponse && "brlPixOutputInfo" in aveniaTicketsQueryResponse.ticket) { + if ("ticket" in aveniaTicketsQueryResponse && "brazilianFiatReceiverInfo" in aveniaTicketsQueryResponse.ticket) { return aveniaTicketsQueryResponse.ticket; } throw new Error("Invalid response from Avenia API for getAveniaPayoutTicket"); diff --git a/packages/shared/src/services/brla/types.ts b/packages/shared/src/services/brla/types.ts index d9b0022716..42d3d2ee8a 100644 --- a/packages/shared/src/services/brla/types.ts +++ b/packages/shared/src/services/brla/types.ts @@ -203,13 +203,6 @@ export interface AveniaPayoutTicket extends BaseTicket { walletAddress: string; txHash: string; }; - brlPixOutputInfo: { - id: string; - ticketId: string; - pixMessage: string; - senderAccountBankName: string; - senderAccountNumber: string; - }; blockchainInputInfo: { id: string; ticketId: string; diff --git a/packages/shared/src/services/squidrouter/onramp.ts b/packages/shared/src/services/squidrouter/onramp.ts index b0a8c7dd71..8ab19d64a7 100644 --- a/packages/shared/src/services/squidrouter/onramp.ts +++ b/packages/shared/src/services/squidrouter/onramp.ts @@ -7,6 +7,7 @@ import { createSquidRouterHash, ERC20_EURE_POLYGON_V1, EvmClientManager, + EvmNetworks, EvmTransactionData, encodePayload, getSquidRouterConfig, @@ -35,6 +36,15 @@ export interface OnrampSquidrouterParamsFromPolygon { destinationAddress: string; } +export interface OnrampSquidrouterParamsOnDestinationChain { + fromAddress: string; + rawAmount: string; + fromToken: `0x${string}`; + toToken: `0x${string}`; + network: EvmNetworks; + destinationAddress: string; +} + export interface OnrampTransactionData { approveData: EvmTransactionData; swapData: EvmTransactionData; @@ -160,3 +170,38 @@ export async function createOnrampSquidrouterTransactionsFromPolygonToMoonbeamWi throw new Error(`Error getting route: ${routeParams}. Error: ${e}`); } } + +export async function createOnrampSquidrouterTransactionsOnDestinationChain( + params: OnrampSquidrouterParamsOnDestinationChain +): Promise { + const evmClientManager = EvmClientManager.getInstance(); + const client = evmClientManager.getClient(params.network); + + const routeParams = createGenericRouteParams({ + ...params, + amount: params.rawAmount, + fromNetwork: params.network, + toNetwork: params.network + }); + + try { + const routeResult = await getRoute(routeParams); + const { route } = routeResult.data; + + const { approveData, swapData, squidRouterQuoteId } = await createTransactionDataFromRoute({ + inputTokenErc20Address: params.fromToken, + publicClient: client, + rawAmount: params.rawAmount, + route + }); + + return { + approveData, + route, + squidRouterQuoteId, + swapData + }; + } catch (e) { + throw new Error(`Error getting route: ${routeParams}. Error: ${e}`); + } +} diff --git a/packages/shared/src/services/squidrouter/route.ts b/packages/shared/src/services/squidrouter/route.ts index aba489ede0..c5ef315c30 100644 --- a/packages/shared/src/services/squidrouter/route.ts +++ b/packages/shared/src/services/squidrouter/route.ts @@ -6,6 +6,57 @@ import { AXL_USDC_MOONBEAM, EvmTokenDetails, EvmTransactionData, getNetworkId, N import logger from "../../logger"; import { getSquidRouterConfig, squidRouterConfigBase } from "./config"; +/** + * Normalizes a numeric string to a format that BigInt can parse. + * Handles scientific notation (e.g., "1.5e18") and decimal strings (e.g., "123.456") + * by converting them to integer strings, truncating any fractional part. + */ +function normalizeBigIntString(value: string): string { + if (!value || value === "") { + return "0"; + } + + // If it's already a valid integer string (decimal or hex), return as-is + if (/^-?\d+$/.test(value) || /^0x[0-9a-fA-F]+$/i.test(value)) { + return value; + } + + // Handle scientific notation and decimals by parsing as Number first, then converting + // This will truncate any fractional part + try { + const num = Number(value); + if (Number.isNaN(num) || !Number.isFinite(num)) { + logger.current.warn(`Invalid numeric value for BigInt conversion: ${value}, defaulting to 0`); + return "0"; + } + // Use BigInt on the truncated integer value to avoid precision issues with large numbers + // For very large numbers, we need to handle them specially + if (Math.abs(num) > Number.MAX_SAFE_INTEGER) { + // For scientific notation with large exponents, parse manually + const match = value.match(/^(-?\d+\.?\d*)[eE]([+-]?\d+)$/); + if (match) { + const [, mantissa, exponent] = match; + const exp = parseInt(exponent, 10); + const [intPart, decPart = ""] = mantissa.replace("-", "").split("."); + const sign = mantissa.startsWith("-") ? "-" : ""; + const totalDigits = intPart + decPart; + const zerosNeeded = exp - decPart.length; + if (zerosNeeded >= 0) { + return sign + totalDigits + "0".repeat(zerosNeeded); + } else { + // Truncate decimal part + return sign + totalDigits.slice(0, totalDigits.length + zerosNeeded) || "0"; + } + } + } + // For smaller numbers, Math.trunc works fine + return BigInt(Math.trunc(num)).toString(); + } catch (e) { + logger.current.warn(`Failed to normalize BigInt string: ${value}, error: ${e}`); + return "0"; + } +} + const SQUIDROUTER_BASE_URL = "https://v2.api.squidrouter.com/v2"; export { splitReceiverABI }; @@ -19,7 +70,8 @@ export interface RouteParams { toToken: `0x${string}`; toAddress: string; bypassGuardrails: boolean; - slippageConfig: { + slippage?: number; + slippageConfig?: { autoMode: number; }; enableExpress: boolean; @@ -95,8 +147,9 @@ export async function getRoute(params: RouteParams): Promise 2.5) { - logger.current.error(`Received route with high slippage: ${slippage}%. Request ID: ${requestId}`); - throw new Error(`The slippage of the route is too high: ${slippage}%. Please try again later.`); + logger.current.warn(`Received route with high slippage: ${slippage}%. Request ID: ${requestId}`); + // FIXME: temporarily disabled because we are facing issues with squidrouter routes failing the swap to USDT + // throw new Error(`The slippage of the route is too high: ${slippage}%. Please try again later.`); } } @@ -221,9 +274,7 @@ export function createRouteParamsWithMoonbeamPostHook(params: { logoURI: "https://pbs.twimg.com/profile_images/1548647667135291394/W2WOtKUq_400x400.jpg", // Add your product or application's logo here provider: "Pendulum" }, - slippageConfig: { - autoMode: 1 - }, + slippage: 4, toAddress: fromAddress, toChain: toChainId.toString(), toToken: AXL_USDC_MOONBEAM @@ -251,9 +302,7 @@ export function createGenericRouteParams(params: { fromAmount: amount, fromChain: fromChainId.toString(), fromToken, - slippageConfig: { - autoMode: 1 - }, + slippage: 4, toAddress: destinationAddress, toChain: toChainId.toString(), toToken @@ -328,11 +377,11 @@ export async function createTransactionDataFromRoute({ const swapData: EvmTransactionData = { data: transactionRequest.data as `0x${string}`, - gas: transactionRequest.gasLimit, + gas: normalizeBigIntString(transactionRequest.gasLimit), maxFeePerGas: maxFeePerGas.toString(), maxPriorityFeePerGas: (maxPriorityFeePerGas ?? maxFeePerGas).toString(), to: transactionRequest.target as `0x${string}`, - value: swapValue ?? transactionRequest.value + value: normalizeBigIntString(swapValue ?? transactionRequest.value) }; if (nonce !== undefined) { diff --git a/packages/shared/src/tokens/evm/config.ts b/packages/shared/src/tokens/evm/config.ts index df5b2672f6..02af40f5dc 100644 --- a/packages/shared/src/tokens/evm/config.ts +++ b/packages/shared/src/tokens/evm/config.ts @@ -38,6 +38,16 @@ export const evmTokenConfig: Record>>; + priceBySymbol: Map; isLoaded: boolean; - error: Error | null; - usedFallback: boolean; } const state: DynamicEvmTokensState = { - error: null, isLoaded: false, - tokens: [], - tokensByNetwork: {} as Record>>, - usedFallback: false + priceBySymbol: new Map(), + tokensByNetwork: {} as Record>> }; +/** + * Iterates over all EVM networks and calls the callback for each. + */ +function forEachEvmNetwork(callback: (network: EvmNetworks) => void): void { + for (const network of Object.values(Networks)) { + if (isNetworkEVM(network)) { + callback(network as EvmNetworks); + } + } +} + +function createEmptyNetworkBuckets(): Record>> { + const buckets = {} as Record>>; + forEachEvmNetwork(network => { + buckets[network] = {}; + }); + return buckets; +} + +const NATIVE_TOKEN_ADDRESS = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" as const; + +function isNativeToken(address: string): boolean { + return address.toLowerCase() === NATIVE_TOKEN_ADDRESS; +} + function getNetworkFromChainId(chainId: string): Networks | null { const chainIdNum = parseInt(chainId, 10); const networkEntries = Object.entries(Networks).filter( @@ -72,16 +78,34 @@ function getNetworkFromChainId(chainId: string): Networks | null { return networkEntries.length > 0 ? (networkEntries[0][1] as Networks) : null; } -function isNativeToken(address: string): boolean { - return address === "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; -} - function getNetworkAssetIcon(network: Networks, symbol: string): string { const networkName = network.toLowerCase(); const cleanSymbol = symbol.replace(/[^a-zA-Z0-9]/g, "").toLowerCase(); return `${networkName}${cleanSymbol}`; } +function generateFallbackLogoURI(chainId: number, address: string): string { + return `https://raw.githubusercontent.com/0xsquid/assets/main/images/migration/webp/${chainId}_${address.toLowerCase()}.webp`; +} + +function shouldIncludeToken(token: SquidRouterToken): boolean { + const symbol = token.symbol.toUpperCase(); + + // Exclude blocklisted tokens (Cosmos native tokens) + if (TOKEN_FILTER_CONFIG.symbolBlocklist.has(symbol)) { + return false; + } + + // Exclude most bridged Axelar tokens except major stablecoins + for (const pattern of TOKEN_FILTER_CONFIG.excludedBridgedPatterns) { + if (pattern.test(token.symbol)) { + return false; + } + } + + return true; +} + function mapSquidTokenToEvmTokenDetails(token: SquidRouterToken): EvmTokenDetails | null { const network = getNetworkFromChainId(token.chainId); if (!network || !isNetworkEVM(network)) { @@ -94,15 +118,13 @@ function mapSquidTokenToEvmTokenDetails(token: SquidRouterToken): EvmTokenDetail const isNative = isNativeToken(token.address); - const erc20Address: `0x${string}` = isNative - ? ("0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" as `0x${string}`) - : (token.address as `0x${string}`); + const erc20Address = token.address as `0x${string}`; return { assetSymbol: token.symbol, decimals: token.decimals, erc20AddressSourceChain: erc20Address, - fallbackLogoURI: `https://raw.githubusercontent.com/0xsquid/assets/main/images/migration/webp/${token.chainId}_${token.address.toLowerCase()}.webp`, + fallbackLogoURI: generateFallbackLogoURI(parseInt(token.chainId, 10), erc20Address), isNative, logoURI: token.logoURI, network, @@ -113,93 +135,136 @@ function mapSquidTokenToEvmTokenDetails(token: SquidRouterToken): EvmTokenDetail }; } -async function fetchSquidRouterTokens(): Promise { - const result = await axios.get("https://v2.api.squidrouter.com/v2/tokens", { - headers: { - "x-integrator-id": squidRouterConfigBase.integratorId - } - }); - return result.data.tokens; -} - +/** + * Groups tokens by their network into a record keyed by EvmNetworks. + * This function only groups - it does not merge with static config. + */ function groupTokensByNetwork(tokens: EvmTokenDetails[]): Record>> { - const grouped = {} as Record>>; - - for (const network of Object.values(Networks)) { - if (isNetworkEVM(network)) { - grouped[network as EvmNetworks] = {}; - } - } + const grouped = createEmptyNetworkBuckets(); for (const token of tokens) { - if (isNetworkEVM(token.network)) { - const network = token.network as EvmNetworks; - if (!grouped[network]) { - grouped[network] = {}; - } - const symbolKey = token.assetSymbol.toUpperCase(); - const existingToken = grouped[network][symbolKey]; - - // If there's already a token with this symbol, keep the better one - if (existingToken) { - // Priority: native > higher USD price > lower address (deterministic) - const tokenPrice = token.usdPrice ?? 0; - const existingPrice = existingToken.usdPrice ?? 0; - - const shouldReplace = - (token.isNative && !existingToken.isNative) || - (!token.isNative && !existingToken.isNative && tokenPrice > existingPrice) || - (!token.isNative && !existingToken.isNative && tokenPrice === existingPrice && - token.erc20AddressSourceChain.toLowerCase() < existingToken.erc20AddressSourceChain.toLowerCase()); - - if (shouldReplace) { - grouped[network][symbolKey] = token; - } - // Otherwise keep the existing token - } else { + const network = token.network as EvmNetworks; + const symbolKey = token.assetSymbol.toUpperCase(); + const existingToken = grouped[network][symbolKey]; + + // If there's already a token with this symbol, keep the better one + if (existingToken) { + // Priority: native > higher USD price > lower address (deterministic) + const tokenPrice = token.usdPrice ?? 0; + const existingPrice = existingToken.usdPrice ?? 0; + + const shouldReplace = + (token.isNative && !existingToken.isNative) || + (!token.isNative && + !existingToken.isNative && + (tokenPrice > existingPrice || + (tokenPrice === existingPrice && + token.erc20AddressSourceChain.toLowerCase() < existingToken.erc20AddressSourceChain.toLowerCase()))); + + if (shouldReplace) { grouped[network][symbolKey] = token; } + // Otherwise keep the existing token + } else { + grouped[network][symbolKey] = token; } } - for (const network of Object.values(Networks)) { - if (isNetworkEVM(network)) { - const evmNetwork = network as EvmNetworks; - const networkTokenConfig = evmTokenConfig[evmNetwork]; - if (networkTokenConfig) { - grouped[evmNetwork] = { - ...networkTokenConfig, - ...grouped[evmNetwork] + return grouped; +} + +/** + * Merges dynamic tokens with static config. + * Static config takes priority for contract addresses, but preserves useful metadata + * (logoURI, usdPrice) from dynamic tokens. + */ +function mergeWithStaticConfig( + dynamicTokens: Record>> +): Record>> { + const merged = createEmptyNetworkBuckets(); + + forEachEvmNetwork(network => { + merged[network] = { ...dynamicTokens[network] }; + + const networkTokenConfig = evmTokenConfig[network]; + if (!networkTokenConfig) return; + + for (const [symbol, staticToken] of Object.entries(networkTokenConfig)) { + if (!staticToken) continue; + + const normalizedSymbol = symbol.toUpperCase(); + const dynamicToken = dynamicTokens[network][normalizedSymbol]; + + if (dynamicToken) { + // Warning if addresses point to different contracts (possible configuration drift or scam token) + if (staticToken.erc20AddressSourceChain.toLowerCase() !== dynamicToken.erc20AddressSourceChain.toLowerCase()) { + logger.current.warn( + `[DynamicEvmTokens] Address mismatch for ${symbol} on ${network}. Config: ${staticToken.erc20AddressSourceChain}, Dynamic: ${dynamicToken.erc20AddressSourceChain}. Using Config preference.` + ); + } + + // Static token exists and dynamic token exists - merge, static takes priority + merged[network][normalizedSymbol] = { + ...staticToken, + fallbackLogoURI: staticToken.fallbackLogoURI ?? dynamicToken.fallbackLogoURI, + logoURI: staticToken.logoURI ?? dynamicToken.logoURI, + usdPrice: dynamicToken.usdPrice ?? staticToken.usdPrice }; + } else { + // Static token exists but no dynamic token - use static as-is + merged[network][normalizedSymbol] = staticToken; } } - } + }); - return grouped; + return merged; } -function buildFallbackFromStaticConfig(): { - tokens: EvmTokenDetails[]; - tokensByNetwork: Record>>; -} { - const tokens: EvmTokenDetails[] = []; - const tokensByNetwork = {} as Record>>; +function buildPriceLookup(tokensByNetwork: Record>>): Map { + const priceMap = new Map(); - for (const network of Object.values(Networks)) { - if (isNetworkEVM(network)) { - const evmNetwork = network as EvmNetworks; - const networkTokenConfig = evmTokenConfig[evmNetwork]; - if (networkTokenConfig) { - tokensByNetwork[evmNetwork] = networkTokenConfig; - const networkTokens = Object.values(networkTokenConfig).filter( - (token): token is EvmTokenDetails => token !== undefined - ); - tokens.push(...networkTokens); + forEachEvmNetwork(network => { + const networkTokens = tokensByNetwork[network]; + for (const token of Object.values(networkTokens)) { + if (token?.usdPrice !== undefined) { + priceMap.set(token.assetSymbol.toUpperCase(), token.usdPrice); } } - } + }); - return { tokens, tokensByNetwork }; + return priceMap; +} + +async function fetchSquidRouterTokens(): Promise { + const result = await axios.get(SQUID_ROUTER_API_URL, { + headers: { + "x-integrator-id": squidRouterConfigBase.integratorId + } + }); + return result.data.tokens; +} + +function buildFallbackFromStaticConfig(): Record>> { + const tokensByNetwork = createEmptyNetworkBuckets(); + + forEachEvmNetwork(network => { + const networkTokenConfig = evmTokenConfig[network]; + if (networkTokenConfig) { + tokensByNetwork[network] = { ...networkTokenConfig }; + } + }); + + return tokensByNetwork; +} + +/** + * Derives a flat array of all tokens from the tokensByNetwork structure. + * Use this instead of storing a separate tokens array. + */ +function deriveAllTokens(tokensByNetwork: Record>>): EvmTokenDetails[] { + return Object.values(tokensByNetwork) + .flatMap(networkTokens => Object.values(networkTokens)) + .filter((token): token is EvmTokenDetails => token !== undefined); } /** @@ -217,21 +282,16 @@ export async function initializeEvmTokens(): Promise { 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); - state.error = null; - state.usedFallback = false; + const groupedTokens = groupTokensByNetwork(evmTokens); + state.tokensByNetwork = mergeWithStaticConfig(groupedTokens); + state.priceBySymbol = buildPriceLookup(state.tokensByNetwork); state.isLoaded = true; } catch (err) { console.error("[DynamicEvmTokens] Failed to fetch tokens from SquidRouter, using fallback:", err); - const fallback = buildFallbackFromStaticConfig(); - state.tokens = fallback.tokens; - state.tokensByNetwork = fallback.tokensByNetwork; - state.error = err instanceof Error ? err : new Error("Failed to fetch tokens"); - state.usedFallback = true; + state.tokensByNetwork = buildFallbackFromStaticConfig(); + state.priceBySymbol = buildPriceLookup(state.tokensByNetwork); state.isLoaded = true; } } @@ -262,31 +322,9 @@ export function getEvmTokensForNetwork(network: EvmNetworks): EvmTokenDetails[] */ export function getAllEvmTokens(): EvmTokenDetails[] { if (!state.isLoaded) { - const fallback = buildFallbackFromStaticConfig(); - return fallback.tokens; + return deriveAllTokens(buildFallbackFromStaticConfig()); } - return state.tokens; -} - -/** - * Check if tokens have been loaded. - */ -export function isTokensLoaded(): boolean { - return state.isLoaded; -} - -/** - * Check if the service used the fallback static config. - */ -export function usedFallbackConfig(): boolean { - return state.usedFallback; -} - -/** - * Get the error if token loading failed. - */ -export function getLoadingError(): Error | null { - return state.error; + return deriveAllTokens(state.tokensByNetwork); } /** @@ -301,10 +339,5 @@ export function getTokenUsdPrice(symbol: string): number | undefined { return undefined; } - const normalizedSymbol = symbol.toUpperCase(); - - // Search through all tokens to find matching symbol - const token = state.tokens.find(t => t.assetSymbol.toUpperCase() === normalizedSymbol); - - return token?.usdPrice; + return state.priceBySymbol.get(symbol.toUpperCase()); } diff --git a/packages/shared/src/tokens/index.ts b/packages/shared/src/tokens/index.ts index 9e69bc526d..e072299a8b 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 */ From ceea6e91ff1c82d732b4954043bd004afc76d1b7 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 30 Jan 2026 19:20:29 +0000 Subject: [PATCH 60/61] Revert "Merge staging branch and resolve conflicts" This reverts commit f1d728c98c2523dfb72bf1909c5987f417cd6584. --- .../admin/partnerApiKeys.controller.ts | 6 +- .../api/controllers/maintenance.controller.ts | 2 +- .../handlers/destination-transfer-handler.ts | 101 ------ .../handlers/distribute-fees-handler.ts | 8 +- .../handlers/final-settlement-subsidy.ts | 270 --------------- .../phases/handlers/fund-ephemeral-handler.ts | 79 +---- .../api/services/phases/handlers/helpers.ts | 31 +- .../pendulum-to-moonbeam-xcm-handler.ts | 66 +--- .../squid-router-pay-phase-handler.ts | 195 +++-------- .../api/services/phases/meta-state-types.ts | 2 - .../api/services/phases/phase-processor.ts | 63 ++-- .../stellar-post-process-handler.ts | 2 +- .../api/services/phases/register-handlers.ts | 4 - .../api/src/api/services/priceFeed.service.ts | 2 +- .../api/services/quote/core/squidrouter.ts | 14 +- apps/api/src/api/services/quote/index.ts | 4 - .../onramp/common/transactions.ts | 79 ----- .../onramp/routes/avenia-to-evm.ts | 98 +----- apps/api/src/api/workers/cleanup.worker.ts | 19 +- .../src/api/workers/ramp-recovery.worker.ts | 6 +- apps/api/src/config/express.ts | 4 +- apps/api/src/config/logger.ts | 12 +- apps/api/src/config/ramp-context.ts | 48 --- apps/api/src/constants/constants.ts | 4 +- apps/frontend/.storybook/main.ts | 2 +- apps/frontend/_redirects | 1 - .../src/components/Accordion/index.tsx | 78 ++--- .../components/AssetNumericInput/index.tsx | 4 +- .../AveniaKYBFlow/AveniaKYBVerifyStep.tsx | 6 +- .../AveniaKycEligibilityFields/index.tsx | 18 +- .../src/components/CollapsibleCard/index.tsx | 47 ++- .../src/components/CurrencyExchange/index.tsx | 48 +-- .../src/components/EmailForm/index.tsx | 2 +- .../src/components/FiatIcon/index.tsx | 4 +- .../src/components/KycLevel2Toggle/index.tsx | 24 +- .../src/components/ListItem/index.tsx | 7 +- .../src/components/Navbar/DesktopNavbar.tsx | 32 +- .../src/components/Navbar/HamburgerButton.tsx | 23 +- .../src/components/Navbar/LogoButton.tsx | 11 +- .../src/components/Navbar/MobileMenu.tsx | 131 ++------ .../src/components/Navbar/MobileNavbar.tsx | 15 +- .../Navbar/hooks/useNavbarHandlers.ts | 6 +- apps/frontend/src/components/Navbar/index.tsx | 2 +- .../src/components/QuoteSummary/index.tsx | 50 +-- .../src/components/Ramp/Offramp/index.tsx | 11 +- .../src/components/Ramp/Onramp/index.tsx | 20 +- .../src/components/RampToggle/index.tsx | 21 +- .../src/components/Stepper/StepCircle.tsx | 2 +- .../src/components/Stepper/StepConnector.tsx | 8 +- .../components/TermsAndConditions/index.tsx | 13 +- .../components/TokenIconWithNetwork/index.tsx | 6 +- .../{TokenIcon => TokenImage}/index.tsx | 6 +- .../animations/SelectionButtonMotion.tsx | 21 +- .../animations/SelectionChevronMotion.tsx | 10 +- .../animations/SelectionDropdownMotion.tsx | 66 ++-- .../components/TokenSelectionControls.tsx | 21 +- .../components/buttons/AssetButton/index.tsx | 29 +- .../HistoryMenu/TransactionItem/index.tsx | 52 +-- .../src/components/menus/Menu/index.tsx | 8 +- .../widget-steps/SummaryStep/AssetDisplay.tsx | 15 +- .../SummaryStep/TransactionTokensDisplay.tsx | 40 ++- apps/frontend/src/constants/animations.ts | 51 --- apps/frontend/src/helpers/tokenHelpers.ts | 21 ++ apps/frontend/src/hooks/useTokenIcon.ts | 127 ------- .../src/machines/actors/sign.actor.ts | 15 +- apps/frontend/src/pages/progress/index.tsx | 5 - .../src/pages/progress/phaseMessages.ts | 14 +- .../src/sections/business/Hero/index.tsx | 92 ++--- .../src/sections/individuals/Hero/index.tsx | 4 +- .../individuals/PopularTokens/index.tsx | 7 +- apps/frontend/src/services/tokens/index.ts | 5 +- .../src/stores/quote/useQuoteStore.ts | 1 - .../src/stories/Accordion.stories.tsx | 193 ----------- .../src/stories/CollapsibleCard.stories.tsx | 234 ------------- .../src/stories/KycLevel2Toggle.stories.tsx | 182 ---------- apps/frontend/src/stories/Menu.stories.tsx | 239 ------------- .../src/stories/MobileMenu.stories.tsx | 197 ----------- .../NetworkSelectionAnimations.stories.tsx | 287 ---------------- .../src/stories/RampToggle.stories.tsx | 182 ---------- .../stories/TermsAndConditions.stories.tsx | 289 ---------------- apps/frontend/src/translations/en.json | 4 +- apps/frontend/src/translations/pt.json | 4 +- bun.lock | 4 +- packages/sdk/package.json | 2 +- packages/sdk/src/services/NetworkManager.ts | 2 +- packages/shared/package.json | 2 +- .../shared/src/endpoints/quote.endpoints.ts | 1 - .../shared/src/endpoints/ramp.endpoints.ts | 6 - packages/shared/src/helpers/signUnsigned.ts | 195 ++++++----- .../src/services/brla/brlaApiService.ts | 2 +- packages/shared/src/services/brla/types.ts | 7 + .../shared/src/services/squidrouter/onramp.ts | 45 --- .../shared/src/services/squidrouter/route.ts | 71 +--- packages/shared/src/tokens/evm/config.ts | 60 ---- .../shared/src/tokens/evm/dynamicEvmTokens.ts | 317 ++++++++---------- packages/shared/src/tokens/index.ts | 4 +- 96 files changed, 779 insertions(+), 4071 deletions(-) delete mode 100644 apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts delete mode 100644 apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts delete mode 100644 apps/api/src/config/ramp-context.ts rename apps/frontend/src/components/{TokenIcon => TokenImage}/index.tsx (86%) create mode 100644 apps/frontend/src/helpers/tokenHelpers.ts delete mode 100644 apps/frontend/src/hooks/useTokenIcon.ts delete mode 100644 apps/frontend/src/stories/Accordion.stories.tsx delete mode 100644 apps/frontend/src/stories/CollapsibleCard.stories.tsx delete mode 100644 apps/frontend/src/stories/KycLevel2Toggle.stories.tsx delete mode 100644 apps/frontend/src/stories/Menu.stories.tsx delete mode 100644 apps/frontend/src/stories/MobileMenu.stories.tsx delete mode 100644 apps/frontend/src/stories/NetworkSelectionAnimations.stories.tsx delete mode 100644 apps/frontend/src/stories/RampToggle.stories.tsx delete mode 100644 apps/frontend/src/stories/TermsAndConditions.stories.tsx diff --git a/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts b/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts index 173216a874..e82b28d3d7 100644 --- a/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts +++ b/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts @@ -10,7 +10,7 @@ import { generateApiKey, getKeyPrefix, hashApiKey } from "../../middlewares/apiK * Create a new API key pair (public + secret) for a partner * POST /v1/admin/partners/:partnerName/api-keys */ -export async function createApiKey(req: Request<{ partnerName: string }>, res: Response): Promise { +export async function createApiKey(req: Request, res: Response): Promise { try { const partnerName = req.params.partnerName as string; const { name, expiresAt } = req.body; @@ -110,7 +110,7 @@ export async function createApiKey(req: Request<{ partnerName: string }>, res: R * List all API keys for a partner (by name) * GET /v1/admin/partners/:partnerName/api-keys */ -export async function listApiKeys(req: Request<{ partnerName: string }>, res: Response): Promise { +export async function listApiKeys(req: Request, res: Response): Promise { try { const partnerName = req.params.partnerName as string; @@ -180,7 +180,7 @@ export async function listApiKeys(req: Request<{ partnerName: string }>, res: Re * Revoke (soft delete) an API key * DELETE /v1/admin/partners/:partnerName/api-keys/:keyId */ -export async function revokeApiKey(req: Request<{ partnerName: string; keyId: string }>, res: Response): Promise { +export async function revokeApiKey(req: Request, res: Response): Promise { try { const { partnerName, keyId } = req.params; diff --git a/apps/api/src/api/controllers/maintenance.controller.ts b/apps/api/src/api/controllers/maintenance.controller.ts index 0f39744a86..e2dde1f66f 100644 --- a/apps/api/src/api/controllers/maintenance.controller.ts +++ b/apps/api/src/api/controllers/maintenance.controller.ts @@ -61,7 +61,7 @@ export const getAllMaintenanceSchedules: RequestHandler = async (_, res) => { * @returns {Object} 404 - Schedule not found * @returns {Object} 500 - Internal server error */ -export const updateScheduleActiveStatus: RequestHandler<{ id: string }> = async (req, res) => { +export const updateScheduleActiveStatus: RequestHandler = async (req, res) => { try { const id = req.params.id as string; const { isActive } = req.body; diff --git a/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts b/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts deleted file mode 100644 index bcdcb87260..0000000000 --- a/apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { - checkEvmBalancePeriodically, - EvmClientManager, - EvmNetworks, - EvmTokenDetails, - FiatToken, - getAnyFiatTokenDetailsMoonbeam, - getOnChainTokenDetails, - isEvmToken, - multiplyByPowerOfTen, - Networks, - RampDirection, - RampPhase -} from "@vortexfi/shared"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { BasePhaseHandler } from "../base-phase-handler"; - -const BALANCE_POLLING_TIME_MS = 5000; -const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes -/** - * Handler for transferring funds to the destination address on EVM networks (onramp only) - */ -export class DestinationTransferHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "destinationTransfer"; - } - - protected async executePhase(state: RampState): Promise { - const evmClientManager = EvmClientManager.getInstance(); - // Only handle onramp operations - if (state.type !== RampDirection.BUY) { - throw new Error("DestinationTransferHandler: Only supports onramp operations"); - } - - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - if (!isEvmToken(quote.outputCurrency)) { - throw new Error("DestinationTransferHandler: Output currency is not an EVM token"); - } - const { txData: destinationTransfer } = this.getPresignedTransaction(state, "destinationTransfer"); - const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails; - const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals).toString(); - const destinationNetwork = quote.network as EvmNetworks; // We can assert this type due to checks before - const { destinationTransferTxHash } = state.state; - if (destinationTransferTxHash) { - try { - const client = evmClientManager.getClient(destinationNetwork); - const receipt = await client.getTransactionReceipt({ hash: destinationTransferTxHash as `0x${string}` }); - - if (receipt.status === "success") { - return this.transitionToNextPhase(state, "complete"); - } else { - throw new Error(`Transaction ${destinationTransferTxHash} failed on chain.`); - } - } catch (error) { - if (error instanceof Error && error.name !== "TransactionReceiptNotFoundError") { - throw error; - } - // If receipt not found, proceed to normal flow - } - } - - // main phase execution loop: - try { - await checkEvmBalancePeriodically( - outTokenDetails.erc20AddressSourceChain, - state.state.evmEphemeralAddress, - expectedAmountRaw, - BALANCE_POLLING_TIME_MS, - EVM_BALANCE_CHECK_TIMEOUT_MS, - destinationNetwork - ); - - // send the transaction, log hash in the state for recovery. - const txHash = await evmClientManager.sendRawTransactionWithRetry( - quote.network as EvmNetworks, - destinationTransfer as `0x${string}` - ); - // store in state - await state.update({ - state: { - ...state.state, - destinationTransferTxHash: txHash - } - }); - // (optional) wait for balance to be updated on user - destination - - return this.transitionToNextPhase(state, "complete"); - } catch (error) { - throw this.createRecoverableError( - `DestinationTransferHandler: Error during phase execution - ${(error as Error).message}` - ); - } - } -} - -export default new DestinationTransferHandler(); diff --git a/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts b/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts index 706e367145..1c919777c3 100644 --- a/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts +++ b/apps/api/src/api/services/phases/handlers/distribute-fees-handler.ts @@ -135,7 +135,7 @@ export class DistributeFeesHandler extends BasePhaseHandler { if (status === ExtrinsicStatus.Success) { return; } else if (status === ExtrinsicStatus.Fail) { - await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); + //throw this.createUnrecoverableError(`Extrinsic failed for hash ${extrinsicHash}`); continue; } else if (status === ExtrinsicStatus.Undefined) { await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); @@ -212,14 +212,10 @@ export class DistributeFeesHandler extends BasePhaseHandler { reject(this.handleDispatchError(api, dispatchError, systemExtrinsicFailedEvent, "distributeFees")); } - if (status.isBroadcast) { + if (status.isBroadcast || status.isInBlock) { logger.info(`Transaction broadcasted: ${status.asBroadcast.toString()}`); resolve(txHash.toHex()); } - if (status.isInBlock) { - logger.info(`Transaction in block: ${status.asInBlock.toString()}`); - resolve(txHash.toHex()); - } }) .catch((error: unknown) => { logger.error("Error submitting transaction to distribute fees:", error); diff --git a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts b/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts deleted file mode 100644 index 83f45bc7e6..0000000000 --- a/apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { - checkEvmBalancePeriodically, - EvmClientManager, - EvmNetworks, - EvmTokenDetails, - getNetworkId, - getOnChainTokenDetails, - getRoute, - isEvmToken, - multiplyByPowerOfTen, - Networks, - RampCurrency, - RampDirection, - RampPhase -} from "@vortexfi/shared"; -import Big from "big.js"; -import { encodeFunctionData, erc20Abi, TransactionReceipt } from "viem"; -import { privateKeyToAccount } from "viem/accounts"; -import logger from "../../../../config/logger"; -import { MAX_FINAL_SETTLEMENT_SUBSIDY_USD, MOONBEAM_FUNDING_PRIVATE_KEY } from "../../../../constants/constants"; -import QuoteTicket from "../../../../models/quoteTicket.model"; -import RampState from "../../../../models/rampState.model"; -import { priceFeedService } from "../../priceFeed.service"; -import { BasePhaseHandler } from "../base-phase-handler"; - -const BALANCE_POLLING_TIME_MS = 5000; -const EVM_BALANCE_CHECK_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes -const NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; - -const NATIVE_TOKENS: Record = { - [Networks.Ethereum]: { decimals: 18, symbol: "ETH" }, - [Networks.Polygon]: { decimals: 18, symbol: "MATIC" }, - [Networks.PolygonAmoy]: { decimals: 18, symbol: "MATIC" }, - [Networks.BSC]: { decimals: 18, symbol: "BNB" }, - [Networks.Arbitrum]: { decimals: 18, symbol: "ETH" }, - [Networks.Base]: { decimals: 18, symbol: "ETH" }, - [Networks.Avalanche]: { decimals: 18, symbol: "AVAX" }, - [Networks.Moonbeam]: { decimals: 18, symbol: "GLMR" } -}; - -/** - * Handler for transferring funds to the destination address on EVM networks (onramp only) - */ -export class FinalSettlementSubsidyHandler extends BasePhaseHandler { - public getPhaseName(): RampPhase { - return "finalSettlementSubsidy"; - } - - protected async executePhase(state: RampState): Promise { - const evmClientManager = EvmClientManager.getInstance(); - const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); - - // Only handle onramp operations - if (state.type !== RampDirection.BUY) { - throw new Error("FinalSettlementSubsidyHandler: Only supports onramp operations"); - } - - const quote = await QuoteTicket.findByPk(state.quoteId); - if (!quote) { - throw new Error("Quote not found for the given state"); - } - - if (!isEvmToken(quote.outputCurrency)) { - throw new Error("FinalSettlementSubsidyHandler: Output currency is not an EVM token"); - } - - const outTokenDetails = getOnChainTokenDetails(quote.network, quote.outputCurrency) as EvmTokenDetails; - const expectedAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outTokenDetails.decimals); - const destinationNetwork = quote.network as EvmNetworks; - const publicClient = evmClientManager.getClient(destinationNetwork); - const ephemeralAddress = state.state.evmEphemeralAddress as `0x${string}`; - - // 1. Idempotency Check - if (state.state.finalSettlementSubsidyTxHash) { - const receipt = await publicClient - .getTransactionReceipt({ - hash: state.state.finalSettlementSubsidyTxHash as `0x${string}` - }) - .catch(() => null); - - if (receipt && receipt.status === "success") { - logger.info( - `FinalSettlementSubsidyHandler: Transaction ${state.state.finalSettlementSubsidyTxHash} already successful. Skipping.` - ); - return this.transitionToNextPhase(state, "destinationTransfer"); - } - } - - const actualBalance = await checkEvmBalancePeriodically( - outTokenDetails.erc20AddressSourceChain, - ephemeralAddress, - "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less. - BALANCE_POLLING_TIME_MS, - EVM_BALANCE_CHECK_TIMEOUT_MS, - destinationNetwork - ); - - const actualBalanceFundingAccount = await publicClient.readContract({ - abi: erc20Abi, - address: outTokenDetails.erc20AddressSourceChain as `0x${string}`, - args: [fundingAccount.address], - functionName: "balanceOf" - }); - - const subsidyAmountRaw = expectedAmountRaw.minus(actualBalance); - - if (subsidyAmountRaw.lte(0)) { - logger.info( - `FinalSettlementSubsidyHandler: Actual balance (${actualBalance.toString()}) meets expected amount. No subsidy needed.` - ); - return this.transitionToNextPhase(state, "destinationTransfer"); - } - - logger.info(`FinalSettlementSubsidyHandler: Subsidizing ${subsidyAmountRaw.toString()} units to ${ephemeralAddress}`); - - // Check if funding account has enough balance - if (new Big(actualBalanceFundingAccount.toString()).lt(subsidyAmountRaw)) { - logger.info( - `FinalSettlementSubsidyHandler: Funding account has insufficient balance. Swapping native token to ${outTokenDetails.assetSymbol}` - ); - - const nativeToken = NATIVE_TOKENS[destinationNetwork]; - const oneUsdInNative = await priceFeedService.convertCurrency( - "1", - "USD" as RampCurrency, - nativeToken.symbol as RampCurrency - ); - const oneUsdInNativeRaw = multiplyByPowerOfTen(oneUsdInNative, nativeToken.decimals).toFixed(0); - console.log("values; oneUsdInNativeRaw:", oneUsdInNativeRaw); - - const chainId = getNetworkId(destinationNetwork).toString(); - const testRouteResult = await getRoute({ - bypassGuardrails: true, - enableExpress: true, - fromAddress: fundingAccount.address, - fromAmount: oneUsdInNativeRaw, - fromChain: chainId, - fromToken: NATIVE_TOKEN_ADDRESS, - slippageConfig: { - autoMode: 1 - }, - toAddress: fundingAccount.address, - toChain: chainId, - toToken: outTokenDetails.erc20AddressSourceChain - }); - - const { route: testRoute } = testRouteResult.data; - const rate = new Big(testRoute.estimate.toAmount).div(new Big(oneUsdInNativeRaw)); - const requiredNativeRaw = subsidyAmountRaw.div(rate).mul(1.1).toFixed(0); - - logger.info( - `FinalSettlementSubsidyHandler: Swapping ${requiredNativeRaw} native units (approx. rate ${rate}) to get required subsidy.` - ); - - // Check the amount of native is not higher than cap, cap specidied in units of usd. - const requiredNative = new Big(requiredNativeRaw).div(new Big(10).pow(nativeToken.decimals)); - const requiredNativeInUsd = await priceFeedService.convertCurrency( - requiredNative.toString(), - nativeToken.symbol as RampCurrency, - "USD" as RampCurrency - ); - - if (new Big(requiredNativeInUsd).gt(MAX_FINAL_SETTLEMENT_SUBSIDY_USD)) { - this.createUnrecoverableError( - `FinalSettlementSubsidyHandler: Required subsidy swap amount $${requiredNativeInUsd} exceeds maximum allowed $${MAX_FINAL_SETTLEMENT_SUBSIDY_USD}` - ); - } - - const swapRouteResult = await getRoute({ - bypassGuardrails: true, - enableExpress: true, - fromAddress: fundingAccount.address, - fromAmount: requiredNativeRaw, - fromChain: chainId, - fromToken: NATIVE_TOKEN_ADDRESS, - slippageConfig: { - autoMode: 1 - }, - toAddress: fundingAccount.address, - toChain: chainId, - toToken: outTokenDetails.erc20AddressSourceChain - }); - - const { route: swapRoute } = swapRouteResult.data; - - const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); - const txHashIdx = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { - data: swapRoute.transactionRequest.data as `0x${string}`, - gas: BigInt(swapRoute.transactionRequest.gasLimit), - maxFeePerGas, - maxPriorityFeePerGas, - to: swapRoute.transactionRequest.target as `0x${string}`, - value: BigInt(swapRoute.transactionRequest.value) - }); - - logger.info(`FinalSettlementSubsidyHandler: Swap transaction sent: ${txHashIdx}. Waiting for receipt...`); - const receipt = await publicClient.waitForTransactionReceipt({ hash: txHashIdx }); - - if (receipt.status !== "success") { - throw new Error(`Swap transaction ${txHashIdx} failed`); - } - - logger.info("FinalSettlementSubsidyHandler: Swap successful. Waiting for balance update..."); - - // Wait for balance checks to pass - await checkEvmBalancePeriodically( - outTokenDetails.erc20AddressSourceChain, - fundingAccount.address, - subsidyAmountRaw.toString(), - BALANCE_POLLING_TIME_MS, - EVM_BALANCE_CHECK_TIMEOUT_MS, - destinationNetwork - ); - } - - // Execution Loop - let txHash: `0x${string}` | undefined = state.state.finalSettlementSubsidyTxHash as `0x${string}` | undefined; - - try { - const data = encodeFunctionData({ - abi: erc20Abi, - args: [ephemeralAddress, BigInt(subsidyAmountRaw.toFixed(0))], - functionName: "transfer" - }); - - const { maxFeePerGas, maxPriorityFeePerGas } = await publicClient.estimateFeesPerGas(); - - let receipt: TransactionReceipt | undefined = undefined; - let attempt = 0; - - while (attempt < 5 && (!receipt || receipt.status !== "success")) { - // Blind retry for transaction submission - txHash = await evmClientManager.sendTransactionWithBlindRetry(destinationNetwork, fundingAccount, { - data, - maxFeePerGas, - maxPriorityFeePerGas, - to: outTokenDetails.erc20AddressSourceChain as `0x${string}`, - value: 0n - }); - - receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); - - if (!receipt || receipt.status !== "success") { - logger.error(`FinalSettlementSubsidyHandler: Transaction ${txHash} failed or was not found. Retrying...`); - attempt++; - await new Promise(resolve => setTimeout(resolve, 20000)); - } - } - - if (!receipt || receipt.status !== "success") { - throw new Error(`Failed to confirm subsidy transaction after ${attempt} attempts`); - } - - await state.update({ - state: { - ...state.state, - finalSettlementSubsidyTxHash: txHash - } - }); - - return this.transitionToNextPhase(state, "destinationTransfer"); - } catch (error) { - throw this.createRecoverableError( - `FinalSettlementSubsidyHandler: Error during phase execution - ${(error as Error).message}` - ); - } - } -} - -export default new FinalSettlementSubsidyHandler(); diff --git a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts index 78ab5fe138..6944b5210f 100644 --- a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts +++ b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts @@ -1,10 +1,8 @@ import { ApiManager, EvmClientManager, - EvmNetworks, FiatToken, getNetworkFromDestination, - isNetworkEVM, Networks, RampDirection, RampPhase @@ -14,7 +12,6 @@ import { privateKeyToAccount } from "viem/accounts"; import { polygon } from "viem/chains"; import logger from "../../../../config/logger"; import { MOONBEAM_FUNDING_PRIVATE_KEY, POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS } from "../../../../constants/constants"; - import QuoteTicket from "../../../../models/quoteTicket.model"; import RampState from "../../../../models/rampState.model"; import { UnrecoverablePhaseError } from "../../../errors/phase-error"; @@ -26,7 +23,6 @@ import { validateStellarPaymentSequenceNumber } from "../helpers/stellar-sequenc import { StateMetadata } from "../meta-state-types"; import { horizonServer, - isDestinationEvmEphemeralFunded, isMoonbeamEphemeralFunded, isPendulumEphemeralFunded, isPolygonEphemeralFunded, @@ -48,17 +44,6 @@ function isOnramp(state: RampState): boolean { return state.type === RampDirection.BUY; } -const DESTINATION_EVM_FUNDING_AMOUNTS: Record = { - [Networks.Ethereum]: "0.00016", // ~0.5 USD @ 3000 - [Networks.Arbitrum]: "0.000034", // ~0.1 USD @ 3000 - [Networks.Base]: "0.000034", // ~0.1 USD @ 3000 - [Networks.Polygon]: "0.4", // ~0.06 USD @ 0.13 - [Networks.BSC]: "0.000115", // ~0.1 USD @ 889 - [Networks.Avalanche]: "0.0034", // ~0.1 USD @ 30 - [Networks.Moonbeam]: "0.34", // ~0.1 USD @ 0.30 - [Networks.PolygonAmoy]: "0.2" // ~0.1 USD @ 0.50 -}; - export class FundEphemeralPhaseHandler extends BasePhaseHandler { public getPhaseName(): RampPhase { return "fundEphemeral"; @@ -88,17 +73,6 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { return false; } - protected getRequiresDestinationEvmFunding(state: RampState): boolean { - // Required for onramps where the destination is an EVM network (not AssetHub) - if (isOnramp(state) && state.to !== Networks.AssetHub) { - const destinationNetwork = getNetworkFromDestination(state.to); - if (destinationNetwork && isNetworkEVM(destinationNetwork)) { - return true; - } - } - return false; - } - protected async executePhase(state: RampState): Promise { const quote = await QuoteTicket.findByPk(state.quoteId); if (!quote) { @@ -113,7 +87,6 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { const requiresPendulumEphemeralAddress = this.getRequiresPendulumEphemeralAddress(state, quote.inputCurrency); const requiresPolygonEphemeralAddress = this.getRequiresPolygonEphemeralAddress(state, quote.inputCurrency); const requiresMoonbeamEphemeralAddress = this.getRequiresMoonbeamEphemeralAddress(state, quote.inputCurrency); - const requiresDestinationEvmFunding = this.getRequiresDestinationEvmFunding(state); // Ephemeral checks. if (!substrateEphemeralAddress && requiresPendulumEphemeralAddress) { @@ -137,12 +110,6 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { const isPolygonFunded = requiresPolygonEphemeralAddress ? await isPolygonEphemeralFunded(evmEphemeralAddress) : true; - const destinationNetwork = getNetworkFromDestination(state.to); - const isDestinationEvmFunded = - requiresDestinationEvmFunding && destinationNetwork && isNetworkEVM(destinationNetwork) // for type safety - ? await isDestinationEvmEphemeralFunded(evmEphemeralAddress, destinationNetwork) - : true; - if (state.state.stellarTarget) { const isFunded = await isStellarEphemeralFunded( state.state.stellarEphemeralAccountId, @@ -162,7 +129,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { } else { await fundEphemeralAccount("pendulum", substrateEphemeralAddress, false); } - } else if (requiresPendulumEphemeralAddress) { + } else { logger.info("Pendulum ephemeral address already funded."); } @@ -181,16 +148,9 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { if (isOnramp(state) && !isPolygonFunded) { logger.info(`Funding polygon ephemeral account ${evmEphemeralAddress}`); await this.fundPolygonEphemeralAccount(state); - } else if (requiresPolygonEphemeralAddress) { + } else { logger.info("Polygon ephemeral address already funded."); } - - if (isOnramp(state) && !isDestinationEvmFunded && destinationNetwork && isNetworkEVM(destinationNetwork)) { - logger.info(`Funding destination EVM ephemeral account ${evmEphemeralAddress} on ${destinationNetwork}`); - await this.fundDestinationEvmEphemeralAccount(state, destinationNetwork); - } else if (requiresDestinationEvmFunding) { - logger.info(`Destination EVM ephemeral address already funded on ${destinationNetwork}.`); - } } catch (e) { console.error("Error in FundEphemeralPhaseHandler:", e); @@ -317,41 +277,6 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { throw new Error("FundEphemeralPhaseHandler: Error during funding Polygon ephemeral: " + error); } } - - protected async fundDestinationEvmEphemeralAccount(state: RampState, destinationNetwork: EvmNetworks): Promise { - try { - const evmClientManager = EvmClientManager.getInstance(); - const destinationClient = evmClientManager.getClient(destinationNetwork); - const chain = destinationClient.chain; - - if (!chain) { - throw new Error(`FundEphemeralPhaseHandler: Could not get chain info for ${destinationNetwork}`); - } - - const ephemeralAddress = state.state.evmEphemeralAddress; - const fundingAmountUnits = DESTINATION_EVM_FUNDING_AMOUNTS[destinationNetwork]; - const fundingAmountRaw = multiplyByPowerOfTen(fundingAmountUnits, chain.nativeCurrency.decimals).toFixed(); - - const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); - const walletClient = evmClientManager.getWalletClient(destinationNetwork, fundingAccount); - - const txHash = await walletClient.sendTransaction({ - to: ephemeralAddress as `0x${string}`, - value: BigInt(fundingAmountRaw) - }); - - const receipt = await destinationClient.waitForTransactionReceipt({ - hash: txHash as `0x${string}` - }); - - if (!receipt || receipt.status !== "success") { - throw new Error(`FundEphemeralPhaseHandler: Transaction ${txHash} failed or was not found on ${destinationNetwork}`); - } - } catch (error) { - console.error(`FundEphemeralPhaseHandler: Error during funding ${destinationNetwork} ephemeral:`, error); - throw new Error(`FundEphemeralPhaseHandler: Error during funding ${destinationNetwork} ephemeral: ` + error); - } - } } export default new FundEphemeralPhaseHandler(); diff --git a/apps/api/src/api/services/phases/handlers/helpers.ts b/apps/api/src/api/services/phases/handlers/helpers.ts index e3578e43f8..b0cfe753b6 100644 --- a/apps/api/src/api/services/phases/handlers/helpers.ts +++ b/apps/api/src/api/services/phases/handlers/helpers.ts @@ -1,11 +1,4 @@ -import { - API, - EvmClientManager, - EvmNetworks, - HORIZON_URL, - StellarTokenDetails, - Networks as VortexNetworks -} from "@vortexfi/shared"; +import { API, EvmClientManager, HORIZON_URL, StellarTokenDetails, Networks as VortexNetworks } from "@vortexfi/shared"; import Big from "big.js"; import { Horizon, Networks } from "stellar-sdk"; import { polygon } from "viem/chains"; @@ -72,25 +65,3 @@ export async function isPolygonEphemeralFunded(polygonEphemeralAddress: string): return Big(balance.toString()).gte(fundingAmountRaw); } - -export async function isDestinationEvmEphemeralFunded( - evmEphemeralAddress: string, - destinationNetwork: EvmNetworks -): Promise { - const evmClientManager = EvmClientManager.getInstance(); - const destinationClient = evmClientManager.getClient(destinationNetwork); - const chain = destinationClient.chain; - - if (!chain) { - return false; - } - - const balance = await destinationClient.getBalance({ - address: evmEphemeralAddress as `0x${string}` - }); - const fundingAmountRaw = new Big( - multiplyByPowerOfTen(POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS, chain.nativeCurrency.decimals).toFixed() - ); - - return Big(balance.toString()).gte(fundingAmountRaw); -} 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 ea0a7fc0b5..9d18322687 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,57 +85,16 @@ export class PendulumToMoonbeamXCMPhaseHandler extends BasePhaseHandler { return balance.gte(expectedOutputAmountRaw); }; - const waitForMoonbeamArrival = async (timeoutMs: number = 120000): Promise => { - const startTime = Date.now(); - const pollIntervalMs = 5000; - - while (Date.now() - startTime < timeoutMs) { - if (await didTokensArriveOnMoonbeam()) { - return true; - } - await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); - } - return false; - }; - try { - // Check if we already have a stored XCM hash (XCM was submitted in a previous attempt) - if (state.state.pendulumToMoonbeamXcmHash) { - logger.info( - `PendulumToMoonbeamPhaseHandler: XCM already submitted (hash: ${state.state.pendulumToMoonbeamXcmHash}) for ramp ${state.id}. Waiting for arrival on Moonbeam...` - ); - - if (await didTokensArriveOnMoonbeam()) { - logger.info(`PendulumToMoonbeamPhaseHandler: Tokens already arrived on Moonbeam for ramp ${state.id}.`); - return this.transitionToNextPhase(state, this.nextPhaseSelector(state)); - } - - const arrived = await waitForMoonbeamArrival(); - if (!arrived) { - throw this.createRecoverableError("Timeout waiting for tokens to arrive on Moonbeam after XCM was already submitted"); - } - return this.transitionToNextPhase(state, this.nextPhaseSelector(state)); - } - - // Check if tokens already left Pendulum (XCM was submitted but hash wasn't stored due to crash) - if (await didTokensLeavePendulum()) { + // We have to check if the input token already arrived on Moonbeam and if it left Pendulum. + // If we'd only check if it arrived on Moonbeam, we might miss transferring them if the target account already has some tokens. + if ((await didTokensLeavePendulum()) && (await didTokensArriveOnMoonbeam())) { logger.info( - `PendulumToMoonbeamPhaseHandler: Tokens already left Pendulum for ramp ${state.id}. XCM likely submitted but hash not stored. Waiting for arrival on Moonbeam...` + `PendulumToMoonbeamPhaseHandler: Input token already arrived on Moonbeam, skipping XCM transfer for ramp ${state.id}.` ); - - if (await didTokensArriveOnMoonbeam()) { - logger.info(`PendulumToMoonbeamPhaseHandler: Tokens already arrived on Moonbeam for ramp ${state.id}.`); - return this.transitionToNextPhase(state, this.nextPhaseSelector(state)); - } - - const arrived = await waitForMoonbeamArrival(); - if (!arrived) { - throw this.createRecoverableError("Timeout waiting for tokens to arrive on Moonbeam after tokens left Pendulum"); - } return this.transitionToNextPhase(state, this.nextPhaseSelector(state)); } - // No previous XCM submission detected, proceed with transfer const { txData: pendulumToMoonbeamTransaction } = this.getPresignedTransaction(state, "pendulumToMoonbeamXcm"); if (typeof pendulumToMoonbeamTransaction !== "string") { @@ -152,24 +111,19 @@ export class PendulumToMoonbeamXCMPhaseHandler extends BasePhaseHandler { logger.info( `PendulumToMoonbeamPhaseHandler: XCM transfer submitted with hash ${hash} for ramp ${state.id}. Waiting for the token to arrive on Moonbeam...` ); + await didTokensArriveOnMoonbeam(); + + // XCM is payed by the ephemeral, in GLMR, with a fixed value of MOONBEAM_XCM_FEE_GLMR + const subsidyAmount = nativeToDecimal(MOONBEAM_XCM_FEE_GLMR, 18).toNumber(); + const hashToStore = hash ?? "0x"; + await this.createSubsidy(state, subsidyAmount, SubsidyToken.GLMR, substrateEphemeralAddress, hashToStore); - // Store the hash immediately after submission to minimize crash window state.state = { ...state.state, pendulumToMoonbeamXcmHash: hash }; await state.update({ state: state.state }); - const arrived = await waitForMoonbeamArrival(); - if (!arrived) { - throw this.createRecoverableError("Timeout waiting for tokens to arrive on Moonbeam after XCM submission"); - } - - // XCM is payed by the ephemeral, in GLMR, with a fixed value of MOONBEAM_XCM_FEE_GLMR - const subsidyAmount = nativeToDecimal(MOONBEAM_XCM_FEE_GLMR, 18).toNumber(); - const hashToStore = hash ?? "0x"; - await this.createSubsidy(state, subsidyAmount, SubsidyToken.GLMR, substrateEphemeralAddress, hashToStore); - return this.transitionToNextPhase(state, this.nextPhaseSelector(state)); } catch (e) { console.error("Error in PendulumToMoonbeamPhase:", e); 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 4f06d46a35..db44262f01 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 @@ -1,19 +1,12 @@ import { AxelarScanStatusFees, - BalanceCheckError, - BalanceCheckErrorType, - checkEvmBalancePeriodically, EvmClientManager, - EvmNetworks, - EvmTokenDetails, FiatToken, getNetworkId, - getOnChainTokenDetails, getStatus, getStatusAxelarScan, Networks, nativeToDecimal, - OnChainToken, RampDirection, RampPhase, SquidRouterPayResponse @@ -34,13 +27,6 @@ import { BasePhaseHandler } from "../base-phase-handler"; const AXELAR_POLLING_INTERVAL_MS = 10000; // 10 seconds const SQUIDROUTER_INITIAL_DELAY_MS = 60000; // 60 seconds const AXL_GAS_SERVICE_EVM = "0x2d5d7d31F671F86C782533cc367F14109a082712"; -const BALANCE_POLLING_TIME_MS = 10000; -// NOTE: This timeout is intentionally longer (15 minutes) than the 3–5 minute balance -// checks in other handlers. For SquidRouter/Axelar bridge flows we wait for cross-chain -// settlement and gas payment on the destination chain, which can legitimately take longer -// under network congestion or bridge delays. Reducing this timeout risks premature failure -// of otherwise successful bridge operations. -const EVM_BALANCE_CHECK_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes const DEFAULT_SQUIDROUTER_GAS_ESTIMATE = "1600000"; // Estimate used to calculate part of the gas fee for SquidRouter transactions. /** * Handler for the squidRouter pay phase. Checks the status of the Axelar bridge and pays on native GLMR fee. @@ -100,7 +86,7 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { if (state.to === Networks.AssetHub) { return this.transitionToNextPhase(state, "moonbeamToPendulum"); } else { - return this.transitionToNextPhase(state, "finalSettlementSubsidy"); + return this.transitionToNextPhase(state, "complete"); } } catch (error: unknown) { logger.error(`SquidRouterPayPhaseHandler: Error in squidRouterPay phase for ramp ${state.id}:`, error); @@ -109,153 +95,78 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { } /** - * Checks the status of the Axelar bridge and balances in parallel. - * If a balance arrived, we consider it a success. - * If the bridge reports success, we consider it a success. - * Only if both fail (timeout) we throw. + * Gets the status of the Axelar bridge + * @param txHash The swap (bridgeCall) transaction hash */ private async checkStatus(state: RampState, swapHash: string, quote: QuoteTicket): Promise { - // If the destination is not an EVM network, skip the EVM balance optimization and rely on bridge status only. - if (quote.to === Networks.AssetHub) { - logger.info("SquidRouterPayPhaseHandler: Destination network is non-EVM; skipping EVM balance check optimization.", { - toNetwork: quote.to - }); - await this.checkBridgeStatus(state, swapHash, quote); - return; - } - - const toChain = quote.to as EvmNetworks; - - let balanceCheckPromise: Promise; - try { - const outTokenDetails = getOnChainTokenDetails(toChain, quote.outputCurrency as OnChainToken) as EvmTokenDetails; - const ephemeralAddress = state.state.evmEphemeralAddress; - - if (outTokenDetails && ephemeralAddress) { - balanceCheckPromise = checkEvmBalancePeriodically( - outTokenDetails.erc20AddressSourceChain, - ephemeralAddress, - "1", // If we passed expectedAmountRaw, we might timeout if the bridge slipped and delivered slightly less. - BALANCE_POLLING_TIME_MS, - EVM_BALANCE_CHECK_TIMEOUT_MS, - toChain - ); - } else { - logger.warn( - "SquidRouterPayPhaseHandler: Cannot perform balance check optimization (missing expected token details or address)." - ); - balanceCheckPromise = Promise.reject(new Error("Skipped balance check")); - } - } catch (err) { - logger.warn(`SquidRouterPayPhaseHandler: Error preparing balance check: ${err}`); - balanceCheckPromise = Promise.reject(err); - } - - // Wrap both promises to prevent unhandled rejections after one succeeds - const bridgeCheckPromise = this.checkBridgeStatus(state, swapHash, quote).catch(err => { - // Re-throw to preserve the error for Promise.any - throw err; - }); - - const balanceCheckWithErrorHandling = balanceCheckPromise.catch(err => { - // Re-throw to preserve the error for Promise.any - throw err; - }); + let isExecuted = false; + let payTxHash: string | undefined = state.state.squidRouterPayTxHash; // in case of recovery, we may have already paid. + // initial delay to allow for API indexing. + await new Promise(resolve => setTimeout(resolve, SQUIDROUTER_INITIAL_DELAY_MS)); + while (!isExecuted) { + const squidRouterStatus = await this.getSquidrouterStatus(swapHash, state, quote); - try { - await Promise.any([bridgeCheckPromise, balanceCheckWithErrorHandling]); - } catch (error) { - // Both failed. - if (error instanceof AggregateError) { - // Distinguish between balance check timeout and read failure - const balanceError = error.errors.find(e => e instanceof BalanceCheckError); - const bridgeError = error.errors.find(e => !(e instanceof BalanceCheckError)); - - let errorMessage = "SquidRouterPayPhaseHandler: Both bridge status check and balance check failed."; - - if (balanceError instanceof BalanceCheckError) { - if (balanceError.type === BalanceCheckErrorType.Timeout) { - errorMessage += ` Balance check timed out after ${EVM_BALANCE_CHECK_TIMEOUT_MS}ms.`; - } else if (balanceError.type === BalanceCheckErrorType.ReadFailure) { - errorMessage += ` Balance check read failure (unexpected infrastructure issue): ${balanceError.message}.`; - } + if (squidRouterStatus.status === "success") { + isExecuted = true; + logger.info(`SquidRouterPayPhaseHandler: Transaction ${swapHash} successfully executed on Squidrouter.`); + break; } - - if (bridgeError) { - errorMessage += ` Bridge check error: ${bridgeError instanceof Error ? bridgeError.message : String(bridgeError)}.`; + if (!squidRouterStatus) { + logger.warn(`SquidRouterPayPhaseHandler: No squidRouter status found for swap hash ${swapHash}.`); + throw this.createRecoverableError("No squidRouter status found for swap hash."); } - throw new Error(errorMessage); - } - throw error; - } - } - - /** - * Gets the status of the Axelar bridge - * @param txHash The swap (bridgeCall) transaction hash - */ - private async checkBridgeStatus(state: RampState, swapHash: string, quote: QuoteTicket): Promise { - let isExecuted = false; - let payTxHash: string | undefined = state.state.squidRouterPayTxHash; - - await new Promise(resolve => setTimeout(resolve, SQUIDROUTER_INITIAL_DELAY_MS)); + // If route is on the same chain, we must skip the Axelar check. + if (!squidRouterStatus.isGMPTransaction) { + await new Promise(resolve => setTimeout(resolve, AXELAR_POLLING_INTERVAL_MS)); + } - while (!isExecuted) { - try { - const squidRouterStatus = await this.getSquidrouterStatus(swapHash, state, quote); + const axelarScanStatus = await getStatusAxelarScan(swapHash); - if (!squidRouterStatus) { - logger.warn(`SquidRouterPayPhaseHandler: No squidRouter status found for swap hash ${swapHash}.`); - } else if (squidRouterStatus.status === "success") { - logger.info(`SquidRouterPayPhaseHandler: Transaction ${swapHash} successfully executed on Squidrouter.`); + //no status found is considered a recoverable error. + if (!axelarScanStatus) { + logger.warn(`SquidRouterPayPhaseHandler: No status found for swap hash ${swapHash}.`); + throw this.createRecoverableError("No status found for swap hash."); + } + if (axelarScanStatus.status === "executed" || axelarScanStatus.status === "express_executed") { isExecuted = true; + logger.info(`SquidRouterPayPhaseHandler: Transaction ${swapHash} successfully executed on Axelar.`); break; } - const isGmp = squidRouterStatus ? squidRouterStatus.isGMPTransaction : true; - - if (isGmp) { - const axelarScanStatus = await getStatusAxelarScan(swapHash); + if (!payTxHash) { + const nativeToFundRaw = this.calculateGasFeeInUnits(axelarScanStatus.fees, DEFAULT_SQUIDROUTER_GAS_ESTIMATE); + const logIndex = Number(axelarScanStatus.id.split("_")[2]); - if (!axelarScanStatus) { - logger.info(`SquidRouterPayPhaseHandler: Axelar status not found yet for hash ${swapHash}.`); - } else if (axelarScanStatus.status === "executed" || axelarScanStatus.status === "express_executed") { - logger.info(`SquidRouterPayPhaseHandler: Transaction ${swapHash} successfully executed on Axelar.`); - isExecuted = true; - break; - } else if (!payTxHash) { - logger.info(`SquidRouterPayPhaseHandler: Bridge transaction detected on Axelar. Proceeding to fund gas.`); + payTxHash = await this.executeFundTransaction(nativeToFundRaw, swapHash as `0x${string}`, logIndex, state, quote); - const nativeToFundRaw = this.calculateGasFeeInUnits(axelarScanStatus.fees, DEFAULT_SQUIDROUTER_GAS_ESTIMATE); - const logIndex = Number(axelarScanStatus.id.split("_")[2]); + const isPolygon = quote.inputCurrency !== FiatToken.BRL; + const subsidyToken = isPolygon ? SubsidyToken.MATIC : SubsidyToken.GLMR; + const subsidyAmount = nativeToDecimal(nativeToFundRaw, 18).toNumber(); // Both MATIC and GLMR have 18 decimals + const payerAccount = isPolygon + ? this.polygonWalletClient.account?.address + : this.moonbeamWalletClient.account?.address; - payTxHash = await this.executeFundTransaction(nativeToFundRaw, swapHash as `0x${string}`, logIndex, state, quote); - - const isPolygon = quote.inputCurrency !== FiatToken.BRL; - const subsidyToken = isPolygon ? SubsidyToken.MATIC : SubsidyToken.GLMR; - const subsidyAmount = nativeToDecimal(nativeToFundRaw, 18).toNumber(); - const payerAccount = isPolygon - ? this.polygonWalletClient.account?.address - : this.moonbeamWalletClient.account?.address; + if (payerAccount) { + await this.createSubsidy(state, subsidyAmount, subsidyToken, payerAccount, payTxHash); + } - if (payerAccount) { - await this.createSubsidy(state, subsidyAmount, subsidyToken, payerAccount, payTxHash); + await state.update({ + state: { + ...state.state, + squidRouterPayTxHash: payTxHash } - - await state.update({ - state: { ...state.state, squidRouterPayTxHash: payTxHash } - }); - } - } else { - logger.info(`SquidRouterPayPhaseHandler: Same-chain transaction detected. Skipping Axelar check.`); + }); } - } catch (error) { - logger.error(`SquidRouterPayPhaseHandler: Error in bridge status loop for ${swapHash}:`, error); - } - await new Promise(resolve => setTimeout(resolve, AXELAR_POLLING_INTERVAL_MS)); + await new Promise(resolve => setTimeout(resolve, AXELAR_POLLING_INTERVAL_MS)); + } + } catch (error) { + if (error && error instanceof PhaseError && error.isRecoverable) { + throw error; + } + throw new Error(`SquidRouterPayPhaseHandler: Error waiting checking for Axelar bridge transaction: ${error}`); } } diff --git a/apps/api/src/api/services/phases/meta-state-types.ts b/apps/api/src/api/services/phases/meta-state-types.ts index d9d79fb214..e32942d59a 100644 --- a/apps/api/src/api/services/phases/meta-state-types.ts +++ b/apps/api/src/api/services/phases/meta-state-types.ts @@ -53,6 +53,4 @@ export interface StateMetadata { // Final transaction hash and explorer link (computed once when ramp is complete) finalTransactionHash?: string; finalTransactionExplorerLink?: string; - destinationTransferTxHash?: string; - finalSettlementSubsidyTxHash?: string; } diff --git a/apps/api/src/api/services/phases/phase-processor.ts b/apps/api/src/api/services/phases/phase-processor.ts index 82e6cb2668..b825e16b3d 100644 --- a/apps/api/src/api/services/phases/phase-processor.ts +++ b/apps/api/src/api/services/phases/phase-processor.ts @@ -1,6 +1,5 @@ import httpStatus from "http-status"; import logger from "../../../config/logger"; -import { runWithRampContext } from "../../../config/ramp-context"; import RampState from "../../../models/rampState.model"; import { APIError } from "../../errors/api-error"; import { PhaseError, RecoverablePhaseError } from "../../errors/phase-error"; @@ -31,43 +30,41 @@ export class PhaseProcessor { * @param rampId The ID of the ramping process */ public async processRamp(rampId: string): Promise { - return runWithRampContext(rampId, async () => { - const state = await RampState.findByPk(rampId); - if (!state) { - throw new APIError({ - message: `Ramp with ID ${rampId} not found`, - status: httpStatus.NOT_FOUND - }); - } + const state = await RampState.findByPk(rampId); + if (!state) { + throw new APIError({ + message: `Ramp with ID ${rampId} not found`, + status: httpStatus.NOT_FOUND + }); + } - // Try to acquire the lock - let lockAcquired = await this.acquireLock(state); - if (!lockAcquired) { - if (this.isLockExpired(state)) { - logger.info(`Lock for ramp ${rampId} has expired. Ignoring previous lock and continue processing...`); - // Force release the expired lock and try to acquire it again - await this.releaseLock(state); - lockAcquired = await this.acquireLock(state); - if (!lockAcquired) { - logger.warn(`Failed to acquire lock for ramp ${rampId} even after clearing expired lock`); - return; - } - } else { - logger.info(`Skipping processing for ramp ${rampId} as it's already being processed`); + // Try to acquire the lock + let lockAcquired = await this.acquireLock(state); + if (!lockAcquired) { + if (this.isLockExpired(state)) { + logger.info(`Lock for ramp ${rampId} has expired. Ignoring previous lock and continue processing...`); + // Force release the expired lock and try to acquire it again + await this.releaseLock(state); + lockAcquired = await this.acquireLock(state); + if (!lockAcquired) { + logger.warn(`Failed to acquire lock for ramp ${rampId} even after clearing expired lock`); return; } + } else { + logger.info(`Skipping processing for ramp ${rampId} as it's already being processed`); + return; } + } - try { - await this.processPhase(state); - // We just return, since the error management should be handled in the processPhase method. - // We do not want to crash the whole process if one ramp fails. - } catch (error) { - logger.error(`Error processing ramp ${rampId}: ${error}`); - } finally { - await this.releaseLock(state); - } - }); + try { + await this.processPhase(state); + // We just return, since the error management should be handled in the processPhase method. + // We do not want to crash the whole process if one ramp fails. + } catch (error) { + logger.error(`Error processing ramp ${rampId}: ${error}`); + } finally { + await this.releaseLock(state); + } } /** diff --git a/apps/api/src/api/services/phases/post-process/stellar-post-process-handler.ts b/apps/api/src/api/services/phases/post-process/stellar-post-process-handler.ts index 298332abdd..4a0dc2254e 100644 --- a/apps/api/src/api/services/phases/post-process/stellar-post-process-handler.ts +++ b/apps/api/src/api/services/phases/post-process/stellar-post-process-handler.ts @@ -23,7 +23,7 @@ export class StellarPostProcessHandler extends BasePostProcessHandler { return false; } - if (state.type !== RampDirection.SELL || this.getPresignedTransaction(state, "stellarCleanup") === undefined) { + if (state.type !== RampDirection.SELL) { return false; } diff --git a/apps/api/src/api/services/phases/register-handlers.ts b/apps/api/src/api/services/phases/register-handlers.ts index 167927628a..76cbdce168 100644 --- a/apps/api/src/api/services/phases/register-handlers.ts +++ b/apps/api/src/api/services/phases/register-handlers.ts @@ -1,9 +1,7 @@ import logger from "../../../config/logger"; import brlaOnrampMintHandler from "./handlers/brla-onramp-mint-handler"; import brlaPayoutMoonbeamHandler from "./handlers/brla-payout-moonbeam-handler"; -import destinationTransferHandler from "./handlers/destination-transfer-handler"; import distributeFeesHandler from "./handlers/distribute-fees-handler"; -import finalSettlementSubsidy from "./handlers/final-settlement-subsidy"; import fundEphemeralHandler from "./handlers/fund-ephemeral-handler"; import hydrationSwapHandler from "./handlers/hydration-swap-handler"; import hydrationToAssethubXcmPhaseHandler from "./handlers/hydration-to-assethub-xcm-phase-handler"; @@ -54,8 +52,6 @@ export function registerPhaseHandlers(): void { phaseRegistry.registerHandler(pendulumToHydrationXcmPhaseHandler); phaseRegistry.registerHandler(hydrationToAssethubXcmPhaseHandler); phaseRegistry.registerHandler(hydrationSwapHandler); - phaseRegistry.registerHandler(finalSettlementSubsidy); - phaseRegistry.registerHandler(destinationTransferHandler); logger.info("Phase handlers registered"); } diff --git a/apps/api/src/api/services/priceFeed.service.ts b/apps/api/src/api/services/priceFeed.service.ts index c6b3ae9968..da165e8ef4 100644 --- a/apps/api/src/api/services/priceFeed.service.ts +++ b/apps/api/src/api/services/priceFeed.service.ts @@ -454,7 +454,7 @@ export class PriceFeedService { ETH: "ethereum", GLMR: "moonbeam", HDX: "hydradx", - MATIC: "polygon-ecosystem-token" + MATIC: "matic-network" }; return tokenIdMap[currency.toUpperCase()] || null; diff --git a/apps/api/src/api/services/quote/core/squidrouter.ts b/apps/api/src/api/services/quote/core/squidrouter.ts index 51a78513e9..3aedb34983 100644 --- a/apps/api/src/api/services/quote/core/squidrouter.ts +++ b/apps/api/src/api/services/quote/core/squidrouter.ts @@ -242,18 +242,8 @@ export async function calculateEvmBridgeAndNetworkFee(request: EvmBridgeRequest) outputTokenDecimals }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error(`Error calculating EVM bridge and network fee: ${errorMessage}`); - - // Check for specific SquidRouter error types - if (errorMessage.toLowerCase().includes("low liquidity") || errorMessage.toLowerCase().includes("reduce swap amount")) { - throw new APIError({ - message: QuoteError.LowLiquidity, - status: httpStatus.BAD_REQUEST - }); - } - - // Default to generic error for other cases + logger.error(`Error calculating EVM bridge and network fee: ${error instanceof Error ? error.message : String(error)}`); + // We assume that the error is due to a low input amount throw new APIError({ message: QuoteError.InputAmountTooLow, status: httpStatus.BAD_REQUEST diff --git a/apps/api/src/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index 12e77efe16..d12c1ba5cb 100644 --- a/apps/api/src/api/services/quote/index.ts +++ b/apps/api/src/api/services/quote/index.ts @@ -131,10 +131,6 @@ export class QuoteService extends BaseRampService { ): Promise { validateChainSupport(request.rampType, request.from, request.to); - if (request.rampType === RampDirection.BUY && request.to === Networks.Ethereum) { - throw new APIError({ message: QuoteError.FailedToCalculateQuote, status: httpStatus.INTERNAL_SERVER_ERROR }); - } - let partner = null; const partnerNameToUse = request.partnerId || request.partnerName; diff --git a/apps/api/src/api/services/transactions/onramp/common/transactions.ts b/apps/api/src/api/services/transactions/onramp/common/transactions.ts index 262562e734..de89593e18 100644 --- a/apps/api/src/api/services/transactions/onramp/common/transactions.ts +++ b/apps/api/src/api/services/transactions/onramp/common/transactions.ts @@ -4,9 +4,6 @@ import { AMM_MINIMUM_OUTPUT_SOFT_MARGIN, createMoonbeamToPendulumXCM, createNablaTransactionsForOnramp, - EvmClientManager, - EvmNetworks, - EvmTransactionData, encodeSubmittableExtrinsic, getNetworkId, Networks, @@ -14,8 +11,6 @@ import { UnsignedTx } from "@vortexfi/shared"; import Big from "big.js"; -import { encodeFunctionData } from "viem/utils"; -import erc20ABI from "../../../../../contracts/ERC20"; import { QuoteTicketAttributes } from "../../../../../models/quoteTicket.model"; import { StateMetadata } from "../../../phases/meta-state-types"; import { prepareMoonbeamCleanupTransaction } from "../../moonbeam/cleanup"; @@ -178,77 +173,3 @@ export async function addPendulumCleanupTx(params: { txData: encodeSubmittableExtrinsic(pendulumCleanupTransaction) }; } - -/** - * Creates transactions to handle the ephemeral account on the destination chain - * @param params Transaction parameters - * @param unsignedTxs Array to add transactions to - * @param nextNonce Next available nonce - * @returns Updated nonce - */ -export async function addOnrampDestinationChainTransactions(params: { - toAddress: string; - toToken: `0x${string}`; - amountRaw: string; - destinationNetwork: EvmNetworks; -}): Promise { - const { toAddress, amountRaw, destinationNetwork, toToken } = params; - - const evmClientManager = EvmClientManager.getInstance(); - const publicClient = evmClientManager.getClient(destinationNetwork); - - const transferCallData = encodeFunctionData({ - abi: erc20ABI, - args: [toAddress, amountRaw], - functionName: "transfer" - }); - - const { maxFeePerGas } = await publicClient.estimateFeesPerGas(); - - const txData: EvmTransactionData = { - data: transferCallData as `0x${string}`, - gas: "100000", - maxFeePerGas: String(maxFeePerGas), - maxPriorityFeePerGas: String(maxFeePerGas), - to: toToken, - value: "0" - }; - - return txData; -} - -/** - * Creates an approval transaction on the destination chain - * @param params Transaction parameters - * @returns EvmTransactionData - */ -export async function addDestinationChainApprovalTransaction(params: { - amountRaw: string; - spenderAddress: string; - tokenAddress: `0x${string}`; - destinationNetwork: EvmNetworks; -}): Promise { - const { amountRaw, spenderAddress, tokenAddress, destinationNetwork } = params; - - const evmClientManager = EvmClientManager.getInstance(); - const publicClient = evmClientManager.getClient(destinationNetwork); - - const approveCallData = encodeFunctionData({ - abi: erc20ABI, - args: [spenderAddress, amountRaw], - functionName: "approve" - }); - - const { maxFeePerGas } = await publicClient.estimateFeesPerGas(); - - const txData: EvmTransactionData = { - data: approveCallData as `0x${string}`, - gas: "100000", - maxFeePerGas: String(maxFeePerGas), - maxPriorityFeePerGas: String(maxFeePerGas), - to: tokenAddress, - value: "0" - }; - - return txData; -} diff --git a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts index a8aafebef5..b6ba996d08 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/avenia-to-evm.ts @@ -1,34 +1,19 @@ import { AXL_USDC_MOONBEAM_DETAILS, createOnrampSquidrouterTransactionsFromMoonbeamToEvm, - createOnrampSquidrouterTransactionsOnDestinationChain, createPendulumToMoonbeamTransfer, - EvmNetworks, - EvmToken, - EvmTokenDetails, EvmTransactionData, encodeSubmittableExtrinsic, - evmTokenConfig, getNetworkId, - getOnChainTokenDetailsOrDefault, getPendulumDetails, isEvmTokenDetails, - multiplyByPowerOfTen, Networks, UnsignedTx } from "@vortexfi/shared"; -import { privateKeyToAccount } from "viem/accounts"; -import { MOONBEAM_FUNDING_PRIVATE_KEY } from "../../../../../constants/constants"; import { StateMetadata } from "../../../phases/meta-state-types"; import { addFeeDistributionTransaction } from "../../common/feeDistribution"; import { encodeEvmTransactionData } from "../../index"; -import { - addDestinationChainApprovalTransaction, - addMoonbeamTransactions, - addNablaSwapTransactions, - addOnrampDestinationChainTransactions, - addPendulumCleanupTx -} from "../common/transactions"; +import { addMoonbeamTransactions, addNablaSwapTransactions, addPendulumCleanupTx } from "../common/transactions"; import { AveniaOnrampTransactionParams, OnrampTransactionsWithMeta } from "../common/types"; import { validateAveniaOnramp } from "../common/validation"; @@ -142,10 +127,8 @@ export async function prepareAveniaToEvmOnrampTransactions({ throw new Error(`Output token must be an EVM token for onramp to any EVM chain, got ${outputTokenDetails.assetSymbol}`); } - const destinationAxlUsdcDetails = getOnChainTokenDetailsOrDefault(toNetwork as Networks, EvmToken.AXLUSDC) as EvmTokenDetails; - const { approveData, swapData } = await createOnrampSquidrouterTransactionsFromMoonbeamToEvm({ - destinationAddress: evmEphemeralEntry.address, + destinationAddress, fromAddress: evmEphemeralEntry.address, fromToken: AXL_USDC_MOONBEAM_DETAILS.erc20AddressSourceChain, moonbeamEphemeralStartingNonce: moonbeamNonce, @@ -174,82 +157,5 @@ export async function prepareAveniaToEvmOnrampTransactions({ }); moonbeamNonce++; - // Fallback swap depends on the EVM chain. For Ethereum, the bridged token is USDC. For the rest, it is axlUSDC. - const bridgedTokenForFallback = - toNetwork === Networks.Ethereum - ? evmTokenConfig.ethereum.USDC!.erc20AddressSourceChain - : destinationAxlUsdcDetails.erc20AddressSourceChain; - - const { approveData: destApproveData, swapData: destSwapData } = await createOnrampSquidrouterTransactionsOnDestinationChain({ - destinationAddress: evmEphemeralEntry.address, - fromAddress: evmEphemeralEntry.address, - fromToken: bridgedTokenForFallback, - network: toNetwork as EvmNetworks, - rawAmount: quote.metadata.moonbeamToEvm.inputAmountRaw, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - - let destinationNonce = 0; - - const finalAmountRaw = multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals); - const finalSettlementTransaction = await addOnrampDestinationChainTransactions({ - amountRaw: finalAmountRaw.toString(), - destinationNetwork: toNetwork as EvmNetworks, - toAddress: destinationAddress, - toToken: outputTokenDetails.erc20AddressSourceChain - }); - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "destinationTransfer", - signer: evmEphemeralEntry.address, - txData: finalSettlementTransaction - }); - - destinationNonce++; - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "backupSquidRouterApprove", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(destApproveData) as EvmTransactionData - }); - destinationNonce++; - - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: destinationNonce, - phase: "backupSquidRouterSwap", - signer: evmEphemeralEntry.address, - txData: encodeEvmTransactionData(destSwapData) as EvmTransactionData - }); - destinationNonce++; - - const maxUint256 = 2n ** 256n - 1n; - const fundingAccount = privateKeyToAccount(MOONBEAM_FUNDING_PRIVATE_KEY as `0x${string}`); - - const backupApproveTransaction = await addDestinationChainApprovalTransaction({ - amountRaw: maxUint256.toString(), - destinationNetwork: toNetwork as EvmNetworks, - spenderAddress: fundingAccount.address, - tokenAddress: bridgedTokenForFallback - }); - - // We set this to 0 on purpose because we don't want to risk that the required nonce is never reached - const backupApproveNonce = 0; - unsignedTxs.push({ - meta: {}, - network: toNetwork, - nonce: backupApproveNonce, - phase: "backupApprove", - signer: evmEphemeralEntry.address, - txData: backupApproveTransaction - }); - return { stateMeta, unsignedTxs }; } diff --git a/apps/api/src/api/workers/cleanup.worker.ts b/apps/api/src/api/workers/cleanup.worker.ts index 5dc2d46925..3e3e3622d7 100644 --- a/apps/api/src/api/workers/cleanup.worker.ts +++ b/apps/api/src/api/workers/cleanup.worker.ts @@ -1,7 +1,6 @@ import { CronJob } from "cron"; import { Op } from "sequelize"; import logger from "../../config/logger"; -import { runWithRampContext } from "../../config/ramp-context"; import RampState from "../../models/rampState.model"; import { postProcessHandlers } from "../services/phases/post-process"; import { BaseRampService } from "../services/ramp/base.service"; @@ -171,16 +170,14 @@ class CleanupWorker { logger.info(`Found ${states.length} completed RampStates that need post-processing`); const processPromises = states.map(async state => { - return runWithRampContext(state.id, async () => { - try { - await this.processCleanup(state); - return { stateId: state.id, status: "fulfilled" }; - } catch (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" }; - } - }); + try { + await this.processCleanup(state); + return { stateId: state.id, status: "fulfilled" }; + } catch (error) { + logger.error(`Error processing cleanup for state ${state.id}:`, error); + // Don't update the state here, processCleanup handles its own updates + return { reason: error, stateId: state.id, status: "rejected" }; + } }); // Use allSettled to allow individual state processing to fail without stopping others diff --git a/apps/api/src/api/workers/ramp-recovery.worker.ts b/apps/api/src/api/workers/ramp-recovery.worker.ts index 61b048d1ed..d2cc7eeb12 100644 --- a/apps/api/src/api/workers/ramp-recovery.worker.ts +++ b/apps/api/src/api/workers/ramp-recovery.worker.ts @@ -75,8 +75,8 @@ class RampRecoveryWorker { // Process each stale state concurrently const recoveryPromises = staleStates.map(async state => { try { - logger.info(`Attempting recovery in phase ${state.currentPhase} for ramp ${state.id}`); - // Process the state (processRamp already wraps execution with runWithRampContext) + logger.info(`Attempting recovery for ramp state ${state.id} in phase ${state.currentPhase}`); + // Process the state await phaseProcessor.processRamp(state.id); logger.info(`Successfully processed ramp state ${state.id}`); return { stateId: state.id, status: "fulfilled" }; @@ -101,7 +101,7 @@ class RampRecoveryWorker { const updateError = updateE as Error; logger.error(`Failed to update ramp state ${state.id} with error log:`, updateError); // Log the original error as well if the update fails - logger.error(`Original recovery error for ${state.id}:`, error); + logger.error(`Original recovery error for state ${state.id}:`, error); } // Return a rejected status for Promise.allSettled return { reason: error, stateId: state.id, status: "rejected" }; diff --git a/apps/api/src/config/express.ts b/apps/api/src/config/express.ts index 3c27ff5e46..cf068e7e1d 100644 --- a/apps/api/src/config/express.ts +++ b/apps/api/src/config/express.ts @@ -58,8 +58,8 @@ app.use(cookieParser()); app.use(morgan(logs)); // parse body params and attach them to req.body -app.use(bodyParser.json({ limit: "50mb" })); -app.use(bodyParser.urlencoded({ extended: true, limit: "50mb" })); +app.use(bodyParser.json()); +app.use(bodyParser.urlencoded({ extended: true })); // gzip compression app.use(compress()); diff --git a/apps/api/src/config/logger.ts b/apps/api/src/config/logger.ts index 635265fec7..3bfdfe6d74 100644 --- a/apps/api/src/config/logger.ts +++ b/apps/api/src/config/logger.ts @@ -1,13 +1,9 @@ import { StreamOptions } from "morgan"; import winston, { format } from "winston"; -import { getRampId } from "./ramp-context"; -const customFormat = winston.format.printf(({ timestamp, level, message, label = "" }) => { - const rampId = getRampId(); - const rampPrefix = rampId ? `[${rampId}] ` : ""; - const timestampPrefix = timestamp ? `[${timestamp}]` : ""; - return `${timestampPrefix} ${level}${label ? ` ${label}` : ""} ${rampPrefix}${message}`; -}); +const customFormat = winston.format.printf( + ({ timestamp, level, message, label = "" }) => `[${timestamp}] ${level}\t ${label} ${message}` +); const logger = winston.createLogger({ level: "info", @@ -22,7 +18,7 @@ const logger = winston.createLogger({ format: format.combine(format.timestamp({ format: "MMM D, YYYY HH:mm:ss" }), format.prettyPrint(), customFormat) }), new winston.transports.Console({ - format: format.combine(format.colorize(), format.prettyPrint(), customFormat) + format: format.combine(format.colorize(), winston.format.simple()) }) ] }); diff --git a/apps/api/src/config/ramp-context.ts b/apps/api/src/config/ramp-context.ts deleted file mode 100644 index 6cea0c8d64..0000000000 --- a/apps/api/src/config/ramp-context.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { AsyncLocalStorage } from "async_hooks"; - -/** - * Context that is available during ramp processing. - * This can be extended with additional fields as needed. - */ -interface RampProcessingContext { - rampId: string; -} - -/** - * AsyncLocalStorage instance for storing ramp processing context. - * This allows us to automatically propagate the rampId through async call chains - * without explicitly passing it as a parameter. - */ -const rampContextStorage = new AsyncLocalStorage(); - -/** - * Run a function within a ramp context. - * All async operations within the callback will have access to the rampId. - * - * @param rampId The ID of the ramp being processed - * @param fn The async function to run within the context - * @returns The result of the async function - */ -export function runWithRampContext(rampId: string, fn: () => Promise): Promise { - return rampContextStorage.run({ rampId }, fn); -} - -/** - * Get the current ramp ID from the AsyncLocalStorage context. - * Returns undefined if not running within a ramp context. - * - * @returns The current ramp ID or undefined - */ -export function getRampId(): string | undefined { - return rampContextStorage.getStore()?.rampId; -} - -/** - * Get the full ramp context from AsyncLocalStorage. - * Returns undefined if not running within a ramp context. - * - * @returns The current ramp context or undefined - */ -export function getRampContext(): RampProcessingContext | undefined { - return rampContextStorage.getStore(); -} diff --git a/apps/api/src/constants/constants.ts b/apps/api/src/constants/constants.ts index fa6ef607f4..33a65d1f2a 100644 --- a/apps/api/src/constants/constants.ts +++ b/apps/api/src/constants/constants.ts @@ -12,7 +12,6 @@ const POLYGON_EPHEMERAL_STARTING_BALANCE_UNITS = "1.5"; // Amount to send to the const DEFAULT_POLLING_INTERVAL = 3000; const GLMR_FUNDING_AMOUNT_RAW = "50000000000000000"; const ASSETHUB_XCM_FEE_USDC_UNITS = 0.013124; -const MAX_FINAL_SETTLEMENT_SUBSIDY_USD = "10"; // 10 USD const WEBHOOKS_CACHE_URL = "https://webhooks-cache.pendulumchain.tech"; // EXAMPLE URL @@ -80,6 +79,5 @@ export { WEBHOOKS_CACHE_URL, DEFAULT_POLLING_INTERVAL, STELLAR_BASE_FEE, - SANDBOX_ENABLED, - MAX_FINAL_SETTLEMENT_SUBSIDY_USD + SANDBOX_ENABLED }; diff --git a/apps/frontend/.storybook/main.ts b/apps/frontend/.storybook/main.ts index 1be6a6a161..7ffb65389c 100644 --- a/apps/frontend/.storybook/main.ts +++ b/apps/frontend/.storybook/main.ts @@ -6,7 +6,7 @@ import { dirname, join } from "path"; * This function is used to resolve the absolute path of a package. * It is needed in projects that use Yarn PnP or are set up within a monorepo. */ -function getAbsolutePath(value: string) { +function getAbsolutePath(value: string): any { return dirname(require.resolve(join(value, "package.json"))); } const config: StorybookConfig = { diff --git a/apps/frontend/_redirects b/apps/frontend/_redirects index d6e27743b0..4f265d0dfb 100644 --- a/apps/frontend/_redirects +++ b/apps/frontend/_redirects @@ -2,4 +2,3 @@ /api/staging/* https://api-staging.vortexfinance.co/:splat 200 /api/sandbox/* https://api-sandbox.vortexfinance.co/:splat 200 /* /index.html 200 -https://vortexfinance.co/* https://www.vortexfinance.co/:splat 301! diff --git a/apps/frontend/src/components/Accordion/index.tsx b/apps/frontend/src/components/Accordion/index.tsx index 8fa71dac38..6da07d247e 100644 --- a/apps/frontend/src/components/Accordion/index.tsx +++ b/apps/frontend/src/components/Accordion/index.tsx @@ -1,7 +1,6 @@ -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { AnimatePresence, motion } from "motion/react"; import { FC } from "react"; import { create } from "zustand"; -import { durations, easings } from "../../constants/animations"; import { cn } from "../../helpers/cn"; interface AccordionProps { @@ -45,7 +44,6 @@ const useAccordionStore = create(set => ({ const Accordion: FC = ({ children, className = "", defaultValue = [] }) => { const setValue = useAccordionStore(state => state.setValue); - const shouldReduceMotion = useReducedMotion(); if (defaultValue.length > 0) { setValue(defaultValue); @@ -55,8 +53,8 @@ const Accordion: FC = ({ children, className = "", defaultValue {children} @@ -65,18 +63,21 @@ const Accordion: FC = ({ children, className = "", defaultValue const AccordionItem: FC = ({ children, className = "", value }) => { const isOpen = useAccordionStore(state => state.value.includes(value)); - const shouldReduceMotion = useReducedMotion(); return ( -
+ {children} -
+
); }; @@ -84,62 +85,61 @@ const AccordionItem: FC = ({ children, className = "", value const AccordionTrigger: FC = ({ children, className = "", value }) => { const toggleValue = useAccordionStore(state => state.toggleValue); const isOpen = useAccordionStore(state => state.value.includes(value)); - const shouldReduceMotion = useReducedMotion(); return ( -
+ toggleValue(value)} - whileHover={shouldReduceMotion ? undefined : { scale: 1.01 }} - whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }} + whileHover={{ scale: 1.01 }} + whileTap={{ scale: 0.99 }} >
- {children} + {children}
-
+ ); }; const AccordionContent: FC = ({ children, className = "", value }) => { const isOpen = useAccordionStore(state => state.value.includes(value)); - const shouldReduceMotion = useReducedMotion(); return ( -
-
- - {isOpen && ( - - {children} - - )} - -
-
+ + {isOpen && ( + + + {children} + + + )} + ); }; diff --git a/apps/frontend/src/components/AssetNumericInput/index.tsx b/apps/frontend/src/components/AssetNumericInput/index.tsx index 8a0dd1a4b3..f827d0bef6 100644 --- a/apps/frontend/src/components/AssetNumericInput/index.tsx +++ b/apps/frontend/src/components/AssetNumericInput/index.tsx @@ -1,4 +1,4 @@ -import { EvmToken, Networks } from "@vortexfi/shared"; +import { EvmToken } from "@vortexfi/shared"; import type { ChangeEvent, FC } from "react"; import type { UseFormRegisterReturn } from "react-hook-form"; import { cn } from "../../helpers/cn"; @@ -24,7 +24,6 @@ interface AssetNumericInputProps { fallbackLogoURI?: string; registerInput: UseFormRegisterReturn; id: string; - network?: Networks; } export const AssetNumericInput: FC = ({ @@ -46,7 +45,6 @@ export const AssetNumericInput: FC = ({ assetIcon={assetIcon} fallbackLogoURI={rest.fallbackLogoURI} logoURI={rest.logoURI} - network={rest.network} onClick={onClick} tokenSymbol={tokenSymbol} /> diff --git a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx index 779aba9734..8dc3b5ef7f 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx @@ -37,11 +37,7 @@ export const AveniaKYBVerifyStep = ({

{t(titleKey)}

- Business Check + Business Check {!isVerificationStarted && (

diff --git a/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx b/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx index d586eae82b..4450045ec6 100644 --- a/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx @@ -1,11 +1,17 @@ import { CNPJ_REGEX, CPF_REGEX, isValidCnpj, isValidCpf, RampDirection } from "@vortexfi/shared"; -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { AnimatePresence, type MotionProps, motion } from "motion/react"; import type { FC } from "react"; import { Trans, useTranslation } from "react-i18next"; -import { durations, easings } from "../../../constants/animations"; import { useRampDirection } from "../../../stores/rampDirectionStore"; import { AveniaField, AveniaFieldValidationPattern, StandardAveniaFieldOptions } from "../AveniaField"; +const containerAnimation: MotionProps = { + animate: { height: "auto", opacity: 1 }, + exit: { height: 0, opacity: 0 }, + initial: { height: 0, opacity: 0 }, + transition: { duration: 0.3 } +}; + const OFFRAMP_FIELDS = [ { id: StandardAveniaFieldOptions.TAX_ID, index: 0, label: "cpfOrCnpj" }, { id: StandardAveniaFieldOptions.PIX_ID, index: 1, label: "pixKey" } @@ -40,18 +46,12 @@ export const AveniaKycEligibilityFields: FC<{ isWalletAddressDisabled?: boolean const { t } = useTranslation(); const rampDirection = useRampDirection(); const isOnramp = rampDirection === RampDirection.BUY; - const shouldReduceMotion = useReducedMotion(); const FIELDS = isOnramp ? ONRAMP_FIELDS : OFFRAMP_FIELDS; return ( - + {FIELDS.map(field => ( { const { isExpanded, detailsId } = useCollapsibleCard(); - const shouldReduceMotion = useReducedMotion(); return ( -

-
- - {isExpanded && ( - - {children} - - )} - -
-
+ + {isExpanded && ( + + + {children} + + + )} + ); }; diff --git a/apps/frontend/src/components/CurrencyExchange/index.tsx b/apps/frontend/src/components/CurrencyExchange/index.tsx index 37ba70cf57..43c6c1ebfd 100644 --- a/apps/frontend/src/components/CurrencyExchange/index.tsx +++ b/apps/frontend/src/components/CurrencyExchange/index.tsx @@ -1,7 +1,5 @@ -import { Networks } from "@vortexfi/shared"; import Arrow from "../../assets/arrow.svg"; -import { useTokenIcon } from "../../hooks/useTokenIcon"; -import { TokenIconWithNetwork } from "../TokenIconWithNetwork"; +import { useGetAssetIcon } from "../../hooks/useGetAssetIcon"; interface CurrencyExchangeProps { inputAmount: string; @@ -12,12 +10,6 @@ interface CurrencyExchangeProps { layout?: "horizontal" | "vertical"; className?: string; showApproximation?: boolean; - inputNetwork?: Networks; - outputNetwork?: Networks; - inputIcon?: string; - outputIcon?: string; - inputFallbackIcon?: string; - outputFallbackIcon?: string; } export const CurrencyExchange = ({ @@ -28,20 +20,10 @@ export const CurrencyExchange = ({ showIcons = false, layout = "horizontal", className = "", - showApproximation = false, - inputNetwork, - outputNetwork, - inputIcon: inputIconProp, - outputIcon: outputIconProp, - inputFallbackIcon, - outputFallbackIcon + showApproximation = false }: CurrencyExchangeProps) => { - // Use useTokenIcon for fallback icons when explicit icon props aren't provided - const inputIconFallback = useTokenIcon(inputCurrency, inputNetwork); - const outputIconFallback = useTokenIcon(outputCurrency, outputNetwork); - - const inputIcon = inputIconProp ?? inputIconFallback.iconSrc; - const outputIcon = outputIconProp ?? outputIconFallback.iconSrc; + const inputIcon = useGetAssetIcon(inputCurrency.toLowerCase()); + const outputIcon = useGetAssetIcon(outputCurrency.toLowerCase()); if (layout === "vertical") { return ( @@ -49,32 +31,14 @@ export const CurrencyExchange = ({
You send
- {showIcons && ( - - )} + {showIcons && {inputCurrency}} {inputAmount} {inputCurrency.toUpperCase()}
You get
- {showIcons && ( - - )} + {showIcons && {outputCurrency}} {showApproximation && "~ "} {outputAmount} {outputCurrency.toUpperCase()}
diff --git a/apps/frontend/src/components/EmailForm/index.tsx b/apps/frontend/src/components/EmailForm/index.tsx index c730acc5f9..26a4339665 100644 --- a/apps/frontend/src/components/EmailForm/index.tsx +++ b/apps/frontend/src/components/EmailForm/index.tsx @@ -56,7 +56,7 @@ export const EmailForm = ({ transactionId, transactionSuccess }: EmailFormProps)
{!isPending && !isSuccess && (
); diff --git a/apps/frontend/src/components/ListItem/index.tsx b/apps/frontend/src/components/ListItem/index.tsx index 57b913178e..525326fdbf 100644 --- a/apps/frontend/src/components/ListItem/index.tsx +++ b/apps/frontend/src/components/ListItem/index.tsx @@ -3,7 +3,7 @@ import { FiatToken, isFiatToken, OnChainToken, OnChainTokenDetails } from "@vort import { memo } from "react"; import { useTranslation } from "react-i18next"; import { getTokenDisabledReason, isFiatTokenDisabled } from "../../config/tokenAvailability"; -import { useTokenIcon } from "../../hooks/useTokenIcon"; +import { useGetAssetIcon } from "../../hooks/useGetAssetIcon"; import { TokenIconWithNetwork } from "../TokenIconWithNetwork"; import { ExtendedTokenDefinition } from "../TokenSelection/TokenSelectionList/hooks/useTokenSelection"; import { UserBalance } from "../UserBalance"; @@ -16,10 +16,9 @@ interface ListItemProps { export const ListItem = memo(function ListItem({ token, isSelected, onSelect }: ListItemProps) { const { t } = useTranslation(); + const fiatIcon = useGetAssetIcon(token.assetIcon); + const tokenIcon = token.logoURI ?? fiatIcon; const isFiat = isFiatToken(token.type); - // Use assetIcon for fiat lookup, with network for on-chain tokens - const iconInfo = useTokenIcon(token.assetIcon, isFiat ? undefined : token.network); - const tokenIcon = token.logoURI ?? iconInfo.iconSrc; const isDisabled = isFiat && isFiatTokenDisabled(token.type as FiatToken); const disabledReason = isFiat && isDisabled ? t(getTokenDisabledReason(token.type as FiatToken)) : undefined; diff --git a/apps/frontend/src/components/Navbar/DesktopNavbar.tsx b/apps/frontend/src/components/Navbar/DesktopNavbar.tsx index 8d88164227..fb865c1c91 100644 --- a/apps/frontend/src/components/Navbar/DesktopNavbar.tsx +++ b/apps/frontend/src/components/Navbar/DesktopNavbar.tsx @@ -1,4 +1,4 @@ -import { Link, useParams, useRouterState } from "@tanstack/react-router"; +import { Link, useParams } from "@tanstack/react-router"; import { useTranslation } from "react-i18next"; import { cn } from "../../helpers/cn"; import { useWidgetMode } from "../../hooks/useWidgetMode"; @@ -10,31 +10,21 @@ export const DesktopNavbar = () => { const isWidgetMode = useWidgetMode(); const { resetRampAndNavigateHome } = useNavbarHandlers(); const params = useParams({ strict: false }); - const routerState = useRouterState(); - - const isBusinessPage = routerState.location.pathname.includes("/business"); - const useTransparentStyle = isWidgetMode || isBusinessPage; return ( -
+
{isWidgetMode ? ( - + ) : ( <>
- + @@ -42,15 +32,9 @@ export const DesktopNavbar = () => { diff --git a/apps/frontend/src/components/Navbar/HamburgerButton.tsx b/apps/frontend/src/components/Navbar/HamburgerButton.tsx index edef42c2df..ece3fd49be 100644 --- a/apps/frontend/src/components/Navbar/HamburgerButton.tsx +++ b/apps/frontend/src/components/Navbar/HamburgerButton.tsx @@ -10,25 +10,26 @@ export const HamburgerButton = ({ isOpen, onClick }: HamburgerButtonProps) => { const { t } = useTranslation(); return ( - - + ); }; diff --git a/apps/frontend/src/components/Navbar/LogoButton.tsx b/apps/frontend/src/components/Navbar/LogoButton.tsx index df94c3bd06..ee106568c0 100644 --- a/apps/frontend/src/components/Navbar/LogoButton.tsx +++ b/apps/frontend/src/components/Navbar/LogoButton.tsx @@ -1,22 +1,19 @@ import { useTranslation } from "react-i18next"; import blueLogo from "../../assets/logo/blue.svg"; import whiteLogo from "../../assets/logo/white.png"; +import { useWidgetMode } from "../../hooks/useWidgetMode"; interface LogoButtonProps { onClick: () => void; - variant?: "blue" | "white"; } -export const LogoButton = ({ onClick, variant = "white" }: LogoButtonProps) => { +export const LogoButton = ({ onClick }: LogoButtonProps) => { const { t } = useTranslation(); + const isWidgetMode = useWidgetMode(); return ( ); }; diff --git a/apps/frontend/src/components/Navbar/MobileMenu.tsx b/apps/frontend/src/components/Navbar/MobileMenu.tsx index 09ae7618ff..2328c14d3b 100644 --- a/apps/frontend/src/components/Navbar/MobileMenu.tsx +++ b/apps/frontend/src/components/Navbar/MobileMenu.tsx @@ -1,115 +1,54 @@ import { Link, useParams } from "@tanstack/react-router"; -import { motion, useReducedMotion, type Variants } from "motion/react"; +import { motion } from "motion/react"; import { useTranslation } from "react-i18next"; -import { durations, easings } from "../../constants/animations"; interface MobileMenuProps { onMenuItemClick: () => void; } -const menuVariants: Variants = { - closed: { - opacity: 0, - transition: { - duration: durations.normal, - ease: easings.easeOutCubic, - staggerChildren: 0.05, - staggerDirection: -1, - when: "afterChildren" - }, - y: -20 - }, - open: { - opacity: 1, - transition: { - duration: durations.slow, - ease: easings.easeOutCubic, - staggerChildren: 0.07, - when: "beforeChildren" - }, - y: 0 - } -}; - -const menuItemVariants: Variants = { - closed: { - opacity: 0, - transition: { duration: durations.fast, ease: easings.easeOutCubic }, - x: -16 - }, - open: { - opacity: 1, - transition: { duration: durations.normal, ease: easings.easeOutCubic }, - x: 0 - } -}; - -const buttonVariants: Variants = { - closed: { - opacity: 0, - scale: 0.95, - transition: { duration: durations.fast, ease: easings.easeOutCubic } - }, - open: { - opacity: 1, - scale: 1, - transition: { damping: 20, stiffness: 300, type: "spring" } - } -}; - -const reducedMotionVariants: Variants = { - closed: { opacity: 0 }, - open: { opacity: 1, transition: { duration: 0 } } -}; - export const MobileMenu = ({ onMenuItemClick }: MobileMenuProps) => { const { t } = useTranslation(); const params = useParams({ strict: false }); - const shouldReduceMotion = useReducedMotion(); return ( - +
+
); }; diff --git a/apps/frontend/src/components/Navbar/MobileNavbar.tsx b/apps/frontend/src/components/Navbar/MobileNavbar.tsx index 445b38b02e..2894b8bf36 100644 --- a/apps/frontend/src/components/Navbar/MobileNavbar.tsx +++ b/apps/frontend/src/components/Navbar/MobileNavbar.tsx @@ -1,4 +1,3 @@ -import { useRouterState } from "@tanstack/react-router"; import { AnimatePresence } from "motion/react"; import { useRef, useState } from "react"; import { cn } from "../../helpers/cn"; @@ -13,13 +12,9 @@ export const MobileNavbar = () => { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const isWidgetMode = useWidgetMode(); const navbarRef = useRef(null); - const routerState = useRouterState(); const { resetRampAndNavigateHome } = useNavbarHandlers(); - const isBusinessPage = routerState.location.pathname.includes("/business"); - const useTransparentStyle = isWidgetMode || isBusinessPage; - const toggleMobileMenu = () => { setIsMobileMenuOpen(!isMobileMenuOpen); }; @@ -31,20 +26,20 @@ export const MobileNavbar = () => { useClickOutside(navbarRef, closeMobileMenu, isMobileMenuOpen); return ( -
-
+
+
{isWidgetMode ? ( - + ) : (
- +
)}
- {isMobileMenuOpen && } + {isMobileMenuOpen && }
); }; diff --git a/apps/frontend/src/components/Navbar/hooks/useNavbarHandlers.ts b/apps/frontend/src/components/Navbar/hooks/useNavbarHandlers.ts index 3f58527de5..2f9630a732 100644 --- a/apps/frontend/src/components/Navbar/hooks/useNavbarHandlers.ts +++ b/apps/frontend/src/components/Navbar/hooks/useNavbarHandlers.ts @@ -1,21 +1,19 @@ import { useParams, useRouter } from "@tanstack/react-router"; import { useRampActor } from "../../../contexts/rampState"; -import { useWidgetMode } from "../../../hooks/useWidgetMode"; export const useNavbarHandlers = () => { const rampActor = useRampActor(); const router = useRouter(); const params = useParams({ strict: false }); - const isWidgetMode = useWidgetMode(); const resetRampAndNavigateHome = () => { - rampActor.send({ skipUrlCleaner: true, type: "RESET_RAMP" }); + rampActor.send({ type: "RESET_RAMP" }); router.navigate({ params: params, replace: true, search: {}, - to: isWidgetMode ? "/{-$locale}/widget" : "/{-$locale}" + to: "/{-$locale}" }); }; diff --git a/apps/frontend/src/components/Navbar/index.tsx b/apps/frontend/src/components/Navbar/index.tsx index 2e8304e91d..80103abcfb 100644 --- a/apps/frontend/src/components/Navbar/index.tsx +++ b/apps/frontend/src/components/Navbar/index.tsx @@ -2,7 +2,7 @@ import { DesktopNavbar } from "./DesktopNavbar"; import { MobileNavbar } from "./MobileNavbar"; export const Navbar = () => ( -
+
diff --git a/apps/frontend/src/components/QuoteSummary/index.tsx b/apps/frontend/src/components/QuoteSummary/index.tsx index 478539b3b6..aaa0ae41a8 100644 --- a/apps/frontend/src/components/QuoteSummary/index.tsx +++ b/apps/frontend/src/components/QuoteSummary/index.tsx @@ -1,36 +1,19 @@ -import { QuoteResponse, RampDirection } from "@vortexfi/shared"; +import { QuoteResponse } from "@vortexfi/shared"; import { useRef } from "react"; import { useTranslation } from "react-i18next"; -import { useTokenIcon } from "../../hooks/useTokenIcon"; +import { useGetAssetIcon } from "../../hooks/useGetAssetIcon"; import { CollapsibleCard, CollapsibleDetails, CollapsibleSummary, useCollapsibleCard } from "../CollapsibleCard"; import { CurrencyExchange } from "../CurrencyExchange"; import { ToggleButton } from "../ToggleButton"; -import { TokenIconWithNetwork } from "../TokenIconWithNetwork"; import { TransactionId } from "../TransactionId"; interface QuoteSummaryProps { quote: QuoteResponse; } -/** - * Hook to get token icons for both currencies in a quote. - * Determines which currency is on-chain based on ramp type. - */ -function useQuoteTokenIcons(quote: QuoteResponse) { - const isOfframp = quote.rampType === RampDirection.SELL; - - // For offramp: input is on-chain (has network), output is fiat (no network) - // For onramp: input is fiat (no network), output is on-chain (has network) - const inputIcon = useTokenIcon(quote.inputCurrency, isOfframp ? quote.network : undefined); - const outputIcon = useTokenIcon(quote.outputCurrency, !isOfframp ? quote.network : undefined); - - return { inputIcon, outputIcon }; -} - const QuoteSummaryCore = ({ quote }: { quote: QuoteResponse }) => { const { t } = useTranslation(); const { toggle, isExpanded, detailsId } = useCollapsibleCard(); - const { inputIcon, outputIcon } = useQuoteTokenIcons(quote); return ( <> @@ -41,14 +24,8 @@ const QuoteSummaryCore = ({ quote }: { quote: QuoteResponse }) => { { const QuoteSummaryDetails = ({ quote }: { quote: QuoteResponse }) => { const { t } = useTranslation(); - const { inputIcon, outputIcon } = useQuoteTokenIcons(quote); + const inputIcon = useGetAssetIcon(quote.inputCurrency.toLowerCase()); + const outputIcon = useGetAssetIcon(quote.outputCurrency.toLowerCase()); return (
@@ -75,29 +53,15 @@ const QuoteSummaryDetails = ({ quote }: { quote: QuoteResponse }) => {
{t("components.quoteSummary.youSend")}
- + {quote.inputCurrency} {quote.inputAmount} {quote.inputCurrency.toUpperCase()}
{t("components.quoteSummary.youReceive")}
- - ~ {quote.outputAmount} {quote.outputCurrency.toUpperCase()} + {quote.outputCurrency}~ {quote.outputAmount}{" "} + {quote.outputCurrency.toUpperCase()}
diff --git a/apps/frontend/src/components/Ramp/Offramp/index.tsx b/apps/frontend/src/components/Ramp/Offramp/index.tsx index b76a383d5d..c89325b177 100644 --- a/apps/frontend/src/components/Ramp/Offramp/index.tsx +++ b/apps/frontend/src/components/Ramp/Offramp/index.tsx @@ -5,11 +5,11 @@ import { FormProvider } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useEventsContext } from "../../../contexts/events"; import { useNetwork } from "../../../contexts/network"; +import { getTokenLogoURIs } from "../../../helpers/tokenHelpers"; import { useQuoteForm } from "../../../hooks/quote/useQuoteForm"; import { useQuoteService } from "../../../hooks/quote/useQuoteService"; import { useRampSubmission } from "../../../hooks/ramp/useRampSubmission"; import { useRampValidation } from "../../../hooks/ramp/useRampValidation"; -import { useTokenIcon } from "../../../hooks/useTokenIcon"; import { useVortexAccount } from "../../../hooks/useVortexAccount"; import { getEvmTokenConfig } from "../../../services/tokens"; import { useFeeComparisonStore } from "../../../stores/feeComparison"; @@ -75,17 +75,16 @@ export const Offramp = () => { const handleBalanceClick = useCallback((amount: string) => form.setValue("inputAmount", amount), [form]); - const fromIconInfo = useTokenIcon(fromToken); + const { logoURI, fallbackLogoURI } = getTokenLogoURIs(fromToken); const WithdrawNumericInput = useMemo( () => ( <> openTokenSelectModal("from")} registerInput={form.register("inputAmount")} @@ -97,7 +96,7 @@ export const Offramp = () => {
), - [form, fromToken, openTokenSelectModal, handleInputChange, handleBalanceClick, isDisconnected, fromIconInfo] + [form, fromToken, openTokenSelectModal, handleInputChange, handleBalanceClick, isDisconnected, logoURI, fallbackLogoURI] ); const ReceiveNumericInput = useMemo( diff --git a/apps/frontend/src/components/Ramp/Onramp/index.tsx b/apps/frontend/src/components/Ramp/Onramp/index.tsx index 1453685a98..10b7f98da5 100644 --- a/apps/frontend/src/components/Ramp/Onramp/index.tsx +++ b/apps/frontend/src/components/Ramp/Onramp/index.tsx @@ -5,11 +5,11 @@ import { FormProvider } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useEventsContext } from "../../../contexts/events"; import { useNetwork } from "../../../contexts/network"; +import { getTokenLogoURIs } from "../../../helpers/tokenHelpers"; import { useQuoteForm } from "../../../hooks/quote/useQuoteForm"; import { useQuoteService } from "../../../hooks/quote/useQuoteService"; import { useRampSubmission } from "../../../hooks/ramp/useRampSubmission"; import { useRampValidation } from "../../../hooks/ramp/useRampValidation"; -import { useTokenIcon } from "../../../hooks/useTokenIcon"; import { getEvmTokenConfig } from "../../../services/tokens"; import { useFeeComparisonStore } from "../../../stores/feeComparison"; import { useFiatToken, useInputAmount, useOnChainToken } from "../../../stores/quote/useQuoteFormStore"; @@ -84,25 +84,33 @@ export const Onramp = () => { [form, fromToken, openTokenSelectModal, handleInputChange] ); - const toIconInfo = useTokenIcon(toToken); + const { logoURI, fallbackLogoURI } = getTokenLogoURIs(toToken); const ReceiveNumericInput = useMemo( () => ( openTokenSelectModal("to")} readOnly={true} registerInput={form.register("outputAmount")} tokenSymbol={toToken.assetSymbol} /> ), - [toToken.networkAssetIcon, toToken.assetSymbol, form, quoteLoading, toAmount, openTokenSelectModal, toIconInfo] + [ + toToken.networkAssetIcon, + toToken.assetSymbol, + form, + quoteLoading, + toAmount, + openTokenSelectModal, + logoURI, + fallbackLogoURI + ] ); const handleConfirm = useCallback(() => { diff --git a/apps/frontend/src/components/RampToggle/index.tsx b/apps/frontend/src/components/RampToggle/index.tsx index 3ee120f99a..465354b086 100644 --- a/apps/frontend/src/components/RampToggle/index.tsx +++ b/apps/frontend/src/components/RampToggle/index.tsx @@ -1,5 +1,5 @@ import { RampDirection } from "@vortexfi/shared"; -import { motion, useReducedMotion } from "motion/react"; +import { motion } from "motion/react"; import { useTranslation } from "react-i18next"; interface RampToggleProps { @@ -9,12 +9,11 @@ interface RampToggleProps { export const RampToggle = ({ activeDirection, onToggle }: RampToggleProps) => { const { t } = useTranslation(); - const shouldReduceMotion = useReducedMotion(); return (
); diff --git a/apps/frontend/src/components/Stepper/StepCircle.tsx b/apps/frontend/src/components/Stepper/StepCircle.tsx index 2122285696..15bb01ff61 100644 --- a/apps/frontend/src/components/Stepper/StepCircle.tsx +++ b/apps/frontend/src/components/Stepper/StepCircle.tsx @@ -4,7 +4,7 @@ import { Step, StepCircleProps } from "./types"; export const getStepCircleStyles = (status: Step["status"], isClickable: boolean): string => { const baseStyles = - "flex h-8 w-8 items-center mx-auto justify-center rounded-full font-medium text-sm transition-[background-color,transform] duration-200 ease-out motion-reduce:transition-none"; + "flex h-8 w-8 items-center mx-auto justify-center rounded-full font-medium text-sm transition-all duration-200 ease-in-out hover:scale-110"; const statusStyles = { active: "bg-blue-500 text-white", diff --git a/apps/frontend/src/components/Stepper/StepConnector.tsx b/apps/frontend/src/components/Stepper/StepConnector.tsx index d08139efd0..950795ce68 100644 --- a/apps/frontend/src/components/Stepper/StepConnector.tsx +++ b/apps/frontend/src/components/Stepper/StepConnector.tsx @@ -1,6 +1,5 @@ -import { motion, useReducedMotion } from "motion/react"; +import { motion } from "motion/react"; import React from "react"; -import { durations, easings } from "../../constants/animations"; import { Step, StepConnectorProps } from "./types"; const getConnectorColor = (currentStatus: Step["status"], nextStatus: Step["status"]): string => { @@ -12,7 +11,6 @@ const getConnectorColor = (currentStatus: Step["status"], nextStatus: Step["stat export const StepConnector: React.FC = ({ currentStepStatus, nextStepStatus }) => { const backgroundColor = getConnectorColor(currentStepStatus, nextStepStatus); - const shouldReduceMotion = useReducedMotion(); return (
@@ -22,8 +20,8 @@ export const StepConnector: React.FC = ({ currentStepStatus, width: currentStepStatus === "complete" || currentStepStatus === "error" ? "100%" : 0 }} className="absolute z-10 h-px" - initial={shouldReduceMotion ? false : { backgroundColor, width: 0 }} - transition={shouldReduceMotion ? { duration: 0 } : { duration: durations.slow * 1.5, ease: easings.easeOutCubic }} + initial={{ backgroundColor, width: 0 }} + transition={{ duration: 0.5, ease: "easeInOut" }} />
diff --git a/apps/frontend/src/components/TermsAndConditions/index.tsx b/apps/frontend/src/components/TermsAndConditions/index.tsx index 7a69643fd9..27f9aec64c 100644 --- a/apps/frontend/src/components/TermsAndConditions/index.tsx +++ b/apps/frontend/src/components/TermsAndConditions/index.tsx @@ -1,7 +1,6 @@ -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { AnimatePresence, motion } from "motion/react"; import { Dispatch } from "react"; import { useTranslation } from "react-i18next"; -import { durations, easings } from "../../constants/animations"; import { cn } from "../../helpers/cn"; interface TermsAndConditionsProps { @@ -15,7 +14,7 @@ interface TermsAndConditionsProps { const fadeOutAnimation = { opacity: [1, 1, 0], scale: [1, 1.05, 0], - transition: { duration: durations.slow, ease: easings.easeOutCubic } + transition: { duration: 0.3 } }; export const TermsAndConditions = (props: TermsAndConditionsProps) => { @@ -49,19 +48,15 @@ const TermsAndConditionsContent = ({ const TermsText = ({ error }: { error: boolean }) => { const { t } = useTranslation(); - const shouldReduceMotion = useReducedMotion(); return ( {t("components.termsAndConditions.text")}{" "} = memo(function return (
- + {shouldShowOverlay && ( {`${network} = memo(function TokenIcon({ src, fallbackSrc, alt, className }) { +export const TokenImage: FC = memo(function TokenImage({ src, fallbackSrc, alt, className }) { const [isLoading, setIsLoading] = useState(true); const [imgError, setImgError] = useState(false); const [fallbackError, setFallbackError] = useState(false); diff --git a/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionButtonMotion.tsx b/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionButtonMotion.tsx index 08232cf53b..472117748a 100644 --- a/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionButtonMotion.tsx +++ b/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionButtonMotion.tsx @@ -1,6 +1,5 @@ -import { motion, useReducedMotion } from "motion/react"; +import { motion } from "motion/react"; import { ReactNode } from "react"; -import { durations, easings } from "../../../../constants/animations"; interface SelectionButtonMotionProps { isExpanded: boolean; @@ -10,8 +9,6 @@ interface SelectionButtonMotionProps { } export const SelectionButtonMotion = ({ isExpanded, children, onClick, className }: SelectionButtonMotionProps) => { - const shouldReduceMotion = useReducedMotion(); - return ( {children} diff --git a/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionChevronMotion.tsx b/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionChevronMotion.tsx index 8efd85846e..869a956244 100644 --- a/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionChevronMotion.tsx +++ b/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionChevronMotion.tsx @@ -1,6 +1,5 @@ -import { motion, useReducedMotion } from "motion/react"; +import { motion } from "motion/react"; import { ReactNode } from "react"; -import { durations, easings } from "../../../../constants/animations"; interface SelectionChevronMotionProps { isOpen: boolean; @@ -8,13 +7,8 @@ interface SelectionChevronMotionProps { } export const SelectionChevronMotion = ({ isOpen, children }: SelectionChevronMotionProps) => { - const shouldReduceMotion = useReducedMotion(); - return ( - + {children} ); diff --git a/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionDropdownMotion.tsx b/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionDropdownMotion.tsx index a9f6b487ca..d6faecf9a8 100644 --- a/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionDropdownMotion.tsx +++ b/apps/frontend/src/components/TokenSelection/NetworkSelectionList/animations/SelectionDropdownMotion.tsx @@ -1,6 +1,5 @@ -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { AnimatePresence, motion } from "motion/react"; import { ReactNode } from "react"; -import { durations, easings } from "../../../../constants/animations"; interface SelectionDropdownMotionProps { isOpen: boolean; @@ -9,36 +8,39 @@ interface SelectionDropdownMotionProps { } export const SelectionDropdownMotion = ({ isOpen, children, className }: SelectionDropdownMotionProps) => { - const shouldReduceMotion = useReducedMotion(); - return ( -
-
- - {isOpen && ( - - {children} - - )} - -
-
+ + {isOpen && ( + + {children} + + )} + ); }; diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/TokenSelectionControls.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/TokenSelectionControls.tsx index 21437ac553..72b2bc5c04 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/TokenSelectionControls.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/components/TokenSelectionControls.tsx @@ -1,6 +1,6 @@ -import { motion, useReducedMotion } from "motion/react"; +import { motion } from "motion/react"; import { useTranslation } from "react-i18next"; -import { durations, easings } from "../../../../constants/animations"; +import { cn } from "../../../../helpers/cn"; import { useIsNetworkDropdownOpen, useTokenSelectionActions } from "../../../../stores/tokenSelectionStore"; import { SearchInput } from "../../../SearchInput"; import { NetworkDropdown } from "../../NetworkSelectionList"; @@ -9,7 +9,6 @@ const TokenSelectionSearchInput = () => { const { t } = useTranslation(); const isNetworkDropdownOpen = useIsNetworkDropdownOpen(); const { setSearchFilter } = useTokenSelectionActions(); - const shouldReduceMotion = useReducedMotion(); const handleSearchChange = (value: string) => { setSearchFilter(value); @@ -25,15 +24,11 @@ const TokenSelectionSearchInput = () => { width: isNetworkDropdownOpen ? 0 : "auto" }} className="flex-grow" - transition={ - shouldReduceMotion - ? { duration: 0 } - : { - delay: isNetworkDropdownOpen ? 0 : durations.slow, - duration: isNetworkDropdownOpen ? 0 : durations.fast, - ease: easings.easeOutCubic - } - } + transition={{ + delay: isNetworkDropdownOpen ? 0 : 0.3, + duration: isNetworkDropdownOpen ? 0 : 0.15, + ease: "linear" + }} > { }; export const TokenSelectionControls = () => ( -
+
diff --git a/apps/frontend/src/components/buttons/AssetButton/index.tsx b/apps/frontend/src/components/buttons/AssetButton/index.tsx index cfae8fef82..4e8bc31653 100644 --- a/apps/frontend/src/components/buttons/AssetButton/index.tsx +++ b/apps/frontend/src/components/buttons/AssetButton/index.tsx @@ -1,8 +1,7 @@ import { ChevronDownIcon } from "@heroicons/react/20/solid"; -import { Networks } from "@vortexfi/shared"; import { cn } from "../../../helpers/cn"; -import { useTokenIcon } from "../../../hooks/useTokenIcon"; -import { TokenIconWithNetwork } from "../../TokenIconWithNetwork"; +import { useGetAssetIcon } from "../../../hooks/useGetAssetIcon"; +import { TokenImage } from "../../TokenImage"; interface AssetButtonProps { assetIcon: string; @@ -11,20 +10,11 @@ interface AssetButtonProps { fallbackLogoURI?: string; onClick: () => void; disabled?: boolean; - network?: Networks; } -export function AssetButton({ - assetIcon, - tokenSymbol, - onClick, - disabled, - logoURI, - fallbackLogoURI, - network -}: AssetButtonProps) { - const fallbackIcon = useTokenIcon(assetIcon); - const primaryIcon = logoURI ?? fallbackIcon.iconSrc; +export function AssetButton({ assetIcon, tokenSymbol, onClick, disabled, logoURI, fallbackLogoURI }: AssetButtonProps) { + const localIcon = useGetAssetIcon(assetIcon); + const primaryIcon = logoURI ?? localIcon; return ( diff --git a/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx b/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx index dac4972650..606c90db7b 100644 --- a/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx +++ b/apps/frontend/src/components/menus/HistoryMenu/TransactionItem/index.tsx @@ -1,17 +1,10 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid"; -import { - EPaymentMethod, - getNetworkDisplayName, - Networks, - PaymentMethod, - roundDownToSignificantDecimals -} from "@vortexfi/shared"; +import { getNetworkDisplayName, Networks, roundDownToSignificantDecimals } from "@vortexfi/shared"; import Big from "big.js"; import { FC, useState } from "react"; -import { useTokenIcon } from "../../../../hooks/useTokenIcon"; +import { useGetAssetIcon } from "../../../../hooks/useGetAssetIcon"; import { StatusBadge } from "../../../StatusBadge"; -import { TokenIconWithNetwork } from "../../../TokenIconWithNetwork"; -import { Transaction, TransactionDestination } from "../types"; +import { Transaction } from "../types"; interface TransactionItemProps { transaction: Transaction; @@ -34,28 +27,17 @@ const formatTooltipDate = (date: Date) => year: "numeric" }); -const PAYMENT_METHODS: PaymentMethod[] = [EPaymentMethod.PIX, EPaymentMethod.SEPA, EPaymentMethod.CBU]; - -function isNetwork(destination: TransactionDestination): destination is Networks { - return !PAYMENT_METHODS.includes(destination as PaymentMethod); -} - -const getNetworkName = (network: TransactionDestination) => { - if (!isNetwork(network)) { +const getNetworkName = (network: Transaction["from"] | Transaction["to"]) => { + if (typeof network === "string" && ["pix", "sepa", "cbu"].includes(network)) { return network.toUpperCase(); } - return getNetworkDisplayName(network); + return getNetworkDisplayName(network as Networks); }; export const TransactionItem: FC = ({ transaction }) => { const [isHovered, setIsHovered] = useState(false); - - // Determine network for each currency (only on-chain tokens have networks) - const fromNetwork = isNetwork(transaction.from) ? transaction.from : undefined; - const toNetwork = isNetwork(transaction.to) ? transaction.to : undefined; - - const fromIcon = useTokenIcon(transaction.fromCurrency, fromNetwork); - const toIcon = useTokenIcon(transaction.toCurrency, toNetwork); + const fromIcon = useGetAssetIcon(transaction.fromCurrency.toLowerCase()); + const toIcon = useGetAssetIcon(transaction.toCurrency.toLowerCase()); return (
= ({ transaction }) => {
- - + {transaction.fromCurrency} + {transaction.toCurrency}
diff --git a/apps/frontend/src/components/menus/Menu/index.tsx b/apps/frontend/src/components/menus/Menu/index.tsx index 05a3cdf5b4..233ad36cd3 100644 --- a/apps/frontend/src/components/menus/Menu/index.tsx +++ b/apps/frontend/src/components/menus/Menu/index.tsx @@ -1,6 +1,5 @@ -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { AnimatePresence, motion } from "motion/react"; import { ReactNode } from "react"; -import { durations, easings } from "../../../constants/animations"; import { useEscapeKey } from "../../../hooks/useEscapeKey"; import { PageHeader } from "../../PageHeader"; @@ -21,7 +20,6 @@ export interface MenuProps { export function Menu({ isOpen, onClose, title, children, animationDirection }: MenuProps) { useEscapeKey(isOpen, onClose); - const shouldReduceMotion = useReducedMotion(); const animationProps = animationDirection === MenuAnimationDirection.RIGHT @@ -43,8 +41,8 @@ export function Menu({ isOpen, onClose, title, children, animationDirection }: M animate={animationProps.animate} className="absolute top-0 right-0 bottom-0 left-0 z-40 flex w-full flex-col overflow-hidden rounded-lg bg-white px-4 pt-4 pb-2 shadow-lg" exit={animationProps.exit} - initial={shouldReduceMotion ? false : animationProps.initial} - transition={shouldReduceMotion ? { duration: 0 } : { duration: durations.slow, ease: easings.easeOutCubic }} + initial={animationProps.initial} + transition={{ duration: 0.3 }} >
diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/AssetDisplay.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/AssetDisplay.tsx index 8c80ce5af2..8c85b7636e 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/AssetDisplay.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/AssetDisplay.tsx @@ -1,26 +1,19 @@ -import { Networks } from "@vortexfi/shared"; import { FC } from "react"; -import { TokenIconWithNetwork } from "../../TokenIconWithNetwork"; +import { TokenImage } from "../../TokenImage"; interface AssetDisplayProps { amount: string; symbol: string; iconSrc: string; + iconAlt: string; fallbackIconSrc?: string; - network?: Networks; } -export const AssetDisplay: FC = ({ amount, symbol, iconSrc, fallbackIconSrc, network }) => ( +export const AssetDisplay: FC = ({ amount, symbol, iconSrc, iconAlt, fallbackIconSrc }) => (
{amount} {symbol} - +
); diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx index e1c3c25525..35274a7676 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx @@ -1,11 +1,15 @@ import { ArrowDownIcon } from "@heroicons/react/20/solid"; import { + AssetHubTokenDetails, BaseFiatTokenDetails, + EvmTokenDetails, FiatToken, FiatTokenDetails, getAddressForFormat, getAnyFiatTokenDetails, getOnChainTokenDetailsOrDefault, + isAssetHubTokenDetails, + isEvmTokenDetails, isStellarOutputTokenDetails, OnChainTokenDetails, RampDirection @@ -18,7 +22,7 @@ import { useNetwork } from "../../../contexts/network"; import { useAssetHubNode } from "../../../contexts/polkadotNode"; import { useRampActor } from "../../../contexts/rampState"; import { trimAddress } from "../../../helpers/addressFormatter"; -import { useTokenIcon } from "../../../hooks/useTokenIcon"; +import { useGetAssetIcon } from "../../../hooks/useGetAssetIcon"; import { useVortexAccount } from "../../../hooks/useVortexAccount"; import { RampExecutionInput } from "../../../types/phases"; import { AssetDisplay } from "./AssetDisplay"; @@ -34,6 +38,16 @@ interface TransactionTokensDisplayProps { rampDirection: RampDirection; } +function getOnChainTokenIcon(tokenDetails: OnChainTokenDetails): { primary?: string; fallback?: string } { + if (isEvmTokenDetails(tokenDetails)) { + return { fallback: tokenDetails.fallbackLogoURI, primary: tokenDetails.logoURI }; + } + if (isAssetHubTokenDetails(tokenDetails)) { + return { primary: tokenDetails.logoURI }; + } + return {}; +} + export const TransactionTokensDisplay: FC = ({ executionInput, isOnramp, rampDirection }) => { const { t } = useTranslation(); const rampActor = useRampActor(); @@ -99,8 +113,16 @@ export const TransactionTokensDisplay: FC = ({ ex ? getOnChainTokenDetailsOrDefault(selectedNetwork, executionInput.onChainToken) : getAnyFiatTokenDetails(executionInput.fiatToken); - const fromIconInfo = useTokenIcon(fromToken); - const toIconInfo = useTokenIcon(toToken); + const fromFiatIcon = useGetAssetIcon(isOnramp ? (fromToken as BaseFiatTokenDetails).fiat.assetIcon : ""); + const toFiatIcon = useGetAssetIcon(!isOnramp ? (toToken as BaseFiatTokenDetails).fiat.assetIcon : ""); + + const fromTokenIcons = isOnramp ? { primary: fromFiatIcon } : getOnChainTokenIcon(fromToken as OnChainTokenDetails); + const toTokenIcons = !isOnramp ? { primary: toFiatIcon } : getOnChainTokenIcon(toToken as OnChainTokenDetails); + + const fromIcon = fromTokenIcons.primary ?? fromFiatIcon; + const toIcon = toTokenIcons.primary ?? toFiatIcon; + const fromFallbackIcon = fromTokenIcons.fallback; + const toFallbackIcon = toTokenIcons.fallback; const getPartnerUrl = (): string => { const fiatToken = (isOnramp ? fromToken : toToken) as FiatTokenDetails; @@ -124,17 +146,17 @@ export const TransactionTokensDisplay: FC = ({ ex
{ - if (typeof currencyOrDetails === "string") { - return currencyOrDetails.toLowerCase(); - } - // For token details, use assetSymbol - return currencyOrDetails.assetSymbol.toLowerCase(); - }, [currencyOrDetails]); - - const fiatIcon = useGetAssetIcon(currencyForFiatLookup); - - return useMemo(() => { - // Handle token details objects - if (typeof currencyOrDetails !== "string") { - // FiatTokenDetails (Stellar or Moonbeam) - if (isFiatTokenDetails(currencyOrDetails)) { - return { - iconSrc: fiatIcon - }; - } - - // OnChainTokenDetails (EVM or AssetHub) - const { logoURI, fallbackLogoURI } = getTokenLogoURIs(currencyOrDetails as OnChainTokenDetails); - return { - fallbackIconSrc: fallbackLogoURI, - iconSrc: logoURI ?? fiatIcon, - network: currencyOrDetails.network - }; - } - - // Handle currency string input - const currency = currencyOrDetails; - - // Fiat tokens use local icons - if (isFiatToken(currency)) { - return { - iconSrc: fiatIcon - }; - } - - // On-chain tokens need to look up details for logoURI - if (network) { - const tokenDetails = getOnChainTokenDetails(network, currency as OnChainToken, getEvmTokenConfig()); - if (tokenDetails) { - const { logoURI, fallbackLogoURI } = getTokenLogoURIs(tokenDetails); - return { - fallbackIconSrc: fallbackLogoURI, - iconSrc: logoURI ?? fiatIcon, - network - }; - } - } - - // Fallback to fiat icon lookup (will return placeholder if not found) - return { - iconSrc: fiatIcon, - network - }; - }, [currencyOrDetails, network, fiatIcon]); -} diff --git a/apps/frontend/src/machines/actors/sign.actor.ts b/apps/frontend/src/machines/actors/sign.actor.ts index 769e751d00..9b8644bce5 100644 --- a/apps/frontend/src/machines/actors/sign.actor.ts +++ b/apps/frontend/src/machines/actors/sign.actor.ts @@ -4,7 +4,6 @@ import { ERC20_EURE_POLYGON_V2, getAddressForFormat, getOnChainTokenDetails, - isEvmTransactionData, Networks, PermitSignature, RampDirection @@ -49,23 +48,15 @@ export const signTransactionsActor = async ({ } const userTxs = rampState?.ramp?.unsignedTxs?.filter(tx => { - // For substrate networks (Pendulum/AssetHub), always use connectedWalletAddress. - // moneriumWalletAddress is only for Monerium flows with EVM transactions. - const isSubstrateTransaction = !isEvmTransactionData(tx.txData); - const signerAddress = isSubstrateTransaction - ? connectedWalletAddress - : executionInput?.moneriumWalletAddress || connectedWalletAddress; - + // If a monerium wallet address is provided in the execution input, we use that as the signer address. + const signerAddress = executionInput?.moneriumWalletAddress || connectedWalletAddress; if (!signerAddress) { return false; } - const isSubstrateNetwork = chainId < 0 && isSubstrateTransaction; - const match = isSubstrateNetwork + return chainId < 0 && (tx.network === Networks.Pendulum || tx.network === Networks.AssetHub) ? getAddressForFormat(tx.signer, 0) === getAddressForFormat(signerAddress, 0) : tx.signer.toLowerCase() === signerAddress.toLowerCase(); - - return match; }); // Add userTx for monerium onramp. Signature is required, which is created in this process. diff --git a/apps/frontend/src/pages/progress/index.tsx b/apps/frontend/src/pages/progress/index.tsx index e22c0150bd..bcf0c77c07 100644 --- a/apps/frontend/src/pages/progress/index.tsx +++ b/apps/frontend/src/pages/progress/index.tsx @@ -16,16 +16,11 @@ import { getMessageForPhase } from "./phaseMessages"; const PHASE_DURATIONS: Record = { assethubToPendulum: 24, - backupApprove: 0, - backupSquidRouterApprove: 0, - backupSquidRouterSwap: 0, brlaOnrampMint: 5 * 60, brlaPayoutOnMoonbeam: 30, complete: 0, - destinationTransfer: 12, distributeFees: 24, failed: 0, - finalSettlementSubsidy: 30, fundEphemeral: 20, hydrationSwap: 30, hydrationToAssethubXcm: 30, diff --git a/apps/frontend/src/pages/progress/phaseMessages.ts b/apps/frontend/src/pages/progress/phaseMessages.ts index fc629795b5..344d82f987 100644 --- a/apps/frontend/src/pages/progress/phaseMessages.ts +++ b/apps/frontend/src/pages/progress/phaseMessages.ts @@ -47,22 +47,16 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr }); const getTransferringMessage = () => t("pages.progress.transferringToLocalPartner"); - const getDestinationTransferMessage = () => t("pages.progress.destinationTransfer", { assetSymbol: outputAssetSymbol }); const messages: Record = { assethubToPendulum: t("pages.progress.assethubToPendulum", { assetSymbol: inputAssetSymbol }), - backupApprove: "", // Not relevant for progress page - backupSquidRouterApprove: "", // Not relevant for progress page - backupSquidRouterSwap: "", // Not relevant for progress page brlaOnrampMint: t("pages.progress.brlaOnrampMint"), brlaPayoutOnMoonbeam: getTransferringMessage(), - complete: "", // Not relevant for progress page - destinationTransfer: getDestinationTransferMessage(), + complete: "", distributeFees: getSwappingMessage(), - failed: "", // Not relevant for progress page - finalSettlementSubsidy: getDestinationTransferMessage(), + failed: "", fundEphemeral: t("pages.progress.fundEphemeral"), hydrationSwap: t("pages.progress.hydrationSwap", { inputAssetSymbol: "USDC", @@ -93,10 +87,10 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr squidRouterApprove: getSquidrouterSwapMessage(), squidRouterPay: getSquidrouterSwapMessage(), squidRouterSwap: getSquidrouterSwapMessage(), - stellarCreateAccount: t("pages.progress.createStellarAccount"), + stellarCreateAccount: t("pages.progress.createStellarAccount"), // Not relevant for progress page stellarPayment: t("pages.progress.stellarPayment", { assetSymbol: outputAssetSymbol - }), + }), // Not relevant for progress page subsidizePostSwap: getSwappingMessage(), subsidizePreSwap: getSwappingMessage(), timedOut: "" // Not relevant for progress page diff --git a/apps/frontend/src/sections/business/Hero/index.tsx b/apps/frontend/src/sections/business/Hero/index.tsx index a30e45ea6b..28dc372815 100644 --- a/apps/frontend/src/sections/business/Hero/index.tsx +++ b/apps/frontend/src/sections/business/Hero/index.tsx @@ -1,119 +1,83 @@ import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; -import { motion, useReducedMotion, type Variants } from "motion/react"; +import { motion } from "motion/react"; import { useTranslation } from "react-i18next"; import WidgetSnippetImageEUR from "../../../assets/metawallet-vortex.png"; import { AnimatedTitle } from "../../../components/AnimatedTitle"; -import { fadeInUp, staggerContainer } from "../../../constants/animations"; - -const heroImageVariants: Variants = { - hidden: { - opacity: 0, - scale: 0.92, - y: 40 - }, - visible: { - opacity: 1, - scale: 1, - transition: { - damping: 25, - delay: 0.3, - duration: 0.8, - ease: [0.25, 0.46, 0.45, 0.94], - stiffness: 100, - type: "spring" - }, - y: 0 - } -}; +import { fadeInUp, prefersReducedMotion, staggerContainer } from "../../../constants/animations"; export function Hero() { const { t } = useTranslation(); - const reducedMotion = useReducedMotion(); + const reducedMotion = prefersReducedMotion(); return (
-
- +
+
- + {t("pages.business.hero.description")}
{t("pages.business.hero.contactUs")} {t("pages.business.hero.readDocs")}
- +
- - - Vortex integration with MetaMask wallet showing EUR cryptocurrency transaction interface - - - + Vortex integration with MetaMask wallet showing EUR cryptocurrency transaction interface +
diff --git a/apps/frontend/src/sections/individuals/Hero/index.tsx b/apps/frontend/src/sections/individuals/Hero/index.tsx index b6bf02bd0d..34900addfc 100644 --- a/apps/frontend/src/sections/individuals/Hero/index.tsx +++ b/apps/frontend/src/sections/individuals/Hero/index.tsx @@ -36,7 +36,7 @@ export const Hero = () => {
- + { {t("pages.main.hero.buyAndSellCrypto")} - + = Object.values(FiatToken) .map(name => ({ @@ -61,8 +60,8 @@ const NetworkBadge = ({ network, isAnimating }: { network: Networks; isAnimating }; const TokenBadge = ({ token, isAnimating }: { token: { name: string; assetIcon: string }; isAnimating: boolean }) => { - const { iconSrc } = useTokenIcon(token.assetIcon); - return ; + const icon = useGetAssetIcon(token.assetIcon); + return ; }; export function PopularTokens() { diff --git a/apps/frontend/src/services/tokens/index.ts b/apps/frontend/src/services/tokens/index.ts index 537dce7d3d..c47983c77a 100644 --- a/apps/frontend/src/services/tokens/index.ts +++ b/apps/frontend/src/services/tokens/index.ts @@ -6,5 +6,8 @@ export { getAllEvmTokens, getEvmTokenConfig, getEvmTokensForNetwork, - initializeEvmTokens + getLoadingError, + initializeEvmTokens, + isTokensLoaded, + usedFallbackConfig } from "@vortexfi/shared"; diff --git a/apps/frontend/src/stores/quote/useQuoteStore.ts b/apps/frontend/src/stores/quote/useQuoteStore.ts index 9cf217466d..4ef47cf17f 100644 --- a/apps/frontend/src/stores/quote/useQuoteStore.ts +++ b/apps/frontend/src/stores/quote/useQuoteStore.ts @@ -77,7 +77,6 @@ const friendlyErrorMessages: Record = { [QuoteError.InputAmountTooLowToCoverCalculatedFees]: "pages.swap.error.tryLargerAmount", [QuoteError.BelowLowerLimitSell]: QuoteError.BelowLowerLimitSell, // We leave this as-is, as the replacement string depends on the context [QuoteError.BelowLowerLimitBuy]: QuoteError.BelowLowerLimitBuy, // We leave this as-is, as the replacement string depends on the context - [QuoteError.LowLiquidity]: "pages.swap.error.lowLiquidity", // Calculation failures - suggest different amount [QuoteError.UnableToGetPendulumTokenDetails]: "pages.swap.error.tryDifferentAmount", [QuoteError.FailedToCalculateQuote]: "pages.swap.error.tryDifferentAmount", diff --git a/apps/frontend/src/stories/Accordion.stories.tsx b/apps/frontend/src/stories/Accordion.stories.tsx deleted file mode 100644 index 6fdf5e1b53..0000000000 --- a/apps/frontend/src/stories/Accordion.stories.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { useState } from "react"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "../components/Accordion"; - -interface StoryArgs { - defaultValue?: string[]; - itemCount?: number; -} - -const faqItems = [ - { - answer: - "Vortex is a cross-border payments gateway built on the Pendulum blockchain. It enables on-ramping and off-ramping of fiat currencies through stablecoins using cross-chain swaps.", - question: "What is Vortex?", - value: "item-1" - }, - { - answer: - "You can buy crypto using PIX (Brazilian instant payment system) or other supported payment methods. Simply enter the amount you want to buy, provide your details, and complete the payment.", - question: "How do I buy crypto?", - value: "item-2" - }, - { - answer: - "Vortex supports multiple stablecoins including USDC, USDT, and BRZ (Brazilian Real stablecoin). We're constantly adding support for more tokens.", - question: "What currencies are supported?", - value: "item-3" - }, - { - answer: - "Transaction times vary depending on the payment method and network conditions. PIX transactions typically complete within minutes, while cross-chain transfers may take a few minutes longer.", - question: "How long do transactions take?", - value: "item-4" - }, - { - answer: - "Yes, Vortex uses industry-standard security practices including encryption, secure key management, and integration with trusted payment partners. Your funds and data are protected at all times.", - question: "Is Vortex secure?", - value: "item-5" - } -]; - -const AccordionWrapper = ({ defaultValue = [], itemCount = 3 }: StoryArgs) => { - const items = faqItems.slice(0, itemCount); - - return ( -
- - {items.map(item => ( - - {item.question} - {item.answer} - - ))} - -
- ); -}; - -const InteractiveDemo = () => { - const [openItems] = useState(["item-1"]); - - return ( -
-
-

Currently open items: {openItems.length > 0 ? openItems.join(", ") : "None"}

-
- - {faqItems.map(item => ( - - {item.question} - {item.answer} - - ))} - -
- ); -}; - -const meta: Meta = { - argTypes: { - defaultValue: { - control: "object", - description: "Array of item values that should be open by default" - }, - itemCount: { - control: { max: 5, min: 1, step: 1, type: "range" }, - description: "Number of accordion items to display" - } - }, - component: AccordionWrapper, - parameters: { - docs: { - description: { - component: - "An accessible accordion component with smooth expand/collapse animations. Features reduced motion support for accessibility and uses GPU-accelerated animations via grid-template-rows instead of height transitions." - } - }, - layout: "centered" - }, - tags: ["autodocs"], - title: "Components/Accordion" -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - defaultValue: [], - itemCount: 3 - }, - parameters: { - docs: { - description: { - story: "Default accordion with all items collapsed. Click on any item to expand it." - } - } - }, - render: AccordionWrapper -}; - -export const SingleItemOpen: Story = { - args: { - defaultValue: ["item-1"], - itemCount: 3 - }, - parameters: { - docs: { - description: { - story: "Accordion with the first item expanded by default." - } - } - }, - render: AccordionWrapper -}; - -export const MultipleItemsOpen: Story = { - args: { - defaultValue: ["item-1", "item-3"], - itemCount: 5 - }, - parameters: { - docs: { - description: { - story: "Accordion with multiple items expanded simultaneously." - } - } - }, - render: AccordionWrapper -}; - -export const AllItems: Story = { - args: { - defaultValue: [], - itemCount: 5 - }, - parameters: { - docs: { - description: { - story: "Full FAQ accordion with all 5 items available." - } - } - }, - render: AccordionWrapper -}; - -export const Interactive: Story = { - parameters: { - docs: { - description: { - story: "Interactive demo showing the accordion state changes. Open/close items to see the state update." - } - } - }, - render: InteractiveDemo -}; - -export const ReducedMotion: Story = { - args: { - defaultValue: ["item-1"], - itemCount: 3 - }, - parameters: { - docs: { - description: { - story: - "Test reduced motion by enabling 'prefers-reduced-motion: reduce' in your browser DevTools (Rendering panel). Animations will be disabled for accessibility." - } - } - }, - render: AccordionWrapper -}; diff --git a/apps/frontend/src/stories/CollapsibleCard.stories.tsx b/apps/frontend/src/stories/CollapsibleCard.stories.tsx deleted file mode 100644 index 258a044fef..0000000000 --- a/apps/frontend/src/stories/CollapsibleCard.stories.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { ChevronDownIcon } from "@heroicons/react/24/outline"; -import type { Meta, StoryObj } from "@storybook/react"; -import { useState } from "react"; -import { CollapsibleCard, CollapsibleDetails, CollapsibleSummary, useCollapsibleCard } from "../components/CollapsibleCard"; - -interface StoryArgs { - defaultExpanded?: boolean; - showToggleButton?: boolean; -} - -const ToggleButton = () => { - const { isExpanded, toggle } = useCollapsibleCard(); - - return ( - - ); -}; - -const CollapsibleCardWrapper = ({ defaultExpanded = false }: StoryArgs) => { - return ( -
- - -
-

Transaction Summary

-

Click to view details

-
- -
- -
-
- Amount - 100 USDC -
-
- Fee - 0.5 USDC -
-
- Network - Polkadot -
-
- Estimated Time - ~2 minutes -
-
-
-
-
- ); -}; - -const InteractiveDemo = () => { - const [isExpanded, setIsExpanded] = useState(false); - const [toggleCount, setToggleCount] = useState(0); - - const handleToggle = (expanded: boolean) => { - setIsExpanded(expanded); - setToggleCount(prev => prev + 1); - }; - - return ( -
-
-

State: {isExpanded ? "Expanded" : "Collapsed"}

-

Toggle count: {toggleCount}

-
- - -
-

Quote Details

-

Your exchange rate and fees

-
- -
- -
-
- You send - 500 BRL -
-
- Exchange rate - 1 USDC = 5.02 BRL -
-
- You receive - ~99.60 USDC -
-
-
-
-
- ); -}; - -const MultipleCardsDemo = () => { - return ( -
- - -
-

Step 1: Connect Wallet

-
- -
- -

Connect your Polkadot wallet to get started with the transaction.

-
-
- - - -
-

Step 2: Enter Details

-
- -
- -

Enter your payment details including the amount and recipient information.

-
-
- - - -
-

Step 3: Confirm

-
- -
- -

Review and confirm your transaction before submitting.

-
-
-
- ); -}; - -const meta: Meta = { - argTypes: { - defaultExpanded: { - control: "boolean", - description: "Whether the card should be expanded by default" - } - }, - component: CollapsibleCardWrapper, - parameters: { - docs: { - description: { - component: - "A collapsible card component with smooth expand/collapse animations. Uses GPU-accelerated grid-template-rows animation instead of height for better performance. Supports reduced motion for accessibility." - } - }, - layout: "centered" - }, - tags: ["autodocs"], - title: "Components/CollapsibleCard" -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - defaultExpanded: false - }, - parameters: { - docs: { - description: { - story: "Default collapsed state. Click the toggle button to expand and see the details." - } - } - }, - render: CollapsibleCardWrapper -}; - -export const Expanded: Story = { - args: { - defaultExpanded: true - }, - parameters: { - docs: { - description: { - story: "Card expanded by default showing all details." - } - } - }, - render: CollapsibleCardWrapper -}; - -export const Interactive: Story = { - parameters: { - docs: { - description: { - story: "Interactive demo with state tracking. Watch the state change as you toggle the card." - } - } - }, - render: InteractiveDemo -}; - -export const MultipleCards: Story = { - parameters: { - docs: { - description: { - story: "Multiple collapsible cards demonstrating independent expand/collapse behavior." - } - } - }, - render: MultipleCardsDemo -}; - -export const ReducedMotion: Story = { - args: { - defaultExpanded: false - }, - parameters: { - docs: { - description: { - story: - "Test reduced motion support by enabling 'prefers-reduced-motion: reduce' in browser DevTools. The expand/collapse animation will be instant." - } - } - }, - render: CollapsibleCardWrapper -}; diff --git a/apps/frontend/src/stories/KycLevel2Toggle.stories.tsx b/apps/frontend/src/stories/KycLevel2Toggle.stories.tsx deleted file mode 100644 index a87969263f..0000000000 --- a/apps/frontend/src/stories/KycLevel2Toggle.stories.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { AveniaDocumentType } from "@vortexfi/shared"; -import { useState } from "react"; -import { KycLevel2Toggle } from "../components/KycLevel2Toggle"; - -interface StoryArgs { - activeDocType?: AveniaDocumentType; -} - -const KycLevel2ToggleWrapper = ({ activeDocType = AveniaDocumentType.ID }: StoryArgs) => { - const [docType, setDocType] = useState(activeDocType); - - return ( -
- -
- ); -}; - -const InteractiveDemo = () => { - const [docType, setDocType] = useState(AveniaDocumentType.ID); - const [toggleCount, setToggleCount] = useState(0); - - const handleToggle = (newDocType: AveniaDocumentType) => { - setDocType(newDocType); - setToggleCount(prev => prev + 1); - }; - - return ( -
-
-

- Selected document: {docType === AveniaDocumentType.ID ? "RG (ID Card)" : "CNH (Driver's License)"} -

-

Toggle count: {toggleCount}

-
- -
- ); -}; - -interface DocumentInfo { - description: string; - icon: string; - instructions: string[]; - title: string; -} - -const KycFlowDemo = () => { - const [docType, setDocType] = useState(AveniaDocumentType.ID); - - const documentInfo: Record = { - [AveniaDocumentType.ID]: { - description: "Brazilian national identity card (Registro Geral)", - icon: "RG", - instructions: ["Front side of the document", "Back side of the document", "Must be valid and not expired"], - title: "Identity Card (RG)" - }, - [AveniaDocumentType.DRIVERS_LICENSE]: { - description: "Brazilian driver's license (Carteira Nacional de Habilitacao)", - icon: "CNH", - instructions: ["Front side of the license", "Back side of the license", "Must be valid and not expired"], - title: "Driver's License (CNH)" - } - }; - - const info = documentInfo[docType]; - - return ( -
-

Document Verification

-

Select your document type for KYC Level 2 verification

- - - -
-

{info.title}

-

{info.description}

-

Required photos:

-
    - {info.instructions.map((instruction, index) => ( -
  • {instruction}
  • - ))} -
-
- - -
- ); -}; - -const meta: Meta = { - argTypes: { - activeDocType: { - control: "select", - description: "Currently selected document type", - options: [AveniaDocumentType.ID, AveniaDocumentType.DRIVERS_LICENSE] - } - }, - component: KycLevel2ToggleWrapper, - parameters: { - docs: { - description: { - component: - "A toggle component for selecting between Brazilian document types (RG or CNH) during KYC Level 2 verification. Features smooth spring animation for the indicator and supports reduced motion preferences." - } - }, - layout: "centered" - }, - tags: ["autodocs"], - title: "Components/KycLevel2Toggle" -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - activeDocType: AveniaDocumentType.ID - }, - parameters: { - docs: { - description: { - story: "Default toggle with RG (Identity Card) selected." - } - } - }, - render: KycLevel2ToggleWrapper -}; - -export const DriversLicenseSelected: Story = { - args: { - activeDocType: AveniaDocumentType.DRIVERS_LICENSE - }, - parameters: { - docs: { - description: { - story: "Toggle with CNH (Driver's License) selected." - } - } - }, - render: KycLevel2ToggleWrapper -}; - -export const Interactive: Story = { - parameters: { - docs: { - description: { - story: "Interactive demo with state tracking. Watch the indicator smoothly animate between options." - } - } - }, - render: InteractiveDemo -}; - -export const KycFlow: Story = { - parameters: { - docs: { - description: { - story: "Real-world example showing the toggle integrated into a KYC verification flow." - } - } - }, - render: KycFlowDemo -}; - -export const ReducedMotion: Story = { - args: { - activeDocType: AveniaDocumentType.ID - }, - parameters: { - docs: { - description: { - story: - "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions." - } - } - }, - render: KycLevel2ToggleWrapper -}; diff --git a/apps/frontend/src/stories/Menu.stories.tsx b/apps/frontend/src/stories/Menu.stories.tsx deleted file mode 100644 index e2ff8ae469..0000000000 --- a/apps/frontend/src/stories/Menu.stories.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { useState } from "react"; -import { Menu, MenuAnimationDirection } from "../components/menus/Menu"; - -interface StoryArgs { - animationDirection?: MenuAnimationDirection; - title?: string; -} - -const MenuWrapper = ({ animationDirection = MenuAnimationDirection.RIGHT, title = "Menu" }: StoryArgs) => { - const [isOpen, setIsOpen] = useState(true); - - return ( -
-
- Main Content - -
- -
-

This is the main content area. The menu will slide over this content.

-
- - setIsOpen(false)} title={title}> -
- - - -
-
-
- ); -}; - -const DirectionDemo = () => { - const [direction, setDirection] = useState(MenuAnimationDirection.RIGHT); - const [isOpen, setIsOpen] = useState(false); - - return ( -
-
- - -
- -
-
- Main Content - -
- -
-

Current direction: {direction}

-
- - setIsOpen(false)} title="Settings"> -
-
- - -
-
- - -
-
-
-
-
- ); -}; - -const TokenSelectionDemo = () => { - const [isOpen, setIsOpen] = useState(false); - const tokens = [ - { balance: "1,234.56", name: "USDC", network: "Polkadot" }, - { balance: "567.89", name: "USDT", network: "Ethereum" }, - { balance: "100.00", name: "BRZ", network: "Stellar" } - ]; - - return ( -
-
- -
- - setIsOpen(false)} - title="Select Token" - > -
- {tokens.map(token => ( - - ))} -
-
-
- ); -}; - -const meta: Meta = { - argTypes: { - animationDirection: { - control: "select", - description: "Direction from which the menu slides in", - options: [MenuAnimationDirection.RIGHT, MenuAnimationDirection.TOP] - }, - title: { - control: "text", - description: "Title displayed in the menu header" - } - }, - component: MenuWrapper, - parameters: { - docs: { - description: { - component: - "A sliding overlay menu component with directional animations. Supports slide-in from right or top with smooth easeOut curves. Features escape key support and reduced motion accessibility." - } - }, - layout: "centered" - }, - tags: ["autodocs"], - title: "Components/Menu" -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - animationDirection: MenuAnimationDirection.RIGHT, - title: "Menu" - }, - parameters: { - docs: { - description: { - story: "Default menu sliding in from the right. Press Escape or click the close button to dismiss." - } - } - }, - render: MenuWrapper -}; - -export const FromTop: Story = { - args: { - animationDirection: MenuAnimationDirection.TOP, - title: "Dropdown Menu" - }, - parameters: { - docs: { - description: { - story: "Menu sliding in from the top, useful for dropdown-style menus." - } - } - }, - render: MenuWrapper -}; - -export const DirectionComparison: Story = { - parameters: { - docs: { - description: { - story: "Interactive demo comparing different animation directions. Select a direction and open the menu." - } - } - }, - render: DirectionDemo -}; - -export const TokenSelection: Story = { - parameters: { - docs: { - description: { - story: "Real-world example showing the menu used for token selection in a swap interface." - } - } - }, - render: TokenSelectionDemo -}; - -export const ReducedMotion: Story = { - args: { - animationDirection: MenuAnimationDirection.RIGHT, - title: "Accessible Menu" - }, - parameters: { - docs: { - description: { - story: - "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions." - } - } - }, - render: MenuWrapper -}; diff --git a/apps/frontend/src/stories/MobileMenu.stories.tsx b/apps/frontend/src/stories/MobileMenu.stories.tsx deleted file mode 100644 index a16f72aa92..0000000000 --- a/apps/frontend/src/stories/MobileMenu.stories.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { AnimatePresence } from "motion/react"; -import { useState } from "react"; -import { MobileMenu } from "../components/Navbar/MobileMenu"; - -interface StoryArgs { - isOpen?: boolean; -} - -const MobileMenuWrapper = ({ isOpen = true }: StoryArgs) => { - const [menuOpen, setMenuOpen] = useState(isOpen); - - const handleMenuItemClick = () => { - setMenuOpen(false); - }; - - return ( -
- {/* Mock navbar header */} -
- Vortex - -
- - {/* Mobile menu with animation */} - {menuOpen && } -
- ); -}; - -const InteractiveDemo = () => { - const [isOpen, setIsOpen] = useState(false); - const [clickedItem, setClickedItem] = useState(null); - - const handleMenuItemClick = () => { - setClickedItem("Menu item clicked!"); - setIsOpen(false); - setTimeout(() => setClickedItem(null), 2000); - }; - - return ( -
-
-

Menu state: {isOpen ? "Open" : "Closed"}

- {clickedItem &&

{clickedItem}

} -
- -
-
- Vortex - -
- - {isOpen && } -
-
- ); -}; - -const AnimationShowcaseDemo = () => { - const [isOpen, setIsOpen] = useState(false); - const [speed, setSpeed] = useState<"normal" | "slow">("normal"); - - return ( -
-
- - -
-

- To see the animation in slow motion, open DevTools → Rendering → check "Emulate CSS media feature - prefers-reduced-motion" -

- -
-
- Vortex - -
- - {isOpen && setIsOpen(false)} />} -
-
- ); -}; - -const meta: Meta = { - argTypes: { - isOpen: { - control: "boolean", - description: "Whether the mobile menu is initially open" - } - }, - component: MobileMenuWrapper, - parameters: { - docs: { - description: { - component: - "Mobile navigation menu with staggered entrance animations. Features smooth slide-in animations for menu items and respects reduced motion preferences for accessibility. Uses easeOut curves for responsive feel." - } - }, - layout: "centered" - }, - tags: ["autodocs"], - title: "Components/MobileMenu" -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - isOpen: true - }, - parameters: { - docs: { - description: { - story: "Mobile menu in its open state showing navigation links and call-to-action button." - } - } - }, - render: MobileMenuWrapper -}; - -export const Closed: Story = { - args: { - isOpen: false - }, - parameters: { - docs: { - description: { - story: "Mobile menu in closed state. Click the button to open and see the entrance animation." - } - } - }, - render: MobileMenuWrapper -}; - -export const Interactive: Story = { - parameters: { - docs: { - description: { - story: "Interactive demo with state tracking. Toggle the menu to see smooth entrance/exit animations." - } - } - }, - render: InteractiveDemo -}; - -export const AnimationShowcase: Story = { - parameters: { - docs: { - description: { - story: - "Showcase the staggered animation effect. Open the menu multiple times to observe how menu items animate in sequence." - } - } - }, - render: AnimationShowcaseDemo -}; - -export const ReducedMotion: Story = { - args: { - isOpen: false - }, - parameters: { - docs: { - description: { - story: - "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions instead of animations." - } - } - }, - render: MobileMenuWrapper -}; diff --git a/apps/frontend/src/stories/NetworkSelectionAnimations.stories.tsx b/apps/frontend/src/stories/NetworkSelectionAnimations.stories.tsx deleted file mode 100644 index 96fe25b4eb..0000000000 --- a/apps/frontend/src/stories/NetworkSelectionAnimations.stories.tsx +++ /dev/null @@ -1,287 +0,0 @@ -import { ChevronDownIcon } from "@heroicons/react/24/outline"; -import type { Meta, StoryObj } from "@storybook/react"; -import { useState } from "react"; -import { SelectionButtonMotion } from "../components/TokenSelection/NetworkSelectionList/animations/SelectionButtonMotion"; -import { SelectionChevronMotion } from "../components/TokenSelection/NetworkSelectionList/animations/SelectionChevronMotion"; -import { SelectionDropdownMotion } from "../components/TokenSelection/NetworkSelectionList/animations/SelectionDropdownMotion"; - -const networks = [ - { icon: "polkadot.svg", id: "polkadot", name: "Polkadot" }, - { icon: "ethereum.svg", id: "ethereum", name: "Ethereum" }, - { icon: "stellar.svg", id: "stellar", name: "Stellar" }, - { icon: "moonbeam.svg", id: "moonbeam", name: "Moonbeam" } -]; - -const SelectionDropdownDemo = () => { - const [isOpen, setIsOpen] = useState(false); - const [selected, setSelected] = useState(networks[0]); - - return ( -
- - - -
- {networks.map(network => ( - - ))} -
-
-
- ); -}; - -const SelectionButtonDemo = () => { - const [isExpanded, setIsExpanded] = useState(false); - - return ( -
-
- setIsExpanded(!isExpanded)} - > - {isExpanded ? "Full width button - click to collapse" : "Click"} - - - {!isExpanded && ( -
- Other content -
- )} -
- -

The button animates between 10% and 100% width. Click to toggle.

-
- ); -}; - -const SelectionChevronDemo = () => { - const [isOpen, setIsOpen] = useState(false); - - return ( -
- - -

Chevron rotates 180° when {isOpen ? "open" : "closed"}

-
- ); -}; - -const NetworkDropdownDemo = () => { - const [isOpen, setIsOpen] = useState(false); - const [selectedNetwork, setSelectedNetwork] = useState(networks[0]); - - return ( -
-
-

Select Network

- -
- - - -
- {networks.map(network => ( - - ))} -
-
-
-
-
- ); -}; - -const AllAnimationsDemo = () => { - const [isExpanded, setIsExpanded] = useState(false); - const [dropdownOpen, setDropdownOpen] = useState(false); - - return ( -
-

All Selection Animations

- -
-

1. SelectionButtonMotion

- setIsExpanded(!isExpanded)} - > - {isExpanded ? "Expanded - Click to Collapse" : "Expand"} - -
- -
-

2. SelectionChevronMotion

-
- -
-
- -
-

3. SelectionDropdownMotion

- -
-

Dropdown Content

-

This content smoothly expands using grid-template-rows animation.

-
-
-
-
- ); -}; - -const meta: Meta = { - parameters: { - docs: { - description: { - component: - "A collection of animation components used in the network/token selection interface. Includes:\n\n" + - "- **SelectionDropdownMotion**: Smooth expand/collapse using GPU-accelerated grid-template-rows\n" + - "- **SelectionButtonMotion**: Width animation with easeOut curve\n" + - "- **SelectionChevronMotion**: 180° rotation animation for dropdown indicators\n\n" + - "All components support reduced motion preferences for accessibility." - } - }, - layout: "centered" - }, - tags: ["autodocs"], - title: "Components/NetworkSelection" -}; - -export default meta; -type Story = StoryObj; - -export const Dropdown: Story = { - parameters: { - docs: { - description: { - story: "SelectionDropdownMotion with SelectionChevronMotion combined for a complete dropdown experience." - } - } - }, - render: SelectionDropdownDemo -}; - -export const Button: Story = { - parameters: { - docs: { - description: { - story: "SelectionButtonMotion demonstrates width animation between collapsed (10%) and expanded (100%) states." - } - } - }, - render: SelectionButtonDemo -}; - -export const Chevron: Story = { - parameters: { - docs: { - description: { - story: "SelectionChevronMotion provides smooth 180° rotation for dropdown indicators." - } - } - }, - render: SelectionChevronDemo -}; - -export const NetworkDropdown: Story = { - parameters: { - docs: { - description: { - story: "Real-world example showing all three animation components working together in a network selector." - } - } - }, - render: NetworkDropdownDemo -}; - -export const AllAnimations: Story = { - parameters: { - docs: { - description: { - story: "Interactive demo showcasing all three animation components side by side." - } - } - }, - render: AllAnimationsDemo -}; - -export const ReducedMotion: Story = { - parameters: { - docs: { - description: { - story: - "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions instead of animations." - } - } - }, - render: AllAnimationsDemo -}; diff --git a/apps/frontend/src/stories/RampToggle.stories.tsx b/apps/frontend/src/stories/RampToggle.stories.tsx deleted file mode 100644 index 72368ac048..0000000000 --- a/apps/frontend/src/stories/RampToggle.stories.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { RampDirection } from "@vortexfi/shared"; -import { useState } from "react"; -import { RampToggle } from "../components/RampToggle"; - -interface StoryArgs { - activeDirection?: RampDirection; -} - -const RampToggleWrapper = ({ activeDirection = RampDirection.BUY }: StoryArgs) => { - const [direction, setDirection] = useState(activeDirection); - - return ( -
- -
- ); -}; - -const InteractiveDemo = () => { - const [direction, setDirection] = useState(RampDirection.BUY); - const [toggleCount, setToggleCount] = useState(0); - - const handleToggle = (newDirection: RampDirection) => { - setDirection(newDirection); - setToggleCount(prev => prev + 1); - }; - - return ( -
-
-

Current direction: {direction === RampDirection.BUY ? "Buy" : "Sell"}

-

Toggle count: {toggleCount}

-
- -
- {direction === RampDirection.BUY ? ( -
-

Buy Crypto

-

Convert fiat currency to cryptocurrency

-
- ) : ( -
-

Sell Crypto

-

Convert cryptocurrency to fiat currency

-
- )} -
-
- ); -}; - -const SwapInterfaceDemo = () => { - const [direction, setDirection] = useState(RampDirection.BUY); - const [amount, setAmount] = useState("100"); - - return ( -
- - -
-
- -
- setAmount(e.target.value)} - type="text" - value={amount} - /> - {direction === RampDirection.BUY ? "BRL" : "USDC"} -
-
- -
- -
- {(parseFloat(amount || "0") / 5).toFixed(2)} - {direction === RampDirection.BUY ? "USDC" : "BRL"} -
-
- - -
-
- ); -}; - -const meta: Meta = { - argTypes: { - activeDirection: { - control: "select", - description: "Currently active ramp direction", - options: [RampDirection.BUY, RampDirection.SELL] - } - }, - component: RampToggleWrapper, - parameters: { - docs: { - description: { - component: - "A toggle component for switching between Buy and Sell modes in the ramp interface. Features a smooth spring animation for the indicator and respects reduced motion preferences." - } - }, - layout: "centered" - }, - tags: ["autodocs"], - title: "Components/RampToggle" -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - activeDirection: RampDirection.BUY - }, - parameters: { - docs: { - description: { - story: "Default toggle with Buy selected. Click to switch between Buy and Sell." - } - } - }, - render: RampToggleWrapper -}; - -export const SellActive: Story = { - args: { - activeDirection: RampDirection.SELL - }, - parameters: { - docs: { - description: { - story: "Toggle with Sell selected." - } - } - }, - render: RampToggleWrapper -}; - -export const Interactive: Story = { - parameters: { - docs: { - description: { - story: "Interactive demo showing how the toggle updates the UI based on the selected direction." - } - } - }, - render: InteractiveDemo -}; - -export const SwapInterface: Story = { - parameters: { - docs: { - description: { - story: - "Real-world example showing the toggle integrated into a swap interface. Notice how the input/output labels and button text change based on the direction." - } - } - }, - render: SwapInterfaceDemo -}; - -export const ReducedMotion: Story = { - args: { - activeDirection: RampDirection.BUY - }, - parameters: { - docs: { - description: { - story: - "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' in browser DevTools to see instant transitions instead of the spring animation." - } - } - }, - render: RampToggleWrapper -}; diff --git a/apps/frontend/src/stories/TermsAndConditions.stories.tsx b/apps/frontend/src/stories/TermsAndConditions.stories.tsx deleted file mode 100644 index c09f2948d7..0000000000 --- a/apps/frontend/src/stories/TermsAndConditions.stories.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { useState } from "react"; -import { TermsAndConditions } from "../components/TermsAndConditions"; - -interface StoryArgs { - termsChecked?: boolean; - termsAccepted?: boolean; - termsError?: boolean; -} - -const TermsAndConditionsWrapper = ({ termsChecked = false, termsAccepted = false, termsError = false }: StoryArgs) => { - const [checked, setChecked] = useState(termsChecked); - const [error, setError] = useState(termsError); - const [accepted, setAccepted] = useState(termsAccepted); - - return ( -
- setChecked(!checked)} - /> - {!accepted && ( -
- - {!checked && ( - - )} -
- )} -
- ); -}; - -const InteractiveDemo = () => { - const [checked, setChecked] = useState(false); - const [error, setError] = useState(false); - const [accepted, setAccepted] = useState(false); - - const handleContinue = () => { - if (!checked) { - setError(true); - return; - } - setAccepted(true); - }; - - const handleReset = () => { - setChecked(false); - setError(false); - setAccepted(false); - }; - - return ( -
-
-

- State: {accepted ? "Accepted" : checked ? "Checked" : error ? "Error" : "Unchecked"} -

-
- - { - setChecked(!checked); - setError(false); - }} - /> - - {accepted ? ( -
-

Terms Accepted!

- -
- ) : ( - - )} -
- ); -}; - -const CheckoutFlowDemo = () => { - const [checked, setChecked] = useState(false); - const [error, setError] = useState(false); - const [accepted, setAccepted] = useState(false); - - return ( -
-

Complete Your Order

- -
-
- Amount - 100 USDC -
-
- Fee - 0.50 USDC -
-
- Total - 100.50 USDC -
-
- - { - setChecked(!checked); - setError(false); - }} - /> - - {accepted ? ( -
-

Order Confirmed!

-
- ) : ( - - )} -
- ); -}; - -const meta: Meta = { - argTypes: { - termsAccepted: { - control: "boolean", - description: "Whether the terms have been accepted (hides the checkbox)" - }, - termsChecked: { - control: "boolean", - description: "Whether the checkbox is checked" - }, - termsError: { - control: "boolean", - description: "Whether to show the error state" - } - }, - component: TermsAndConditionsWrapper, - parameters: { - docs: { - description: { - component: - "A terms and conditions checkbox component with animated error states and fade-out on acceptance. Features a subtle scale animation on error and supports reduced motion preferences." - } - }, - layout: "centered" - }, - tags: ["autodocs"], - title: "Components/TermsAndConditions" -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - termsAccepted: false, - termsChecked: false, - termsError: false - }, - parameters: { - docs: { - description: { - story: "Default unchecked state. Check the box before continuing." - } - } - }, - render: TermsAndConditionsWrapper -}; - -export const Checked: Story = { - args: { - termsAccepted: false, - termsChecked: true, - termsError: false - }, - parameters: { - docs: { - description: { - story: "Checkbox in checked state, ready to continue." - } - } - }, - render: TermsAndConditionsWrapper -}; - -export const Error: Story = { - args: { - termsAccepted: false, - termsChecked: false, - termsError: true - }, - parameters: { - docs: { - description: { - story: "Error state shown when user tries to continue without accepting terms." - } - } - }, - render: TermsAndConditionsWrapper -}; - -export const Accepted: Story = { - args: { - termsAccepted: true, - termsChecked: true, - termsError: false - }, - parameters: { - docs: { - description: { - story: "Accepted state - the checkbox fades out with a scale animation." - } - } - }, - render: TermsAndConditionsWrapper -}; - -export const Interactive: Story = { - parameters: { - docs: { - description: { - story: "Interactive demo showing the full flow: unchecked -> error -> checked -> accepted." - } - } - }, - render: InteractiveDemo -}; - -export const CheckoutFlow: Story = { - parameters: { - docs: { - description: { - story: "Real-world example showing the terms checkbox in a checkout/confirmation flow." - } - } - }, - render: CheckoutFlowDemo -}; - -export const ReducedMotion: Story = { - args: { - termsAccepted: false, - termsChecked: false, - termsError: false - }, - parameters: { - docs: { - description: { - story: - "Test reduced motion support. Enable 'prefers-reduced-motion: reduce' to see instant transitions instead of animations." - } - } - }, - render: TermsAndConditionsWrapper -}; diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 8f3c31ff42..297266b693 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -198,7 +198,7 @@ "title": "Connect wallet" }, "polkadotWalletSelectorDialogLoading": { - "description": "Please approve {{selectedWallet}} and approve the transaction.", + "description": "Please approve {selectedWallet} and approve the transaction.", "title": "Connecting wallet" }, "selectionModal": { @@ -880,7 +880,6 @@ "brlaOnrampMint": "Your payment is being processed. This can take up to 5 minutes.", "closeProgressScreenText": "You’re all set! You can now close this tab or grab a coffee while we finish up in the background.", "createStellarAccount": "Creating Stellar account", - "destinationTransfer": "Transferring {{assetSymbol}} to your wallet", "estimatedTimeAssetHub": "This usually takes 4-6 minutes.", "estimatedTimeEVM": "This usually takes 6-8 minutes.", "executeSpacewalkRedeem": "Bridging {{assetSymbol}} to Stellar via Spacewalk", @@ -944,7 +943,6 @@ "buy": "Minimum buy amount is {{minAmountUnits}} {{assetSymbol}}.", "sell": "Minimum sell amount is {{minAmountUnits}} {{assetSymbol}}." }, - "lowLiquidity": "Low liquidity for this route. Please try a smaller amount.", "missingFields": "Missing required fields", "moreThanMaximumWithdrawal": { "buy": "Maximum buy amount is {{maxAmountUnits}} {{assetSymbol}}.", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index b8029d8f27..0734d69cde 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -199,7 +199,7 @@ "title": "Conectar wallet" }, "polkadotWalletSelectorDialogLoading": { - "description": "Por favor, aprove {{selectedWallet}} e aprove a transação.", + "description": "Por favor, aprove {selectedWallet} e aprove a transação.", "title": "Conectando wallet" }, "selectionModal": { @@ -874,7 +874,6 @@ "brlaOnrampMint": "Seu pagamento está sendo processado. Isso pode levar até 5 minutos.", "closeProgressScreenText": "Tudo pronto! Você já pode fechar esta aba ou pegar um café enquanto finalizamos o processo em segundo plano.", "createStellarAccount": "Criando conta Stellar", - "destinationTransfer": "Transferindo {{assetSymbol}} para sua carteira", "estimatedTimeAssetHub": "Isso geralmente leva de 4 a 6 minutos.", "estimatedTimeEVM": "Isso geralmente leva de 6 a 8 minutos.", "executeSpacewalkRedeem": "Transferindo {{assetSymbol}} para Stellar via Spacewalk", @@ -938,7 +937,6 @@ "buy": "O valor mínimo de compra é {{minAmountUnits}} {{assetSymbol}}.", "sell": "O valor mínimo de venda é {{minAmountUnits}} {{assetSymbol}}." }, - "lowLiquidity": "Baixa liquidez para esta rota. Por favor, tente um valor menor.", "missingFields": "Campos obrigatórios ausentes", "moreThanMaximumWithdrawal": { "buy": "O valor máximo de compra é {{maxAmountUnits}} {{assetSymbol}}.", diff --git a/bun.lock b/bun.lock index a6cb24a372..dd9202fa05 100644 --- a/bun.lock +++ b/bun.lock @@ -235,7 +235,7 @@ }, "packages/sdk": { "name": "@vortexfi/sdk", - "version": "0.5.1", + "version": "0.4.10", "dependencies": { "@vortexfi/shared": "workspace:*", }, @@ -251,7 +251,7 @@ }, "packages/shared": { "name": "@vortexfi/shared", - "version": "0.1.1", + "version": "0.0.11", "dependencies": { "@paraspell/sdk-pjs": "^11.8.5", "@pendulum-chain/api-solang": "catalog:", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index cf4b44413a..aad00c1504 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -44,5 +44,5 @@ }, "type": "module", "types": "./dist/index.d.ts", - "version": "0.5.1" + "version": "0.4.10" } diff --git a/packages/sdk/src/services/NetworkManager.ts b/packages/sdk/src/services/NetworkManager.ts index f28e0bbb83..6f723850cc 100644 --- a/packages/sdk/src/services/NetworkManager.ts +++ b/packages/sdk/src/services/NetworkManager.ts @@ -59,7 +59,7 @@ export class NetworkManager { } getAlchemyApiKey(): string | undefined { - return "9nk8Nf7Eaz_4smCzIcPUk"; + return this.config.alchemyApiKey; } private async initializeApis(): Promise { diff --git a/packages/shared/package.json b/packages/shared/package.json index dbf3aaf73e..db496203e6 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -53,5 +53,5 @@ "prepublishOnly": "bun run build" }, "types": "./dist/index.d.ts", - "version": "0.1.1" + "version": "0.0.11" } diff --git a/packages/shared/src/endpoints/quote.endpoints.ts b/packages/shared/src/endpoints/quote.endpoints.ts index df98166da2..265fe05507 100644 --- a/packages/shared/src/endpoints/quote.endpoints.ts +++ b/packages/shared/src/endpoints/quote.endpoints.ts @@ -95,7 +95,6 @@ export enum QuoteError { InputAmountForSwapMustBeGreaterThanZero = "Input amount for swap must be greater than 0", InputAmountTooLow = "Input amount too low. Please try a larger amount.", InputAmountTooLowToCoverCalculatedFees = "Input amount too low to cover calculated fees.", - LowLiquidity = "Low liquidity for this route. Please try a smaller amount.", BelowLowerLimitSell = "Output amount below minimum SELL limit of", BelowLowerLimitBuy = "Input amount below minimum BUY limit of", diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index 6ce16a4b1d..31cc209480 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -10,7 +10,6 @@ export type RampPhase = | "squidRouterSwap" | "squidRouterPay" | "fundEphemeral" - | "destinationTransfer" | "nablaApprove" | "nablaSwap" | "hydrationSwap" @@ -30,11 +29,6 @@ export type RampPhase = | "brlaPayoutOnMoonbeam" | "failed" | "timedOut" - | "finalSettlementSubsidy" - | "destinationTransfer" - | "backupSquidRouterApprove" - | "backupSquidRouterSwap" - | "backupApprove" | "complete"; export type CleanupPhase = "moonbeamCleanup" | "pendulumCleanup" | "stellarCleanup"; diff --git a/packages/shared/src/helpers/signUnsigned.ts b/packages/shared/src/helpers/signUnsigned.ts index cf47032d67..1dea0ab334 100644 --- a/packages/shared/src/helpers/signUnsigned.ts +++ b/packages/shared/src/helpers/signUnsigned.ts @@ -3,13 +3,14 @@ import { AddressOrPair } from "@polkadot/api/types"; import { hexToU8a } from "@polkadot/util"; import { cryptoWaitReady } from "@polkadot/util-crypto"; import { Keypair, Networks as StellarNetworks, Transaction } from "stellar-sdk"; -import { createWalletClient, fallback, http, WalletClient } from "viem"; +import { createWalletClient, http, WalletClient, webSocket } from "viem"; import { privateKeyToAccount } from "viem/accounts"; -import { arbitrum, avalanche, base, bsc, mainnet, moonbeam, polygon, polygonAmoy } from "viem/chains"; +import { moonbeam, polygon } from "viem/chains"; import { decodeSubmittableExtrinsic, EphemeralAccount, isEvmTransactionData, + MOONBEAM_WSS, Networks, PresignedTx, SANDBOX_ENABLED, @@ -43,6 +44,12 @@ export function addAdditionalTransactionsToMeta(primaryTx: PresignedTx, multiSig /** * Signs multiple Stellar transactions with increasing sequence numbers + * + * @param tx - The original backend-signed transaction. Can contain meta field with multiple-nonce transactions. + * @param keypair - The Stellar keypair to sign with + * @param networkPassphrase - The Stellar network passphrase + * @param startingNonce - The starting nonce/sequence number value + * @returns - Multi-nonce presigned transaction object. */ async function signMultipleStellarTransactions( tx: UnsignedTx, @@ -73,7 +80,13 @@ async function signMultipleStellarTransactions( } /** - * Signs multiple Substrate transactions with increasing nonces + * Signs multiple Substrate (Pendulum) transactions with increasing nonces + * + * @param tx - The original unsigned transaction + * @param keypair - The keypair to sign with + * @param api - The Polkadot API instance + * @param startingNonce - The starting nonce value + * @returns - Array of signed transactions with increasing nonces */ async function signMultipleSubstrateTransactions( tx: UnsignedTx, @@ -103,73 +116,41 @@ async function signMultipleSubstrateTransactions( } /** - * Creates a wallet client for a specific EVM network using the ephemeral secret + * Creates wallet clients for both Moonbeam and Polygon networks using the same ephemeral secret * - * @param network - The network enum to create the client for - * @param evmEphemeral - The ephemeral account containing the secret - * @param apiKey - Optional Alchemy API key - * @returns WalletClient for the specified network + * @param moonbeamEphemeral - The ephemeral account containing the secret + * @param alchemyApiKey - Optional Alchemy API key for Polygon transport + * @returns Object containing both wallet clients */ -function createEvmClient( - network: string, // Accept string to match UnsignedTx.network type usually being string/enum - evmEphemeral: EphemeralAccount, - apiKey?: string -): WalletClient { - const privateKey = evmEphemeral.secret as `0x${string}`; +function createEvmWalletClients( + moonbeamEphemeral: EphemeralAccount, + alchemyApiKey?: string +): { moonbeamClient: WalletClient; polygonClient: WalletClient } { + const privateKey = moonbeamEphemeral.secret as `0x${string}`; const evmAccount = privateKeyToAccount(privateKey); + const moonbeamClient = createWalletClient({ + account: evmAccount, + chain: moonbeam, + transport: alchemyApiKey ? http(`https://moonbeam-mainnet.g.alchemy.com/v2/${alchemyApiKey}`) : webSocket(MOONBEAM_WSS) + }); - let chain; - let rpcUrls: string[] = []; - - switch (network) { - case Networks.Polygon: - chain = polygon; - rpcUrls = apiKey ? [`https://polygon-mainnet.g.alchemy.com/v2/${apiKey}`] : []; - break; - case Networks.PolygonAmoy: - chain = polygonAmoy; - rpcUrls = ["https://polygon-amoy.api.onfinality.io/public"]; - break; - case Networks.Moonbeam: - chain = moonbeam; - rpcUrls = ["https://rpc.api.moonbeam.network", "https://moonbeam-rpc.publicnode.com"]; - break; - case Networks.Arbitrum: - chain = arbitrum; - rpcUrls = apiKey ? [`https://arb-mainnet.g.alchemy.com/v2/${apiKey}`] : []; - break; - case Networks.Avalanche: - chain = avalanche; - rpcUrls = apiKey ? [`https://avax-mainnet.g.alchemy.com/v2/${apiKey}`] : []; - break; - case Networks.Base: - chain = base; - rpcUrls = apiKey ? [`https://base-mainnet.g.alchemy.com/v2/${apiKey}`] : []; - break; - case Networks.BSC: - chain = bsc; - rpcUrls = apiKey ? [`https://bnb-mainnet.g.alchemy.com/v2/${apiKey}`] : []; - break; - case Networks.Ethereum: - chain = mainnet; - rpcUrls = apiKey ? [`https://eth-mainnet.g.alchemy.com/v2/${apiKey}`] : []; - break; - default: - throw new Error(`Unsupported or unconfigured EVM network: ${network}`); - } - - const transports = rpcUrls.filter(url => url !== "").map(url => http(url)); - transports.push(http()); // add default viem transport as last resort - - return createWalletClient({ + const polygonTransport = alchemyApiKey ? http(`https://polygon-mainnet.g.alchemy.com/v2/${alchemyApiKey}`) : http(); + const polygonClient = createWalletClient({ account: evmAccount, - chain: chain, - transport: fallback(transports) + chain: polygon, + transport: polygonTransport }); + + return { moonbeamClient, polygonClient }; } /** - * Signs multiple EVM transactions with increasing nonces + * Signs multiple EVM (Moonbeam) transactions with increasing nonces + * + * @param tx - The original unsigned transaction + * @param walletClient - The viem wallet client + * @param startingNonce - The starting nonce value + * @returns - Array of signed transactions with increasing nonces */ async function signMultipleEvmTransactions( tx: UnsignedTx, @@ -185,6 +166,8 @@ async function signMultipleEvmTransactions( for (let i = 0; i < NUMBER_OF_PRESIGNED_TXS; i++) { const currentNonce = startingNonce + i; + // Ensure the transaction data is in the correct format + if (!walletClient.account) { throw new Error("Wallet client account is undefined"); } @@ -215,6 +198,43 @@ async function signMultipleEvmTransactions( return signedTxs; } +/** + * Signs an array of unsigned transactions using network-specific methods. + * It signs multiple transactions with increasing nonces and includes them in the meta field. + * + * The signUnsignedTransactions function receives: + * - unsignedTxs: an array of UnsignedTx + * - ephemerals: an object mapping networks to EphemeralAccount for Stellar, Pendulum (substrate), + * and EVM (Moonbeam) transactions. + * + * For each unsigned transaction, the function selects the appropriate signing method: + * + * • Stellar: + * - Uses stellar-sdk to create a Transaction from the provided XDR (tx_data). + * - Signs using the ephemeral key (assumed to be passed). + * - Signs NUMBER_OF_PRESIGNED_TXS transactions with increasing nonces. + * + * • Pendulum (substrate): + * - Uses @polkadot/api Keyring to generate a keypair from the ephemeral secret. + * - Simulates signing via extrinsic.signAsync with options { nonce, era }. + * - Signs NUMBER_OF_PRESIGNED_TXS transactions with increasing nonces. + * + * • Moonbeam (EVM): + * - Uses the viem client to create a wallet client for EVM transactions. + * - Signs the transaction via walletClient.signTransaction. + * - Signs NUMBER_OF_PRESIGNED_TXS transactions with increasing nonces. + * + * For each transaction, signed transactions with nonces > n (where n is the original specified nonce) + * are stored in the meta.additionalTxs field of the first transaction. Each transaction is named + * by its phase property appended with the nonce offset (e.g., "phase1", "phase2" for nonce+1, nonce+2). + * + * @param unsignedTxs - Array of transactions to be signed. + * @param ephemerals - Mapping from network to its corresponding EphemeralAccount. + * Expected keys: stellar, pendulum, evm. + * @param pendulumApi - ApiPromise instance for Pendulum transactions. + * @param moonbeamApi - ApiPromise instance for Moonbeam transactions. + * @returns Promise resolving to an array of SignedTx with additional signed transactions in meta fields. + */ export async function signUnsignedTransactions( unsignedTxs: UnsignedTx[], ephemerals: { @@ -232,17 +252,15 @@ export async function signUnsignedTransactions( const signedTxs: PresignedTx[] = []; - // Group transactions + // Create EVM wallet clients once at the beginning if needed + let evmClients: { moonbeamClient: WalletClient; polygonClient: WalletClient } | null = null; const moonbeamTxs = unsignedTxs.filter(tx => tx.network === Networks.Moonbeam); const polygonTxs = unsignedTxs.filter(tx => tx.network === Networks.Polygon || tx.network === Networks.PolygonAmoy); const hydrationTxs = unsignedTxs.filter(tx => tx.network === Networks.Hydration); - const destinationNetworkTxs = unsignedTxs.filter( - tx => - tx.phase === "destinationTransfer" || - tx.phase === "backupSquidRouterApprove" || - tx.phase === "backupSquidRouterSwap" || - tx.phase === "backupApprove" - ); + + if ((moonbeamTxs.length > 0 || polygonTxs.length > 0) && ephemerals.evmEphemeral) { + evmClients = createEvmWalletClients(ephemerals.evmEphemeral, alchemyApiKey); + } try { const stellarTxs = unsignedTxs.filter(tx => tx.network === "stellar").sort((a, b) => a.nonce - b.nonce); @@ -284,13 +302,14 @@ export async function signUnsignedTransactions( const keypair = keyring.addFromUri(ephemerals.substrateEphemeral.secret); const multiSignedTxs = await signMultipleSubstrateTransactions(tx, keypair, hydrationApi, tx.nonce); + const primaryTx = multiSignedTxs[0]; + const txWithMeta = addAdditionalTransactionsToMeta(primaryTx, multiSignedTxs); signedTxs.push(txWithMeta); } - // Process Pendulum transactions for (const tx of pendulumTxs) { if (!ephemerals.substrateEphemeral) { throw new Error("Missing Pendulum ephemeral account"); @@ -308,7 +327,9 @@ export async function signUnsignedTransactions( const keypair = keyring.addFromUri(ephemerals.substrateEphemeral.secret); const multiSignedTxs = await signMultipleSubstrateTransactions(tx, keypair, pendulumApi, tx.nonce); + const primaryTx = multiSignedTxs[0]; + const txWithMeta = addAdditionalTransactionsToMeta(primaryTx, multiSignedTxs); signedTxs.push(txWithMeta); @@ -320,21 +341,28 @@ export async function signUnsignedTransactions( throw new Error("Missing EVM ephemeral account"); } + if (!evmClients) { + throw new Error("EVM clients not initialized"); + } + if (isEvmTransactionData(tx.txData)) { - const client = createEvmClient(Networks.Moonbeam, ephemerals.evmEphemeral, alchemyApiKey); - const multiSignedTxs = await signMultipleEvmTransactions(tx, client, tx.nonce); + const multiSignedTxs = await signMultipleEvmTransactions(tx, evmClients.moonbeamClient, tx.nonce); + const primaryTx = multiSignedTxs[0]; + const txWithMeta = addAdditionalTransactionsToMeta(primaryTx, multiSignedTxs); signedTxs.push(txWithMeta); } else { - // Handle Moonbeam Substrate transactions const keyring = new Keyring({ type: "ethereum" }); + const privateKey = ephemerals.evmEphemeral.secret as `0x${string}`; const keypair = keyring.addFromSeed(hexToU8a(privateKey)); const multiSignedTxs = await signMultipleSubstrateTransactions(tx, keypair, moonbeamApi, tx.nonce); + const primaryTx = multiSignedTxs[0]; + const txWithMeta = addAdditionalTransactionsToMeta(primaryTx, multiSignedTxs); signedTxs.push(txWithMeta); @@ -347,26 +375,11 @@ export async function signUnsignedTransactions( throw new Error("Missing EVM ephemeral account"); } - const client = createEvmClient(tx.network, ephemerals.evmEphemeral, alchemyApiKey); - const multiSignedTxs = await signMultipleEvmTransactions(tx, client, tx.nonce); - const primaryTx = multiSignedTxs[0]; - const txWithMeta = addAdditionalTransactionsToMeta(primaryTx, multiSignedTxs); - - signedTxs.push(txWithMeta); - } - - // Process Destination Network (EVM) transactions - for (const tx of destinationNetworkTxs) { - if (!ephemerals.evmEphemeral) { - throw new Error("Missing EVM ephemeral account"); + if (!evmClients) { + throw new Error("EVM clients not initialized"); } - // Check if already signed to avoid duplication - const alreadySigned = signedTxs.some(st => st === tx || (st.txData === tx.txData && st.nonce === tx.nonce)); - if (alreadySigned) continue; - - const client = createEvmClient(tx.network, ephemerals.evmEphemeral, alchemyApiKey); - const multiSignedTxs = await signMultipleEvmTransactions(tx, client, tx.nonce); + const multiSignedTxs = await signMultipleEvmTransactions(tx, evmClients.polygonClient, tx.nonce); const primaryTx = multiSignedTxs[0]; const txWithMeta = addAdditionalTransactionsToMeta(primaryTx, multiSignedTxs); diff --git a/packages/shared/src/services/brla/brlaApiService.ts b/packages/shared/src/services/brla/brlaApiService.ts index 5b75437e2c..026f25d722 100644 --- a/packages/shared/src/services/brla/brlaApiService.ts +++ b/packages/shared/src/services/brla/brlaApiService.ts @@ -289,7 +289,7 @@ export class BrlaApiService { const query = `subAccountId=${encodeURIComponent(subAccountId)}`; const aveniaTicketsQueryResponse = await this.sendRequest(Endpoint.Tickets, "GET", query, undefined, ticketId); - if ("ticket" in aveniaTicketsQueryResponse && "brazilianFiatReceiverInfo" in aveniaTicketsQueryResponse.ticket) { + if ("ticket" in aveniaTicketsQueryResponse && "brlPixOutputInfo" in aveniaTicketsQueryResponse.ticket) { return aveniaTicketsQueryResponse.ticket; } throw new Error("Invalid response from Avenia API for getAveniaPayoutTicket"); diff --git a/packages/shared/src/services/brla/types.ts b/packages/shared/src/services/brla/types.ts index 42d3d2ee8a..d9b0022716 100644 --- a/packages/shared/src/services/brla/types.ts +++ b/packages/shared/src/services/brla/types.ts @@ -203,6 +203,13 @@ export interface AveniaPayoutTicket extends BaseTicket { walletAddress: string; txHash: string; }; + brlPixOutputInfo: { + id: string; + ticketId: string; + pixMessage: string; + senderAccountBankName: string; + senderAccountNumber: string; + }; blockchainInputInfo: { id: string; ticketId: string; diff --git a/packages/shared/src/services/squidrouter/onramp.ts b/packages/shared/src/services/squidrouter/onramp.ts index 8ab19d64a7..b0a8c7dd71 100644 --- a/packages/shared/src/services/squidrouter/onramp.ts +++ b/packages/shared/src/services/squidrouter/onramp.ts @@ -7,7 +7,6 @@ import { createSquidRouterHash, ERC20_EURE_POLYGON_V1, EvmClientManager, - EvmNetworks, EvmTransactionData, encodePayload, getSquidRouterConfig, @@ -36,15 +35,6 @@ export interface OnrampSquidrouterParamsFromPolygon { destinationAddress: string; } -export interface OnrampSquidrouterParamsOnDestinationChain { - fromAddress: string; - rawAmount: string; - fromToken: `0x${string}`; - toToken: `0x${string}`; - network: EvmNetworks; - destinationAddress: string; -} - export interface OnrampTransactionData { approveData: EvmTransactionData; swapData: EvmTransactionData; @@ -170,38 +160,3 @@ export async function createOnrampSquidrouterTransactionsFromPolygonToMoonbeamWi throw new Error(`Error getting route: ${routeParams}. Error: ${e}`); } } - -export async function createOnrampSquidrouterTransactionsOnDestinationChain( - params: OnrampSquidrouterParamsOnDestinationChain -): Promise { - const evmClientManager = EvmClientManager.getInstance(); - const client = evmClientManager.getClient(params.network); - - const routeParams = createGenericRouteParams({ - ...params, - amount: params.rawAmount, - fromNetwork: params.network, - toNetwork: params.network - }); - - try { - const routeResult = await getRoute(routeParams); - const { route } = routeResult.data; - - const { approveData, swapData, squidRouterQuoteId } = await createTransactionDataFromRoute({ - inputTokenErc20Address: params.fromToken, - publicClient: client, - rawAmount: params.rawAmount, - route - }); - - return { - approveData, - route, - squidRouterQuoteId, - swapData - }; - } catch (e) { - throw new Error(`Error getting route: ${routeParams}. Error: ${e}`); - } -} diff --git a/packages/shared/src/services/squidrouter/route.ts b/packages/shared/src/services/squidrouter/route.ts index c5ef315c30..aba489ede0 100644 --- a/packages/shared/src/services/squidrouter/route.ts +++ b/packages/shared/src/services/squidrouter/route.ts @@ -6,57 +6,6 @@ import { AXL_USDC_MOONBEAM, EvmTokenDetails, EvmTransactionData, getNetworkId, N import logger from "../../logger"; import { getSquidRouterConfig, squidRouterConfigBase } from "./config"; -/** - * Normalizes a numeric string to a format that BigInt can parse. - * Handles scientific notation (e.g., "1.5e18") and decimal strings (e.g., "123.456") - * by converting them to integer strings, truncating any fractional part. - */ -function normalizeBigIntString(value: string): string { - if (!value || value === "") { - return "0"; - } - - // If it's already a valid integer string (decimal or hex), return as-is - if (/^-?\d+$/.test(value) || /^0x[0-9a-fA-F]+$/i.test(value)) { - return value; - } - - // Handle scientific notation and decimals by parsing as Number first, then converting - // This will truncate any fractional part - try { - const num = Number(value); - if (Number.isNaN(num) || !Number.isFinite(num)) { - logger.current.warn(`Invalid numeric value for BigInt conversion: ${value}, defaulting to 0`); - return "0"; - } - // Use BigInt on the truncated integer value to avoid precision issues with large numbers - // For very large numbers, we need to handle them specially - if (Math.abs(num) > Number.MAX_SAFE_INTEGER) { - // For scientific notation with large exponents, parse manually - const match = value.match(/^(-?\d+\.?\d*)[eE]([+-]?\d+)$/); - if (match) { - const [, mantissa, exponent] = match; - const exp = parseInt(exponent, 10); - const [intPart, decPart = ""] = mantissa.replace("-", "").split("."); - const sign = mantissa.startsWith("-") ? "-" : ""; - const totalDigits = intPart + decPart; - const zerosNeeded = exp - decPart.length; - if (zerosNeeded >= 0) { - return sign + totalDigits + "0".repeat(zerosNeeded); - } else { - // Truncate decimal part - return sign + totalDigits.slice(0, totalDigits.length + zerosNeeded) || "0"; - } - } - } - // For smaller numbers, Math.trunc works fine - return BigInt(Math.trunc(num)).toString(); - } catch (e) { - logger.current.warn(`Failed to normalize BigInt string: ${value}, error: ${e}`); - return "0"; - } -} - const SQUIDROUTER_BASE_URL = "https://v2.api.squidrouter.com/v2"; export { splitReceiverABI }; @@ -70,8 +19,7 @@ export interface RouteParams { toToken: `0x${string}`; toAddress: string; bypassGuardrails: boolean; - slippage?: number; - slippageConfig?: { + slippageConfig: { autoMode: number; }; enableExpress: boolean; @@ -147,9 +95,8 @@ export async function getRoute(params: RouteParams): Promise 2.5) { - logger.current.warn(`Received route with high slippage: ${slippage}%. Request ID: ${requestId}`); - // FIXME: temporarily disabled because we are facing issues with squidrouter routes failing the swap to USDT - // throw new Error(`The slippage of the route is too high: ${slippage}%. Please try again later.`); + logger.current.error(`Received route with high slippage: ${slippage}%. Request ID: ${requestId}`); + throw new Error(`The slippage of the route is too high: ${slippage}%. Please try again later.`); } } @@ -274,7 +221,9 @@ export function createRouteParamsWithMoonbeamPostHook(params: { logoURI: "https://pbs.twimg.com/profile_images/1548647667135291394/W2WOtKUq_400x400.jpg", // Add your product or application's logo here provider: "Pendulum" }, - slippage: 4, + slippageConfig: { + autoMode: 1 + }, toAddress: fromAddress, toChain: toChainId.toString(), toToken: AXL_USDC_MOONBEAM @@ -302,7 +251,9 @@ export function createGenericRouteParams(params: { fromAmount: amount, fromChain: fromChainId.toString(), fromToken, - slippage: 4, + slippageConfig: { + autoMode: 1 + }, toAddress: destinationAddress, toChain: toChainId.toString(), toToken @@ -377,11 +328,11 @@ export async function createTransactionDataFromRoute({ const swapData: EvmTransactionData = { data: transactionRequest.data as `0x${string}`, - gas: normalizeBigIntString(transactionRequest.gasLimit), + gas: transactionRequest.gasLimit, maxFeePerGas: maxFeePerGas.toString(), maxPriorityFeePerGas: (maxPriorityFeePerGas ?? maxFeePerGas).toString(), to: transactionRequest.target as `0x${string}`, - value: normalizeBigIntString(swapValue ?? transactionRequest.value) + value: swapValue ?? transactionRequest.value }; if (nonce !== undefined) { diff --git a/packages/shared/src/tokens/evm/config.ts b/packages/shared/src/tokens/evm/config.ts index 02af40f5dc..df5b2672f6 100644 --- a/packages/shared/src/tokens/evm/config.ts +++ b/packages/shared/src/tokens/evm/config.ts @@ -38,16 +38,6 @@ export const evmTokenConfig: Record>>; - priceBySymbol: Map; isLoaded: boolean; + error: Error | null; + usedFallback: boolean; } const state: DynamicEvmTokensState = { + error: null, isLoaded: false, - priceBySymbol: new Map(), - tokensByNetwork: {} as Record>> + tokens: [], + tokensByNetwork: {} as Record>>, + usedFallback: false }; -/** - * Iterates over all EVM networks and calls the callback for each. - */ -function forEachEvmNetwork(callback: (network: EvmNetworks) => void): void { - for (const network of Object.values(Networks)) { - if (isNetworkEVM(network)) { - callback(network as EvmNetworks); - } - } -} - -function createEmptyNetworkBuckets(): Record>> { - const buckets = {} as Record>>; - forEachEvmNetwork(network => { - buckets[network] = {}; - }); - return buckets; -} - -const NATIVE_TOKEN_ADDRESS = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" as const; - -function isNativeToken(address: string): boolean { - return address.toLowerCase() === NATIVE_TOKEN_ADDRESS; -} - function getNetworkFromChainId(chainId: string): Networks | null { const chainIdNum = parseInt(chainId, 10); const networkEntries = Object.entries(Networks).filter( @@ -78,34 +72,16 @@ function getNetworkFromChainId(chainId: string): Networks | null { return networkEntries.length > 0 ? (networkEntries[0][1] as Networks) : null; } +function isNativeToken(address: string): boolean { + return address === "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; +} + function getNetworkAssetIcon(network: Networks, symbol: string): string { const networkName = network.toLowerCase(); const cleanSymbol = symbol.replace(/[^a-zA-Z0-9]/g, "").toLowerCase(); return `${networkName}${cleanSymbol}`; } -function generateFallbackLogoURI(chainId: number, address: string): string { - return `https://raw.githubusercontent.com/0xsquid/assets/main/images/migration/webp/${chainId}_${address.toLowerCase()}.webp`; -} - -function shouldIncludeToken(token: SquidRouterToken): boolean { - const symbol = token.symbol.toUpperCase(); - - // Exclude blocklisted tokens (Cosmos native tokens) - if (TOKEN_FILTER_CONFIG.symbolBlocklist.has(symbol)) { - return false; - } - - // Exclude most bridged Axelar tokens except major stablecoins - for (const pattern of TOKEN_FILTER_CONFIG.excludedBridgedPatterns) { - if (pattern.test(token.symbol)) { - return false; - } - } - - return true; -} - function mapSquidTokenToEvmTokenDetails(token: SquidRouterToken): EvmTokenDetails | null { const network = getNetworkFromChainId(token.chainId); if (!network || !isNetworkEVM(network)) { @@ -118,13 +94,15 @@ function mapSquidTokenToEvmTokenDetails(token: SquidRouterToken): EvmTokenDetail const isNative = isNativeToken(token.address); - const erc20Address = token.address as `0x${string}`; + const erc20Address: `0x${string}` = isNative + ? ("0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" as `0x${string}`) + : (token.address as `0x${string}`); return { assetSymbol: token.symbol, decimals: token.decimals, erc20AddressSourceChain: erc20Address, - fallbackLogoURI: generateFallbackLogoURI(parseInt(token.chainId, 10), erc20Address), + fallbackLogoURI: `https://raw.githubusercontent.com/0xsquid/assets/main/images/migration/webp/${token.chainId}_${token.address.toLowerCase()}.webp`, isNative, logoURI: token.logoURI, network, @@ -135,136 +113,93 @@ function mapSquidTokenToEvmTokenDetails(token: SquidRouterToken): EvmTokenDetail }; } -/** - * Groups tokens by their network into a record keyed by EvmNetworks. - * This function only groups - it does not merge with static config. - */ -function groupTokensByNetwork(tokens: EvmTokenDetails[]): Record>> { - const grouped = createEmptyNetworkBuckets(); - - for (const token of tokens) { - const network = token.network as EvmNetworks; - const symbolKey = token.assetSymbol.toUpperCase(); - const existingToken = grouped[network][symbolKey]; - - // If there's already a token with this symbol, keep the better one - if (existingToken) { - // Priority: native > higher USD price > lower address (deterministic) - const tokenPrice = token.usdPrice ?? 0; - const existingPrice = existingToken.usdPrice ?? 0; - - const shouldReplace = - (token.isNative && !existingToken.isNative) || - (!token.isNative && - !existingToken.isNative && - (tokenPrice > existingPrice || - (tokenPrice === existingPrice && - token.erc20AddressSourceChain.toLowerCase() < existingToken.erc20AddressSourceChain.toLowerCase()))); - - if (shouldReplace) { - grouped[network][symbolKey] = token; - } - // Otherwise keep the existing token - } else { - grouped[network][symbolKey] = token; +async function fetchSquidRouterTokens(): Promise { + const result = await axios.get("https://v2.api.squidrouter.com/v2/tokens", { + headers: { + "x-integrator-id": squidRouterConfigBase.integratorId } - } - - return grouped; + }); + return result.data.tokens; } -/** - * Merges dynamic tokens with static config. - * Static config takes priority for contract addresses, but preserves useful metadata - * (logoURI, usdPrice) from dynamic tokens. - */ -function mergeWithStaticConfig( - dynamicTokens: Record>> -): Record>> { - const merged = createEmptyNetworkBuckets(); - - forEachEvmNetwork(network => { - merged[network] = { ...dynamicTokens[network] }; - - const networkTokenConfig = evmTokenConfig[network]; - if (!networkTokenConfig) return; - - for (const [symbol, staticToken] of Object.entries(networkTokenConfig)) { - if (!staticToken) continue; +function groupTokensByNetwork(tokens: EvmTokenDetails[]): Record>> { + const grouped = {} as Record>>; - const normalizedSymbol = symbol.toUpperCase(); - const dynamicToken = dynamicTokens[network][normalizedSymbol]; + for (const network of Object.values(Networks)) { + if (isNetworkEVM(network)) { + grouped[network as EvmNetworks] = {}; + } + } - if (dynamicToken) { - // Warning if addresses point to different contracts (possible configuration drift or scam token) - if (staticToken.erc20AddressSourceChain.toLowerCase() !== dynamicToken.erc20AddressSourceChain.toLowerCase()) { - logger.current.warn( - `[DynamicEvmTokens] Address mismatch for ${symbol} on ${network}. Config: ${staticToken.erc20AddressSourceChain}, Dynamic: ${dynamicToken.erc20AddressSourceChain}. Using Config preference.` - ); + for (const token of tokens) { + if (isNetworkEVM(token.network)) { + const network = token.network as EvmNetworks; + if (!grouped[network]) { + grouped[network] = {}; + } + const symbolKey = token.assetSymbol.toUpperCase(); + const existingToken = grouped[network][symbolKey]; + + // If there's already a token with this symbol, keep the better one + if (existingToken) { + // Priority: native > higher USD price > lower address (deterministic) + const tokenPrice = token.usdPrice ?? 0; + const existingPrice = existingToken.usdPrice ?? 0; + + const shouldReplace = + (token.isNative && !existingToken.isNative) || + (!token.isNative && !existingToken.isNative && tokenPrice > existingPrice) || + (!token.isNative && !existingToken.isNative && tokenPrice === existingPrice && + token.erc20AddressSourceChain.toLowerCase() < existingToken.erc20AddressSourceChain.toLowerCase()); + + if (shouldReplace) { + grouped[network][symbolKey] = token; } - - // Static token exists and dynamic token exists - merge, static takes priority - merged[network][normalizedSymbol] = { - ...staticToken, - fallbackLogoURI: staticToken.fallbackLogoURI ?? dynamicToken.fallbackLogoURI, - logoURI: staticToken.logoURI ?? dynamicToken.logoURI, - usdPrice: dynamicToken.usdPrice ?? staticToken.usdPrice - }; + // Otherwise keep the existing token } else { - // Static token exists but no dynamic token - use static as-is - merged[network][normalizedSymbol] = staticToken; + grouped[network][symbolKey] = token; } } - }); - - return merged; -} - -function buildPriceLookup(tokensByNetwork: Record>>): Map { - const priceMap = new Map(); + } - forEachEvmNetwork(network => { - const networkTokens = tokensByNetwork[network]; - for (const token of Object.values(networkTokens)) { - if (token?.usdPrice !== undefined) { - priceMap.set(token.assetSymbol.toUpperCase(), token.usdPrice); + for (const network of Object.values(Networks)) { + if (isNetworkEVM(network)) { + const evmNetwork = network as EvmNetworks; + const networkTokenConfig = evmTokenConfig[evmNetwork]; + if (networkTokenConfig) { + grouped[evmNetwork] = { + ...networkTokenConfig, + ...grouped[evmNetwork] + }; } } - }); - - return priceMap; -} + } -async function fetchSquidRouterTokens(): Promise { - const result = await axios.get(SQUID_ROUTER_API_URL, { - headers: { - "x-integrator-id": squidRouterConfigBase.integratorId - } - }); - return result.data.tokens; + return grouped; } -function buildFallbackFromStaticConfig(): Record>> { - const tokensByNetwork = createEmptyNetworkBuckets(); +function buildFallbackFromStaticConfig(): { + tokens: EvmTokenDetails[]; + tokensByNetwork: Record>>; +} { + const tokens: EvmTokenDetails[] = []; + const tokensByNetwork = {} as Record>>; - forEachEvmNetwork(network => { - const networkTokenConfig = evmTokenConfig[network]; - if (networkTokenConfig) { - tokensByNetwork[network] = { ...networkTokenConfig }; + for (const network of Object.values(Networks)) { + if (isNetworkEVM(network)) { + const evmNetwork = network as EvmNetworks; + const networkTokenConfig = evmTokenConfig[evmNetwork]; + if (networkTokenConfig) { + tokensByNetwork[evmNetwork] = networkTokenConfig; + const networkTokens = Object.values(networkTokenConfig).filter( + (token): token is EvmTokenDetails => token !== undefined + ); + tokens.push(...networkTokens); + } } - }); - - return tokensByNetwork; -} + } -/** - * Derives a flat array of all tokens from the tokensByNetwork structure. - * Use this instead of storing a separate tokens array. - */ -function deriveAllTokens(tokensByNetwork: Record>>): EvmTokenDetails[] { - return Object.values(tokensByNetwork) - .flatMap(networkTokens => Object.values(networkTokens)) - .filter((token): token is EvmTokenDetails => token !== undefined); + return { tokens, tokensByNetwork }; } /** @@ -282,16 +217,21 @@ export async function initializeEvmTokens(): Promise { 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 - const groupedTokens = groupTokensByNetwork(evmTokens); - state.tokensByNetwork = mergeWithStaticConfig(groupedTokens); - state.priceBySymbol = buildPriceLookup(state.tokensByNetwork); + state.tokens = evmTokens; + state.tokensByNetwork = groupTokensByNetwork(evmTokens); + state.error = null; + state.usedFallback = false; state.isLoaded = true; } catch (err) { console.error("[DynamicEvmTokens] Failed to fetch tokens from SquidRouter, using fallback:", err); - state.tokensByNetwork = buildFallbackFromStaticConfig(); - state.priceBySymbol = buildPriceLookup(state.tokensByNetwork); + const fallback = buildFallbackFromStaticConfig(); + state.tokens = fallback.tokens; + state.tokensByNetwork = fallback.tokensByNetwork; + state.error = err instanceof Error ? err : new Error("Failed to fetch tokens"); + state.usedFallback = true; state.isLoaded = true; } } @@ -322,9 +262,31 @@ export function getEvmTokensForNetwork(network: EvmNetworks): EvmTokenDetails[] */ export function getAllEvmTokens(): EvmTokenDetails[] { if (!state.isLoaded) { - return deriveAllTokens(buildFallbackFromStaticConfig()); + const fallback = buildFallbackFromStaticConfig(); + return fallback.tokens; } - return deriveAllTokens(state.tokensByNetwork); + return state.tokens; +} + +/** + * Check if tokens have been loaded. + */ +export function isTokensLoaded(): boolean { + return state.isLoaded; +} + +/** + * Check if the service used the fallback static config. + */ +export function usedFallbackConfig(): boolean { + return state.usedFallback; +} + +/** + * Get the error if token loading failed. + */ +export function getLoadingError(): Error | null { + return state.error; } /** @@ -339,5 +301,10 @@ export function getTokenUsdPrice(symbol: string): number | undefined { return undefined; } - return state.priceBySymbol.get(symbol.toUpperCase()); + const normalizedSymbol = symbol.toUpperCase(); + + // Search through all tokens to find matching symbol + const token = state.tokens.find(t => t.assetSymbol.toUpperCase() === normalizedSymbol); + + return token?.usdPrice; } diff --git a/packages/shared/src/tokens/index.ts b/packages/shared/src/tokens/index.ts index e072299a8b..9e69bc526d 100644 --- a/packages/shared/src/tokens/index.ts +++ b/packages/shared/src/tokens/index.ts @@ -8,8 +8,6 @@ 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"; @@ -26,4 +24,6 @@ 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 */ From 2839e369a4ce5970e62b8e3a5222ff3496642a31 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 30 Jan 2026 19:23:34 +0000 Subject: [PATCH 61/61] Small amend --- apps/api/src/api/services/quote/core/squidrouter.ts | 5 ----- apps/frontend/src/constants/animations.ts | 4 ++-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/api/src/api/services/quote/core/squidrouter.ts b/apps/api/src/api/services/quote/core/squidrouter.ts index 51a78513e9..a9a6c98ea1 100644 --- a/apps/api/src/api/services/quote/core/squidrouter.ts +++ b/apps/api/src/api/services/quote/core/squidrouter.ts @@ -64,11 +64,6 @@ export function getTokenDetailsForEvmDestination( }); } - logger.debug("Getting token details for EVM destination", { - finalOutputCurrency, - network - }); - const tokenDetails = getOnChainTokenDetails(network, finalOutputCurrency); if (!tokenDetails || !isEvmTokenDetails(tokenDetails)) { diff --git a/apps/frontend/src/constants/animations.ts b/apps/frontend/src/constants/animations.ts index b67d35bbe5..13110157bf 100644 --- a/apps/frontend/src/constants/animations.ts +++ b/apps/frontend/src/constants/animations.ts @@ -16,8 +16,8 @@ export const easings = { * Duration guidelines for different interaction types */ export const durations = { - fast: 0.15, // 100ms - micro-interactions (hover states) - micro: 0.1, // 150ms - tooltips, dropdowns + fast: 0.15, // 150ms - tooltips, dropdowns + micro: 0.1, // 100ms - micro-interactions (hover states) normal: 0.2, // 200ms - standard UI animations slow: 0.3 // 300ms - modals, drawers, complex transitions };