From 3002cc441c204b025b1f14dadfc9f986ef812ed4 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Fri, 13 Dec 2024 11:35:14 -0800 Subject: [PATCH] add new uti functions --- src/core/api/getSwapLiteQuote.ts | 68 +++++++ src/swap/components/SwapProvider.test.tsx | 12 +- src/swap/components/SwapProvider.tsx | 4 +- src/swap/hooks/useResetSwapLiteInputs.test.ts | 166 ++++++++++++++++++ src/swap/hooks/useResetSwapLiteInputs.ts | 27 +++ src/swap/hooks/useSwapLiteTokens.test.ts | 132 ++++++++++++++ src/swap/hooks/useSwapLiteTokens.ts | 104 +++++++++++ src/swap/types.ts | 17 +- 8 files changed, 513 insertions(+), 17 deletions(-) create mode 100644 src/core/api/getSwapLiteQuote.ts create mode 100644 src/swap/hooks/useResetSwapLiteInputs.test.ts create mode 100644 src/swap/hooks/useResetSwapLiteInputs.ts create mode 100644 src/swap/hooks/useSwapLiteTokens.test.ts create mode 100644 src/swap/hooks/useSwapLiteTokens.ts diff --git a/src/core/api/getSwapLiteQuote.ts b/src/core/api/getSwapLiteQuote.ts new file mode 100644 index 0000000000..6109bd8a59 --- /dev/null +++ b/src/core/api/getSwapLiteQuote.ts @@ -0,0 +1,68 @@ +import type { SwapError, SwapUnit } from '../../swap/types'; +import { isSwapError } from '../../swap/utils/isSwapError'; +import type { Token } from '../../token'; +import { formatTokenAmount } from '../utils/formatTokenAmount'; +import { getSwapQuote } from './getSwapQuote'; +import type { + APIError, + GetSwapQuoteParams, + GetSwapQuoteResponse, +} from './types'; + +type GetSwapLiteQuoteResponse = { + response?: GetSwapQuoteResponse; + error?: APIError; + formattedFromAmount?: string; +}; + +type GetSwapLiteQuoteParams = Omit & { + fromSwapUnit: SwapUnit; + from?: Token; +}; + +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 = 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/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..a69744bfd1 --- /dev/null +++ b/src/swap/hooks/useResetSwapLiteInputs.test.ts @@ -0,0 +1,166 @@ +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 mockFromTokenResponse = { + refetch: vi.fn().mockResolvedValue(undefined), + }; + const mockFromETHTokenResponse = { + refetch: vi.fn().mockResolvedValue(undefined), + }; + const mockFromUSDCTokenResponse = { + refetch: vi.fn().mockResolvedValue(undefined), + }; + const mockToTokenResponse = { 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, + }; + const mockFromETH: SwapUnit = { + balance: '100', + balanceResponse: mockFromETHTokenResponse, + amount: '50', + setAmount: vi.fn(), + setAmountUSD: vi.fn(), + token: undefined, + loading: false, + setLoading: vi.fn(), + error: undefined, + }; + const mockFromUSDC: SwapUnit = { + balance: '100', + balanceResponse: mockFromUSDCTokenResponse, + amount: '50', + setAmount: vi.fn(), + setAmountUSD: vi.fn(), + token: undefined, + loading: false, + setLoading: vi.fn(), + error: undefined, + }; + const mockTo: SwapUnit = { + balance: '200', + balanceResponse: mockToTokenResponse, + amount: '75', + setAmount: vi.fn(), + setAmountUSD: vi.fn(), + token: undefined, + loading: false, + setLoading: vi.fn(), + error: undefined, + }; + + 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) }, + }; + const newMockFromUSDC = { + ...mockFromUSDC, + balanceResponse: { refetch: vi.fn().mockResolvedValue(undefined) }, + }; + 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 }; + const mockFromUSDCWithNullResponse = { + ...mockFromUSDC, + balanceResponse: null, + }; + const mockToWithNullResponse = { ...mockTo, balanceResponse: null }; + 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/useSwapLiteTokens.test.ts b/src/swap/hooks/useSwapLiteTokens.test.ts new file mode 100644 index 0000000000..f952a421de --- /dev/null +++ b/src/swap/hooks/useSwapLiteTokens.test.ts @@ -0,0 +1,132 @@ +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 { USDC_TOKEN } from '../mocks'; +import { useSwapLiteTokens } from './useSwapLiteTokens'; +import { useSwapBalances } from './useSwapBalances'; + +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, +}; + +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, +}; + +describe('useSwapLiteTokens', () => { + 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, + amount: '100', + amountUSD: '150', + response: props.response, + setAmount: vi.fn(), + setAmountUSD: vi.fn(), + setLoading: vi.fn(), + token: USDC_TOKEN, + })); + const { result } = renderHook(() => + useSwapLiteTokens(toToken, undefined, '0x123'), + ); + expect(result.current.fromETH).toEqual({ + amount: '100', + amountUSD: '150', + balance: '100', + balanceResponse: { refetch: expect.any(Function) }, + error: null, + loading: false, + setAmount: expect.any(Function), + setAmountUSD: expect.any(Function), + setLoading: expect.any(Function), + token: USDC_TOKEN, + }); + expect(result.current.to).toEqual({ + amount: '100', + amountUSD: '150', + balance: '200', + balanceResponse: { refetch: expect.any(Function) }, + error: null, + loading: false, + setAmount: expect.any(Function), + setAmountUSD: expect.any(Function), + setLoading: expect.any(Function), + token: USDC_TOKEN, + }); + }); + + 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(() => + useSwapLiteTokens(toToken, undefined, '0x123'), + ); + await act(async () => { + await result.current.fromETH.balanceResponse?.refetch(); + }); + expect(mockFromRefetch).toHaveBeenCalledTimes(1); + expect(mockToRefetch).not.toHaveBeenCalled(); + }); + + 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..81e7757ac5 --- /dev/null +++ b/src/swap/hooks/useSwapLiteTokens.ts @@ -0,0 +1,104 @@ +import { useState } from 'react'; +import type { Address } from 'viem'; +import { useValue } from '../../core-react/internal/hooks/useValue'; +import type { Token } from '../../token'; +import type { SwapLiteTokens } from '../types'; +import { useSwapBalances } from './useSwapBalances'; +import { ethToken, usdcToken } from '../constants'; + +export const useSwapLiteTokens = ( + toToken: Token, + fromToken?: Token, + address?: Address, +): SwapLiteTokens => { + const [toAmount, setToAmount] = useState(''); + const [toAmountUSD, setToAmountUSD] = useState(''); + const [toLoading, setToLoading] = useState(false); + + const [fromETHAmount, setFromETHAmount] = useState(''); + const [fromETHAmountUSD, setFromETHAmountUSD] = useState(''); + const [fromETHLoading, setFromETHLoading] = useState(false); + + const [fromUSDCAmount, setFromUSDCAmount] = useState(''); + const [fromUSDCAmountUSD, setFromUSDCAmountUSD] = useState(''); + const [fromUSDCLoading, setFromUSDCLoading] = useState(false); + + const [fromAmount, setFromAmount] = useState(''); + const [fromAmountUSD, setFromAmountUSD] = useState(''); + const [fromLoading, setFromLoading] = useState(false); + + const { + fromBalanceString: fromETHBalanceString, + fromTokenBalanceError: fromEthBalanceError, + toBalanceString, + toTokenBalanceError, + fromTokenResponse: fromETHResponse, + toTokenResponse, + } = useSwapBalances({ address, fromToken: ethToken, toToken }); + + const { + fromBalanceString: fromUSDCBalanceString, + fromTokenBalanceError: fromUSDCBalanceError, + fromTokenResponse: fromUSDCResponse, + } = useSwapBalances({ address, fromToken: usdcToken, toToken }); + + const { + fromBalanceString, + fromTokenBalanceError: fromBalanceError, + fromTokenResponse: fromResponse, + } = useSwapBalances({ address, fromToken, toToken }); + + const fromETH = useValue({ + balance: fromETHBalanceString, + balanceResponse: fromETHResponse, + amount: fromETHAmount, + setAmount: setFromETHAmount, + amountUSD: fromETHAmountUSD, + setAmountUSD: setFromETHAmountUSD, + token: ethToken, + loading: fromETHLoading, + setLoading: setFromETHLoading, + error: fromEthBalanceError, + }); + + const fromUSDC = useValue({ + balance: fromUSDCBalanceString, + balanceResponse: fromUSDCResponse, + amount: fromUSDCAmount, + setAmount: setFromUSDCAmount, + amountUSD: fromUSDCAmountUSD, + setAmountUSD: setFromUSDCAmountUSD, + token: usdcToken, + loading: fromUSDCLoading, + setLoading: setFromUSDCLoading, + error: fromUSDCBalanceError, + }); + + const from = useValue({ + balance: fromBalanceString, + balanceResponse: fromResponse, + amount: fromAmount, + setAmount: setFromAmount, + amountUSD: fromAmountUSD, + setAmountUSD: setFromAmountUSD, + token: fromToken, + loading: fromLoading, + setLoading: setFromLoading, + error: fromBalanceError, + }); + + const to = useValue({ + balance: toBalanceString, + balanceResponse: toTokenResponse, + amount: toAmount, + amountUSD: toAmountUSD, + setAmountUSD: setToAmountUSD, + setAmount: setToAmount, + token: toToken, + loading: toLoading, + setLoading: setToLoading, + error: toTokenBalanceError, + }); + + return { fromETH, from, fromUSDC, to }; +}; diff --git a/src/swap/types.ts b/src/swap/types.ts index 32fca9c145..44436dbcfc 100644 --- a/src/swap/types.ts +++ b/src/swap/types.ts @@ -54,14 +54,6 @@ export type FromTo = { to: SwapUnit; }; -export type FundSwapTokens = { - fromETH: FundSwapUnit; - fromUSDC: FundSwapUnit; - to: FundSwapUnit; -}; - -export type FundSwapUnit = Omit; - export type GetSwapMessageParams = { address?: Address; lifecycleStatus: LifecycleStatus; @@ -181,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 */ @@ -378,7 +377,7 @@ export type SwapUnit = { setAmount: Dispatch>; setAmountUSD: Dispatch>; setLoading: Dispatch>; - setToken: Dispatch>; + setToken?: Dispatch>; token: Token | undefined; };