Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Swap USD values #1286

Merged
merged 35 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/beige-hairs-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@coinbase/onchainkit': patch
---

feat: Add Swap USD values. By @cpcramer #1286
82 changes: 48 additions & 34 deletions src/swap/components/SwapAmountInput.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(),
Expand All @@ -54,6 +48,7 @@ const mockContextValue = {
},
to: {
amount: '20',
amountUSD: '2000',
setAmount: vi.fn(),
setLoading: vi.fn(),
setToken: vi.fn(),
Expand All @@ -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', () => {
Expand All @@ -76,32 +75,26 @@ describe('SwapAmountInput', () => {
it('should render the component with the correct label and token', () => {
useSwapContextMock.mockReturnValue(mockContextValue);
render(<SwapAmountInput label="From" token={ETH_TOKEN} type="from" />);
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(<SwapAmountInput label="From" token={ETH_TOKEN} type="from" />);
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(<SwapAmountInput label="From" token={ETH_TOKEN} type="to" />);
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(<SwapAmountInput label="From" token={ETH_TOKEN} type="from" />);
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', () => {
Expand Down Expand Up @@ -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(<SwapAmountInput label="From" token={ETH_TOKEN} type="from" />);
const input = screen.getByTestId('ockTextInput_Input');
Expand Down Expand Up @@ -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(
<SwapAmountInput
Expand All @@ -192,7 +185,7 @@ describe('SwapAmountInput', () => {
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();
});
});
Expand All @@ -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(
<SwapAmountInput
Expand Down Expand Up @@ -264,7 +254,7 @@ describe('SwapAmountInput', () => {
useSwapContextMock.mockReturnValue(mockContextValueWithLowBalance);
render(<SwapAmountInput label="From" token={ETH_TOKEN} type="from" />);
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', () => {
Expand All @@ -278,7 +268,7 @@ describe('SwapAmountInput', () => {
render(<SwapAmountInput label="To" token={USDC_TOKEN} type="to" />);
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 () => {
Expand All @@ -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(<SwapAmountInput label="From" token={ETH_TOKEN} type="from" />);
expect(screen.queryByText(/\$/)).toBeNull();
});
});
14 changes: 13 additions & 1 deletion src/swap/components/SwapAmountInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 (
<div
className={cn(
Expand Down Expand Up @@ -105,6 +112,11 @@ export function SwapAmountInput({
)}
</div>
<div className="mt-4 flex w-full justify-between">
<div className="flex items-center">
<span className={cn(text.label2, color.foregroundMuted)}>
{formatUSD(source.amountUSD)}
</span>
</div>
<span className={cn(text.label2, color.foregroundMuted)}>{''}</span>
<div className="flex items-center">
{source.balance && (
Expand Down
7 changes: 6 additions & 1 deletion src/swap/components/SwapProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down
6 changes: 6 additions & 0 deletions src/swap/hooks/useFromTo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,31 +30,37 @@ 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,
}));
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,
Expand Down
6 changes: 6 additions & 0 deletions src/swap/hooks/useFromTo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Token>();
const [toAmount, setToAmount] = useState('');
const [toAmountUSD, setToAmountUSD] = useState('');
const [toToken, setToToken] = useState<Token>();
const [toLoading, setToLoading] = useState(false);
const [fromLoading, setFromLoading] = useState(false);
Expand All @@ -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,
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/swap/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

Expand Down Expand Up @@ -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<SetStateAction<string>>;
setAmountUSD: Dispatch<SetStateAction<string>>;
setLoading: Dispatch<SetStateAction<boolean>>;
setToken: Dispatch<SetStateAction<Token | undefined>>;
token: Token | undefined;
Expand Down