diff --git a/.changeset/beige-hairs-explain.md b/.changeset/beige-hairs-explain.md new file mode 100644 index 0000000000..8941aaae48 --- /dev/null +++ b/.changeset/beige-hairs-explain.md @@ -0,0 +1,5 @@ +--- +'@coinbase/onchainkit': patch +--- + +feat: Add Swap USD values. By @cpcramer #1286 diff --git a/src/swap/components/SwapAmountInput.test.tsx b/src/swap/components/SwapAmountInput.test.tsx index b43d4649dc..2fe2d68d16 100644 --- a/src/swap/components/SwapAmountInput.test.tsx +++ b/src/swap/components/SwapAmountInput.test.tsx @@ -1,14 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import { - type Mock, - type MockedFunction, - beforeEach, - describe, - expect, - it, - vi, -} from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Token } from '../../token'; import { DAI_TOKEN, ETH_TOKEN, USDC_TOKEN } from '../mocks'; import type { SwapContextType } from '../types'; @@ -39,12 +30,15 @@ vi.mock('./SwapProvider', () => ({ useSwapContext: vi.fn(), })); -const useSwapContextMock = useSwapContext as Mock; +const useSwapContextMock = useSwapContext as unknown as ReturnType< + typeof vi.fn +>; const mockContextValue = { address: '0x123', from: { amount: '10', + amountUSD: '1000', balance: '0.0002851826238227', setAmount: vi.fn(), setLoading: vi.fn(), @@ -54,6 +48,7 @@ const mockContextValue = { }, to: { amount: '20', + amountUSD: '2000', setAmount: vi.fn(), setLoading: vi.fn(), setToken: vi.fn(), @@ -66,6 +61,10 @@ const mockContextValue = { handleAmountChange: vi.fn(), } as SwapContextType; +vi.mock('../../internal/utils/getRoundedAmount', () => ({ + getRoundedAmount: vi.fn((value) => value.slice(0, 10)), +})); + const mockSwappableTokens: Token[] = [ETH_TOKEN, USDC_TOKEN, DAI_TOKEN]; describe('SwapAmountInput', () => { @@ -76,32 +75,26 @@ describe('SwapAmountInput', () => { it('should render the component with the correct label and token', () => { useSwapContextMock.mockReturnValue(mockContextValue); render(); - expect(screen.getByText('From')).toBeInTheDocument(); + expect(screen.getByText('From')).toBeDefined(); }); it('should render from token input with max button and balance', () => { useSwapContextMock.mockReturnValue(mockContextValue); render(); - expect(screen.getByText('Balance: 0.00028518')).toBeInTheDocument(); - expect( - screen.getByTestId('ockSwapAmountInput_MaxButton'), - ).toBeInTheDocument(); + expect(screen.getByText('Balance: 0.00028518')).toBeDefined(); + expect(screen.getByTestId('ockSwapAmountInput_MaxButton')).toBeDefined(); }); it('should not render max button for to token input', () => { useSwapContextMock.mockReturnValue(mockContextValue); render(); - expect( - screen.queryByTestId('ockSwapAmountInput_MaxButton'), - ).not.toBeInTheDocument(); + expect(screen.queryByTestId('ockSwapAmountInput_MaxButton')).toBeNull(); }); it('should not render max button if wallet not connected', () => { useSwapContextMock.mockReturnValue({ ...mockContextValue, address: '' }); render(); - expect( - screen.queryByTestId('ockSwapAmountInput_MaxButton'), - ).not.toBeInTheDocument(); + expect(screen.queryByTestId('ockSwapAmountInput_MaxButton')).toBeNull(); }); it('should update input value with balance amount on max button click', () => { @@ -129,7 +122,7 @@ describe('SwapAmountInput', () => { expect(mockContextValue.from.setAmount).not.toHaveBeenCalled(); }); - it('shoukd display the correct amount when this type is "from"', () => { + it('should display the correct amount when this type is "from"', () => { useSwapContextMock.mockReturnValue(mockContextValue); render(); const input = screen.getByTestId('ockTextInput_Input'); @@ -179,7 +172,7 @@ describe('SwapAmountInput', () => { expect(mockContextValue.to.setToken).toHaveBeenCalledWith(ETH_TOKEN); }); - it('should call handleAmountChange when type is "from" and delayMs is 0', () => { + it('should call handleAmountChange when type is "from" and delayMs is 0', async () => { useSwapContextMock.mockReturnValue(mockContextValue); render( { const input = screen.getByTestId('ockTextInput_Input'); fireEvent.change(input, { target: { value: '15' } }); expect(mockContextValue.from.setAmount).toHaveBeenCalledWith('15'); - waitFor(() => { + await waitFor(() => { expect(mockContextValue.handleAmountChange).toHaveBeenCalled(); }); }); @@ -215,13 +208,10 @@ describe('SwapAmountInput', () => { />, ); const dropdown = screen.getByText(/TokenSelectDropdown/i); - expect(dropdown).toBeInTheDocument(); + expect(dropdown).toBeDefined(); }); it('should correctly select a token from the dropdown using mouse and keyboard', () => { - const useSwapContextMock = useSwapContext as MockedFunction< - typeof useSwapContext - >; useSwapContextMock.mockReturnValue(mockContextValue); render( { useSwapContextMock.mockReturnValue(mockContextValueWithLowBalance); render(); const input = screen.getByTestId('ockTextInput_Input'); - expect(input).toHaveClass('text-ock-error'); + expect(input.className).toContain('text-ock-error'); }); it('should render a TokenChip component if swappableTokens are not passed as prop', () => { @@ -278,7 +268,7 @@ describe('SwapAmountInput', () => { render(); const chips = screen.getAllByText('TokenChip'); expect(chips.length).toBeGreaterThan(0); - expect(chips[0]).toBeInTheDocument(); + expect(chips[0]).toBeDefined(); }); it('should apply the given className to the button', async () => { @@ -291,8 +281,32 @@ describe('SwapAmountInput', () => { className="custom-class" />, ); - expect(screen.getByTestId('ockSwapAmountInput_Container')).toHaveClass( - 'custom-class', - ); + expect( + screen.getByTestId('ockSwapAmountInput_Container').className, + ).toContain('custom-class'); + }); + + it('should not display anything when amountUSD is null', () => { + const mockContextValueWithNullUSD = { + ...mockContextValue, + from: { + ...mockContextValue.from, + amountUSD: null, + }, + }; + useSwapContextMock.mockReturnValue(mockContextValueWithNullUSD); + expect(screen.queryByText(/\$/)).toBeNull(); + }); + + it('should return null when amount is falsy', () => { + useSwapContextMock.mockReturnValue({ + ...mockContextValue, + from: { + ...mockContextValue.from, + amountUSD: '', + }, + }); + render(); + expect(screen.queryByText(/\$/)).toBeNull(); }); }); diff --git a/src/swap/components/SwapAmountInput.tsx b/src/swap/components/SwapAmountInput.tsx index 1cba4a713e..afe322373c 100644 --- a/src/swap/components/SwapAmountInput.tsx +++ b/src/swap/components/SwapAmountInput.tsx @@ -21,7 +21,6 @@ export function SwapAmountInput({ const source = useValue(type === 'from' ? from : to); const destination = useValue(type === 'from' ? to : from); - useEffect(() => { if (token) { source.setToken(token); @@ -64,6 +63,14 @@ export function SwapAmountInput({ const hasInsufficientBalance = type === 'from' && Number(source.balance) < Number(source.amount); + const formatUSD = (amount: string) => { + if (!amount) { + return null; + } + const roundedAmount = Number(getRoundedAmount(amount, 2)); + return `~$${roundedAmount.toFixed(2)}`; + }; + return (
+
+ + {formatUSD(source.amountUSD)} + +
{''}
{source.balance && ( diff --git a/src/swap/components/SwapProvider.tsx b/src/swap/components/SwapProvider.tsx index ec91f772c5..547d2ba1af 100644 --- a/src/swap/components/SwapProvider.tsx +++ b/src/swap/components/SwapProvider.tsx @@ -224,7 +224,10 @@ export function SwapProvider({ return; } if (amount === '' || amount === '.' || Number.parseFloat(amount) === 0) { - return destination.setAmount(''); + destination.setAmount(''); + destination.setAmountUSD(''); + source.setAmountUSD(''); + return; } // When toAmount changes we fetch quote for fromAmount @@ -272,7 +275,9 @@ export function SwapProvider({ response.toAmount, response.to.decimals, ); + destination.setAmountUSD(response.toAmountUSD); destination.setAmount(formattedAmount); + source.setAmountUSD(response.fromAmountUSD); updateLifecycleStatus({ statusName: 'amountChange', statusData: { diff --git a/src/swap/hooks/useFromTo.test.ts b/src/swap/hooks/useFromTo.test.ts index 12932d5437..929a707d7b 100644 --- a/src/swap/hooks/useFromTo.test.ts +++ b/src/swap/hooks/useFromTo.test.ts @@ -30,8 +30,10 @@ describe('useFromTo', () => { (useValue as Mock).mockImplementation((props) => ({ ...props, amount: '100', + amountUSD: '150', response: props.response, setAmount: vi.fn(), + setAmountUSD: vi.fn(), setLoading: vi.fn(), setToken: vi.fn(), token: USDC_TOKEN, @@ -39,22 +41,26 @@ describe('useFromTo', () => { const { result } = renderHook(() => useFromTo('0x123')); expect(result.current.from).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), setToken: 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), setToken: expect.any(Function), token: USDC_TOKEN, diff --git a/src/swap/hooks/useFromTo.ts b/src/swap/hooks/useFromTo.ts index 12db7f2a4f..3731035ae5 100644 --- a/src/swap/hooks/useFromTo.ts +++ b/src/swap/hooks/useFromTo.ts @@ -7,8 +7,10 @@ import { useSwapBalances } from './useSwapBalances'; export const useFromTo = (address?: Address): FromTo => { const [fromAmount, setFromAmount] = useState(''); + const [fromAmountUSD, setFromAmountUSD] = useState(''); const [fromToken, setFromToken] = useState(); const [toAmount, setToAmount] = useState(''); + const [toAmountUSD, setToAmountUSD] = useState(''); const [toToken, setToToken] = useState(); const [toLoading, setToLoading] = useState(false); const [fromLoading, setFromLoading] = useState(false); @@ -27,6 +29,8 @@ export const useFromTo = (address?: Address): FromTo => { balanceResponse: fromTokenResponse, amount: fromAmount, setAmount: setFromAmount, + amountUSD: fromAmountUSD, + setAmountUSD: setFromAmountUSD, token: fromToken, setToken: setFromToken, loading: fromLoading, @@ -38,6 +42,8 @@ export const useFromTo = (address?: Address): FromTo => { balance: toBalanceString, balanceResponse: toTokenResponse, amount: toAmount, + amountUSD: toAmountUSD, + setAmountUSD: setToAmountUSD, setAmount: setToAmount, token: toToken, setToken: setToToken, diff --git a/src/swap/types.ts b/src/swap/types.ts index b99811b0c7..a3406028dc 100644 --- a/src/swap/types.ts +++ b/src/swap/types.ts @@ -242,11 +242,13 @@ export type SwapQuote = { amountReference: string; // The reference amount for the quote from: Token; // The source token for the swap fromAmount: string; // The amount of the source token + fromAmountUSD: string; // The USD value of the source token hasHighPriceImpact: boolean; // Whether the price impact is high priceImpact: string; // The price impact of the swap slippage: string; // The slippage of the swap to: Token; // The destination token for the swap toAmount: string; // The amount of the destination token + toAmountUSD: string; // The USD value of the destination token warning?: QuoteWarning; // The warning associated with the quote }; @@ -338,11 +340,13 @@ export type SwapTransactionType = 'Batched' | 'ERC20' | 'Permit2' | 'Swap'; // C export type SwapUnit = { amount: string; + amountUSD: string; balance?: string; balanceResponse?: UseBalanceReturnType | UseReadContractReturnType; error?: SwapError; loading: boolean; setAmount: Dispatch>; + setAmountUSD: Dispatch>; setLoading: Dispatch>; setToken: Dispatch>; token: Token | undefined;