diff --git a/playground/nextjs-app-router/components/demo/Swap.tsx b/playground/nextjs-app-router/components/demo/Swap.tsx index e3b0b69d5c..ff9df06297 100644 --- a/playground/nextjs-app-router/components/demo/Swap.tsx +++ b/playground/nextjs-app-router/components/demo/Swap.tsx @@ -11,8 +11,10 @@ import { SwapSettingsSlippageTitle, SwapToggleButton, } from '@coinbase/onchainkit/swap'; +import type { SwapError } from '@coinbase/onchainkit/swap'; import type { Token } from '@coinbase/onchainkit/token'; import { useCallback, useContext } from 'react'; +import type { TransactionReceipt } from 'viem'; import { base } from 'viem/chains'; import { AppContext } from '../AppProvider'; @@ -65,6 +67,17 @@ function SwapComponent() { console.log('Status:', lifeCycleStatus); }, []); + const handleOnSuccess = useCallback( + (transactionReceipt: TransactionReceipt) => { + console.log('Success:', transactionReceipt); + }, + [], + ); + + const handleOnError = useCallback((swapError: SwapError) => { + console.log('Error:', swapError); + }, []); + return (
{chainId !== base.id ? ( @@ -87,7 +100,12 @@ function SwapComponent() {
) : null} - + Max. slippage diff --git a/src/swap/components/SwapButton.test.tsx b/src/swap/components/SwapButton.test.tsx index f9e1e4fa79..89351398d0 100644 --- a/src/swap/components/SwapButton.test.tsx +++ b/src/swap/components/SwapButton.test.tsx @@ -31,7 +31,7 @@ describe('SwapButton', () => { address: '0x123', to: { loading: false, amount: 1, token: 'ETH' }, from: { loading: false, amount: 1, token: 'BTC' }, - loading: false, + lifeCycleStatus: { statusName: 'init' }, handleSubmit: mockHandleSubmit, }); render(); @@ -44,7 +44,33 @@ describe('SwapButton', () => { useSwapContextMock.mockReturnValue({ to: { loading: true, amount: 1, token: 'ETH' }, from: { loading: false, amount: 1, token: 'BTC' }, - loading: false, + lifeCycleStatus: { statusName: 'init' }, + handleSubmit: mockHandleSubmit, + }); + render(); + const button = screen.getByTestId('ockSwapButton_Button'); + expect(screen.getByTestId('spinner')).toBeInTheDocument(); + expect(button).toBeDisabled(); + }); + + it('should render Spinner when transaction is pending', () => { + useSwapContextMock.mockReturnValue({ + to: { loading: false, amount: 1, token: 'ETH' }, + from: { loading: false, amount: 1, token: 'BTC' }, + lifeCycleStatus: { statusName: 'transactionPending' }, + handleSubmit: mockHandleSubmit, + }); + render(); + const button = screen.getByTestId('ockSwapButton_Button'); + expect(screen.getByTestId('spinner')).toBeInTheDocument(); + expect(button).toBeDisabled(); + }); + + it('should render Spinner when transaction is approved', () => { + useSwapContextMock.mockReturnValue({ + to: { loading: false, amount: 1, token: 'ETH' }, + from: { loading: false, amount: 1, token: 'BTC' }, + lifeCycleStatus: { statusName: 'transactionApproved' }, handleSubmit: mockHandleSubmit, }); render(); @@ -57,7 +83,7 @@ describe('SwapButton', () => { useSwapContextMock.mockReturnValue({ to: { loading: false, amount: 1, token: 'ETH' }, from: { loading: false, amount: null, token: 'BTC' }, - loading: false, + lifeCycleStatus: { statusName: 'init' }, handleSubmit: mockHandleSubmit, }); render(); @@ -70,7 +96,7 @@ describe('SwapButton', () => { address: '0x123', to: { loading: false, amount: 1, token: 'ETH' }, from: { loading: false, amount: 1, token: 'BTC' }, - loading: false, + lifeCycleStatus: { statusName: 'init' }, handleSubmit: mockHandleSubmit, }); render(); @@ -84,7 +110,7 @@ describe('SwapButton', () => { address: '0x123', to: { loading: false, amount: 1, token: 'ETH' }, from: { loading: false, amount: 1, token: 'BTC' }, - loading: false, + lifeCycleStatus: { statusName: 'init' }, handleSubmit: mockHandleSubmit, }); const customClass = 'custom-class'; @@ -97,7 +123,7 @@ describe('SwapButton', () => { useSwapContextMock.mockReturnValue({ to: { loading: false, amount: 1, token: 'ETH' }, from: { loading: false, amount: 1, token: 'BTC' }, - loading: false, + lifeCycleStatus: { statusName: 'init' }, handleSubmit: mockHandleSubmit, }); vi.mocked(useAccount).mockReturnValue({ diff --git a/src/swap/components/SwapButton.tsx b/src/swap/components/SwapButton.tsx index 68dbb7d123..71405f424c 100644 --- a/src/swap/components/SwapButton.tsx +++ b/src/swap/components/SwapButton.tsx @@ -5,11 +5,19 @@ import type { SwapButtonReact } from '../types'; import { useSwapContext } from './SwapProvider'; export function SwapButton({ className, disabled = false }: SwapButtonReact) { - const { address, to, from, loading, isTransactionPending, handleSubmit } = - useSwapContext(); + const { + address, + to, + from, + lifeCycleStatus: { statusName }, + handleSubmit, + } = useSwapContext(); const isLoading = - to.loading || from.loading || loading || isTransactionPending; + to.loading || + from.loading || + statusName === 'transactionPending' || + statusName === 'transactionApproved'; const isDisabled = !from.amount || diff --git a/src/swap/components/SwapMessage.test.tsx b/src/swap/components/SwapMessage.test.tsx index 122f95f4de..49e458122c 100644 --- a/src/swap/components/SwapMessage.test.tsx +++ b/src/swap/components/SwapMessage.test.tsx @@ -27,9 +27,7 @@ describe('SwapMessage', () => { const mockContext = { to: {}, from: {}, - error: null, - loading: false, - lifeCycleStatus: { statusData: null }, + lifeCycleStatus: { statusName: 'init', statusData: null }, }; useSwapContextMock.mockReturnValue(mockContext); mockGetSwapMessage.mockReturnValue(mockMessage); @@ -44,9 +42,10 @@ describe('SwapMessage', () => { const mockContext = { to: {}, from: {}, - error: 'Error occurred', - loading: false, - lifeCycleStatus: { statusData: null }, + lifeCycleStatus: { + statusName: 'error', + statusData: { message: 'Error occurred' }, + }, }; useSwapContextMock.mockReturnValue(mockContext); mockGetSwapMessage.mockReturnValue(mockMessage); @@ -55,14 +54,26 @@ describe('SwapMessage', () => { expect(messageDiv).toHaveTextContent(mockMessage); }); - it('should render with loading message', () => { + it('should render with loading message in transactionPending status', () => { const mockMessage = 'Loading...'; const mockContext = { to: {}, from: {}, - error: null, - loading: true, - lifeCycleStatus: { statusData: null }, + lifeCycleStatus: { statusName: 'transactionPending', statusData: null }, + }; + useSwapContextMock.mockReturnValue(mockContext); + mockGetSwapMessage.mockReturnValue(mockMessage); + render(); + const messageDiv = screen.getByTestId('ockSwapMessage_Message'); + expect(messageDiv).toHaveTextContent(mockMessage); + }); + + it('should render with loading message in transactionApproved status', () => { + const mockMessage = 'Loading...'; + const mockContext = { + to: {}, + from: {}, + lifeCycleStatus: { statusName: 'transactionApproved', statusData: null }, }; useSwapContextMock.mockReturnValue(mockContext); mockGetSwapMessage.mockReturnValue(mockMessage); @@ -75,9 +86,7 @@ describe('SwapMessage', () => { const mockContext = { to: {}, from: {}, - error: null, - loading: false, - lifeCycleStatus: { statusData: null }, + lifeCycleStatus: { statusName: 'init', statusData: null }, }; useSwapContextMock.mockReturnValue(mockContext); @@ -89,24 +98,22 @@ describe('SwapMessage', () => { }); it('should set isMissingRequiredFields to true when reflected in statusData', () => { + const mockLifeCycleStatus = { + statusName: 'init', + statusData: { isMissingRequiredField: true }, + }; const mockContext = { to: { amount: 1, token: 'ETH' }, from: { amount: null, token: 'DAI' }, - error: null, - loading: false, - isTransactionPending: false, address: '0x123', - lifeCycleStatus: { statusData: { isMissingRequiredField: true } }, + lifeCycleStatus: mockLifeCycleStatus, }; useSwapContextMock.mockReturnValue(mockContext); render(); expect(mockGetSwapMessage).toHaveBeenCalledWith({ address: '0x123', - error: null, from: { amount: null, token: 'DAI' }, - loading: false, - isMissingRequiredFields: true, - isTransactionPending: false, + lifeCycleStatus: mockLifeCycleStatus, to: { amount: 1, token: 'ETH' }, }); }); diff --git a/src/swap/components/SwapMessage.tsx b/src/swap/components/SwapMessage.tsx index c6ca3ac4a5..da5185077e 100644 --- a/src/swap/components/SwapMessage.tsx +++ b/src/swap/components/SwapMessage.tsx @@ -4,28 +4,12 @@ import { getSwapMessage } from '../utils/getSwapMessage'; import { useSwapContext } from './SwapProvider'; export function SwapMessage({ className }: SwapMessageReact) { - const { - address, - to, - from, - error, - loading, - isTransactionPending, - lifeCycleStatus: { statusData }, - } = useSwapContext(); - - const isMissingRequiredFields = - !!statusData && - 'isMissingRequiredField' in statusData && - statusData?.isMissingRequiredField; + const { address, to, from, lifeCycleStatus } = useSwapContext(); const message = getSwapMessage({ address, - error, from, - loading, - isMissingRequiredFields, - isTransactionPending, + lifeCycleStatus, to, }); diff --git a/src/swap/components/SwapProvider.test.tsx b/src/swap/components/SwapProvider.test.tsx index 8cc2e6590c..95792343af 100644 --- a/src/swap/components/SwapProvider.test.tsx +++ b/src/swap/components/SwapProvider.test.tsx @@ -113,7 +113,7 @@ const TestSwapComponent = () => { context.to.setToken(DEGEN_TOKEN); }, [context]); const handleStatusError = async () => { - context.setLifeCycleStatus({ + context.updateLifeCycleStatus({ statusName: 'error', statusData: { code: 'code', @@ -126,7 +126,7 @@ const TestSwapComponent = () => { }); }; const handleStatusAmountChange = async () => { - context.setLifeCycleStatus({ + context.updateLifeCycleStatus({ statusName: 'amountChange', statusData: { amountFrom: '', @@ -138,7 +138,7 @@ const TestSwapComponent = () => { }); }; const handleStatusTransactionPending = async () => { - context.setLifeCycleStatus({ + context.updateLifeCycleStatus({ statusName: 'transactionPending', statusData: { // LifecycleStatus shared data @@ -148,7 +148,7 @@ const TestSwapComponent = () => { }); }; const handleStatusTransactionApproved = async () => { - context.setLifeCycleStatus({ + context.updateLifeCycleStatus({ statusName: 'transactionApproved', statusData: { transactionHash: '0x123', @@ -160,10 +160,10 @@ const TestSwapComponent = () => { }); }; const handleStatusSuccess = async () => { - context.setLifeCycleStatus({ + context.updateLifeCycleStatus({ statusName: 'success', statusData: { - receipt: ['0x123'], + transactionReceipt: { transactionHash: '0x123' }, // LifecycleStatus shared data isMissingRequiredField: false, maxSlippage: 3, @@ -258,48 +258,13 @@ describe('SwapProvider', () => { }); }); - it('should call setError when setLifeCycleStatus is called with error', async () => { - const { result } = renderHook(() => useSwapContext(), { wrapper }); - const errorStatusData = { - code: 'code', - error: 'error_long_messages', - message: 'test', - // LifecycleStatus shared data - isMissingRequiredField: false, - maxSlippage: 3, - }; - await act(async () => { - result.current.setLifeCycleStatus({ - statusName: 'error', - statusData: errorStatusData, - }); - }); - expect(result.current.error).toBe(errorStatusData); - }); - - it('should call setError with undefined when setLifeCycleStatus is called with success', async () => { - const { result } = renderHook(() => useSwapContext(), { wrapper }); - await act(async () => { - result.current.setLifeCycleStatus({ - statusName: 'success', - statusData: { - receipt: ['0x123'], - // LifecycleStatus shared data - isMissingRequiredField: false, - maxSlippage: 5, - }, - }); - }); - expect(result.current.error).toBeUndefined(); - }); - it('should reset inputs when setLifeCycleStatus is called with success', async () => { const { result } = renderHook(() => useSwapContext(), { wrapper }); await act(async () => { - result.current.setLifeCycleStatus({ + result.current.updateLifeCycleStatus({ statusName: 'success', statusData: { - transactionReceipt: '0x123', + transactionReceipt: { transactionHash: '0x123' }, // LifecycleStatus shared data isMissingRequiredField: false, maxSlippage: 5, @@ -456,10 +421,10 @@ describe('SwapProvider', () => { expect(onStatusMock).toHaveBeenCalledWith( expect.objectContaining({ statusName: 'init', - statusData: expect.objectContaining({ - isMissingRequiredField: false, - maxSlippage: 3, - }), + statusData: { + isMissingRequiredField: true, + maxSlippage: 10, + }, }), ); }); @@ -594,15 +559,6 @@ describe('SwapProvider', () => { expect(result.current.to.amount).toBe('10'); }); - it('should handle submit with missing data', async () => { - const { result } = renderHook(() => useSwapContext(), { wrapper }); - await act(async () => { - result.current.handleSubmit(); - }); - expect(result.current.error).toBeUndefined(); - expect(result.current.loading).toBe(false); - }); - it('should update amount and trigger quote', async () => { const { result } = renderHook(() => useSwapContext(), { wrapper }); await act(async () => { @@ -647,14 +603,11 @@ describe('SwapProvider', () => { }); expect(result.current.lifeCycleStatus).toEqual({ statusName: 'error', - statusData: { + statusData: expect.objectContaining({ code: 'TmSPc01', error: JSON.stringify(mockError), message: '', - // LifecycleStatus shared data - isMissingRequiredField: true, - maxSlippage: 5, - }, + }), }); }); @@ -670,14 +623,11 @@ describe('SwapProvider', () => { }); expect(result.current.lifeCycleStatus).toEqual({ statusName: 'error', - statusData: { + statusData: expect.objectContaining({ code: 'UNCAUGHT_SWAP_QUOTE_ERROR', error: 'Something went wrong', message: '', - // LifecycleStatus shared data - isMissingRequiredField: true, - maxSlippage: 5, - }, + }), }); }); @@ -730,9 +680,6 @@ describe('SwapProvider', () => { code: getSwapErrorCode('uncaught-swap'), error: 'Something went wrong', message: '', - // LifecycleStatus shared data - isMissingRequiredField: false, - maxSlippage: 3, }); renderWithProviders({ Component: TestSwapComponent }); fireEvent.click(screen.getByText('Swap')); diff --git a/src/swap/components/SwapProvider.tsx b/src/swap/components/SwapProvider.tsx index e9f9a7d980..fce639dd7d 100644 --- a/src/swap/components/SwapProvider.tsx +++ b/src/swap/components/SwapProvider.tsx @@ -18,8 +18,8 @@ import { useFromTo } from '../hooks/useFromTo'; import { useResetInputs } from '../hooks/useResetInputs'; import type { LifeCycleStatus, + LifeCycleStatusUpdate, SwapContextType, - SwapError, SwapProviderReact, } from '../types'; import { isSwapError } from '../utils/isSwapError'; @@ -52,9 +52,6 @@ export function SwapProvider({ const { useAggregator } = experimental; // Core Hooks const accountConfig = useConfig(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(); - const [isTransactionPending, setPendingTransaction] = useState(false); const [lifeCycleStatus, setLifeCycleStatus] = useState({ statusName: 'init', statusData: { @@ -62,6 +59,30 @@ export function SwapProvider({ maxSlippage: config.maxSlippage, }, }); // Component lifecycle + + // Update lifecycle status, statusData will be persisted for the full lifeCycle + const updateLifeCycleStatus = useCallback( + (newStatus: LifeCycleStatusUpdate) => { + setLifeCycleStatus((prevStatus: LifeCycleStatus) => { + // do not persist errors + const persistedStatusData = + prevStatus.statusName === 'error' + ? (({ error, code, message, ...statusData }) => statusData)( + prevStatus.statusData, + ) + : prevStatus.statusData; + return { + statusName: newStatus.statusName, + statusData: { + ...persistedStatusData, + ...newStatus.statusData, + }, + } as LifeCycleStatus; + }); + }, + [], + ); + const [hasHandledSuccess, setHasHandledSuccess] = useState(false); const { from, to } = useFromTo(address); const { sendTransactionAsync } = useSendTransaction(); // Sending the transaction (and approval, if applicable) @@ -73,26 +94,10 @@ export function SwapProvider({ useEffect(() => { // Error if (lifeCycleStatus.statusName === 'error') { - setLoading(false); - setPendingTransaction(false); - setError(lifeCycleStatus.statusData); onError?.(lifeCycleStatus.statusData); } - if (lifeCycleStatus.statusName === 'amountChange') { - setError(undefined); - } - if (lifeCycleStatus.statusName === 'transactionPending') { - setLoading(true); - setPendingTransaction(true); - } - if (lifeCycleStatus.statusName === 'transactionApproved') { - setPendingTransaction(false); - } // Success if (lifeCycleStatus.statusName === 'success') { - setError(undefined); - setLoading(false); - setPendingTransaction(false); onSuccess?.(lifeCycleStatus.statusData.transactionReceipt); setHasHandledSuccess(true); } @@ -118,22 +123,21 @@ export function SwapProvider({ }, [hasHandledSuccess, lifeCycleStatus.statusName, resetInputs]); useEffect(() => { - const maxSlippage = lifeCycleStatus.statusData.maxSlippage; // Reset status to init after success has been handled if (lifeCycleStatus.statusName === 'success' && hasHandledSuccess) { - setLifeCycleStatus({ + updateLifeCycleStatus({ statusName: 'init', statusData: { - isMissingRequiredField: - lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage, + isMissingRequiredField: true, + maxSlippage: config.maxSlippage, }, }); } }, [ + config.maxSlippage, hasHandledSuccess, - lifeCycleStatus.statusData, lifeCycleStatus.statusName, + updateLifeCycleStatus, ]); const handleToggle = useCallback(() => { @@ -141,7 +145,20 @@ export function SwapProvider({ to.setAmount(from.amount); from.setToken(to.token); to.setToken(from.token); - }, [from, to]); + + updateLifeCycleStatus({ + statusName: 'amountChange', + statusData: { + amountFrom: from.amount, + amountTo: to.amount, + tokenFrom: from.token, + tokenTo: to.token, + // token is missing + isMissingRequiredField: + !from.token || !to.token || !from.amount || !to.amount, + }, + }); + }, [from, to, updateLifeCycleStatus]); const handleAmountChange = useCallback( async ( @@ -151,7 +168,6 @@ export function SwapProvider({ dToken?: Token, // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: TODO Refactor this component ) => { - const maxSlippage = lifeCycleStatus.statusData.maxSlippage; const source = type === 'from' ? from : to; const destination = type === 'from' ? to : from; @@ -160,12 +176,11 @@ export function SwapProvider({ // if token is missing alert user via isMissingRequiredField if (source.token === undefined || destination.token === undefined) { - setLifeCycleStatus({ + updateLifeCycleStatus({ statusName: 'amountChange', statusData: { amountFrom: from.amount, amountTo: to.amount, - maxSlippage, tokenFrom: from.token, tokenTo: to.token, // token is missing @@ -181,23 +196,23 @@ export function SwapProvider({ // When toAmount changes we fetch quote for fromAmount // so set isFromQuoteLoading to true destination.setLoading(true); - setLifeCycleStatus({ + updateLifeCycleStatus({ statusName: 'amountChange', statusData: { // when fetching quote, the previous // amount is irrelevant amountFrom: type === 'from' ? amount : '', amountTo: type === 'to' ? amount : '', + tokenFrom: from.token, + tokenTo: to.token, // when fetching quote, the destination // amount is missing isMissingRequiredField: true, - maxSlippage, - tokenFrom: from.token, - tokenTo: to.token, }, }); try { + const maxSlippage = lifeCycleStatus.statusData.maxSlippage; const response = await getSwapQuote({ amount, amountReference: 'from', @@ -209,16 +224,12 @@ export function SwapProvider({ // If request resolves to error response set the quoteError // property of error state to the SwapError response if (isSwapError(response)) { - setLifeCycleStatus({ + updateLifeCycleStatus({ statusName: 'error', statusData: { code: response.code, error: response.error, message: '', - // LifecycleStatus shared data - isMissingRequiredField: - lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage, }, }); return; @@ -228,30 +239,25 @@ export function SwapProvider({ response.to.decimals, ); destination.setAmount(formattedAmount); - setLifeCycleStatus({ + updateLifeCycleStatus({ statusName: 'amountChange', statusData: { amountFrom: type === 'from' ? amount : formattedAmount, amountTo: type === 'to' ? amount : formattedAmount, + tokenFrom: from.token, + tokenTo: to.token, // if quote was fetched successfully, we // have all required fields isMissingRequiredField: !formattedAmount, - maxSlippage, - tokenFrom: from.token, - tokenTo: to.token, }, }); } catch (err) { - setLifeCycleStatus({ + updateLifeCycleStatus({ statusName: 'error', statusData: { code: 'TmSPc01', // Transaction module SwapProvider component 01 error error: JSON.stringify(err), message: '', - // LifecycleStatus shared data - isMissingRequiredField: - lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage, }, }); } finally { @@ -259,23 +265,16 @@ export function SwapProvider({ destination.setLoading(false); } }, - [from, lifeCycleStatus, to, useAggregator], + [from, to, lifeCycleStatus, updateLifeCycleStatus, useAggregator], ); const handleSubmit = useCallback(async () => { if (!address || !from.token || !to.token || !from.amount) { return; } - const maxSlippage = lifeCycleStatus.statusData.maxSlippage; - setLifeCycleStatus({ - statusName: 'init', - statusData: { - isMissingRequiredField: false, - maxSlippage, - }, - }); try { + const maxSlippage = lifeCycleStatus.statusData.maxSlippage; const response = await buildSwapTransaction({ amount: from.amount, fromAddress: address, @@ -285,25 +284,20 @@ export function SwapProvider({ useAggregator, }); if (isSwapError(response)) { - setLifeCycleStatus({ + updateLifeCycleStatus({ statusName: 'error', statusData: { code: response.code, error: response.error, message: response.message, - // LifecycleStatus shared data - isMissingRequiredField: - lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage, }, }); return; } await processSwapTransaction({ config: accountConfig, - lifeCycleStatus, sendTransactionAsync, - setLifeCycleStatus, + updateLifeCycleStatus, swapTransaction: response, useAggregator, }); @@ -313,16 +307,12 @@ export function SwapProvider({ const errorMessage = isUserRejectedRequestError(err) ? 'Request denied.' : GENERIC_ERROR_MESSAGE; - setLifeCycleStatus({ + updateLifeCycleStatus({ statusName: 'error', statusData: { code: 'TmSPc02', // Transaction module SwapProvider component 02 error error: JSON.stringify(err), message: errorMessage, - // LifecycleStatus shared data - isMissingRequiredField: - lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage, }, }); } @@ -334,20 +324,18 @@ export function SwapProvider({ lifeCycleStatus, sendTransactionAsync, to.token, + updateLifeCycleStatus, useAggregator, ]); const value = useValue({ address, - error, from, - loading, handleAmountChange, handleToggle, handleSubmit, lifeCycleStatus, - isTransactionPending, - setLifeCycleStatus, + updateLifeCycleStatus, to, }); diff --git a/src/swap/components/SwapSettingsSlippageInput.test.tsx b/src/swap/components/SwapSettingsSlippageInput.test.tsx index 721dcd56cc..7f7084d46f 100644 --- a/src/swap/components/SwapSettingsSlippageInput.test.tsx +++ b/src/swap/components/SwapSettingsSlippageInput.test.tsx @@ -13,7 +13,7 @@ let mockLifeCycleStatus = { vi.mock('./SwapProvider', () => ({ useSwapContext: () => ({ - setLifeCycleStatus: mockSetLifeCycleStatus, + updateLifeCycleStatus: mockSetLifeCycleStatus, lifeCycleStatus: mockLifeCycleStatus, }), })); @@ -63,7 +63,6 @@ describe('SwapSettingsSlippageInput', () => { expect(mockSetLifeCycleStatus).toHaveBeenCalledWith({ statusName: 'slippageChange', statusData: { - isMissingRequiredField: false, maxSlippage: 2.5, }, }); @@ -88,7 +87,6 @@ describe('SwapSettingsSlippageInput', () => { expect(mockSetLifeCycleStatus).toHaveBeenCalledWith({ statusName: 'slippageChange', statusData: { - isMissingRequiredField: false, maxSlippage: 1.5, }, }); @@ -112,7 +110,6 @@ describe('SwapSettingsSlippageInput', () => { expect(mockSetLifeCycleStatus).toHaveBeenCalledWith({ statusName: 'slippageChange', statusData: { - isMissingRequiredField: false, maxSlippage: 2.75, }, }); @@ -195,7 +192,6 @@ describe('SwapSettingsSlippageInput', () => { expect(mockSetLifeCycleStatus).toHaveBeenLastCalledWith({ statusName: 'slippageChange', statusData: { - isMissingRequiredField: false, maxSlippage: 3, }, }); diff --git a/src/swap/components/SwapSettingsSlippageInput.tsx b/src/swap/components/SwapSettingsSlippageInput.tsx index becd646657..96d1d2f84b 100644 --- a/src/swap/components/SwapSettingsSlippageInput.tsx +++ b/src/swap/components/SwapSettingsSlippageInput.tsx @@ -12,7 +12,7 @@ export function SwapSettingsSlippageInput({ className, defaultSlippage = 3, }: SwapSettingsSlippageInputReact) { - const { setLifeCycleStatus, lifeCycleStatus } = useSwapContext(); + const { updateLifeCycleStatus, lifeCycleStatus } = useSwapContext(); const getMaxSlippage = useCallback(() => { if (lifeCycleStatus.statusName !== 'error') { return lifeCycleStatus.statusData.maxSlippage; @@ -32,15 +32,14 @@ export function SwapSettingsSlippageInput({ const updateSlippage = useCallback( (newSlippage: number) => { setSlippage(newSlippage); - setLifeCycleStatus({ + updateLifeCycleStatus({ statusName: 'slippageChange', statusData: { - isMissingRequiredField: false, maxSlippage: newSlippage, }, }); }, - [setLifeCycleStatus], + [updateLifeCycleStatus], ); // Handles user input for custom slippage diff --git a/src/swap/types.ts b/src/swap/types.ts index 0a5f5fd482..aa29c2c0bc 100644 --- a/src/swap/types.ts +++ b/src/swap/types.ts @@ -25,10 +25,7 @@ export type FromTo = { export type GetSwapMessageParams = { address?: Address; - error?: SwapError; - loading?: boolean; - isTransactionPending?: boolean; - isMissingRequiredFields?: boolean; + lifeCycleStatus: LifeCycleStatus; to: SwapUnit; from: SwapUnit; }; @@ -42,7 +39,7 @@ export type QuoteWarning = { type?: string; // The type of the warning }; -type LifecycleStatusDataShared = { +type LifeCycleStatusDataShared = { isMissingRequiredField: boolean; maxSlippage: number; }; @@ -56,11 +53,11 @@ type LifecycleStatusDataShared = { export type LifeCycleStatus = | { statusName: 'init'; - statusData: LifecycleStatusDataShared; + statusData: LifeCycleStatusDataShared; } | { statusName: 'error'; - statusData: SwapError & LifecycleStatusDataShared; + statusData: SwapError & LifeCycleStatusDataShared; } | { statusName: 'amountChange'; @@ -69,34 +66,69 @@ export type LifeCycleStatus = amountTo: string; tokenFrom?: Token; tokenTo?: Token; - } & LifecycleStatusDataShared; + } & LifeCycleStatusDataShared; } | { statusName: 'slippageChange'; - statusData: LifecycleStatusDataShared; + statusData: LifeCycleStatusDataShared; } | { statusName: 'transactionPending'; - statusData: LifecycleStatusDataShared; + statusData: LifeCycleStatusDataShared; } | { statusName: 'transactionApproved'; statusData: { transactionHash: Hex; transactionType: 'ERC20' | 'Permit2'; - } & LifecycleStatusDataShared; + } & LifeCycleStatusDataShared; } | { statusName: 'success'; statusData: { transactionReceipt: TransactionReceipt; - } & LifecycleStatusDataShared; + } & LifeCycleStatusDataShared; }; +// make all keys in T optional if they are in K +type PartialKeys = Omit & + Partial> extends infer O + ? { [P in keyof O]: O[P] } + : never; + +// check if all keys in T are a key of LifeCycleStatusDataShared +type AllKeysInShared = keyof T extends keyof LifeCycleStatusDataShared + ? true + : false; + +/** + * LifeCycleStatus updater type + * Used to type the statuses used to update LifeCycleStatus + * LifeCycleStatusData is persisted across state updates allowing SharedData to be optional except for in init step + */ +export type LifeCycleStatusUpdate = LifeCycleStatus extends infer T + ? T extends { statusName: infer N; statusData: infer D } + ? { statusName: N } & (N extends 'init' // statusData required in statusName "init" + ? { statusData: D } + : AllKeysInShared extends true // is statusData is LifeCycleStatusDataShared, make optional + ? { + statusData?: PartialKeys< + D, + keyof D & keyof LifeCycleStatusDataShared + >; + } // make all keys in LifeCycleStatusDataShared optional + : { + statusData: PartialKeys< + D, + keyof D & keyof LifeCycleStatusDataShared + >; + }) + : never + : never; + export type ProcessSwapTransactionParams = { config: Config; - lifeCycleStatus: LifeCycleStatus; - setLifeCycleStatus: (state: LifeCycleStatus) => void; + updateLifeCycleStatus: (state: LifeCycleStatusUpdate) => void; sendTransactionAsync: SendTransactionMutateAsync; swapTransaction: BuildSwapTransaction; useAggregator: boolean; @@ -132,11 +164,8 @@ export type SwapButtonReact = { export type SwapContextType = { address?: Address; // Used to check if user is connected in SwapButton - error?: SwapError; from: SwapUnit; lifeCycleStatus: LifeCycleStatus; - loading: boolean; - isTransactionPending: boolean; handleAmountChange: ( t: 'from' | 'to', amount: string, @@ -145,7 +174,7 @@ export type SwapContextType = { ) => void; handleSubmit: () => void; handleToggle: () => void; - setLifeCycleStatus: (state: LifeCycleStatus) => void; // A function to set the lifecycle status of the component + updateLifeCycleStatus: (state: LifeCycleStatusUpdate) => void; // A function to set the lifecycle status of the component to: SwapUnit; }; diff --git a/src/swap/utils/getSwapMessage.test.ts b/src/swap/utils/getSwapMessage.test.ts index 0516bbe1d1..b22985e1d3 100644 --- a/src/swap/utils/getSwapMessage.test.ts +++ b/src/swap/utils/getSwapMessage.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { LOW_LIQUIDITY_ERROR_CODE, TOO_MANY_REQUESTS_ERROR_CODE, @@ -12,8 +12,7 @@ import { SwapMessage, getSwapMessage } from './getSwapMessage'; describe('getSwapMessage', () => { const baseParams = { - address: '0x123', - error: undefined, + address: '0x123' as `0x${string}`, from: { error: undefined, balance: '0', @@ -33,8 +32,10 @@ describe('getSwapMessage', () => { setLoading: vi.fn(), setToken: vi.fn(), }, - loading: false, - isMissingRequiredFields: false, + lifeCycleStatus: { + statusName: 'init', + statusData: { isMissingRequiredField: false, maxSlippage: 3 }, + }, }; it('should return BALANCE_ERROR when from or to has an error', () => { @@ -68,7 +69,7 @@ describe('getSwapMessage', () => { it('should return CONFIRM IN WALLET when pending transaction', () => { const params = { ...baseParams, - isTransactionPending: true, + lifeCycleStatus: { statusName: 'transactionPending', statusData: null }, }; expect(getSwapMessage(params)).toBe(SwapMessage.CONFIRM_IN_WALLET); }); @@ -76,7 +77,7 @@ describe('getSwapMessage', () => { it('should return SWAP_IN_PROGRESS when loading is true', () => { const params = { ...baseParams, - loading: true, + lifeCycleStatus: { statusName: 'transactionApproved', statusData: null }, }; expect(getSwapMessage(params)).toBe(SwapMessage.SWAP_IN_PROGRESS); }); @@ -98,7 +99,10 @@ describe('getSwapMessage', () => { it('should return INCOMPLETE_FIELD when required fields are missing', () => { const params = { ...baseParams, - isMissingRequiredFields: true, + lifeCycleStatus: { + statusName: 'init', + statusData: { isMissingRequiredField: true }, + }, }; expect(getSwapMessage(params)).toBe(SwapMessage.INCOMPLETE_FIELD); }); @@ -113,10 +117,13 @@ describe('getSwapMessage', () => { token: ETH_TOKEN, }, to: { ...baseParams.to, amount: '5', token: USDC_TOKEN }, - error: { - code: TOO_MANY_REQUESTS_ERROR_CODE, - error: 'Too many requests error', - message: '', + lifeCycleStatus: { + statusName: 'error', + statusData: { + code: TOO_MANY_REQUESTS_ERROR_CODE, + error: 'Too many requests error', + message: '', + }, }, }; expect(getSwapMessage(params)).toBe(SwapMessage.TOO_MANY_REQUESTS); @@ -132,10 +139,13 @@ describe('getSwapMessage', () => { token: ETH_TOKEN, }, to: { ...baseParams.to, amount: '5', token: USDC_TOKEN }, - error: { - code: LOW_LIQUIDITY_ERROR_CODE, - error: 'Low liquidity error', - message: '', + lifeCycleStatus: { + statusName: 'error', + statusData: { + code: LOW_LIQUIDITY_ERROR_CODE, + error: 'Low liquidity error', + message: '', + }, }, }; expect(getSwapMessage(params)).toBe(SwapMessage.LOW_LIQUIDITY); @@ -151,10 +161,13 @@ describe('getSwapMessage', () => { token: ETH_TOKEN, }, to: { ...baseParams.to, amount: '5', token: USDC_TOKEN }, - error: { - code: USER_REJECTED_ERROR_CODE, - error: 'User rejected error', - message: '', + lifeCycleStatus: { + statusName: 'error', + statusData: { + code: USER_REJECTED_ERROR_CODE, + error: 'User rejected error', + message: '', + }, }, }; expect(getSwapMessage(params)).toBe(SwapMessage.USER_REJECTED); @@ -170,10 +183,13 @@ describe('getSwapMessage', () => { token: ETH_TOKEN, }, to: { ...baseParams.to, amount: '5', token: USDC_TOKEN }, - error: { - code: 'general_error_code', - error: 'General error occurred', - message: '', + lifeCycleStatus: { + statusName: 'error', + statusData: { + code: 'general_error_code', + error: 'General error occurred', + message: '', + }, }, }; expect(getSwapMessage(params)).toBe(''); diff --git a/src/swap/utils/getSwapMessage.ts b/src/swap/utils/getSwapMessage.ts index 55b75e59ba..83985d6014 100644 --- a/src/swap/utils/getSwapMessage.ts +++ b/src/swap/utils/getSwapMessage.ts @@ -16,11 +16,8 @@ export enum SwapMessage { export function getSwapMessage({ address, - error, from, - loading, - isMissingRequiredFields, - isTransactionPending, + lifeCycleStatus, to, }: GetSwapMessageParams) { // handle balance error @@ -32,23 +29,25 @@ export function getSwapMessage({ return SwapMessage.INSUFFICIENT_BALANCE; } // handle pending transaction - if (isTransactionPending) { + if (lifeCycleStatus.statusName === 'transactionPending') { return SwapMessage.CONFIRM_IN_WALLET; } // handle loading states - if (loading) { + if (lifeCycleStatus.statusName === 'transactionApproved') { return SwapMessage.SWAP_IN_PROGRESS; } if (to.loading || from.loading) { return SwapMessage.FETCHING_QUOTE; } // missing required fields - if (isMissingRequiredFields) { + if (lifeCycleStatus.statusData.isMissingRequiredField) { return SwapMessage.INCOMPLETE_FIELD; } - if (!error) { - return ''; - } + // handle specific error codes - return getErrorMessage(error); + if (lifeCycleStatus.statusName === 'error') { + return getErrorMessage(lifeCycleStatus.statusData); + } + + return ''; } diff --git a/src/swap/utils/processSwapTransaction.test.ts b/src/swap/utils/processSwapTransaction.test.ts index ea51cea8bf..74cfd9282f 100644 --- a/src/swap/utils/processSwapTransaction.test.ts +++ b/src/swap/utils/processSwapTransaction.test.ts @@ -1,11 +1,11 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; import { http, createConfig } from 'wagmi'; import { waitForTransactionReceipt } from 'wagmi/actions'; import { mainnet, sepolia } from 'wagmi/chains'; import { mock } from 'wagmi/connectors'; +import type { BuildSwapTransaction } from '../../api/types'; import { PERMIT2_CONTRACT_ADDRESS } from '../constants'; import { DEGEN_TOKEN, ETH_TOKEN, USDC_TOKEN } from '../mocks'; -import type { BuildSwapTransaction, LifeCycleStatus } from '../types'; import { processSwapTransaction } from './processSwapTransaction'; vi.mock('wagmi/actions', () => ({ @@ -13,16 +13,9 @@ vi.mock('wagmi/actions', () => ({ })); describe('processSwapTransaction', () => { - const setLifeCycleStatus = vi.fn(); - const sendTransactionAsync = vi - .fn() - .mockResolvedValueOnce('approveTxHash') - .mockResolvedValueOnce('txHash'); - const sendTransactionAsyncPermit2 = vi - .fn() - .mockResolvedValueOnce('approveTxHash') - .mockResolvedValueOnce('permit2TxHash') - .mockResolvedValueOnce('txHash'); + const updateLifeCycleStatus = vi.fn(); + let sendTransactionAsync: Mock; + let sendTransactionAsyncPermit2: Mock; const config = createConfig({ chains: [mainnet, sepolia], @@ -41,16 +34,19 @@ describe('processSwapTransaction', () => { }, }); - const defaultLifeCycleStatus: LifeCycleStatus = { - statusName: 'init', - statusData: { - isMissingRequiredField: false, - maxSlippage: 3, - }, - }; - beforeEach(() => { vi.clearAllMocks(); + + sendTransactionAsync = vi + .fn() + .mockResolvedValueOnce('approveTxHash') + .mockResolvedValueOnce('txHash'); + + sendTransactionAsyncPermit2 = vi + .fn() + .mockResolvedValueOnce('approveTxHash') + .mockResolvedValueOnce('permit2TxHash') + .mockResolvedValueOnce('txHash'); }); it('should request approval and make the swap for ERC-20 tokens', async () => { @@ -105,35 +101,29 @@ describe('processSwapTransaction', () => { await processSwapTransaction({ config, sendTransactionAsync, - setLifeCycleStatus, + updateLifeCycleStatus, swapTransaction, useAggregator: true, - lifeCycleStatus: defaultLifeCycleStatus, }); - expect(setLifeCycleStatus).toHaveBeenCalledTimes(4); - expect(setLifeCycleStatus).toHaveBeenNthCalledWith(1, { + expect(updateLifeCycleStatus).toHaveBeenCalledTimes(5); + expect(updateLifeCycleStatus).toHaveBeenNthCalledWith(1, { statusName: 'transactionPending', - statusData: { - isMissingRequiredField: false, - maxSlippage: 3, - }, }); - expect(setLifeCycleStatus).toHaveBeenNthCalledWith(2, { + expect(updateLifeCycleStatus).toHaveBeenNthCalledWith(2, { statusName: 'transactionApproved', statusData: { transactionHash: 'approveTxHash', transactionType: 'ERC20', - // LifecycleStatus shared data - isMissingRequiredField: false, - maxSlippage: 3, }, }); - expect(setLifeCycleStatus).toHaveBeenNthCalledWith(3, { + expect(updateLifeCycleStatus).toHaveBeenNthCalledWith(3, { statusName: 'transactionPending', + }); + expect(updateLifeCycleStatus).toHaveBeenNthCalledWith(4, { + statusName: 'transactionApproved', statusData: { - // LifecycleStatus shared data - isMissingRequiredField: false, - maxSlippage: 3, + transactionHash: 'txHash', + transactionType: 'ERC20', }, }); expect(sendTransactionAsync).toHaveBeenCalledTimes(2); @@ -170,18 +160,19 @@ describe('processSwapTransaction', () => { await processSwapTransaction({ config, sendTransactionAsync, - setLifeCycleStatus, + updateLifeCycleStatus, swapTransaction, useAggregator: true, - lifeCycleStatus: defaultLifeCycleStatus, }); - expect(setLifeCycleStatus).toHaveBeenCalledTimes(2); - expect(setLifeCycleStatus).toHaveBeenNthCalledWith(1, { + expect(updateLifeCycleStatus).toHaveBeenCalledTimes(3); + expect(updateLifeCycleStatus).toHaveBeenNthCalledWith(1, { statusName: 'transactionPending', + }); + expect(updateLifeCycleStatus).toHaveBeenNthCalledWith(2, { + statusName: 'transactionApproved', statusData: { - // LifecycleStatus shared data - isMissingRequiredField: false, - maxSlippage: 3, + transactionHash: 'approveTxHash', + transactionType: 'ERC20', }, }); expect(sendTransactionAsync).toHaveBeenCalledTimes(1); @@ -224,46 +215,39 @@ describe('processSwapTransaction', () => { await processSwapTransaction({ config, sendTransactionAsync: sendTransactionAsyncPermit2, - setLifeCycleStatus, + updateLifeCycleStatus, swapTransaction, useAggregator: false, - lifeCycleStatus: defaultLifeCycleStatus, }); - expect(setLifeCycleStatus).toHaveBeenCalledTimes(6); - expect(setLifeCycleStatus).toHaveBeenNthCalledWith(1, { + expect(updateLifeCycleStatus).toHaveBeenCalledTimes(7); + expect(updateLifeCycleStatus).toHaveBeenNthCalledWith(1, { statusName: 'transactionPending', - statusData: { - // LifecycleStatus shared data - isMissingRequiredField: false, - maxSlippage: 3, - }, }); - expect(setLifeCycleStatus).toHaveBeenNthCalledWith(2, { + expect(updateLifeCycleStatus).toHaveBeenNthCalledWith(2, { statusName: 'transactionApproved', statusData: { transactionHash: 'approveTxHash', transactionType: 'Permit2', - // LifecycleStatus shared data - isMissingRequiredField: false, - maxSlippage: 3, }, }); - expect(setLifeCycleStatus).toHaveBeenNthCalledWith(3, { + expect(updateLifeCycleStatus).toHaveBeenNthCalledWith(3, { statusName: 'transactionPending', - statusData: { - // LifecycleStatus shared data - isMissingRequiredField: false, - maxSlippage: 3, - }, }); - expect(setLifeCycleStatus).toHaveBeenNthCalledWith(4, { + expect(updateLifeCycleStatus).toHaveBeenNthCalledWith(4, { statusName: 'transactionApproved', statusData: { transactionHash: 'permit2TxHash', transactionType: 'ERC20', - // LifecycleStatus shared data - isMissingRequiredField: false, - maxSlippage: 3, + }, + }); + expect(updateLifeCycleStatus).toHaveBeenNthCalledWith(5, { + statusName: 'transactionPending', + }); + expect(updateLifeCycleStatus).toHaveBeenNthCalledWith(6, { + statusName: 'transactionApproved', + statusData: { + transactionHash: 'txHash', + transactionType: 'Permit2', }, }); expect(sendTransactionAsyncPermit2).toHaveBeenCalledTimes(3); @@ -274,63 +258,4 @@ describe('processSwapTransaction', () => { value: 0n, }); }); - - it('should use default maxSlippage when lifeCycleStatus is error', async () => { - const errorLifeCycleStatus: LifeCycleStatus = { - statusName: 'error', - statusData: { - code: 'UNKNOWN_ERROR', - error: 'Some error occurred', - message: 'Some error occurred', - // LifecycleStatus shared data - isMissingRequiredField: false, - maxSlippage: 3, - }, - }; - const swapTransaction: BuildSwapTransaction = { - transaction: { - to: '0x123', - value: 0n, - data: '0x', - chainId: 8453, - gas: 0n, - }, - approveTransaction: undefined, - quote: { - from: ETH_TOKEN, - to: DEGEN_TOKEN, - fromAmount: '100000000000000', - toAmount: '19395353519910973703', - amountReference: 'from', - priceImpact: '0.94', - hasHighPriceImpact: false, - slippage: '3', - warning: undefined, - }, - fee: { - baseAsset: DEGEN_TOKEN, - percentage: '1', - amount: '195912661817282562', - }, - }; - await processSwapTransaction({ - config, - sendTransactionAsync, - setLifeCycleStatus, - swapTransaction, - useAggregator: true, - lifeCycleStatus: errorLifeCycleStatus, - }); - expect(setLifeCycleStatus).toHaveBeenCalledTimes(2); - expect(setLifeCycleStatus).toHaveBeenNthCalledWith(1, { - statusName: 'transactionPending', - statusData: { - // LifecycleStatus shared data - isMissingRequiredField: false, - maxSlippage: 3, - }, - }); - expect(sendTransactionAsync).toHaveBeenCalledTimes(1); - expect(waitForTransactionReceipt).toHaveBeenCalledTimes(1); - }); }); diff --git a/src/swap/utils/processSwapTransaction.ts b/src/swap/utils/processSwapTransaction.ts index 65af6c4df8..e031728c78 100644 --- a/src/swap/utils/processSwapTransaction.ts +++ b/src/swap/utils/processSwapTransaction.ts @@ -9,9 +9,8 @@ import type { ProcessSwapTransactionParams } from '../types'; export async function processSwapTransaction({ config, - lifeCycleStatus, sendTransactionAsync, - setLifeCycleStatus, + updateLifeCycleStatus, swapTransaction, useAggregator, }: ProcessSwapTransactionParams) { @@ -23,29 +22,19 @@ export async function processSwapTransaction({ // for V1 API, `approveTx` will be an ERC-20 approval against the Router // for V2 API, `approveTx` will be an ERC-20 approval against the `Permit2` contract if (approveTransaction?.data) { - setLifeCycleStatus({ + updateLifeCycleStatus({ statusName: 'transactionPending', - statusData: { - // LifecycleStatus shared data - isMissingRequiredField: - lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage: lifeCycleStatus.statusData.maxSlippage, - }, }); const approveTxHash = await sendTransactionAsync({ to: approveTransaction.to, value: approveTransaction.value, data: approveTransaction.data, }); - setLifeCycleStatus({ + updateLifeCycleStatus({ statusName: 'transactionApproved', statusData: { transactionHash: approveTxHash, transactionType: useAggregator ? 'ERC20' : 'Permit2', - // LifecycleStatus shared data - isMissingRequiredField: - lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage: lifeCycleStatus.statusData.maxSlippage, }, }); await waitForTransactionReceipt(config, { @@ -59,14 +48,8 @@ export async function processSwapTransaction({ // this would typically be a (gasless) signature, but we're using a transaction here to allow batching for Smart Wallets // read more: https://blog.uniswap.org/permit2-and-universal-router if (!useAggregator) { - setLifeCycleStatus({ + updateLifeCycleStatus({ statusName: 'transactionPending', - statusData: { - // LifecycleStatus shared data - isMissingRequiredField: - lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage: lifeCycleStatus.statusData.maxSlippage, - }, }); const permit2ContractAbi = parseAbi([ 'function approve(address token, address spender, uint160 amount, uint48 expiration) external', @@ -86,15 +69,11 @@ export async function processSwapTransaction({ data: data, value: 0n, }); - setLifeCycleStatus({ + updateLifeCycleStatus({ statusName: 'transactionApproved', statusData: { transactionHash: permitTxnHash, transactionType: 'ERC20', - // LifecycleStatus shared data - isMissingRequiredField: - lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage: lifeCycleStatus.statusData.maxSlippage, }, }); await waitForTransactionReceipt(config, { @@ -105,32 +84,30 @@ export async function processSwapTransaction({ } // make the swap - setLifeCycleStatus({ + updateLifeCycleStatus({ statusName: 'transactionPending', - statusData: { - // LifecycleStatus shared data - isMissingRequiredField: lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage: lifeCycleStatus.statusData.maxSlippage, - }, }); const txHash = await sendTransactionAsync({ to: transaction.to, value: transaction.value, data: transaction.data, }); - + updateLifeCycleStatus({ + statusName: 'transactionApproved', + statusData: { + transactionHash: txHash, + transactionType: useAggregator ? 'ERC20' : 'Permit2', + }, + }); // wait for swap to land onchain const transactionReceipt = await waitForTransactionReceipt(config, { hash: txHash, confirmations: 1, }); - setLifeCycleStatus({ + updateLifeCycleStatus({ statusName: 'success', statusData: { transactionReceipt: transactionReceipt, - // LifecycleStatus shared data - isMissingRequiredField: lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage: lifeCycleStatus.statusData.maxSlippage, }, }); }