diff --git a/src/swap/components/SwapButton.tsx b/src/swap/components/SwapButton.tsx index 68dbb7d123e..2edbcfb7b07 100644 --- a/src/swap/components/SwapButton.tsx +++ b/src/swap/components/SwapButton.tsx @@ -5,11 +5,14 @@ import type { SwapButtonReact } from '../types'; import { useSwapContext } from './SwapProvider'; export function SwapButton({ className, disabled = false }: SwapButtonReact) { - const { address, to, from, loading, isTransactionPending, handleSubmit } = + const { address, to, from, lifeCycleStatus: { statusData }, handleSubmit } = useSwapContext(); const isLoading = - to.loading || from.loading || loading || isTransactionPending; + to.loading || + from.loading || + statusData.loading || + statusData.isTransactionPending; const isDisabled = !from.amount || diff --git a/src/swap/components/SwapMessage.tsx b/src/swap/components/SwapMessage.tsx index c6ca3ac4a5e..98ab4bbe115 100644 --- a/src/swap/components/SwapMessage.tsx +++ b/src/swap/components/SwapMessage.tsx @@ -8,24 +8,16 @@ export function SwapMessage({ className }: SwapMessageReact) { address, to, from, - error, - loading, - isTransactionPending, lifeCycleStatus: { statusData }, } = useSwapContext(); - const isMissingRequiredFields = - !!statusData && - 'isMissingRequiredField' in statusData && - statusData?.isMissingRequiredField; - const message = getSwapMessage({ address, - error, + error: statusData.error, from, - loading, - isMissingRequiredFields, - isTransactionPending, + loading: statusData.loading, + isMissingRequiredFields: statusData.isMissingRequiredField, + isTransactionPending: statusData.isTransactionPending, to, }); diff --git a/src/swap/components/SwapProvider.tsx b/src/swap/components/SwapProvider.tsx index c753f22fe87..dd4b1c5f19f 100644 --- a/src/swap/components/SwapProvider.tsx +++ b/src/swap/components/SwapProvider.tsx @@ -18,7 +18,6 @@ import { useResetInputs } from '../hooks/useResetInputs'; import type { LifeCycleStatus, SwapContextType, - SwapError, SwapProviderReact, } from '../types'; import { isSwapError } from '../utils/isSwapError'; @@ -46,21 +45,19 @@ export function SwapProvider({ const { address } = useAccount(); // Feature flags const { useAggregator } = experimental; - const [maxSlippage, _setMaxSlippage] = useState( - experimental.maxSlippage || 3, - ); + // Core Hooks const config = useConfig(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(); - const [isTransactionPending, setPendingTransaction] = useState(false); - const [lifeCycleStatus, setLifeCycleStatus] = useState({ + const initialLifecycleStatus = { statusName: 'init', statusData: { + loading: false, + isTransactionPending: false, isMissingRequiredField: true, - maxSlippage, - }, - }); // Component lifecycle + maxSlippage: experimental.maxSlippage || 3 + } + } as LifeCycleStatus; + const [lifeCycleStatus, setLifeCycleStatus] = useState(initialLifecycleStatus); // Component lifecycle const [hasHandledSuccess, setHasHandledSuccess] = useState(false); const { from, to } = useFromTo(address); const { sendTransactionAsync } = useSendTransaction(); // Sending the transaction (and approval, if applicable) @@ -72,26 +69,9 @@ 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); + onError?.(lifeCycleStatus.statusData.error); } - // Success if (lifeCycleStatus.statusName === 'success') { - setError(undefined); - setLoading(false); - setPendingTransaction(false); onSuccess?.(lifeCycleStatus.statusData.transactionReceipt); setHasHandledSuccess(true); } @@ -119,20 +99,12 @@ export function SwapProvider({ useEffect(() => { // Reset status to init after success has been handled if (lifeCycleStatus.statusName === 'success' && hasHandledSuccess) { - setLifeCycleStatus({ - statusName: 'init', - statusData: { - isMissingRequiredField: - lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage, - }, - }); + setLifeCycleStatus(initialLifecycleStatus); } }, [ + initialLifecycleStatus, hasHandledSuccess, - lifeCycleStatus.statusData, lifeCycleStatus.statusName, - maxSlippage, ]); const handleToggle = useCallback(() => { @@ -158,18 +130,18 @@ export function SwapProvider({ // if token is missing alert user via isMissingRequiredField if (source.token === undefined || destination.token === undefined) { - setLifeCycleStatus({ + setLifeCycleStatus((status: LifeCycleStatus) => ({ statusName: 'amountChange', statusData: { + ...status.statusData, amountFrom: from.amount, amountTo: to.amount, - maxSlippage, tokenFrom: from.token, tokenTo: to.token, // token is missing isMissingRequiredField: true, }, - }); + })); return; } if (amount === '' || amount === '.' || Number.parseFloat(amount) === 0) { @@ -179,9 +151,10 @@ export function SwapProvider({ // When toAmount changes we fetch quote for fromAmount // so set isFromQuoteLoading to true destination.setLoading(true); - setLifeCycleStatus({ + setLifeCycleStatus((status: LifeCycleStatus) => ({ statusName: 'amountChange', statusData: { + ...status.statusData, // when fetching quote, the previous // amount is irrelevant amountFrom: type === 'from' ? amount : '', @@ -189,11 +162,10 @@ export function SwapProvider({ // when fetching quote, the destination // amount is missing isMissingRequiredField: true, - maxSlippage, - tokenFrom: from.token, + tokenFrom: from.token, // are these needed??? tokenTo: to.token, }, - }); + })); try { const response = await getSwapQuote({ @@ -201,24 +173,25 @@ export function SwapProvider({ amountReference: 'from', from: source.token, to: destination.token, - maxSlippage: maxSlippage.toString(), + maxSlippage: lifeCycleStatus.statusData.maxSlippage.toString(), useAggregator, }); // If request resolves to error response set the quoteError // property of error state to the SwapError response if (isSwapError(response)) { - setLifeCycleStatus({ + setLifeCycleStatus((status: LifeCycleStatus) => ({ statusName: 'error', statusData: { - code: response.code, - error: response.error, - message: '', - // LifecycleStatus shared data - isMissingRequiredField: - lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage, + ...status.statusData, + loading: false, + isTransactionPending: false, + error: { + code: response.code, + error: response.error, + message: '', + } }, - }); + })); return; } const formattedAmount = formatTokenAmount( @@ -226,51 +199,54 @@ export function SwapProvider({ response.to.decimals, ); destination.setAmount(formattedAmount); - setLifeCycleStatus({ + setLifeCycleStatus((status: LifeCycleStatus) => ({ statusName: 'amountChange', statusData: { + ...status.statusData, amountFrom: type === 'from' ? amount : formattedAmount, amountTo: type === 'to' ? amount : formattedAmount, // if quote was fetched successfully, we // have all required fields isMissingRequiredField: !formattedAmount, - maxSlippage, tokenFrom: from.token, tokenTo: to.token, }, - }); + })); } catch (err) { - setLifeCycleStatus({ + setLifeCycleStatus((status: LifeCycleStatus) => ({ statusName: 'error', statusData: { - code: 'TmSPc01', // Transaction module SwapProvider component 01 error - error: JSON.stringify(err), - message: '', - // LifecycleStatus shared data - isMissingRequiredField: - lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage, + ...status.statusData, + loading: false, + isTransactionPending: false, + error: { + code: 'TmSPc01', // Transaction module SwapProvider component 01 error + error: JSON.stringify(err), + message: '', + } }, - }); + })); } finally { // reset loading state when quote request resolves destination.setLoading(false); } }, - [from, lifeCycleStatus, maxSlippage, to, useAggregator], + [from, lifeCycleStatus, to, useAggregator], ); const handleSubmit = useCallback(async () => { if (!address || !from.token || !to.token || !from.amount) { return; } - setLifeCycleStatus({ + // TODO: it feels wrong to call init here, although i understand the need to set isMissingRequiredFields to false + setLifeCycleStatus((status: LifeCycleStatus) => ({ statusName: 'init', statusData: { + ...status.statusData, + // has all required fields isMissingRequiredField: false, - maxSlippage, }, - }); + })); try { const response = await buildSwapTransaction({ @@ -279,26 +255,26 @@ export function SwapProvider({ from: from.token, to: to.token, useAggregator, - maxSlippage: maxSlippage.toString(), + maxSlippage: lifeCycleStatus.statusData.maxSlippage.toString(), }); if (isSwapError(response)) { - setLifeCycleStatus({ + setLifeCycleStatus((status: LifeCycleStatus) => ({ statusName: 'error', statusData: { - code: response.code, - error: response.error, - message: response.message, - // LifecycleStatus shared data - isMissingRequiredField: - lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage, + ...status.statusData, + loading: false, + isTransactionPending: false, + error: { + code: response.code, + error: response.error, + message: response.message, + } }, - }); + })); return; } await processSwapTransaction({ config, - lifeCycleStatus, sendTransactionAsync, setLifeCycleStatus, swapTransaction: response, @@ -310,18 +286,19 @@ export function SwapProvider({ const errorMessage = isUserRejectedRequestError(err) ? 'Request denied.' : GENERIC_ERROR_MESSAGE; - setLifeCycleStatus({ + setLifeCycleStatus((status: LifeCycleStatus) => ({ 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, + ...status.statusData, + loading: false, + isTransactionPending: false, + error: { + code: 'TmSPc02', // Transaction module SwapProvider component 02 error + error: JSON.stringify(err), + message: errorMessage, + }, }, - }); + })); } }, [ address, @@ -329,7 +306,6 @@ export function SwapProvider({ from.amount, from.token, lifeCycleStatus, - maxSlippage, sendTransactionAsync, to.token, useAggregator, @@ -337,14 +313,11 @@ export function SwapProvider({ const value = useValue({ address, - error, from, - loading, handleAmountChange, handleToggle, handleSubmit, lifeCycleStatus, - isTransactionPending, setLifeCycleStatus, to, }); diff --git a/src/swap/components/SwapSettingsSlippageInput.tsx b/src/swap/components/SwapSettingsSlippageInput.tsx index becd646657b..a2774fe1de7 100644 --- a/src/swap/components/SwapSettingsSlippageInput.tsx +++ b/src/swap/components/SwapSettingsSlippageInput.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react'; import { background, border, cn, color, pressable } from '../../styles/theme'; -import type { SwapSettingsSlippageInputReact } from '../types'; +import type { LifeCycleStatus, SwapSettingsSlippageInputReact } from '../types'; import { useSwapContext } from './SwapProvider'; const SLIPPAGE_SETTINGS = { @@ -32,13 +32,14 @@ export function SwapSettingsSlippageInput({ const updateSlippage = useCallback( (newSlippage: number) => { setSlippage(newSlippage); - setLifeCycleStatus({ + setLifeCycleStatus((status: LifeCycleStatus) => ({ statusName: 'slippageChange', statusData: { + ...status.statusData, isMissingRequiredField: false, maxSlippage: newSlippage, }, - }); + })); }, [setLifeCycleStatus], ); diff --git a/src/swap/types.ts b/src/swap/types.ts index bbdf3b2fcc7..93b57a7d962 100644 --- a/src/swap/types.ts +++ b/src/swap/types.ts @@ -58,7 +58,24 @@ export type QuoteWarning = { type?: string; // The type of the warning }; -type LifecycleStatusDataShared = { +/** + * lifecycle data will be persisted, but only set in certain statuses + */ +type LifecycleStatusDataBase = { + error?: SwapError; + + amountFrom?: string; + amountTo?: string; + tokenFrom?: Token; + tokenTo?: Token; + + transactionHash?: Hex; + transactionType?: 'ERC20' | 'Permit2'; + + transactionReceipt?: TransactionReceipt; + + loading: boolean; + isTransactionPending: boolean; isMissingRequiredField: boolean; maxSlippage: number; }; @@ -72,47 +89,48 @@ type LifecycleStatusDataShared = { export type LifeCycleStatus = | { statusName: 'init'; - statusData: LifecycleStatusDataShared; + statusData: LifecycleStatusDataBase; } | { statusName: 'error'; - statusData: SwapError & LifecycleStatusDataShared; + statusData: LifecycleStatusDataBase & { + error: SwapError; + }; } | { statusName: 'amountChange'; - statusData: { + statusData: LifecycleStatusDataBase & { amountFrom: string; amountTo: string; tokenFrom?: Token; tokenTo?: Token; - } & LifecycleStatusDataShared; + }; } | { statusName: 'slippageChange'; - statusData: LifecycleStatusDataShared; + statusData: LifecycleStatusDataBase; } | { statusName: 'transactionPending'; - statusData: LifecycleStatusDataShared; + statusData: LifecycleStatusDataBase; } | { statusName: 'transactionApproved'; - statusData: { + statusData: LifecycleStatusDataBase & { transactionHash: Hex; transactionType: 'ERC20' | 'Permit2'; - } & LifecycleStatusDataShared; + }; } | { statusName: 'success'; - statusData: { + statusData: LifecycleStatusDataBase & { transactionReceipt: TransactionReceipt; - } & LifecycleStatusDataShared; + }; }; export type ProcessSwapTransactionParams = { config: Config; - lifeCycleStatus: LifeCycleStatus; - setLifeCycleStatus: (state: LifeCycleStatus) => void; + setLifeCycleStatus: React.Dispatch>; sendTransactionAsync: SendTransactionMutateAsync; swapTransaction: BuildSwapTransaction; useAggregator: boolean; @@ -148,11 +166,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, @@ -161,7 +176,7 @@ export type SwapContextType = { ) => void; handleSubmit: () => void; handleToggle: () => void; - setLifeCycleStatus: (state: LifeCycleStatus) => void; // A function to set the lifecycle status of the component + setLifeCycleStatus: React.Dispatch>; // A function to set the lifecycle status of the component to: SwapUnit; }; diff --git a/src/swap/utils/processSwapTransaction.ts b/src/swap/utils/processSwapTransaction.ts index 65af6c4df89..4c5f39a44d5 100644 --- a/src/swap/utils/processSwapTransaction.ts +++ b/src/swap/utils/processSwapTransaction.ts @@ -5,11 +5,10 @@ import { PERMIT2_CONTRACT_ADDRESS, UNIVERSALROUTER_CONTRACT_ADDRESS, } from '../constants'; -import type { ProcessSwapTransactionParams } from '../types'; +import type { LifeCycleStatus, ProcessSwapTransactionParams } from '../types'; export async function processSwapTransaction({ config, - lifeCycleStatus, sendTransactionAsync, setLifeCycleStatus, swapTransaction, @@ -23,31 +22,28 @@ 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({ + setLifeCycleStatus((status: LifeCycleStatus) => ({ statusName: 'transactionPending', statusData: { - // LifecycleStatus shared data - isMissingRequiredField: - lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage: lifeCycleStatus.statusData.maxSlippage, - }, - }); + ...status.statusData, + loading: true, + isTransactionPending: true, + } + })); const approveTxHash = await sendTransactionAsync({ to: approveTransaction.to, value: approveTransaction.value, data: approveTransaction.data, }); - setLifeCycleStatus({ + setLifeCycleStatus((status: LifeCycleStatus) => ({ statusName: 'transactionApproved', statusData: { + ...status.statusData, transactionHash: approveTxHash, transactionType: useAggregator ? 'ERC20' : 'Permit2', - // LifecycleStatus shared data - isMissingRequiredField: - lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage: lifeCycleStatus.statusData.maxSlippage, + isTransactionPending: false, }, - }); + })); await waitForTransactionReceipt(config, { hash: approveTxHash, confirmations: 1, @@ -59,15 +55,14 @@ 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({ + setLifeCycleStatus((status: LifeCycleStatus) => ({ statusName: 'transactionPending', statusData: { - // LifecycleStatus shared data - isMissingRequiredField: - lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage: lifeCycleStatus.statusData.maxSlippage, - }, - }); + ...status.statusData, + loading: true, + isPendingTransaction: true, + } + })); const permit2ContractAbi = parseAbi([ 'function approve(address token, address spender, uint160 amount, uint48 expiration) external', ]); @@ -86,17 +81,15 @@ export async function processSwapTransaction({ data: data, value: 0n, }); - setLifeCycleStatus({ + setLifeCycleStatus((status: LifeCycleStatus) => ({ statusName: 'transactionApproved', statusData: { + ...status.statusData, transactionHash: permitTxnHash, transactionType: 'ERC20', - // LifecycleStatus shared data - isMissingRequiredField: - lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage: lifeCycleStatus.statusData.maxSlippage, + isTransactionPending: false, }, - }); + })); await waitForTransactionReceipt(config, { hash: permitTxnHash, confirmations: 1, @@ -105,14 +98,14 @@ export async function processSwapTransaction({ } // make the swap - setLifeCycleStatus({ + setLifeCycleStatus((status: LifeCycleStatus) => ({ statusName: 'transactionPending', statusData: { - // LifecycleStatus shared data - isMissingRequiredField: lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage: lifeCycleStatus.statusData.maxSlippage, - }, - }); + ...status.statusData, + loading: true, + isPendingTransaction: true, + } + })); const txHash = await sendTransactionAsync({ to: transaction.to, value: transaction.value, @@ -124,13 +117,14 @@ export async function processSwapTransaction({ hash: txHash, confirmations: 1, }); - setLifeCycleStatus({ + setLifeCycleStatus((status: LifeCycleStatus) => ({ statusName: 'success', statusData: { + ...status.statusData, transactionReceipt: transactionReceipt, - // LifecycleStatus shared data - isMissingRequiredField: lifeCycleStatus.statusData.isMissingRequiredField, - maxSlippage: lifeCycleStatus.statusData.maxSlippage, + error: undefined, + loading: false, + isTransactionPending: false }, - }); + })); }