diff --git a/src/swap/components/SwapAmountInput.tsx b/src/swap/components/SwapAmountInput.tsx index f6b9cc28c8..4e7082d7e3 100644 --- a/src/swap/components/SwapAmountInput.tsx +++ b/src/swap/components/SwapAmountInput.tsx @@ -31,7 +31,7 @@ export function SwapAmountInput({ const destination = useValue(type === 'from' ? to : from); useEffect(() => { if (token) { - source.setToken(token); + source.setToken?.(token); } }, [token, source.setToken]); @@ -52,7 +52,7 @@ export function SwapAmountInput({ const handleSetToken = useCallback( (token: Token) => { - source.setToken(token); + source.setToken?.(token); handleAmountChange(type, source.amount, token); }, [source.amount, source.setToken, handleAmountChange, type], diff --git a/src/swap/components/SwapProvider.test.tsx b/src/swap/components/SwapProvider.test.tsx index 8fcfffda7b..c2528b1b70 100644 --- a/src/swap/components/SwapProvider.test.tsx +++ b/src/swap/components/SwapProvider.test.tsx @@ -144,9 +144,9 @@ const renderWithProviders = ({ const TestSwapComponent = () => { const context = useSwapContext(); useEffect(() => { - context.from.setToken(ETH_TOKEN); + context.from.setToken?.(ETH_TOKEN); context.from.setAmount('100'); - context.to.setToken(DEGEN_TOKEN); + context.to.setToken?.(DEGEN_TOKEN); }, [context]); const handleStatusError = async () => { context.updateLifecycleStatus({ @@ -555,8 +555,8 @@ describe('SwapProvider', () => { React.useEffect(() => { const initializeSwap = async () => { await act(async () => { - from.setToken(ETH_TOKEN); - to.setToken(DEGEN_TOKEN); + from.setToken?.(ETH_TOKEN); + to.setToken?.(DEGEN_TOKEN); handleToggle(); }); }; @@ -652,9 +652,9 @@ describe('SwapProvider', () => { it('should toggle tokens and amounts', async () => { const { result } = renderHook(() => useSwapContext(), { wrapper }); await act(async () => { - result.current.from.setToken(ETH_TOKEN); + result.current.from.setToken?.(ETH_TOKEN); result.current.from.setAmount('10'); - result.current.to.setToken(DEGEN_TOKEN); + result.current.to.setToken?.(DEGEN_TOKEN); result.current.to.setAmount('1000'); }); await act(async () => { diff --git a/src/swap/components/SwapProvider.tsx b/src/swap/components/SwapProvider.tsx index 97480fcf5a..7f8fc1824c 100644 --- a/src/swap/components/SwapProvider.tsx +++ b/src/swap/components/SwapProvider.tsx @@ -161,8 +161,8 @@ export function SwapProvider({ const handleToggle = useCallback(() => { from.setAmount(to.amount); to.setAmount(from.amount); - from.setToken(to.token); - to.setToken(from.token); + from.setToken?.(to.token); + to.setToken?.(from.token); updateLifecycleStatus({ statusName: 'amountChange', diff --git a/src/swap/hooks/useResetSwapLiteInputs.test.ts b/src/swap/hooks/useResetSwapLiteInputs.test.ts new file mode 100644 index 0000000000..c1232384c5 --- /dev/null +++ b/src/swap/hooks/useResetSwapLiteInputs.test.ts @@ -0,0 +1,187 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SwapUnit } from '../types'; +import { useResetSwapLiteInputs } from './useResetSwapLiteInputs'; + +describe('useResetSwapLiteInputs', () => { + const mockQueryResponse = { + data: undefined, + error: null, + isError: false, + isPending: true, + isSuccess: false, + status: 'pending', + } as const; + + const mockFromTokenResponse = { + ...mockQueryResponse, + refetch: vi.fn().mockResolvedValue(undefined), + }; + const mockFromETHTokenResponse = { + ...mockQueryResponse, + refetch: vi.fn().mockResolvedValue(undefined), + }; + const mockFromUSDCTokenResponse = { + ...mockQueryResponse, + refetch: vi.fn().mockResolvedValue(undefined), + }; + const mockToTokenResponse = { + ...mockQueryResponse, + refetch: vi.fn().mockResolvedValue(undefined), + }; + const mockFrom: SwapUnit = { + balance: '100', + balanceResponse: mockFromTokenResponse, + amount: '50', + setAmount: vi.fn(), + setAmountUSD: vi.fn(), + token: undefined, + loading: false, + setLoading: vi.fn(), + error: undefined, + } as unknown as SwapUnit; + const mockFromETH: SwapUnit = { + balance: '100', + balanceResponse: mockFromETHTokenResponse, + amount: '50', + setAmount: vi.fn(), + setAmountUSD: vi.fn(), + token: undefined, + loading: false, + setLoading: vi.fn(), + error: undefined, + } as unknown as SwapUnit; + const mockFromUSDC: SwapUnit = { + balance: '100', + balanceResponse: mockFromUSDCTokenResponse, + amount: '50', + setAmount: vi.fn(), + setAmountUSD: vi.fn(), + token: undefined, + loading: false, + setLoading: vi.fn(), + error: undefined, + } as unknown as SwapUnit; + const mockTo: SwapUnit = { + balance: '200', + balanceResponse: mockToTokenResponse, + amount: '75', + setAmount: vi.fn(), + setAmountUSD: vi.fn(), + token: undefined, + loading: false, + setLoading: vi.fn(), + error: undefined, + } as unknown as SwapUnit; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return a function', () => { + const { result } = renderHook(() => + useResetSwapLiteInputs({ + fromETH: mockFromETH, + fromUSDC: mockFromUSDC, + from: mockFrom, + to: mockTo, + }), + ); + expect(typeof result.current).toBe('function'); + }); + + it('should call refetch on responses and set amounts to empty strings when executed', async () => { + const { result } = renderHook(() => + useResetSwapLiteInputs({ + fromETH: mockFromETH, + fromUSDC: mockFromUSDC, + from: mockFrom, + to: mockTo, + }), + ); + await act(async () => { + await result.current(); + }); + expect(mockFromETHTokenResponse.refetch).toHaveBeenCalledTimes(1); + expect(mockToTokenResponse.refetch).toHaveBeenCalledTimes(1); + expect(mockFromETH.setAmount).toHaveBeenCalledWith(''); + expect(mockFromETH.setAmountUSD).toHaveBeenCalledWith(''); + expect(mockTo.setAmount).toHaveBeenCalledWith(''); + expect(mockTo.setAmountUSD).toHaveBeenCalledWith(''); + }); + + it("should not create a new function reference if from and to haven't changed", () => { + const { result, rerender } = renderHook(() => + useResetSwapLiteInputs({ + fromETH: mockFromETH, + fromUSDC: mockFromUSDC, + to: mockTo, + }), + ); + const firstRender = result.current; + rerender(); + expect(result.current).toBe(firstRender); + }); + + it('should create a new function reference if from or to change', () => { + const { result, rerender } = renderHook( + ({ fromETH, fromUSDC, to }) => + useResetSwapLiteInputs({ + fromETH, + fromUSDC, + to, + }), + { + initialProps: { + fromETH: mockFromETH, + fromUSDC: mockFromUSDC, + to: mockTo, + }, + }, + ); + const firstRender = result.current; + const newMockFromETH = { + ...mockFromETH, + balanceResponse: { refetch: vi.fn().mockResolvedValue(undefined) }, + } as unknown as SwapUnit; + const newMockFromUSDC = { + ...mockFromUSDC, + balanceResponse: { refetch: vi.fn().mockResolvedValue(undefined) }, + } as unknown as SwapUnit; + rerender({ + fromETH: newMockFromETH, + fromUSDC: newMockFromUSDC, + to: mockTo, + }); + expect(result.current).not.toBe(firstRender); + }); + + it('should handle null responses gracefully', async () => { + const mockFromWithNullResponse = { + ...mockFromETH, + balanceResponse: null, + } as unknown as SwapUnit; + const mockFromUSDCWithNullResponse = { + ...mockFromUSDC, + balanceResponse: null, + } as unknown as SwapUnit; + const mockToWithNullResponse = { + ...mockTo, + balanceResponse: null, + } as unknown as SwapUnit; + const { result } = renderHook(() => + useResetSwapLiteInputs({ + fromETH: mockFromWithNullResponse, + fromUSDC: mockFromUSDCWithNullResponse, + to: mockToWithNullResponse, + }), + ); + await act(async () => { + await result.current(); + }); + expect(mockFromWithNullResponse.setAmount).toHaveBeenCalledWith(''); + expect(mockFromWithNullResponse.setAmountUSD).toHaveBeenCalledWith(''); + expect(mockToWithNullResponse.setAmount).toHaveBeenCalledWith(''); + expect(mockToWithNullResponse.setAmountUSD).toHaveBeenCalledWith(''); + }); +}); diff --git a/src/swap/hooks/useResetSwapLiteInputs.ts b/src/swap/hooks/useResetSwapLiteInputs.ts new file mode 100644 index 0000000000..af62ad64ce --- /dev/null +++ b/src/swap/hooks/useResetSwapLiteInputs.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react'; +import type { SwapLiteTokens } from '../types'; + +// Refreshes balances and inputs post-swap +export const useResetSwapLiteInputs = ({ + fromETH, + fromUSDC, + from, + to, +}: SwapLiteTokens) => { + return useCallback(async () => { + await Promise.all([ + from?.balanceResponse?.refetch(), + from?.setAmount(''), + from?.setAmountUSD(''), + fromETH.balanceResponse?.refetch(), + fromETH.setAmount(''), + fromETH.setAmountUSD(''), + fromUSDC.balanceResponse?.refetch(), + fromUSDC.setAmount(''), + fromUSDC.setAmountUSD(''), + to.balanceResponse?.refetch(), + to.setAmount(''), + to.setAmountUSD(''), + ]); + }, [from, fromETH, fromUSDC, to]); +}; diff --git a/src/swap/hooks/useSwapLiteToken.test.ts b/src/swap/hooks/useSwapLiteToken.test.ts new file mode 100644 index 0000000000..4d4d6dc9fd --- /dev/null +++ b/src/swap/hooks/useSwapLiteToken.test.ts @@ -0,0 +1,82 @@ +import { act, renderHook } from '@testing-library/react'; +import { base } from 'viem/chains'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useValue } from '../../core-react/internal/hooks/useValue'; +import type { Token } from '../../token'; +import { usdcToken } from '../../token/constants'; +import { useSwapBalances } from './useSwapBalances'; +import { useSwapLiteToken } from './useSwapLiteToken'; + +vi.mock('./useSwapBalances', () => ({ + useSwapBalances: vi.fn(), +})); + +vi.mock('../../core-react/internal/hooks/useValue', () => ({ + useValue: vi.fn(), +})); + +const toToken: Token = { + name: 'DEGEN', + address: '0x4ed4e862860bed51a9570b96d89af5e1b0efefed', + symbol: 'DEGEN', + decimals: 18, + image: + 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/3b/bf/3bbf118b5e6dc2f9e7fc607a6e7526647b4ba8f0bea87125f971446d57b296d2-MDNmNjY0MmEtNGFiZi00N2I0LWIwMTItMDUyMzg2ZDZhMWNm', + chainId: base.id, +}; + +describe('useSwapLiteToken', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return correct values', () => { + (useSwapBalances as Mock).mockReturnValue({ + fromBalanceString: '100', + fromTokenBalanceError: null, + fromTokenResponse: { refetch: vi.fn() }, + toBalanceString: '200', + toTokenBalanceError: null, + toTokenResponse: { refetch: vi.fn() }, + }); + (useValue as Mock).mockImplementation((props) => ({ + ...props, + })); + const { result } = renderHook(() => + useSwapLiteToken(toToken, usdcToken, '0x123'), + ); + expect(result.current).toEqual({ + amount: '', + amountUSD: '', + balance: '100', + balanceResponse: { refetch: expect.any(Function) }, + error: null, + loading: false, + setAmount: expect.any(Function), + setAmountUSD: expect.any(Function), + setLoading: expect.any(Function), + token: usdcToken, + }); + }); + + it('should call fromTokenResponse.refetch when fromETH.response.refetch is called', async () => { + const mockFromRefetch = vi.fn().mockResolvedValue(undefined); + const mockToRefetch = vi.fn().mockResolvedValue(undefined); + (useSwapBalances as Mock).mockReturnValue({ + fromTokenResponse: { refetch: mockFromRefetch }, + toTokenResponse: { refetch: mockToRefetch }, + }); + (useValue as Mock).mockImplementation((props) => ({ + ...props, + response: props.response, + })); + const { result } = renderHook(() => + useSwapLiteToken(toToken, usdcToken, '0x123'), + ); + await act(async () => { + await result.current.balanceResponse?.refetch(); + }); + expect(mockFromRefetch).toHaveBeenCalledTimes(1); + expect(mockToRefetch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/swap/hooks/useSwapLiteToken.ts b/src/swap/hooks/useSwapLiteToken.ts new file mode 100644 index 0000000000..8c3f620517 --- /dev/null +++ b/src/swap/hooks/useSwapLiteToken.ts @@ -0,0 +1,34 @@ +import { useState } from 'react'; +import type { Address } from 'viem'; +import { useValue } from '../../core-react/internal/hooks/useValue'; +import type { Token } from '../../token'; +import { useSwapBalances } from './useSwapBalances'; + +export const useSwapLiteToken = ( + toToken: Token, + token: Token | undefined, + address: Address | undefined, +) => { + const [amount, setAmount] = useState(''); + const [amountUSD, setAmountUSD] = useState(''); + const [loading, setLoading] = useState(false); + + const { + fromBalanceString: balance, + fromTokenBalanceError: error, + fromTokenResponse: balanceResponse, + } = useSwapBalances({ address, fromToken: token, toToken }); + + return useValue({ + balance, + balanceResponse, + amount, + setAmount, + amountUSD, + setAmountUSD, + token, + loading, + setLoading, + error, + }); +}; diff --git a/src/swap/hooks/useSwapLiteTokens.test.ts b/src/swap/hooks/useSwapLiteTokens.test.ts new file mode 100644 index 0000000000..7b8280f6f8 --- /dev/null +++ b/src/swap/hooks/useSwapLiteTokens.test.ts @@ -0,0 +1,162 @@ +import { act, renderHook } from '@testing-library/react'; +import { base } from 'viem/chains'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useValue } from '../../core-react/internal/hooks/useValue'; +import type { Token } from '../../token'; +import { + daiToken, + degenToken, + ethToken, + usdcToken, +} from '../../token/constants'; +import { useSwapBalances } from './useSwapBalances'; +import { useSwapLiteToken } from './useSwapLiteToken'; +import { useSwapLiteTokens } from './useSwapLiteTokens'; + +vi.mock('./useSwapLiteToken'); +vi.mock('./useSwapBalances'); +vi.mock('../../core-react/internal/hooks/useValue'); + +const toToken: Token = { + name: 'DEGEN', + address: '0x4ed4e862860bed51a9570b96d89af5e1b0efefed', + symbol: 'DEGEN', + decimals: 18, + image: + 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/3b/bf/3bbf118b5e6dc2f9e7fc607a6e7526647b4ba8f0bea87125f971446d57b296d2-MDNmNjY0MmEtNGFiZi00N2I0LWIwMTItMDUyMzg2ZDZhMWNm', + chainId: base.id, +}; + +const mockFromETH = { + balance: '100', + balanceResponse: { refetch: vi.fn() }, + error: null, + loading: false, + setAmount: vi.fn(), + setAmountUSD: vi.fn(), + setLoading: vi.fn(), + token: ethToken, +}; + +const mockFromUSDC = { + balance: '50', + balanceResponse: { refetch: vi.fn() }, + error: null, + loading: false, + setAmount: vi.fn(), + setAmountUSD: vi.fn(), + setLoading: vi.fn(), + token: usdcToken, +}; + +const mockFrom = { + balance: '50', + balanceResponse: { refetch: vi.fn() }, + error: null, + loading: false, + setAmount: vi.fn(), + setAmountUSD: vi.fn(), + setLoading: vi.fn(), + token: degenToken, +}; + +const mockTo = { + balance: '1000', + balanceResponse: { refetch: vi.fn() }, + error: null, + loading: false, + setAmount: vi.fn(), + setAmountUSD: vi.fn(), + setLoading: vi.fn(), + token: daiToken, +}; + +const address = '0x123'; + +describe('useSwapLiteTokens', () => { + beforeEach(() => { + vi.clearAllMocks(); + (useSwapLiteToken as Mock).mockImplementation((_toToken, fromToken) => { + if (fromToken === ethToken) { + return mockFromETH; + } + if (fromToken === usdcToken) { + return mockFromUSDC; + } + return mockFrom; + }); + + (useSwapBalances as Mock).mockReturnValue({ + toBalanceString: '1000', + toTokenBalanceError: null, + toTokenResponse: { balance: '1000' }, + }); + + (useValue as Mock).mockReturnValue(mockTo); + }); + + it('should return expected swap tokens', () => { + const { result } = renderHook(() => + useSwapLiteTokens(toToken, daiToken, address), + ); + + expect(useSwapLiteToken).toHaveBeenCalledWith(toToken, ethToken, address); + expect(useSwapLiteToken).toHaveBeenCalledWith(toToken, usdcToken, address); + expect(useSwapLiteToken).toHaveBeenCalledWith(toToken, daiToken, address); + expect(useSwapBalances).toHaveBeenCalledWith({ + address, + fromToken: ethToken, + toToken, + }); + expect(useValue).toHaveBeenCalledWith({ + balance: '1000', + balanceResponse: { balance: '1000' }, + amount: '', + setAmount: expect.any(Function), + amountUSD: '', + setAmountUSD: expect.any(Function), + token: toToken, + loading: false, + setLoading: expect.any(Function), + error: null, + }); + + expect(result.current).toEqual({ + fromETH: mockFromETH, + fromUSDC: mockFromUSDC, + from: mockFrom, + to: mockTo, + }); + }); + + it('should handle toToken.symbol === ETH', () => { + renderHook(() => useSwapLiteTokens(ethToken, degenToken, address)); + + expect(useSwapBalances).toHaveBeenCalledWith({ + address, + fromToken: usdcToken, + toToken: ethToken, + }); + }); + + it('should call toTokenResponse.refetch when to.response.refetch is called', async () => { + const mockFromRefetch = vi.fn().mockResolvedValue(undefined); + const mockToRefetch = vi.fn().mockResolvedValue(undefined); + (useSwapBalances as Mock).mockReturnValue({ + fromTokenResponse: { refetch: mockFromRefetch }, + toTokenResponse: { refetch: mockToRefetch }, + }); + (useValue as Mock).mockImplementation((props) => ({ + ...props, + response: props.response, + })); + const { result } = renderHook(() => + useSwapLiteTokens(toToken, undefined, '0x123'), + ); + await act(async () => { + await result.current.to.balanceResponse?.refetch(); + }); + expect(mockToRefetch).toHaveBeenCalledTimes(1); + expect(mockFromRefetch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/swap/hooks/useSwapLiteTokens.ts b/src/swap/hooks/useSwapLiteTokens.ts new file mode 100644 index 0000000000..e364df6faf --- /dev/null +++ b/src/swap/hooks/useSwapLiteTokens.ts @@ -0,0 +1,46 @@ +import { useState } from 'react'; +import type { Address } from 'viem'; +import { useValue } from '../../core-react/internal/hooks/useValue'; +import type { Token } from '../../token'; +import { ethToken, usdcToken } from '../../token/constants'; +import type { SwapLiteTokens } from '../types'; +import { useSwapBalances } from './useSwapBalances'; +import { useSwapLiteToken } from './useSwapLiteToken'; + +export const useSwapLiteTokens = ( + toToken: Token, + fromToken?: Token, + address?: Address, +): SwapLiteTokens => { + const fromETH = useSwapLiteToken(toToken, ethToken, address); + const fromUSDC = useSwapLiteToken(toToken, usdcToken, address); + const from = useSwapLiteToken(toToken, fromToken, address); + + const [toAmount, setToAmount] = useState(''); + const [toAmountUSD, setToAmountUSD] = useState(''); + const [toLoading, setToLoading] = useState(false); + + // If the toToken is ETH, use USDC for swapQuote + const token = toToken?.symbol === 'ETH' ? usdcToken : ethToken; + + const { + toBalanceString: balance, + toTokenBalanceError: error, + toTokenResponse: balanceResponse, + } = useSwapBalances({ address, fromToken: token, toToken }); + + const to = useValue({ + balance, + balanceResponse, + amount: toAmount, + setAmount: setToAmount, + amountUSD: toAmountUSD, + setAmountUSD: setToAmountUSD, + token: toToken, + loading: toLoading, + setLoading: setToLoading, + error, + }); + + return { fromETH, fromUSDC, from, to }; +}; diff --git a/src/swap/types.ts b/src/swap/types.ts index de617e9994..44436dbcfc 100644 --- a/src/swap/types.ts +++ b/src/swap/types.ts @@ -173,6 +173,13 @@ export type ProcessSwapTransactionParams = { walletCapabilities: WalletCapabilities; // EIP-5792 wallet capabilities }; +export type SwapLiteTokens = { + fromETH: SwapUnit; + fromUSDC: SwapUnit; + to: SwapUnit; + from?: SwapUnit; +}; + /** * Note: exported as public Type */ @@ -370,7 +377,7 @@ export type SwapUnit = { setAmount: Dispatch>; setAmountUSD: Dispatch>; setLoading: Dispatch>; - setToken: Dispatch>; + setToken?: Dispatch>; token: Token | undefined; }; diff --git a/src/swap/utils/getSwapLiteQuote.test.ts b/src/swap/utils/getSwapLiteQuote.test.ts new file mode 100644 index 0000000000..b31ec306e2 --- /dev/null +++ b/src/swap/utils/getSwapLiteQuote.test.ts @@ -0,0 +1,207 @@ +import { getSwapQuote } from '@/core/api/getSwapQuote'; +import { formatTokenAmount } from '@/core/utils/formatTokenAmount'; +import type { Token } from '@/token/types'; +import { base } from 'viem/chains'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { isSwapError } from '../../swap/utils/isSwapError'; +import { getSwapLiteQuote } from './getSwapLiteQuote'; + +vi.mock('@/core/api/getSwapQuote'); +vi.mock('@/core/utils/formatTokenAmount'); +vi.mock('../../swap/utils/isSwapError'); + +const toToken: Token = { + name: 'DEGEN', + address: '0x4ed4e862860bed51a9570b96d89af5e1b0efefed', + symbol: 'DEGEN', + decimals: 18, + image: + 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/3b/bf/3bbf118b5e6dc2f9e7fc607a6e7526647b4ba8f0bea87125f971446d57b296d2-MDNmNjY0MmEtNGFiZi00N2I0LWIwMTItMDUyMzg2ZDZhMWNm', + chainId: base.id, +}; + +const fromToken: Token = { + name: 'ETH', + address: '', + symbol: 'ETH', + decimals: 18, + image: + 'https://wallet-api-production.s3.amazonaws.com/uploads/tokens/eth_288.png', + chainId: base.id, +}; + +const mockResponse = { + from: fromToken, + to: toToken, + fromAmount: '100000000000000000', + toAmount: '16732157880511600003860', + amountReference: 'from', + priceImpact: '0.07', + chainId: 8453, + hasHighPriceImpact: false, + slippage: '3', + fromAmountUSD: '100', +}; + +const mockEmptyResponse = { + from: fromToken, + to: toToken, + fromAmount: '', + toAmount: '16732157880511600003860', + amountReference: 'from', + priceImpact: '0.07', + chainId: 8453, + hasHighPriceImpact: false, + slippage: '3', + fromAmountUSD: '', +}; + +const mockFromSwapUnit = { + setAmountUSD: vi.fn(), + setAmount: vi.fn(), + amount: '1', + amountUSD: '1', + loading: false, + setLoading: vi.fn(), + token: fromToken, +}; + +describe('getSwapLiteQuote', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return default values if `from` token is not provided', async () => { + const result = await getSwapLiteQuote({ + amount: '1', + amountReference: 'exactIn', + maxSlippage: '0.5', + to: toToken, + useAggregator: true, + fromSwapUnit: mockFromSwapUnit, + }); + + expect(result).toEqual({ + response: undefined, + formattedFromAmount: '', + error: undefined, + }); + }); + + it('should call `getSwapQuote` if `from` and `to` tokens are different', async () => { + (getSwapQuote as Mock).mockResolvedValue(mockResponse); + (isSwapError as unknown as Mock).mockReturnValue(false); + (formatTokenAmount as Mock).mockReturnValue('1.0'); + + const result = await getSwapLiteQuote({ + amount: '1', + amountReference: 'exactIn', + from: fromToken, + maxSlippage: '0.5', + to: toToken, + useAggregator: true, + fromSwapUnit: mockFromSwapUnit, + }); + + expect(getSwapQuote).toHaveBeenCalledWith({ + amount: '1', + amountReference: 'exactIn', + from: fromToken, + maxSlippage: '0.5', + to: toToken, + useAggregator: true, + }); + + expect(formatTokenAmount).toHaveBeenCalledWith('100000000000000000', 18); + expect(mockFromSwapUnit.setAmountUSD).toHaveBeenCalledWith('100'); + expect(mockFromSwapUnit.setAmount).toHaveBeenCalledWith('1.0'); + + expect(result).toEqual({ + response: mockResponse, + formattedFromAmount: '1.0', + error: undefined, + }); + }); + + it('should handle case where amount values are undefined', async () => { + (getSwapQuote as Mock).mockResolvedValue(mockEmptyResponse); + (isSwapError as unknown as Mock).mockReturnValue(false); + (formatTokenAmount as Mock).mockReturnValue('1.0'); + + const result = await getSwapLiteQuote({ + amount: '1', + amountReference: 'exactIn', + from: fromToken, + maxSlippage: '0.5', + to: toToken, + useAggregator: true, + fromSwapUnit: mockFromSwapUnit, + }); + + expect(getSwapQuote).toHaveBeenCalledWith({ + amount: '1', + amountReference: 'exactIn', + from: fromToken, + maxSlippage: '0.5', + to: toToken, + useAggregator: true, + }); + + expect(formatTokenAmount).not.toHaveBeenCalled(); + expect(mockFromSwapUnit.setAmountUSD).toHaveBeenCalledWith(''); + expect(mockFromSwapUnit.setAmount).toHaveBeenCalledWith(''); + + expect(result).toEqual({ + response: mockEmptyResponse, + formattedFromAmount: '', + error: undefined, + }); + }); + + it('should handle swap errors correctly', async () => { + const mockError = { + code: 'UNCAUGHT_SWAP_QUOTE_ERROR', + error: 'Something went wrong', + message: '', + }; + + (getSwapQuote as Mock).mockResolvedValue(mockError); + (isSwapError as unknown as Mock).mockReturnValue(true); + + const result = await getSwapLiteQuote({ + amount: '1', + amountReference: 'exactIn', + from: fromToken, + maxSlippage: '0.5', + to: toToken, + useAggregator: true, + fromSwapUnit: mockFromSwapUnit, + }); + + expect(isSwapError).toHaveBeenCalledWith(mockError); + expect(result).toEqual({ + response: undefined, + formattedFromAmount: '', + error: mockError, + }); + }); + + it('should not call `getSwapQuote` if `from` and `to` tokens are the same', async () => { + const result = await getSwapLiteQuote({ + amount: '1', + amountReference: 'exactIn', + from: fromToken, + maxSlippage: '0.5', + to: fromToken, + useAggregator: true, + fromSwapUnit: mockFromSwapUnit, + }); + + expect(getSwapQuote).not.toHaveBeenCalled(); + expect(result).toEqual({ + response: undefined, + formattedFromAmount: '', + error: undefined, + }); + }); +}); diff --git a/src/swap/utils/getSwapLiteQuote.ts b/src/swap/utils/getSwapLiteQuote.ts new file mode 100644 index 0000000000..cd1009a76d --- /dev/null +++ b/src/swap/utils/getSwapLiteQuote.ts @@ -0,0 +1,71 @@ +import { getSwapQuote } from '@/core/api/getSwapQuote'; +import type { + APIError, + GetSwapQuoteParams, + GetSwapQuoteResponse, +} from '@/core/api/types'; +import { formatTokenAmount } from '@/core/utils/formatTokenAmount'; +import type { SwapError, SwapUnit } from '../../swap/types'; +import { isSwapError } from '../../swap/utils/isSwapError'; +import type { Token } from '../../token'; + +type GetSwapLiteQuoteResponse = { + response?: GetSwapQuoteResponse; + error?: APIError; + formattedFromAmount?: string; +}; + +type GetSwapLiteQuoteParams = Omit & { + fromSwapUnit: SwapUnit; + from?: Token; +}; + +/** + * Fetches a quote for a swap, but only if the from and to tokens are different. + */ + +export async function getSwapLiteQuote({ + amount, + amountReference, + from, + maxSlippage, + to, + useAggregator, + fromSwapUnit, +}: GetSwapLiteQuoteParams): Promise { + // only fetch quote if the from token is provided + if (!from) { + return { response: undefined, formattedFromAmount: '', error: undefined }; + } + + let response: GetSwapQuoteResponse | undefined; + // only fetch quote if the from and to tokens are different + if (to?.symbol !== from?.symbol) { + response = await getSwapQuote({ + amount, + amountReference, + from, + maxSlippage, + to, + useAggregator, + }); + } + + let formattedFromAmount = ''; + if (response && !isSwapError(response)) { + formattedFromAmount = response?.fromAmount + ? formatTokenAmount(response.fromAmount, response.from.decimals) + : ''; + + fromSwapUnit.setAmountUSD(response?.fromAmountUSD || ''); + fromSwapUnit.setAmount(formattedFromAmount || ''); + } + + let error: SwapError | undefined; + if (isSwapError(response)) { + error = response; + response = undefined; + } + + return { response, formattedFromAmount, error }; +} diff --git a/src/token/constants.ts b/src/token/constants.ts new file mode 100644 index 0000000000..21c1852e76 --- /dev/null +++ b/src/token/constants.ts @@ -0,0 +1,42 @@ +import { base } from 'viem/chains'; +import type { Token } from './types'; + +export const ethToken: Token = { + name: 'ETH', + address: '', + symbol: 'ETH', + decimals: 18, + image: + 'https://wallet-api-production.s3.amazonaws.com/uploads/tokens/eth_288.png', + chainId: base.id, +}; + +export const usdcToken: Token = { + name: 'USDC', + address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + symbol: 'USDC', + decimals: 6, + image: + 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/44/2b/442b80bd16af0c0d9b22e03a16753823fe826e5bfd457292b55fa0ba8c1ba213-ZWUzYjJmZGUtMDYxNy00NDcyLTg0NjQtMWI4OGEwYjBiODE2', + chainId: base.id, +}; + +export const degenToken: Token = { + name: 'DEGEN', + address: '0x4ed4e862860bed51a9570b96d89af5e1b0efefed', + symbol: 'DEGEN', + decimals: 18, + image: + 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/3b/bf/3bbf118b5e6dc2f9e7fc607a6e7526647b4ba8f0bea87125f971446d57b296d2-MDNmNjY0MmEtNGFiZi00N2I0LWIwMTItMDUyMzg2ZDZhMWNm', + chainId: base.id, +}; + +export const daiToken: Token = { + name: 'DAI', + address: '0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb', + symbol: 'DAI', + decimals: 18, + image: + 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/92/13/9213e31b84c98a693f4c624580fdbe6e4c1cb550efbba15aa9ea68fd25ffb90c-ZTE1NmNjMGUtZGVkYi00ZDliLWI2N2QtNTY2ZWRjMmYwZmMw', + chainId: base.id, +};