diff --git a/site/docs/pages/swap/swap-amount-input.mdx b/site/docs/pages/swap/swap-amount-input.mdx deleted file mode 100644 index d146f3e42f..0000000000 --- a/site/docs/pages/swap/swap-amount-input.mdx +++ /dev/null @@ -1,38 +0,0 @@ -{/* import { SwapAmountInput } from '../../../../src/swap'; */} -import App from '../App'; -import SwapAmountInputContainer from '../../components/SwapAmountInputContainer.tsx'; - -# `` - -The `SwapAmountInput` component is a stylized input field designed for users to specify the amount of a particular token they wish to swap. - -This component integrates the `TokenSelector` component to allow users to select different tokens. - -## Usage - -```tsx - -``` - -## Props - -[`SwapAmountInputReact`](/swap/types#SwapAmountInputReact) diff --git a/site/docs/pages/swap/swap.mdx b/site/docs/pages/swap/swap.mdx new file mode 100644 index 0000000000..61ba9c7ad5 --- /dev/null +++ b/site/docs/pages/swap/swap.mdx @@ -0,0 +1,22 @@ +{/* import { Swap, SwapAmountInput, SwapButton } from '../../../../src/swap'; */} +import App from '../App'; + +# `` + +### Alert! Component is actively in development. Stay tuned for upcoming releases. + +The `Swap` component is a comprehensive interface for users to execute token swaps. It includes two instances of the `SwapAmountInput` component, enabling users to specify the amount of tokens to sell and buy. Additionally, the component features a "Swap" button for initiating the transaction. + +## Usage + +```tsx [code] + + + + + +``` + +## Props + +[`SwapReact`](/swap/types#SwapReact) diff --git a/site/docs/pages/swap/types.mdx b/site/docs/pages/swap/types.mdx index c8fb8d341e..163997b6b7 100644 --- a/site/docs/pages/swap/types.mdx +++ b/site/docs/pages/swap/types.mdx @@ -9,13 +9,19 @@ description: Glossary of Types in Swap components & utilities. ```ts type SwapAmountInputReact = { - amount?: string; // Token amount - disabled?: boolean; // Whether the input is disabled label: string; // Descriptive label for the input field - setAmount: (amount: string) => void; // Callback function when the amount changes - setToken: () => void; // Callback function when the token selector is clicked - swappableTokens: Token[]; // Tokens available for swap token?: Token; // Selected token - tokenBalance?: string; // Amount of selected token user owns + type: 'to' | 'from'; // Identifies if component is for toToken or fromToken +}; +``` + +## `SwapReact` + +```ts +export type SwapReact = { + account: Account; // Ethereum account + children: ReactNode; // Children components to render + onError?: (error: SwapError) => void; // Callback when swap is unsuccessful + onSuccess?: (swapTransaction: BuildSwapTransaction) => void; // Callback when swap is successful }; ``` diff --git a/site/sidebar.ts b/site/sidebar.ts index 54f6b81e78..f4592a4dd9 100644 --- a/site/sidebar.ts +++ b/site/sidebar.ts @@ -142,8 +142,8 @@ export const sidebar = [ text: 'Components', items: [ { - text: 'SwapAmountInput', - link: '/swap/swap-amount-input', + text: 'Swap', + link: '/swap/swap', }, ], }, diff --git a/src/swap/components/Swap.tsx b/src/swap/components/Swap.tsx new file mode 100644 index 0000000000..7cc2e7c873 --- /dev/null +++ b/src/swap/components/Swap.tsx @@ -0,0 +1,107 @@ +import { useCallback, useMemo, useState } from 'react'; +import { cn } from '../../lib/utils'; +import { SwapContext } from '../context'; +import { getSwapQuote } from '../core/getSwapQuote'; +import { isSwapError } from '../utils'; +import type { SwapError, SwapReact } from '../types'; +import type { Token } from '../../token'; + +export function Swap({ account, children, onError }: SwapReact) { + const [fromAmount, setFromAmount] = useState(''); + const [fromToken, setFromToken] = useState(); + const [toAmount, setToAmount] = useState(''); + const [toToken, setToToken] = useState(); + + const handleToAmountChange = useCallback( + async (amount: string) => { + setToAmount(amount); + const hasRequiredFields = fromToken && toToken && amount; + if (!hasRequiredFields) { + return; + } + try { + const response = await getSwapQuote({ + from: fromToken, + to: toToken, + amount, + amountReference: 'to', + }); + if (isSwapError(response)) { + onError?.(response); + return; + } + setFromAmount(response?.fromAmount); + } catch (error) { + onError?.(error as SwapError); + } + }, + [fromToken, toToken, setFromAmount, setToAmount], + ); + + const handleFromAmountChange = useCallback( + async (amount: string) => { + setFromAmount(amount); + const hasRequiredFields = fromToken && toToken && amount; + if (!hasRequiredFields) { + return; + } + try { + const response = await getSwapQuote({ + from: fromToken, + to: toToken, + amount, + amountReference: 'from', + }); + if (isSwapError(response)) { + onError?.(response); + return; + } + setToAmount(response?.toAmount); + } catch (error) { + onError?.(error as SwapError); + } + }, + [fromToken, toToken, setFromAmount, setToAmount], + ); + + const value = useMemo(() => { + return { + account, + fromAmount, + fromToken, + setFromAmount: handleFromAmountChange, + setFromToken, + setToAmount: handleToAmountChange, + setToToken, + toAmount, + toToken, + }; + }, [ + account, + fromAmount, + fromToken, + handleFromAmountChange, + handleToAmountChange, + setFromToken, + setToAmount, + setToToken, + toAmount, + toToken, + ]); + + return ( + +
+ + {children} +
+
+ ); +} diff --git a/src/swap/components/SwapAmountInput.test.tsx b/src/swap/components/SwapAmountInput.test.tsx index 4932cc4a81..185593d324 100644 --- a/src/swap/components/SwapAmountInput.test.tsx +++ b/src/swap/components/SwapAmountInput.test.tsx @@ -2,144 +2,100 @@ * @jest-environment jsdom */ import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { fireEvent, render, screen, within } from '@testing-library/react'; -import type { Address } from 'viem'; import { SwapAmountInput } from './SwapAmountInput'; -import type { Token } from '../../token'; +import { SwapContext } from '../context'; +import { Token, TokenChip } from '../../token'; +import { SwapContextType } from '../types'; +import { Account, Address } from 'viem'; -const setAmountMock = jest.fn(); -const selectTokenClickMock = jest.fn(); +jest.mock('../../token', () => ({ + TokenChip: jest.fn(() =>
TokenChip
), +})); -const token = { - address: '0x123' as Address, - chainId: 1, - decimals: 2, - image: 'imageURL', - name: 'Ether', +const mockAccount = { + address: '0x5FbDB2315678afecb367f032d93F642f64180aa3' as Address, +} as Account; + +const mockContextValue = { + fromAmount: '10', + setFromAmount: jest.fn(), + setFromToken: jest.fn(), + setToAmount: jest.fn(), + setToToken: jest.fn(), + toAmount: '20', + account: mockAccount, +} as SwapContextType; + +const mockToken: Token = { + name: 'ETH', + address: '', symbol: 'ETH', + decimals: 18, + image: 'https://wallet-api-production.s3.amazonaws.com/uploads/tokens/eth_288.png', + chainId: 8453, }; -const swappableTokens: Token[] = [ - { - name: 'Ethereum', - address: '', - symbol: 'ETH', - decimals: 18, - image: 'https://wallet-api-production.s3.amazonaws.com/uploads/tokens/eth_288.png', - chainId: 8453, - }, - { - name: 'USDC', - address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', - symbol: 'USDC', - decimals: 6, - image: - 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/44/2b/442b80bd16af0c0d9b22e03a16753823fe826e5bfd457292b55fa0ba8c1ba213-ZWUzYjJmZGUtMDYxNy00NDcyLTg0NjQtMWI4OGEwYjBiODE2', - chainId: 8453, - }, - { - name: 'Dai', - address: '0x50c5725949a6f0c72e6c4a641f24049a917db0cb', - symbol: 'DAI', - decimals: 18, - image: - 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/d0/d7/d0d7784975771dbbac9a22c8c0c12928cc6f658cbcf2bbbf7c909f0fa2426dec-NmU4ZWViMDItOTQyYy00Yjk5LTkzODUtNGJlZmJiMTUxOTgy', - chainId: 8453, - }, -]; - -describe('SwapAmountInput Component', () => { - it('should render', async () => { +describe('SwapAmountInput', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the component with the correct label and token', () => { render( - , + + + , ); - const amountInput = screen.getByTestId('ockSwapAmountInput_Container'); - expect(amountInput).toBeInTheDocument(); - - const labelElement = within(amountInput).getByText('Sell'); - expect(labelElement).toBeInTheDocument(); - - const balanceElement = within(amountInput).getByText('Balance: 100'); - expect(balanceElement).toBeInTheDocument(); + expect(screen.getByText('From')).toBeInTheDocument(); }); - it('should update the amount if a user enters a number', async () => { + it('displays the correct amount based on the type', () => { render( - , + + + , ); - const input = screen.getByTestId('ockSwapAmountInput_Input') as HTMLInputElement; - fireEvent.change(input, { target: { value: '2' } }); - expect(setAmountMock).toHaveBeenCalledWith('2'); - expect(input.value).toBe('2'); + const input = screen.getByTestId('ockSwapAmountInput_Input'); + expect(input).toHaveValue('10'); }); - it('should not update the amount if a user enters a non-number', async () => { + it('calls setAmount when valid input is entered', () => { render( - , + + + , ); - const input = screen.getByTestId('ockSwapAmountInput_Input') as HTMLInputElement; - fireEvent.change(input, { target: { value: 'a' } }); - expect(setAmountMock).not.toHaveBeenCalledWith('a'); - expect(input.value).toBe('1'); + const input = screen.getByTestId('ockSwapAmountInput_Input'); + fireEvent.change(input, { target: { value: '15' } }); + + expect(mockContextValue.setFromAmount).toHaveBeenCalledWith('15'); }); - it('should call setAmount with tokenBalance when the max button is clicked', async () => { + it('does not call setAmount when invalid input is entered', () => { render( - , + + + , ); - const maxButton = screen.getByTestId('ockSwapAmountInput_MaxButton'); - fireEvent.click(maxButton); - expect(setAmountMock).toHaveBeenCalledWith('100'); + const input = screen.getByTestId('ockSwapAmountInput_Input'); + fireEvent.change(input, { target: { value: 'invalid' } }); + + expect(mockContextValue.setFromAmount).not.toHaveBeenCalled(); }); - it('should disable the input when disabled prop is true', async () => { + it('calls setToken when token prop is provided', () => { render( - , + + + , ); - const input = screen.getByTestId('ockSwapAmountInput_Input') as HTMLInputElement; - expect(input).toBeDisabled(); + expect(mockContextValue.setFromToken).toHaveBeenCalledWith(mockToken); }); }); diff --git a/src/swap/components/SwapAmountInput.tsx b/src/swap/components/SwapAmountInput.tsx index 2356630455..3ff529f6e9 100644 --- a/src/swap/components/SwapAmountInput.tsx +++ b/src/swap/components/SwapAmountInput.tsx @@ -1,59 +1,71 @@ -import { useCallback } from 'react'; +import { useCallback, useContext, useEffect, useMemo } from 'react'; import { isValidAmount } from '../utils'; +import { TokenChip } from '../../token'; +import { cn } from '../../lib/utils'; +import { SwapContext } from '../context'; import type { SwapAmountInputReact } from '../types'; -export function SwapAmountInput({ - amount, - disabled = false, - label, - setAmount, - tokenBalance, -}: SwapAmountInputReact) { +export function SwapAmountInput({ label, token, type }: SwapAmountInputReact) { + const { fromAmount, setFromAmount, setFromToken, setToAmount, setToToken, toAmount } = + useContext(SwapContext); + + /* istanbul ignore next */ + const amount = useMemo(() => { + if (type === 'to') { + return toAmount; + } + return fromAmount; + }, [type, toAmount, fromAmount]); + + /* istanbul ignore next */ + const setAmount = useMemo(() => { + if (type === 'to') { + return setToAmount; + } + return setFromAmount; + }, [type, setToAmount, setFromAmount]); + + /* istanbul ignore next */ + const setToken = useMemo(() => { + if (type === 'to') { + return setToToken; + } + return setFromToken; + }, [type, setFromToken, setToToken]); + const handleAmountChange = useCallback( (event: React.ChangeEvent) => { if (isValidAmount(event.target.value)) { - setAmount(event.target.value); + setAmount?.(event.target.value); } }, [setAmount], ); - const handleMaxButtonClick = useCallback(() => { - if (tokenBalance && isValidAmount(tokenBalance)) { - setAmount(tokenBalance); + useEffect(() => { + if (token) { + setToken(token); } - }, [tokenBalance, setAmount]); + }, [token, setToken]); return (
- {tokenBalance && ( - - )}
- {/* TODO: add back in when TokenSelector is complete */} - {/* - - */} - +
{ + if (account && fromToken && toToken && fromAmount) { + try { + const response = await buildSwapTransaction({ + amount: fromAmount, + fromAddress: account.address, + from: fromToken, + to: toToken, + }); + if (isSwapError(response)) { + onError?.(response); + } else { + onSubmit?.(response); + } + } catch (error) { + onError?.(error as SwapError); + } + } + }, [account, fromAmount, fromToken, toToken]); + + return ( +
+ +
+ ); +} diff --git a/src/swap/context.ts b/src/swap/context.ts new file mode 100644 index 0000000000..2046d85f6b --- /dev/null +++ b/src/swap/context.ts @@ -0,0 +1,4 @@ +import { createContext } from 'react'; +import { SwapContextType } from './types'; + +export const SwapContext = createContext({} as SwapContextType); diff --git a/src/swap/index.ts b/src/swap/index.ts index e2d3f2f744..d56f333e70 100644 --- a/src/swap/index.ts +++ b/src/swap/index.ts @@ -1,6 +1,5 @@ // 🌲☀️🌲 export { getSwapQuote } from './core/getSwapQuote'; -export { buildSwapTransaction } from './core/buildSwapTransaction'; export type { BuildSwapTransactionParams, BuildSwapTransactionResponse, diff --git a/src/swap/types.ts b/src/swap/types.ts index 732dc54f5d..b60ab3f295 100644 --- a/src/swap/types.ts +++ b/src/swap/types.ts @@ -1,5 +1,6 @@ -import type { Address, Hex } from 'viem'; +import type { Account, Address, Hex } from 'viem'; import type { Token } from '../token/types'; +import { ReactNode } from 'react'; export type AddressOrETH = Address | 'ETH'; @@ -85,6 +86,15 @@ export type RawTransactionData = { value: string; // The value of the transaction }; +/** + * Note: exported as public Type + */ +export type SwapAmountInputReact = { + label: string; // Descriptive label for the input field + token: Token; // Selected token + type: 'to' | 'from'; +}; + export type SwapAPIParams = GetQuoteAPIParams | GetSwapAPIParams; export type SwapAPIResponse = { @@ -95,18 +105,34 @@ export type SwapAPIResponse = { tx: RawTransactionData; // The trade transaction }; -/** - * Note: exported as public Type - */ -export type SwapAmountInputReact = { - amount?: string; // Token amount - disabled?: boolean; // Whether the input is disabled - label: string; // Descriptive label for the input field - setAmount: (amount: string) => void; // Callback function when the amount changes - setToken: () => void; // Callback function when the token selector is clicked - swappableTokens: Token[]; // Tokens available for swap - token?: Token; // Selected token - tokenBalance?: string; // Amount of selected token user owns +export type SwapButtonReact = { + onError?: (error: SwapError) => void; + onSubmit?: (swapTransaction: BuildSwapTransaction) => void; +}; + +export type SwapContextType = { + account: Account; + fromAmount: string; + fromToken?: Token; + setFromAmount: (a: string) => void; + setFromToken: (t: Token) => void; + setToAmount: (a: string) => void; + setToToken: (t: Token) => void; + toAmount: string; + toToken?: Token; +}; + +export type SwapParams = { + amount: string; + fromAddress: Address; + from: Token; + to: Token; +}; + +export type SwapReact = { + account: Account; + children: ReactNode; + onError?: (error: SwapError) => void; }; /** diff --git a/src/swap/utils.test.ts b/src/swap/utils.test.ts index 5982a45094..bf81027428 100644 --- a/src/swap/utils.test.ts +++ b/src/swap/utils.test.ts @@ -1,4 +1,4 @@ -import { isValidAmount } from './utils'; // Adjust the import path as needed +import { isValidAmount, isSwapError } from './utils'; // Adjust the import path as needed describe('isValidAmount', () => { it('should return true for an empty string', () => { @@ -37,3 +37,35 @@ describe('isValidAmount', () => { expect(isValidAmount('123 45')).toBe(false); }); }); + +describe('isSwapError function', () => { + it('returns true for a valid SwapError object', () => { + const response = { + error: 'Swap failed', + details: 'Insufficient balance', + }; + + expect(isSwapError(response)).toBe(true); + }); + + it('returns false for null or non-object inputs', () => { + expect(isSwapError(null)).toBe(false); + expect(isSwapError(undefined)).toBe(false); + expect(isSwapError('error')).toBe(false); + expect(isSwapError(123)).toBe(false); + }); + + it('returns false for objects without the "error" property', () => { + const response = { + message: 'An error occurred', + }; + + expect(isSwapError(response)).toBe(false); + }); + + it('returns false for empty objects', () => { + const response = {}; + + expect(isSwapError(response)).toBe(false); + }); +}); diff --git a/src/swap/utils.ts b/src/swap/utils.ts index 6275805179..9781492140 100644 --- a/src/swap/utils.ts +++ b/src/swap/utils.ts @@ -1,3 +1,5 @@ +import type { SwapError } from './types'; + // checks that input is a number export function isValidAmount(value: string) { if (value.length > 11) { @@ -9,3 +11,7 @@ export function isValidAmount(value: string) { const regex = /^[0-9]*\.?[0-9]*$/; return regex.test(value); } + +export function isSwapError(response: unknown): response is SwapError { + return response !== null && typeof response === 'object' && 'error' in response; +}