diff --git a/src/apps/the-exchange/components/ExchangeAction/ExchangeAction.tsx b/src/apps/the-exchange/components/ExchangeAction/ExchangeAction.tsx index f8e94a69..9782c814 100644 --- a/src/apps/the-exchange/components/ExchangeAction/ExchangeAction.tsx +++ b/src/apps/the-exchange/components/ExchangeAction/ExchangeAction.tsx @@ -9,16 +9,18 @@ import { formatEther } from 'viem'; import { Token, chainNameToChainIdTokensData, + convertPortfolioAPIResponseToToken, } from '../../../../services/tokensData'; // hooks import useBottomMenuModal from '../../../../hooks/useBottomMenuModal'; import useGlobalTransactionsBatch from '../../../../hooks/useGlobalTransactionsBatch'; import { useTransactionDebugLogger } from '../../../../hooks/useTransactionDebugLogger'; -import useOffer from '../../hooks/useOffer'; +import useOffer, { getNativeBalanceFromPortfolio } from '../../hooks/useOffer'; import { useAppSelector } from '../../hooks/useReducerHooks'; // types +import { PortfolioData } from '../../../../types/api'; import { SwapOffer } from '../../utils/types'; // utils @@ -33,6 +35,12 @@ import NumberText from '../Typography/NumberText'; // images import ArrowRight from '../../images/arrow-right.png'; +import { + getFeeAmount, + getFeeSymbol, + isERC20FeeTx, + isNativeFeeTx, +} from '../../utils/blockchain'; const ExchangeAction = () => { const bestOffer = useAppSelector( @@ -49,18 +57,96 @@ const ExchangeAction = () => { const [errorMessage, setErrorMessage] = useState(''); const [isAddingToBatch, setIsAddingToBatch] = useState(false); + const [feeInfo, setFeeInfo] = useState<{ + amount: string; + symbol: string; + recipient: string; + warning?: string; + } | null>(null); const { addToBatch } = useGlobalTransactionsBatch(); const { showSend, setShowBatchSendModal } = useBottomMenuModal(); const { getStepTransactions } = useOffer(); const walletAddress = useWalletAddress(); const { transactionDebugLog } = useTransactionDebugLogger(); + const walletPortfolio = useAppSelector( + (state) => state.swap.walletPortfolio as PortfolioData | undefined + ); - useEffect(() => { + const feeReceiver = import.meta.env.VITE_SWAP_FEE_RECEIVER; + + const isNoValidOffer = + !bestOffer || + !bestOffer.tokenAmountToReceive || + Number(bestOffer.tokenAmountToReceive) === 0; + + const fetchFeeInfo = async () => { + setFeeInfo(null); setErrorMessage(''); + if (!bestOffer || !swapToken || !walletAddress) return; + try { + // Extract cached native balance for the relevant chain + const nativeBalance = getNativeBalanceFromPortfolio( + walletPortfolio + ? convertPortfolioAPIResponseToToken(walletPortfolio) + : undefined, + bestOffer.offer.fromChainId + ); + // Only get the fee transaction, not the whole batch + const stepTxs = await getStepTransactions( + swapToken, + bestOffer.offer, + walletAddress, + nativeBalance + ); + transactionDebugLog( + 'Step transactions:', + stepTxs, + 'Fee receiver:', + feeReceiver + ); + // Find the fee transfer (to feeReceiver for native, or ERC20 transfer for stablecoin) + const feeTx = stepTxs.find( + (tx) => isNativeFeeTx(tx, feeReceiver) || isERC20FeeTx(tx, swapToken) + ); + if (feeTx) { + const amount = getFeeAmount(feeTx, swapToken, swapToken.decimals); + const symbol = getFeeSymbol( + feeTx, + swapToken, + bestOffer.offer.fromChainId + ); + setFeeInfo({ + amount, + symbol, + recipient: String(feeReceiver), + warning: undefined, + }); + } else { + transactionDebugLog( + 'No fee transaction found in stepTxs for feeReceiver:', + feeReceiver + ); + setFeeInfo({ + amount: '0', + symbol: swapToken.symbol, + recipient: String(feeReceiver), + warning: + 'Unable to prepare the swap. Please check your wallet, refresh the page and try again.', + }); + } + } catch (e) { + setFeeInfo(null); + transactionDebugLog('Fee estimation error:', e); + setErrorMessage( + 'Unable to prepare the swap. Please check your wallet, refresh the page and try again.' + ); + } + }; - transactionDebugLog('The Exchange - Offer:', bestOffer); + useEffect(() => { + fetchFeeInfo(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [bestOffer]); + }, [bestOffer, swapToken, walletAddress]); const getTransactionTitle = ( index: number, @@ -85,7 +171,7 @@ const ExchangeAction = () => { return; } - if (!bestOffer) { + if (isNoValidOffer) { setErrorMessage( 'No offer was found! Please try changing the amounts to try again.' ); @@ -95,10 +181,18 @@ const ExchangeAction = () => { try { setIsAddingToBatch(true); + // Extract cached native balance for the relevant chain + const nativeBalance = getNativeBalanceFromPortfolio( + walletPortfolio + ? convertPortfolioAPIResponseToToken(walletPortfolio) + : undefined, + bestOffer.offer.fromChainId + ); const stepTransactions = await getStepTransactions( swapToken, bestOffer.offer, - walletAddress as `0x${string}` + walletAddress as `0x${string}`, + nativeBalance ); transactionDebugLog( @@ -146,7 +240,7 @@ const ExchangeAction = () => { } setIsAddingToBatch(false); } catch (error) { - console.error('Something went wrong. Please try again', error); + transactionDebugLog('Swap batch error:', error); setErrorMessage( 'We were not able to add this to the queue at the moment. Please try again.' ); @@ -160,7 +254,7 @@ const ExchangeAction = () => { className="flex flex-col w-full tablet:max-w-[420px] desktop:max-w-[420px] mb-20" >
You receive
@@ -168,7 +262,9 @@ const ExchangeAction = () => { ) : ( - {formatTokenAmount(bestOffer?.tokenAmountToReceive)} + {isNoValidOffer + ? '0' + : formatTokenAmount(bestOffer?.tokenAmountToReceive)} )}
@@ -180,11 +276,23 @@ const ExchangeAction = () => { {receiveToken?.symbol ?? ''}
+ {!isNoValidOffer && feeInfo && ( +
+ + Fee: {feeInfo.amount} {feeInfo.symbol} + + {feeInfo.warning && ( + + {feeInfo.warning} + + )} +
+ )}
Exchange {errorMessage && {errorMessage}} diff --git a/src/apps/the-exchange/components/ExchangeAction/test/ExchangeAction.test.tsx b/src/apps/the-exchange/components/ExchangeAction/test/ExchangeAction.test.tsx index 88907420..2f8ca5a2 100644 --- a/src/apps/the-exchange/components/ExchangeAction/test/ExchangeAction.test.tsx +++ b/src/apps/the-exchange/components/ExchangeAction/test/ExchangeAction.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import renderer, { act } from 'react-test-renderer'; +import { encodeFunctionData, erc20Abi } from 'viem'; import { vi } from 'vitest'; // provider @@ -28,6 +29,7 @@ import ExchangeAction from '../ExchangeAction'; // types import { Token } from '../../../../../services/tokensData'; +import * as useOffer from '../../../hooks/useOffer'; import { SwapOffer } from '../../../utils/types'; const mockTokenAssets: Token[] = [ @@ -86,6 +88,8 @@ export const mockBestOffer: SwapOffer = { }, }; +const FEE_RECEIVER = '0xfee0000000000000000000000000000000000000'; + // Mock hooks and utils vi.mock('../../../../../hooks/useGlobalTransactionsBatch', () => ({ _esModule: true, @@ -132,6 +136,7 @@ vi.mock('@lifi/sdk', () => ({ describe('', () => { beforeEach(() => { + import.meta.env.VITE_SWAP_FEE_RECEIVER = FEE_RECEIVER; vi.clearAllMocks(); act(() => { store.dispatch(setIsSwapOpen(false)); @@ -216,4 +221,232 @@ describe('', () => { expect(screen.getByText('Exchange')).toBeInTheDocument(); }); }); + + it('displays fee info for native token fee', async () => { + vi.spyOn(useOffer, 'default').mockReturnValue({ + getStepTransactions: vi.fn().mockResolvedValue([ + { + to: FEE_RECEIVER, + value: BigInt('1000000000000000'), // 0.001 ETH in wei + data: '0x', + chainId: 1, + }, + ]), + getBestOffer: vi.fn(), + }); + + render( + + + + ); + act(() => { + store.dispatch(setBestOffer(mockBestOffer)); + }); + await waitFor(() => + expect( + screen.getByText( + (content) => + content.includes('Fee:') && + content.includes('0.001') && + content.includes('ETH') + ) + ).toBeInTheDocument() + ); + }); + + it('displays fee info for stablecoin fee', async () => { + // Encode 10 USDC (6 decimals) + const usdcAmount = BigInt(10 * 10 ** 6); + const usdcData = encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [FEE_RECEIVER, usdcAmount], + }); + vi.spyOn(useOffer, 'default').mockReturnValue({ + getStepTransactions: vi.fn().mockResolvedValue([ + { + to: '0x02', // stablecoin contract + value: BigInt(0), + data: usdcData, + chainId: 137, + }, + ]), + getBestOffer: vi.fn(), + }); + act(() => { + store.dispatch( + setSwapToken({ + ...mockTokenAssets[1], + contract: '0x02', + symbol: 'USDC', + decimals: 6, + }) + ); + store.dispatch( + setBestOffer({ + ...mockBestOffer, + offer: { + ...mockBestOffer.offer, + fromToken: { + ...mockBestOffer.offer.fromToken, + address: '0x02', + symbol: 'USDC', + decimals: 6, + }, + fromChainId: 137, + }, + }) + ); + }); + render( + + + + ); + await waitFor(() => + expect( + screen.getByText( + (content) => + content.includes('Fee:') && + content.includes('10') && + content.includes('USDC') + ) + ).toBeInTheDocument() + ); + }); + + it('displays fee info for wrapped token fee', async () => { + // Encode 10 WETH (18 decimals) + const wethAmount = BigInt(10 * 10 ** 18); + const wethData = encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [FEE_RECEIVER, wethAmount], + }); + vi.spyOn(useOffer, 'default').mockReturnValue({ + getStepTransactions: vi.fn().mockResolvedValue([ + { + to: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', // WETH contract + value: BigInt(0), + data: wethData, + chainId: 1, + }, + ]), + getBestOffer: vi.fn(), + }); + act(() => { + store.dispatch( + setSwapToken({ + ...mockTokenAssets[0], + contract: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + symbol: 'WETH', + decimals: 18, + }) + ); + store.dispatch( + setBestOffer({ + ...mockBestOffer, + offer: { + ...mockBestOffer.offer, + fromToken: { + ...mockBestOffer.offer.fromToken, + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + symbol: 'WETH', + decimals: 18, + }, + fromChainId: 1, + }, + }) + ); + }); + render( + + + + ); + await waitFor(() => + expect( + screen.getByText( + (content) => + content.includes('Fee:') && + content.includes('10') && + content.includes('WETH') + ) + ).toBeInTheDocument() + ); + }); + + it('displays fee info for non-stable ERC20 fee (native fallback)', async () => { + vi.spyOn(useOffer, 'default').mockReturnValue({ + getStepTransactions: vi.fn().mockResolvedValue([ + { + to: FEE_RECEIVER, + value: BigInt('2000000000000000'), // 0.002 ETH in wei + data: '0x', + chainId: 1, + }, + ]), + getBestOffer: vi.fn(), + }); + act(() => { + store.dispatch( + setSwapToken({ + ...mockTokenAssets[0], + contract: '0xSOMEERC20', + symbol: 'RANDOM', + }) + ); + store.dispatch( + setBestOffer({ + ...mockBestOffer, + offer: { + ...mockBestOffer.offer, + fromToken: { + ...mockBestOffer.offer.fromToken, + address: '0xSOMEERC20', + symbol: 'RANDOM', + decimals: 18, + }, + fromChainId: 1, + }, + }) + ); + }); + render( + + + + ); + await waitFor(() => + expect( + screen.getByText( + (content) => + content.includes('Fee:') && + content.includes('0.002') && + content.includes('ETH') + ) + ).toBeInTheDocument() + ); + }); + + it('shows an error if getStepTransactions fails', async () => { + vi.spyOn(useOffer, 'default').mockReturnValue({ + getStepTransactions: vi.fn().mockRejectedValue(new Error('Test error')), + getBestOffer: vi.fn(), // Provide a default mock for getBestOffer + }); + render( + + + + ); + act(() => { + store.dispatch(setBestOffer(mockBestOffer)); + }); + await waitFor(() => + expect( + screen.getByText(/unable to prepare the swap/i) + ).toBeInTheDocument() + ); + }); }); diff --git a/src/apps/the-exchange/components/ExchangeAction/test/__snapshots__/ExchangeAction.test.tsx.snap b/src/apps/the-exchange/components/ExchangeAction/test/__snapshots__/ExchangeAction.test.tsx.snap index 570b2b19..f6a12e71 100644 --- a/src/apps/the-exchange/components/ExchangeAction/test/__snapshots__/ExchangeAction.test.tsx.snap +++ b/src/apps/the-exchange/components/ExchangeAction/test/__snapshots__/ExchangeAction.test.tsx.snap @@ -19,7 +19,7 @@ exports[` > renders correctly and matches snapshot 1`] = `

- 0.00000000 + 0

{ + return parseUnits(String(amount), decimals); +}; + +// Utility: Extract native token balance from walletPortfolio for a given chainId +export const getNativeBalanceFromPortfolio = ( + walletPortfolio: Token[] | undefined, + chainId: number +): string | undefined => { + if (!walletPortfolio) return undefined; + // Find the native token for the chain (by contract address) + const nativeToken = walletPortfolio.find( + (token) => + chainNameToChainIdTokensData(token.blockchain) === chainId && + isNativeToken(token.contract) + ); + return nativeToken ? String(nativeToken.balance) : undefined; +}; + const useOffer = () => { const { isZeroAddress } = useEtherspotUtils(); + const { transactionDebugLog } = useTransactionDebugLogger(); + + const getNativeFeeForERC20 = async ({ + tokenAddress, + chainId, + feeAmount, + slippage = 0.03, + }: { + tokenAddress: string; + chainId: number; + feeAmount: string; + slippage?: number; + }) => { + try { + const feeRouteRequest: RoutesRequest = { + fromChainId: chainId, + toChainId: chainId, + fromTokenAddress: tokenAddress, + toTokenAddress: zeroAddress, + fromAmount: feeAmount, + options: { + slippage, + bridges: { + allow: ['relay'], + }, + exchanges: { allow: ['openocean', 'kyberswap'] }, + }, + }; + + const result = await getRoutes(feeRouteRequest); + + const route = result.routes?.[0]; + if (!route) return undefined; + + transactionDebugLog( + 'Get native fee for ERC20 swap, the route:', + route, + 'the request:', + feeRouteRequest + ); + return route; + } catch (e) { + console.error('Failed to get native fee estimation via LiFi:', e); + return undefined; + } + }; const getBestOffer = async ({ fromAmount, @@ -52,12 +127,14 @@ const useOffer = () => { fromChainId ); + const fromAmountFeeDeducted = Number(fromAmount) * 0.99; + const routesRequest: RoutesRequest = { fromChainId, toChainId, fromTokenAddress: fromTokenAddressWithWrappedCheck, toTokenAddress, - fromAmount: `${parseUnits(`${fromAmount}`, fromTokenDecimals)}`, + fromAmount: `${parseUnits(`${fromAmountFeeDeducted}`, fromTokenDecimals)}`, options: { slippage, bridges: { @@ -132,7 +209,8 @@ const useOffer = () => { const getStepTransactions = async ( tokenToSwap: Token, route: Route, - fromAccount: string + fromAccount: string, + cachedNativeBalance?: string // Optional: pass cached native balance from walletPortfolio ): Promise => { const stepTransactions: StepTransaction[] = []; @@ -143,6 +221,104 @@ const useOffer = () => { chainNameToChainIdTokensData(tokenToSwap.blockchain) ); + // --- 1% FEE LOGIC --- + // Always deduct 1% from the From Token (already done in getBestOffer) + const feeReceiver = import.meta.env.VITE_SWAP_FEE_RECEIVER; + const feeAmount = BigInt(route.fromAmount) / BigInt(100); // 1% + const fromTokenAddress = route.fromToken.address; + const fromTokenChainId = route.fromToken.chainId; + + // 1. If From Token is native, transfer 1% directly to fee address + if (isZeroAddress(fromTokenAddress)) { + const feeStep = { + to: feeReceiver, + value: feeAmount, + data: '0x' as `0x${string}`, + chainId: fromTokenChainId, + }; + stepTransactions.push(feeStep); + transactionDebugLog('Pushed native fee step:', feeStep); + } else if (isStableCoin(fromTokenAddress, fromTokenChainId)) { + // 2. If input is stablecoin, push ERC20 transfer transaction + const calldata = encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [feeReceiver, feeAmount], + }); + const feeStep = { + to: fromTokenAddress, + value: BigInt(0), + data: calldata, + chainId: fromTokenChainId, + }; + stepTransactions.push(feeStep); + transactionDebugLog('Pushed stablecoin fee step:', feeStep); + } else if (isWrappedToken(fromTokenAddress, fromTokenChainId)) { + // 3. If input is a wrapped token, push ERC20 transfer transaction (like stablecoin) + const calldata = encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [feeReceiver, feeAmount], + }); + const feeStep = { + to: fromTokenAddress, + value: BigInt(0), + data: calldata, + chainId: fromTokenChainId, + }; + stepTransactions.push(feeStep); + transactionDebugLog('Pushed wrapped token fee step:', feeStep); + } else { + // 4. If input is ERC20 non-stable, estimate native equivalent of 1% and transfer that as fee + try { + const nativeFeeRoute = await getNativeFeeForERC20({ + tokenAddress: fromTokenAddress, + chainId: fromTokenChainId, + feeAmount: feeAmount.toString(), + }); + if (nativeFeeRoute && nativeFeeRoute.toAmount) { + // Add a 1% buffer to the estimated native fee + const estimatedNativeFee = BigInt(nativeFeeRoute.toAmount); + const bufferedNativeFee = + estimatedNativeFee + estimatedNativeFee / BigInt(100); // +1% + + // Use cachedNativeBalance if provided, else fetch + const userNativeBalanceStr = + cachedNativeBalance !== undefined + ? cachedNativeBalance + : await getNativeBalance(fromAccount, fromTokenChainId); + + const userNativeBalance = toWei(userNativeBalanceStr, 18); + + if (userNativeBalance < bufferedNativeFee) { + throw new Error( + 'Insufficient native token balance to pay the fee. Please ensure you have enough native token to cover the fee.' + ); + } + + const feeStep = { + to: feeReceiver, + value: bufferedNativeFee, + data: '0x' as `0x${string}`, + chainId: fromTokenChainId, + }; + stepTransactions.push(feeStep); + transactionDebugLog( + 'Pushed ERC20 non-stable fee step (native):', + feeStep + ); + } else { + throw new Error( + 'Failed to estimate native fee for ERC20. No route found.' + ); + } + } catch (e) { + // Rethrow as error for UI to catch + throw new Error('Failed to estimate native fee for ERC20.'); + } + } + // --- END FEE LOGIC --- + // If wrapping is required, we will add an extra step transaction with // a wrapped token deposit first if (isWrapRequired) { diff --git a/src/apps/the-exchange/utils/blockchain.ts b/src/apps/the-exchange/utils/blockchain.ts index 0a843586..faa3b6de 100644 --- a/src/apps/the-exchange/utils/blockchain.ts +++ b/src/apps/the-exchange/utils/blockchain.ts @@ -1,5 +1,10 @@ import { BigNumber, BigNumberish } from 'ethers'; import { formatEther, formatUnits } from 'ethers/lib/utils'; +import { decodeFunctionData, erc20Abi } from 'viem'; + +// types +import { Token } from '../../../services/tokensData'; +import { StepTransaction } from './types'; export const processBigNumber = (val: BigNumber): number => Number(val.toString()); @@ -11,3 +16,79 @@ export const processEth = (val: BigNumberish, dec: number): number => { return +parseFloat(formatUnits(val as BigNumberish, dec)); }; + +// Utility: get native token symbol for a chain +export const NATIVE_SYMBOLS: Record = { + 1: 'ETH', + 100: 'xDAI', + 137: 'POL', + 10: 'ETH', + 42161: 'ETH', + 56: 'BNB', + 8453: 'ETH', +}; + +// Helper: Detect if a tx is a native fee step +export const isNativeFeeTx = ( + tx: StepTransaction, + feeReceiver: string +): boolean => { + return ( + typeof tx.to === 'string' && + typeof feeReceiver === 'string' && + tx.to.toLowerCase() === feeReceiver.toLowerCase() + ); +}; + +// Helper: Detect if a tx is an ERC20 (stablecoin or wrapped) fee step +export const isERC20FeeTx = ( + tx: StepTransaction, + swapToken: Token +): boolean => { + return ( + typeof tx.to === 'string' && + typeof swapToken.contract === 'string' && + tx.to.toLowerCase() === swapToken.contract.toLowerCase() && + tx.value === BigInt(0) && + typeof tx.data === 'string' && + tx.data.startsWith('0xa9059cbb') + ); +}; + +// Helper: Extract fee amount from tx +export const getFeeAmount = ( + tx: StepTransaction, + swapToken: Token, + decimals: number +): string => { + if (tx.value && tx.data === '0x') { + // Native + return formatEther(tx.value); + } + if (isERC20FeeTx(tx, swapToken)) { + try { + const decoded = decodeFunctionData({ + abi: erc20Abi, + data: tx.data || '0x', + }); + if (decoded.args && typeof decoded.args[1] === 'bigint') { + return formatUnits(decoded.args[1], decimals); + } + } catch (e) { + return '0'; + } + } + return '0'; +}; + +// Helper: Get fee symbol +export const getFeeSymbol = ( + tx: StepTransaction, + swapToken: Token, + chainId: number +): string => { + if (tx.value && tx.data === '0x') { + return NATIVE_SYMBOLS[chainId] || 'NATIVE'; + } + return swapToken.symbol; +}; diff --git a/src/apps/the-exchange/utils/wrappedTokens.ts b/src/apps/the-exchange/utils/wrappedTokens.ts index 11214903..c96d686d 100644 --- a/src/apps/the-exchange/utils/wrappedTokens.ts +++ b/src/apps/the-exchange/utils/wrappedTokens.ts @@ -1,6 +1,7 @@ export const NATIVE_TOKEN_ADDRESSES = new Set([ '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', '0x0000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000001010', ]); // Not including XDAI below diff --git a/src/utils/blockchain.ts b/src/utils/blockchain.ts index 615a69c5..8d86b858 100644 --- a/src/utils/blockchain.ts +++ b/src/utils/blockchain.ts @@ -308,3 +308,48 @@ export const CompatibleChains = [ chainName: 'Arbitrum', }, ]; + +const STABLECOIN_ADDRESSES: Record> = { + 1: new Set([ + // Ethereum mainnet + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC + '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT + ]), + 137: new Set([ + // Polygon + '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', // USDC + '0xc2132d05d31c914a87c6611c10748aeb04b58e8f', // USDT + ]), + 8453: new Set([ + // Base + '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', // USDC + '0xfde4c96c8593536e31f229ea8f37b2ada2699bb2', // Bridged USDT + ]), + 100: new Set([ + // Gnosis + '0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83', // USDC + '0x4ecaba5870353805a9f068101a40e0f32ed605c6', // USDT + ]), + 56: new Set([ + // BNB Smart Chain + '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', // USDC + '0x55d398326f99059fF775485246999027B3197955', // BSC-USD + ]), + 10: new Set([ + // Optimism + '0x0b2c639c533813f4aa9d7837caf62653d097ff85', // USDC + '0x94b008aa00579c1307b0ef2c499ad98a8ce58e58', // USDT + ]), + 42161: new Set([ + // Arbitrum + '0xaf88d065e77c8cc2239327c5edb3a432268e5831', // USDC + '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9', // USDT + ]), +}; + +export function isStableCoin(address: string, chainId: number): boolean { + if (!address || !chainId) return false; + const set = STABLECOIN_ADDRESSES[chainId]; + if (!set) return false; + return set.has(address.toLowerCase()); +}