From 204a694f371685081f495e98ecb4f0f7fc0995a5 Mon Sep 17 00:00:00 2001 From: RanaBug Date: Fri, 11 Jul 2025 17:28:26 +0100 Subject: [PATCH 1/2] fee capture implementation on the exchange app --- .../ExchangeAction/ExchangeAction.tsx | 127 +++++++++++-- .../test/ExchangeAction.test.tsx | 56 ++++++ .../ExchangeAction.test.tsx.snap | 2 +- src/apps/the-exchange/hooks/useOffer.tsx | 169 +++++++++++++++++- src/apps/the-exchange/utils/blockchain.ts | 81 +++++++++ src/utils/blockchain.ts | 44 +++++ 6 files changed, 464 insertions(+), 15 deletions(-) diff --git a/src/apps/the-exchange/components/ExchangeAction/ExchangeAction.tsx b/src/apps/the-exchange/components/ExchangeAction/ExchangeAction.tsx index f8e94a69..cebf1314 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,95 @@ 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: 'Fee transaction not found. Please check your swap setup.', + }); + } + } catch (e) { + setFeeInfo(null); + transactionDebugLog('Fee estimation error:', e); + setErrorMessage( + 'Unable to prepare the swap. Please check your wallet 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 +170,7 @@ const ExchangeAction = () => { return; } - if (!bestOffer) { + if (isNoValidOffer) { setErrorMessage( 'No offer was found! Please try changing the amounts to try again.' ); @@ -95,10 +180,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 +239,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 +253,7 @@ const ExchangeAction = () => { className="flex flex-col w-full tablet:max-w-[420px] desktop:max-w-[420px] mb-20" >
You receive
@@ -168,7 +261,9 @@ const ExchangeAction = () => { ) : ( - {formatTokenAmount(bestOffer?.tokenAmountToReceive)} + {isNoValidOffer + ? '0' + : formatTokenAmount(bestOffer?.tokenAmountToReceive)} )}
@@ -180,11 +275,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..abdb3228 100644 --- a/src/apps/the-exchange/components/ExchangeAction/test/ExchangeAction.test.tsx +++ b/src/apps/the-exchange/components/ExchangeAction/test/ExchangeAction.test.tsx @@ -28,6 +28,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[] = [ @@ -132,6 +133,7 @@ vi.mock('@lifi/sdk', () => ({ describe('', () => { beforeEach(() => { + import.meta.env.VITE_SWAP_FEE_RECEIVER = '0xFEEADDRESS'; vi.clearAllMocks(); act(() => { store.dispatch(setIsSwapOpen(false)); @@ -216,4 +218,58 @@ describe('', () => { expect(screen.getByText('Exchange')).toBeInTheDocument(); }); }); + + it('displays fee info when offer is present', async () => { + vi.spyOn(useOffer, 'default').mockReturnValue({ + getStepTransactions: vi.fn().mockResolvedValue([ + { + to: '0xFEEADDRESS', + value: BigInt('1000000000000000'), // 0.001 ETH in wei + data: '0x', + chainId: 1, + }, + ]), + getBestOffer: vi.fn(), + }); + + render( + + + + ); + act(() => { + store.dispatch(setBestOffer(mockBestOffer)); + }); + // Wait for fee info to appear + await waitFor(() => + expect( + screen.getByText( + (content) => + content.includes('Fee:') && + content.includes('0.001') && + 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 && + (token.contract === '0x0000000000000000000000000000000000000000' || + token.contract === '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee') + ); + 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,89 @@ 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 { + // 3. 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..8addc5eb 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: 'MATIC', + 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) 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/utils/blockchain.ts b/src/utils/blockchain.ts index 615a69c5..8be3ab12 100644 --- a/src/utils/blockchain.ts +++ b/src/utils/blockchain.ts @@ -308,3 +308,47 @@ 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 + // no USDC or USDT? + ]), + 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()); +} From 07c48f31be1b74c41475d570fd4e4b81a731275c Mon Sep 17 00:00:00 2001 From: RanaBug Date: Mon, 14 Jul 2025 10:44:00 +0100 Subject: [PATCH 2/2] add wrapped token logic and minor fixes after review --- .../ExchangeAction/ExchangeAction.tsx | 5 +- .../test/ExchangeAction.test.tsx | 185 +++++++++++++++++- src/apps/the-exchange/hooks/useOffer.tsx | 21 +- src/apps/the-exchange/utils/blockchain.ts | 4 +- src/apps/the-exchange/utils/wrappedTokens.ts | 1 + src/utils/blockchain.ts | 3 +- 6 files changed, 207 insertions(+), 12 deletions(-) diff --git a/src/apps/the-exchange/components/ExchangeAction/ExchangeAction.tsx b/src/apps/the-exchange/components/ExchangeAction/ExchangeAction.tsx index cebf1314..9782c814 100644 --- a/src/apps/the-exchange/components/ExchangeAction/ExchangeAction.tsx +++ b/src/apps/the-exchange/components/ExchangeAction/ExchangeAction.tsx @@ -130,14 +130,15 @@ const ExchangeAction = () => { amount: '0', symbol: swapToken.symbol, recipient: String(feeReceiver), - warning: 'Fee transaction not found. Please check your swap setup.', + 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 and try again.' + 'Unable to prepare the swap. Please check your wallet, refresh the page and try again.' ); } }; 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 abdb3228..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 @@ -87,6 +88,8 @@ export const mockBestOffer: SwapOffer = { }, }; +const FEE_RECEIVER = '0xfee0000000000000000000000000000000000000'; + // Mock hooks and utils vi.mock('../../../../../hooks/useGlobalTransactionsBatch', () => ({ _esModule: true, @@ -133,7 +136,7 @@ vi.mock('@lifi/sdk', () => ({ describe('', () => { beforeEach(() => { - import.meta.env.VITE_SWAP_FEE_RECEIVER = '0xFEEADDRESS'; + import.meta.env.VITE_SWAP_FEE_RECEIVER = FEE_RECEIVER; vi.clearAllMocks(); act(() => { store.dispatch(setIsSwapOpen(false)); @@ -219,11 +222,11 @@ describe('', () => { }); }); - it('displays fee info when offer is present', async () => { + it('displays fee info for native token fee', async () => { vi.spyOn(useOffer, 'default').mockReturnValue({ getStepTransactions: vi.fn().mockResolvedValue([ { - to: '0xFEEADDRESS', + to: FEE_RECEIVER, value: BigInt('1000000000000000'), // 0.001 ETH in wei data: '0x', chainId: 1, @@ -240,7 +243,6 @@ describe('', () => { act(() => { store.dispatch(setBestOffer(mockBestOffer)); }); - // Wait for fee info to appear await waitFor(() => expect( screen.getByText( @@ -253,6 +255,181 @@ describe('', () => { ); }); + 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')), diff --git a/src/apps/the-exchange/hooks/useOffer.tsx b/src/apps/the-exchange/hooks/useOffer.tsx index c0357781..2095ee00 100644 --- a/src/apps/the-exchange/hooks/useOffer.tsx +++ b/src/apps/the-exchange/hooks/useOffer.tsx @@ -35,6 +35,7 @@ import { import { processEth } from '../utils/blockchain'; import { getWrappedTokenAddressIfNative, + isNativeToken, isWrappedToken, } from '../utils/wrappedTokens'; @@ -53,8 +54,7 @@ export const getNativeBalanceFromPortfolio = ( const nativeToken = walletPortfolio.find( (token) => chainNameToChainIdTokensData(token.blockchain) === chainId && - (token.contract === '0x0000000000000000000000000000000000000000' || - token.contract === '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee') + isNativeToken(token.contract) ); return nativeToken ? String(nativeToken.balance) : undefined; }; @@ -253,8 +253,23 @@ const useOffer = () => { }; 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 { - // 3. If input is ERC20 non-stable, estimate native equivalent of 1% and transfer that as fee + // 4. If input is ERC20 non-stable, estimate native equivalent of 1% and transfer that as fee try { const nativeFeeRoute = await getNativeFeeForERC20({ tokenAddress: fromTokenAddress, diff --git a/src/apps/the-exchange/utils/blockchain.ts b/src/apps/the-exchange/utils/blockchain.ts index 8addc5eb..faa3b6de 100644 --- a/src/apps/the-exchange/utils/blockchain.ts +++ b/src/apps/the-exchange/utils/blockchain.ts @@ -21,7 +21,7 @@ export const processEth = (val: BigNumberish, dec: number): number => { export const NATIVE_SYMBOLS: Record = { 1: 'ETH', 100: 'xDAI', - 137: 'MATIC', + 137: 'POL', 10: 'ETH', 42161: 'ETH', 56: 'BNB', @@ -40,7 +40,7 @@ export const isNativeFeeTx = ( ); }; -// Helper: Detect if a tx is an ERC20 (stablecoin) fee step +// Helper: Detect if a tx is an ERC20 (stablecoin or wrapped) fee step export const isERC20FeeTx = ( tx: StepTransaction, swapToken: Token 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 8be3ab12..8d86b858 100644 --- a/src/utils/blockchain.ts +++ b/src/utils/blockchain.ts @@ -332,7 +332,8 @@ const STABLECOIN_ADDRESSES: Record> = { ]), 56: new Set([ // BNB Smart Chain - // no USDC or USDT? + '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', // USDC + '0x55d398326f99059fF775485246999027B3197955', // BSC-USD ]), 10: new Set([ // Optimism