From 5e987c63535133e447ce7430499a1a6013805548 Mon Sep 17 00:00:00 2001 From: Dong-Ha Kim Date: Wed, 16 Apr 2025 18:28:52 +0700 Subject: [PATCH] feat: support svm useBalance hooks --- src/hooks/useBalance_new/factory.ts | 145 ++++++++++++++++++ src/hooks/useBalance_new/index.ts | 60 ++++++++ src/hooks/useBalance_new/strategies/evm.ts | 43 ++++++ src/hooks/useBalance_new/strategies/svm.ts | 62 ++++++++ src/hooks/useBalance_new/strategies/types.ts | 10 ++ src/utils/query-keys.ts | 5 +- src/views/Bridge/components/ChainSelector.tsx | 6 +- src/views/Bridge/components/TokenSelector.tsx | 5 +- src/views/Bridge/hooks/useAmountInput.ts | 8 +- src/views/Bridge/hooks/useMaxBalance.ts | 3 +- .../components/ActionInputBlock.tsx | 5 +- 11 files changed, 335 insertions(+), 17 deletions(-) create mode 100644 src/hooks/useBalance_new/factory.ts create mode 100644 src/hooks/useBalance_new/index.ts create mode 100644 src/hooks/useBalance_new/strategies/evm.ts create mode 100644 src/hooks/useBalance_new/strategies/svm.ts create mode 100644 src/hooks/useBalance_new/strategies/types.ts diff --git a/src/hooks/useBalance_new/factory.ts b/src/hooks/useBalance_new/factory.ts new file mode 100644 index 000000000..8479d9dfa --- /dev/null +++ b/src/hooks/useBalance_new/factory.ts @@ -0,0 +1,145 @@ +import { useQuery, useQueries, UseQueryOptions } from "@tanstack/react-query"; +import { BigNumber } from "ethers"; +import { BalanceStrategy } from "./strategies/types"; +import { + getEcosystem, + balanceQueryKey, + TOKEN_SYMBOLS_MAP, + getNativeTokenSymbol, +} from "utils"; + +type StrategyPerEcosystem = { + evm: BalanceStrategy; + svm: BalanceStrategy; +}; + +type BalanceQueryKey = ReturnType; + +type MultiBalanceQuery = UseQueryOptions< + BigNumber | undefined, + Error, + BigNumber | undefined, + BalanceQueryKey +>; + +async function fetchBalance( + queryKey: BalanceQueryKey, + strategies: StrategyPerEcosystem +) { + const [, chainId, tokenSymbol, account] = queryKey; + + if (!chainId || !tokenSymbol) { + return BigNumber.from(0); + } + + const ecosystem = getEcosystem(chainId); + const strategy = strategies[ecosystem]; + + const accountToQuery = account ?? strategy.getAccount(); + console.log("accountToQuery", queryKey, { accountToQuery }); + + if (!accountToQuery) { + return BigNumber.from(0); + } + + return strategy.getBalance(chainId, tokenSymbol, accountToQuery); +} + +export function createBalanceHook(strategies: StrategyPerEcosystem) { + return function useBalance( + tokenSymbol?: string, + chainId?: number, + account?: string + ) { + const { data: balance, ...delegated } = useQuery({ + queryKey: balanceQueryKey(account, chainId, tokenSymbol, "balance"), + queryFn: ({ queryKey }) => fetchBalance(queryKey, strategies), + enabled: Boolean(chainId && tokenSymbol), + refetchInterval: 10_000, + }); + + return { + balance, + ...delegated, + }; + }; +} + +export function createBalancesBySymbolsHook(strategies: StrategyPerEcosystem) { + return function useBalancesBySymbols({ + tokenSymbols, + chainId, + account, + }: { + tokenSymbols: string[]; + chainId?: number; + account?: string; + }) { + const result = useQueries({ + queries: tokenSymbols.map((tokenSymbol) => ({ + queryKey: balanceQueryKey( + account, + chainId, + tokenSymbol, + "balances-by-symbols" + ), + queryFn: ({ queryKey }) => fetchBalance(queryKey, strategies), + enabled: Boolean(chainId && tokenSymbols.length), + refetchInterval: 10_000, + })), + }); + return { + balances: result.map((result) => result.data), + isLoading: result.some((s) => s.isLoading), + }; + }; +} + +export function createBalanceBySymbolPerChainHook( + strategies: StrategyPerEcosystem +) { + return function useBalanceBySymbolPerChain({ + tokenSymbol, + chainIds, + account, + }: { + tokenSymbol?: string; + chainIds: number[]; + account?: string; + }) { + const result = useQueries({ + queries: chainIds.map((chainId) => ({ + queryKey: balanceQueryKey( + account, + chainId, + tokenSymbol, + "balance-by-symbol-per-chain" + ), + queryFn: async ({ queryKey }) => { + const [, chainIdToQuery, tokenSymbolToQuery] = queryKey; + if ( + tokenSymbolToQuery === TOKEN_SYMBOLS_MAP.ETH.symbol && + getNativeTokenSymbol(chainIdToQuery!) !== + TOKEN_SYMBOLS_MAP.ETH.symbol + ) { + return Promise.resolve(BigNumber.from(0)); + } + return fetchBalance(queryKey, strategies); + }, + enabled: Boolean(chainId && tokenSymbol), + refetchInterval: 10_000, + })), + }); + + return { + balances: result.reduce( + (acc, { data }, idx) => ({ + ...acc, + [chainIds[idx]]: (data ?? BigNumber.from(0)) as BigNumber, + }), + {} as Record + ), + isLoading: result.some((s) => s.isLoading), + }; + }; +} diff --git a/src/hooks/useBalance_new/index.ts b/src/hooks/useBalance_new/index.ts new file mode 100644 index 000000000..1a5afe994 --- /dev/null +++ b/src/hooks/useBalance_new/index.ts @@ -0,0 +1,60 @@ +import { useConnectionEVM } from "../useConnectionEVM"; +import { useConnectionSVM } from "../useConnectionSVM"; +import { + createBalanceHook, + createBalancesBySymbolsHook, + createBalanceBySymbolPerChainHook, +} from "./factory"; +import { EVMBalanceStrategy } from "./strategies/evm"; +import { SVMBalanceStrategy } from "./strategies/svm"; + +function useBalanceStrategies() { + const connectionEVM = useConnectionEVM(); + const connectionSVM = useConnectionSVM(); + return { + evm: new EVMBalanceStrategy(connectionEVM), + svm: new SVMBalanceStrategy(connectionSVM), + }; +} +export function useBalance( + tokenSymbol?: string, + chainId?: number, + account?: string +) { + const strategies = useBalanceStrategies(); + return createBalanceHook(strategies)(tokenSymbol, chainId, account); +} + +export function useBalancesBySymbols({ + tokenSymbols, + chainId, + account, +}: { + tokenSymbols: string[]; + chainId?: number; + account?: string; +}) { + const strategies = useBalanceStrategies(); + return createBalancesBySymbolsHook(strategies)({ + tokenSymbols, + chainId, + account, + }); +} + +export function useBalanceBySymbolPerChain({ + tokenSymbol, + chainIds, + account, +}: { + tokenSymbol?: string; + chainIds: number[]; + account?: string; +}) { + const strategies = useBalanceStrategies(); + return createBalanceBySymbolPerChainHook(strategies)({ + tokenSymbol, + chainIds, + account, + }); +} diff --git a/src/hooks/useBalance_new/strategies/evm.ts b/src/hooks/useBalance_new/strategies/evm.ts new file mode 100644 index 000000000..c1dd28fd0 --- /dev/null +++ b/src/hooks/useBalance_new/strategies/evm.ts @@ -0,0 +1,43 @@ +import { BigNumber } from "ethers"; +import { BalanceStrategy } from "./types"; +import { getBalance, getNativeBalance, getConfig, getProvider } from "utils"; +import { useConnectionEVM } from "hooks/useConnectionEVM"; + +export class EVMBalanceStrategy implements BalanceStrategy { + constructor( + private readonly connection: ReturnType + ) {} + + getAccount() { + return this.connection.account; + } + + async getBalance( + chainId: number, + tokenSymbol: string, + account: string + ): Promise { + const config = getConfig(); + const tokenInfo = config.getTokenInfoBySymbolSafe(chainId, tokenSymbol); + const provider = + this.connection.chainId === chainId + ? this.connection.provider + : getProvider(chainId); + + if (!tokenInfo || !tokenInfo.addresses?.[chainId]) { + return BigNumber.from(0); + } + + if (tokenInfo.isNative) { + return getNativeBalance(chainId, account, "latest", provider); + } else { + return getBalance( + chainId, + account, + tokenInfo.addresses[chainId], + "latest", + provider + ); + } + } +} diff --git a/src/hooks/useBalance_new/strategies/svm.ts b/src/hooks/useBalance_new/strategies/svm.ts new file mode 100644 index 000000000..e228aa8f6 --- /dev/null +++ b/src/hooks/useBalance_new/strategies/svm.ts @@ -0,0 +1,62 @@ +import { BigNumber } from "ethers"; +import { PublicKey } from "@solana/web3.js"; +import { + getAssociatedTokenAddressSync, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import { BalanceStrategy } from "./types"; +import { getConfig } from "utils"; +import { useConnectionSVM } from "hooks/useConnectionSVM"; + +export class SVMBalanceStrategy implements BalanceStrategy { + constructor( + private readonly connection: ReturnType + ) {} + + getAccount() { + return this.connection.account?.toString(); + } + + async getBalance( + chainId: number, + tokenSymbol: string, + account: string + ): Promise { + const config = getConfig(); + const tokenInfo = config.getTokenInfoBySymbolSafe(chainId, tokenSymbol); + + if (!tokenInfo || !tokenInfo.addresses?.[chainId]) { + return BigNumber.from(0); + } + + if (tokenInfo.isNative) { + // Get native SOL balance + const balance = await this.connection.provider.getBalance( + new PublicKey(account) + ); + return BigNumber.from(balance.toString()); + } else { + // Get SPL token balance + const tokenMint = new PublicKey(tokenInfo.addresses[chainId]); + const owner = new PublicKey(account); + + // Get the associated token account address + const tokenAccount = getAssociatedTokenAddressSync( + tokenMint, + owner, + true, // allowOwnerOffCurve + TOKEN_PROGRAM_ID + ); + + try { + // Get token account info + const tokenAccountInfo = + await this.connection.provider.getTokenAccountBalance(tokenAccount); + return BigNumber.from(tokenAccountInfo.value.amount); + } catch (error) { + // If token account doesn't exist or other error, return 0 balance + return BigNumber.from(0); + } + } + } +} diff --git a/src/hooks/useBalance_new/strategies/types.ts b/src/hooks/useBalance_new/strategies/types.ts new file mode 100644 index 000000000..80c9d1ee4 --- /dev/null +++ b/src/hooks/useBalance_new/strategies/types.ts @@ -0,0 +1,10 @@ +import { BigNumber } from "ethers"; + +export interface BalanceStrategy { + getAccount(): string | undefined; + getBalance( + chainId: number, + tokenSymbol: string, + account: string + ): Promise; +} diff --git a/src/utils/query-keys.ts b/src/utils/query-keys.ts index 46a9424a1..8df885220 100644 --- a/src/utils/query-keys.ts +++ b/src/utils/query-keys.ts @@ -21,9 +21,10 @@ export function latestBlockQueryKey(chainId: ChainId) { export function balanceQueryKey( account?: string, chainId?: ChainId, - token?: string + token?: string, + prefix = "balance" ) { - return ["balance", chainId, token, account] as const; + return [prefix, chainId, token, account] as const; } /** diff --git a/src/views/Bridge/components/ChainSelector.tsx b/src/views/Bridge/components/ChainSelector.tsx index 3bf843cc5..e2384d817 100644 --- a/src/views/Bridge/components/ChainSelector.tsx +++ b/src/views/Bridge/components/ChainSelector.tsx @@ -13,7 +13,8 @@ import { shortenAddress, } from "utils"; -import { useBalanceBySymbolPerChain, useConnection } from "hooks"; +import { useConnection } from "hooks"; +import { useBalanceBySymbolPerChain } from "hooks/useBalance_new"; import { useMemo } from "react"; import { BigNumber } from "ethers"; import { getSupportedChains } from "../utils"; @@ -41,11 +42,10 @@ export function ChainSelector({ // Get supported chains and filter based on external projects const availableChains = filterAvailableChains(fromOrTo, selectedRoute); - const { account, isConnected } = useConnection(); + const { isConnected } = useConnection(); const { balances } = useBalanceBySymbolPerChain({ tokenSymbol: tokenInfo.symbol, chainIds: availableChains.map((c) => c.chainId), - account, }); const sortedChains = useMemo( diff --git a/src/views/Bridge/components/TokenSelector.tsx b/src/views/Bridge/components/TokenSelector.tsx index 7f0f3f622..a15a66192 100644 --- a/src/views/Bridge/components/TokenSelector.tsx +++ b/src/views/Bridge/components/TokenSelector.tsx @@ -12,7 +12,7 @@ import { getToken, tokenList, } from "utils"; -import { useBalancesBySymbols, useConnection } from "hooks"; +import { useBalancesBySymbols } from "hooks/useBalance_new"; import { RouteNotSupportedTooltipText } from "./RouteNotSupportedTooltipText"; import { @@ -58,8 +58,6 @@ export function TokenSelector({ ? getToken(receiveTokenSymbol) : selectedToken; - const { account } = useConnection(); - const orderedTokens: Array< TokenInfo & { disabled?: boolean; @@ -102,7 +100,6 @@ export function TokenSelector({ const { balances } = useBalancesBySymbols({ tokenSymbols: orderedTokens.filter((t) => !t.disabled).map((t) => t.symbol), chainId: isInputTokenSelector ? fromChain : toChain, - account, }); return ( diff --git a/src/views/Bridge/hooks/useAmountInput.ts b/src/views/Bridge/hooks/useAmountInput.ts index 49ead0893..07c249b3e 100644 --- a/src/views/Bridge/hooks/useAmountInput.ts +++ b/src/views/Bridge/hooks/useAmountInput.ts @@ -2,7 +2,8 @@ import { useState, useEffect, useCallback } from "react"; import { BigNumber, utils } from "ethers"; import { debounce } from "lodash-es"; -import { useAmplitude, useBalanceBySymbol, usePrevious } from "hooks"; +import { useAmplitude, usePrevious } from "hooks"; +import { useBalance } from "hooks/useBalance_new"; import { getConfig, trackMaxButtonClicked } from "utils"; import { SelectedRoute, areTokensInterchangeable } from "../utils"; @@ -23,10 +24,7 @@ export function useAmountInput(selectedRoute: SelectedRoute) { ? selectedRoute.swapTokenSymbol : selectedRoute.fromTokenSymbol; - const { balance } = useBalanceBySymbol( - amountTokenSymbol, - selectedRoute.fromChain - ); + const { balance } = useBalance(amountTokenSymbol, selectedRoute.fromChain); const { data: maxBalance } = useMaxBalance(selectedRoute); diff --git a/src/views/Bridge/hooks/useMaxBalance.ts b/src/views/Bridge/hooks/useMaxBalance.ts index 724cfb41d..ad779648e 100644 --- a/src/views/Bridge/hooks/useMaxBalance.ts +++ b/src/views/Bridge/hooks/useMaxBalance.ts @@ -16,11 +16,12 @@ export function useMaxBalance(selectedRoute: SelectedRoute) { ? selectedRoute.swapTokenSymbol : selectedRoute.fromTokenSymbol; + const { account, signer } = useConnection(); + const { balance } = useBalanceBySymbol( balanceTokenSymbol, selectedRoute.fromChain ); - const { account, signer } = useConnection(); return useQuery({ queryKey: [ diff --git a/src/views/LiquidityPool/components/ActionInputBlock.tsx b/src/views/LiquidityPool/components/ActionInputBlock.tsx index 288200975..a7589c62f 100644 --- a/src/views/LiquidityPool/components/ActionInputBlock.tsx +++ b/src/views/LiquidityPool/components/ActionInputBlock.tsx @@ -3,7 +3,8 @@ import { useEffect, useState } from "react"; import { utils } from "ethers"; import { QUERIESV2, trackMaxButtonClicked, getConfig } from "utils"; -import { useStakingPool, useAmplitude, useBalanceBySymbol } from "hooks"; +import { useStakingPool, useAmplitude } from "hooks"; +import { useBalance } from "hooks/useBalance_new"; import { Text, AmountInput } from "components"; import { @@ -34,7 +35,7 @@ export function ActionInputBlock({ action, selectedToken }: Props) { selectedToken.l1TokenAddress, selectedToken.symbol ); - const balanceQuery = useBalanceBySymbol( + const balanceQuery = useBalance( selectedToken.symbol, config.getHubPoolChainId() );