diff --git a/.eslintignore b/.eslintignore index 5ba9f5090f..e6a481dac4 100644 --- a/.eslintignore +++ b/.eslintignore @@ -20,3 +20,5 @@ src/locales/ *.md *.log *.lock + +src/components/transactions/Swap/backup/**/*.* \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000000..51945768ce --- /dev/null +++ b/.npmrc @@ -0,0 +1,6 @@ +# Uncomment for CoW Preview Releases + +# @cowprotocol:registry=https://npm.pkg.github.com +# always-auth=true +# # registry=https://registry.npmjs.org/ +# //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} diff --git a/package.json b/package.json index 228768e51b..38bd3686fd 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,11 @@ "@aave/math-utils": "1.36.1", "@aave/react": "0.6.1", "@amplitude/analytics-browser": "^2.13.0", + "@cowprotocol/sdk-app-data": "4.1.6", + "@cowprotocol/cow-sdk": "7.1.1", + "@cowprotocol/sdk-flash-loans": "1.5.2", + "@cowprotocol/sdk-viem-adapter": "0.2.0", "@bgd-labs/aave-address-book": "^4.36.0", - "@cowprotocol/app-data": "^3.1.0", - "@cowprotocol/cow-sdk": "6.3.3", "@emotion/cache": "11.10.3", "@emotion/react": "11.10.4", "@emotion/server": "latest", @@ -157,4 +159,4 @@ "budgetPercentIncreaseRed": 20, "showDetails": true } -} +} \ No newline at end of file diff --git a/pages/_app.page.tsx b/pages/_app.page.tsx index d1882d0e8f..0ab60dbcc4 100644 --- a/pages/_app.page.tsx +++ b/pages/_app.page.tsx @@ -16,10 +16,10 @@ import { AddressBlocked } from 'src/components/AddressBlocked'; import { Meta } from 'src/components/Meta'; import { TransactionEventHandler } from 'src/components/TransactionEventHandler'; import { GasStationProvider } from 'src/components/transactions/GasStation/GasStationProvider'; -import { CowOrderToast } from 'src/components/transactions/Switch/cowprotocol/CowOrderToast'; +import { CowOrderToast } from 'src/components/transactions/Swap/modals/result/CowOrderToast'; import { AppDataProvider } from 'src/hooks/app-data-provider/useAppDataProvider'; -import { CowOrderToastProvider } from 'src/hooks/useCowOrderToast'; import { ModalContextProvider } from 'src/hooks/useModal'; +import { SwapOrdersTrackingProvider } from 'src/hooks/useSwapOrdersTracking'; import { Web3ContextProvider } from 'src/libs/web3-data-provider/Web3Provider'; import { useRootStore } from 'src/store/root'; import { SharedDependenciesProvider } from 'src/ui-config/SharedDependenciesProvider'; @@ -31,16 +31,22 @@ import createEmotionCache from '../src/createEmotionCache'; import { AppGlobalStyles } from '../src/layouts/AppGlobalStyles'; import { LanguageProvider } from '../src/libs/LanguageProvider'; -const SwitchModal = dynamic(() => - import('src/components/transactions/Switch/SwitchModal').then((module) => module.SwitchModal) +const SwapModal = dynamic(() => + import('src/components/transactions/Swap/modals/SwapModal').then((module) => module.SwapModal) ); const CollateralSwapModal = dynamic(() => - import('src/components/transactions/Switch/CollateralSwap/CollateralSwapModal').then( + import('src/components/transactions/Swap/modals/CollateralSwapModal').then( (module) => module.CollateralSwapModal ) ); +const DebtSwapModal = dynamic(() => + import('src/components/transactions/Swap/modals/DebtSwapModal').then( + (module) => module.DebtSwapModal + ) +); + const BridgeModal = dynamic(() => import('src/components/transactions/Bridge/BridgeModal').then((module) => module.BridgeModal) ); @@ -53,16 +59,6 @@ const ClaimRewardsModal = dynamic(() => (module) => module.ClaimRewardsModal ) ); -const CollateralChangeModal = dynamic(() => - import('src/components/transactions/CollateralChange/CollateralChangeModal').then( - (module) => module.CollateralChangeModal - ) -); -const DebtSwitchModal = dynamic(() => - import('src/components/transactions/DebtSwitch/DebtSwitchModal').then( - (module) => module.DebtSwitchModal - ) -); const EmodeModal = dynamic(() => import('src/components/transactions/Emode/EmodeModal').then((module) => module.EmodeModal) ); @@ -160,7 +156,7 @@ export default function MyApp(props: MyAppProps) { - + @@ -170,24 +166,25 @@ export default function MyApp(props: MyAppProps) { - - - - - + + {/* Swap Modals */} + + + + - + diff --git a/src/components/MarketSwitcher.tsx b/src/components/MarketSwitcher.tsx index 3dc1eb5844..01ace4f7a4 100644 --- a/src/components/MarketSwitcher.tsx +++ b/src/components/MarketSwitcher.tsx @@ -78,7 +78,13 @@ type MarketLogoProps = { export const MarketLogo = ({ size, logo, testChainName, sx }: MarketLogoProps) => { return ( - + {testChainName && ( diff --git a/src/components/StyledToggleButton.tsx b/src/components/StyledToggleButton.tsx index 8786f83bfd..ec0128d8b6 100644 --- a/src/components/StyledToggleButton.tsx +++ b/src/components/StyledToggleButton.tsx @@ -37,18 +37,31 @@ const CustomTxModalToggleButton = styled(ToggleButton)(({ the color: theme.palette.text.muted, borderRadius: '4px', + // Selected (active) state '&.Mui-selected, &.Mui-selected:hover': { border: `1px solid ${theme.palette.other.standardInputLine}`, backgroundColor: '#FFFFFF', borderRadius: '4px !important', - }, - - '&.Mui-selected, &.Mui-disabled': { + color: theme.palette.background.header, zIndex: 100, height: '100%', display: 'flex', justifyContent: 'center', + }, + + // Disabled but NOT selected: keep readable text with slight fade + '&.Mui-disabled:not(.Mui-selected)': { + color: theme.palette.text.secondary, + opacity: 0.55, + }, + + // Disabled + selected: preserve the selected look + '&.Mui-disabled.Mui-selected': { + border: `1px solid ${theme.palette.other.standardInputLine}`, + backgroundColor: '#FFFFFF', + borderRadius: '4px !important', color: theme.palette.background.header, + opacity: 1, }, })) as typeof ToggleButton; diff --git a/src/components/infoTooltips/EstimatedCostsForLimitSwap.tsx b/src/components/infoTooltips/EstimatedCostsForLimitSwap.tsx new file mode 100644 index 0000000000..787e467ec8 --- /dev/null +++ b/src/components/infoTooltips/EstimatedCostsForLimitSwap.tsx @@ -0,0 +1,15 @@ +import { Trans } from '@lingui/macro'; + +import { TextWithTooltip } from '../TextWithTooltip'; + +export const EstimatedCostsForLimitSwapTooltip = () => { + return ( + Estimated Costs & Fees}> + + These are the estimated costs associated with your limit swap, including costs and fees. + Consider these costs when setting your order amounts to help optimize execution and maximize + your chances of filling the order. + + + ); +}; diff --git a/src/components/infoTooltips/ExecutionFeeTooltip.tsx b/src/components/infoTooltips/ExecutionFeeTooltip.tsx new file mode 100644 index 0000000000..796e4d9dbd --- /dev/null +++ b/src/components/infoTooltips/ExecutionFeeTooltip.tsx @@ -0,0 +1,11 @@ +import { Trans } from '@lingui/macro'; + +import { TextWithTooltip } from '../TextWithTooltip'; + +export const ExecutionFeeTooltip = () => { + return ( + Execution fee}> + This is the fee for executing position changes, set by governance. + + ); +}; diff --git a/src/components/transactions/CancelCowOrder/CancelAdapterOrderActions.tsx b/src/components/transactions/CancelCowOrder/CancelAdapterOrderActions.tsx new file mode 100644 index 0000000000..66e610ac7b --- /dev/null +++ b/src/components/transactions/CancelCowOrder/CancelAdapterOrderActions.tsx @@ -0,0 +1,119 @@ +import { OrderStatus, SupportedChainId } from '@cowprotocol/cow-sdk'; +import { Trans } from '@lingui/macro'; +import { useQueryClient } from '@tanstack/react-query'; +import { Interface } from 'ethers/lib/utils'; +import { useIsWrongNetwork } from 'src/hooks/useIsWrongNetwork'; +import { useModalContext } from 'src/hooks/useModal'; +import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; +import { + ActionName, + CowSwapSubset, + isCowSwapSubset, + SwapActionFields, + TransactionHistoryItem, +} from 'src/modules/history/types'; +import { useRootStore } from 'src/store/root'; +import { getErrorTextFromError, TxAction } from 'src/ui-config/errorMapping'; +import { updateCowOrderStatus } from 'src/utils/swapAdapterHistory'; + +import { ADAPTER_FACTORY } from '../Swap/constants/cow.constants'; +import { TxActionsWrapper } from '../TxActionsWrapper'; + +interface CancelAdapterOrderActionsProps { + cowOrder: TransactionHistoryItem< + | SwapActionFields[ActionName.DebtSwap] + | SwapActionFields[ActionName.RepayWithCollateral] + | SwapActionFields[ActionName.CollateralSwap] + >; + blocked: boolean; +} + +// ABI for cancelInstance function +const ADAPTER_ABI = ['function cancelInstance(address instance) external']; + +export const CancelAdapterOrderActions = ({ + cowOrder, + blocked, +}: CancelAdapterOrderActionsProps) => { + const { isWrongNetwork } = useIsWrongNetwork(cowOrder.chainId); + const { mainTxState, loadingTxns, setMainTxState, setTxError } = useModalContext(); + const { sendTx } = useWeb3Context(); + const queryClient = useQueryClient(); + const account = useRootStore((state) => state.account); + + const action = async () => { + try { + setMainTxState({ ...mainTxState, loading: true }); + + // Type guard to ensure we have a CowSwapSubset with adapter fields + if (!isCowSwapSubset(cowOrder)) { + throw new Error('Order is not a CoW swap order'); + } + + // At this point TypeScript knows cowOrder is CowSwapSubset, but we need to assert it has adapter fields + const cowSwapOrder = cowOrder as CowSwapSubset; + + if (!cowSwapOrder.adapterInstanceAddress) { + throw new Error('Adapter instance address not found'); + } + + const adapterInterface = new Interface(ADAPTER_ABI); + + const factoryAddress = ADAPTER_FACTORY[cowOrder.chainId as SupportedChainId]; + + if (!factoryAddress) { + throw new Error('Factory address not found for this chain'); + } + + const data = adapterInterface.encodeFunctionData('cancelInstance', [ + cowSwapOrder.adapterInstanceAddress, + ]); + + const txResponse = await sendTx({ + to: factoryAddress, + data, + chainId: cowOrder.chainId, + }); + + await txResponse.wait(1); + + // Update order status to cancelled in local storage + if (account && cowSwapOrder.orderId) { + updateCowOrderStatus( + cowOrder.chainId, + account, + cowSwapOrder.orderId, + OrderStatus.CANCELLED + ); + } + + queryClient.invalidateQueries({ queryKey: 'transactionHistory' }); + setMainTxState({ + ...mainTxState, + loading: false, + success: true, + txHash: txResponse.hash, + }); + } catch (error) { + const parsedError = getErrorTextFromError(error, TxAction.MAIN_ACTION, false); + setTxError(parsedError); + setMainTxState({ + txHash: undefined, + loading: false, + }); + } + }; + + return ( + Cancel order} + actionInProgressText={Cancelling order...} + blocked={blocked} + mainTxState={mainTxState} + requiresApproval={false} + preparingTransactions={loadingTxns} + /> + ); +}; diff --git a/src/components/transactions/CancelCowOrder/CancelCowOrderActions.tsx b/src/components/transactions/CancelCowOrder/CancelCowOrderActions.tsx index 1bf4897988..abc1a19c42 100644 --- a/src/components/transactions/CancelCowOrder/CancelCowOrderActions.tsx +++ b/src/components/transactions/CancelCowOrder/CancelCowOrderActions.tsx @@ -1,17 +1,21 @@ -import { OrderBookApi, OrderSigningUtils } from '@cowprotocol/cow-sdk'; +import { AdapterContext, OrderBookApi, OrderSigningUtils, OrderStatus } from '@cowprotocol/cow-sdk'; import { Trans } from '@lingui/macro'; import { useQueryClient } from '@tanstack/react-query'; import { useIsWrongNetwork } from 'src/hooks/useIsWrongNetwork'; import { useModalContext } from 'src/hooks/useModal'; -import { getEthersProvider } from 'src/libs/web3-data-provider/adapters/EthersAdapter'; -import { ActionFields, TransactionHistoryItem } from 'src/modules/history/types'; +import { ActionName, SwapActionFields, TransactionHistoryItem } from 'src/modules/history/types'; +import { useRootStore } from 'src/store/root'; import { getErrorTextFromError, TxAction } from 'src/ui-config/errorMapping'; import { wagmiConfig } from 'src/ui-config/wagmiConfig'; +import { updateCowOrderStatus } from 'src/utils/swapAdapterHistory'; +import { getWalletClient } from 'wagmi/actions'; +import { COW_ENV, getCowAdapter } from '../Swap/helpers/cow'; import { TxActionsWrapper } from '../TxActionsWrapper'; +// TODO: check with cow if we can cancel adapters orders interface CancelCowOrderActionsProps { - cowOrder: TransactionHistoryItem; + cowOrder: TransactionHistoryItem; blocked: boolean; } @@ -19,23 +23,36 @@ export const CancelCowOrderActions = ({ cowOrder, blocked }: CancelCowOrderActio const { isWrongNetwork } = useIsWrongNetwork(cowOrder.chainId); const { mainTxState, loadingTxns, setMainTxState, setTxError } = useModalContext(); const queryClient = useQueryClient(); + const account = useRootStore((state) => state.account); const action = async () => { try { setMainTxState({ ...mainTxState, loading: true }); - const provider = getEthersProvider(wagmiConfig, { chainId: cowOrder.chainId }); - const signer = (await provider).getSigner(); - const orderBookApi = new OrderBookApi({ chainId: cowOrder.chainId }); + + const adapter = await getCowAdapter(cowOrder.chainId); + AdapterContext.getInstance().setAdapter(adapter); + const orderBookApi = new OrderBookApi({ chainId: cowOrder.chainId, env: COW_ENV }); + const walletClient = await getWalletClient(wagmiConfig, { chainId: cowOrder.chainId }); + + if (!walletClient || !walletClient.account) { + throw new Error('Wallet not connected for signing'); + } const { signature, signingScheme } = await OrderSigningUtils.signOrderCancellation( cowOrder.id, cowOrder.chainId, - signer + walletClient ); await orderBookApi.sendSignedOrderCancellations({ orderUids: [cowOrder.id], signature, signingScheme, }); + + // Update order status to cancelled in local storage + if (account && cowOrder.id) { + updateCowOrderStatus(cowOrder.chainId, account, cowOrder.id, OrderStatus.CANCELLED); + } + queryClient.invalidateQueries({ queryKey: 'transactionHistory' }); setTimeout(() => { setMainTxState({ diff --git a/src/components/transactions/CancelCowOrder/CancelCowOrderModal.tsx b/src/components/transactions/CancelCowOrder/CancelCowOrderModal.tsx index e71769310e..55486b6c2f 100644 --- a/src/components/transactions/CancelCowOrder/CancelCowOrderModal.tsx +++ b/src/components/transactions/CancelCowOrder/CancelCowOrderModal.tsx @@ -1,13 +1,19 @@ import { BasicModal } from 'src/components/primitives/BasicModal'; import { ModalContextType, ModalType, useModalContext } from 'src/hooks/useModal'; -import { ActionFields, TransactionHistoryItem } from 'src/modules/history/types'; +import { ActionName, SwapActionFields, TransactionHistoryItem } from 'src/modules/history/types'; import { TxModalTitle } from '../FlowCommons/TxModalTitle'; import { CancelCowOrderModalContent } from './CancelCowOrderModalContent'; export const CancelCowOrderModal = () => { const { type, close, args } = useModalContext() as ModalContextType<{ - cowOrder: TransactionHistoryItem; + cowOrder: TransactionHistoryItem< + | SwapActionFields[ActionName.Swap] + | SwapActionFields[ActionName.CollateralSwap] + | SwapActionFields[ActionName.DebtSwap] + | SwapActionFields[ActionName.RepayWithCollateral] + | SwapActionFields[ActionName.WithdrawAndSwap] + >; }>; return ( diff --git a/src/components/transactions/CancelCowOrder/CancelCowOrderModalContent.tsx b/src/components/transactions/CancelCowOrder/CancelCowOrderModalContent.tsx index 3fd17ba106..f4447b068e 100644 --- a/src/components/transactions/CancelCowOrder/CancelCowOrderModalContent.tsx +++ b/src/components/transactions/CancelCowOrder/CancelCowOrderModalContent.tsx @@ -3,7 +3,12 @@ import { Typography } from '@mui/material'; import { useIsWrongNetwork } from 'src/hooks/useIsWrongNetwork'; import { useModalContext } from 'src/hooks/useModal'; import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; -import { ActionFields, TransactionHistoryItem } from 'src/modules/history/types'; +import { + ActionName, + isCowSwapSubset, + SwapActionFields, + TransactionHistoryItem, +} from 'src/modules/history/types'; import { getNetworkConfig } from 'src/utils/marketsAndNetworksConfig'; import { formatUnits } from 'viem'; @@ -11,10 +16,17 @@ import { BaseSuccessView } from '../FlowCommons/BaseSuccess'; import { GasEstimationError } from '../FlowCommons/GasEstimationError'; import { DetailsNumberLine, DetailsTextLine, TxModalDetails } from '../FlowCommons/TxModalDetails'; import { ChangeNetworkWarning } from '../Warnings/ChangeNetworkWarning'; +import { CancelAdapterOrderActions } from './CancelAdapterOrderActions'; import { CancelCowOrderActions } from './CancelCowOrderActions'; interface CancelCowOrderModalContentProps { - cowOrder: TransactionHistoryItem; + cowOrder: TransactionHistoryItem< + | SwapActionFields[ActionName.Swap] + | SwapActionFields[ActionName.CollateralSwap] + | SwapActionFields[ActionName.DebtSwap] + | SwapActionFields[ActionName.RepayWithCollateral] + | SwapActionFields[ActionName.WithdrawAndSwap] + >; } export const CancelCowOrderModalContent = ({ cowOrder }: CancelCowOrderModalContentProps) => { @@ -26,8 +38,11 @@ export const CancelCowOrderModalContent = ({ cowOrder }: CancelCowOrderModalCont const showNetworkWarning = isWrongNetwork && !readOnlyMode; if (mainTxState.success) { + // Show explorer link if txHash exists (adapter cancellations have txHash) + const hasTxHash = !!mainTxState.txHash; + return ( - + Cancellation submited @@ -39,6 +54,23 @@ export const CancelCowOrderModalContent = ({ cowOrder }: CancelCowOrderModalCont <> {showNetworkWarning && } + + Cancel order + + + {isCowSwapSubset(cowOrder) && cowOrder.usedAdapter ? ( + + This will cancel the order via an on-chain transaction. Note that the order will not + be marked as cancelled in the CoW Protocol system, but will remain open and expire + naturally. Keep in mind that a solver may already have filled your order. + + ) : ( + + This is an off-chain operation. Keep in mind that a solver may already have filled + your order. + + )} + {txError && } - + {isCowSwapSubset(cowOrder) && cowOrder.usedAdapter && cowOrder.adapterInstanceAddress ? ( + + } + blocked={false} + /> + ) : ( + } + blocked={false} + /> + )} ); }; diff --git a/src/components/transactions/CollateralChange/CollateralChangeActions.tsx b/src/components/transactions/CollateralChange/CollateralChangeActions.tsx deleted file mode 100644 index ff819b4c54..0000000000 --- a/src/components/transactions/CollateralChange/CollateralChangeActions.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { ProtocolAction } from '@aave/contract-helpers'; -import { Trans } from '@lingui/macro'; -import { useTransactionHandler } from 'src/helpers/useTransactionHandler'; -import { ComputedReserveData } from 'src/hooks/app-data-provider/useAppDataProvider'; -import { useRootStore } from 'src/store/root'; - -import { TxActionsWrapper } from '../TxActionsWrapper'; - -export type CollateralChangeActionsProps = { - poolReserve: ComputedReserveData; - isWrongNetwork: boolean; - usageAsCollateral: boolean; - blocked: boolean; - symbol: string; -}; - -export const CollateralChangeActions = ({ - poolReserve, - isWrongNetwork, - usageAsCollateral, - blocked, - symbol, -}: CollateralChangeActionsProps) => { - const setUsageAsCollateral = useRootStore((state) => state.setUsageAsCollateral); - - const { action, loadingTxns, mainTxState, requiresApproval } = useTransactionHandler({ - tryPermit: false, - protocolAction: ProtocolAction.setUsageAsCollateral, - eventTxInfo: { - assetName: poolReserve.name, - asset: poolReserve.underlyingAsset, - previousState: (!usageAsCollateral).toString(), - newState: usageAsCollateral.toString(), - }, - - handleGetTxns: async () => { - return setUsageAsCollateral({ - reserve: poolReserve.underlyingAsset, - usageAsCollateral, - }); - }, - skip: blocked, - }); - - return ( - Enable {symbol} as collateral - ) : ( - Disable {symbol} as collateral - ) - } - actionInProgressText={Pending...} - handleAction={action} - /> - ); -}; diff --git a/src/components/transactions/CollateralChange/CollateralChangeModal.tsx b/src/components/transactions/CollateralChange/CollateralChangeModal.tsx deleted file mode 100644 index ed044938e8..0000000000 --- a/src/components/transactions/CollateralChange/CollateralChangeModal.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Trans } from '@lingui/macro'; -import React from 'react'; -import { UserAuthenticated } from 'src/components/UserAuthenticated'; -import { ModalContextType, ModalType, useModalContext } from 'src/hooks/useModal'; - -import { BasicModal } from '../../primitives/BasicModal'; -import { ModalWrapper } from '../FlowCommons/ModalWrapper'; -import { CollateralChangeModalContent } from './CollateralChangeModalContent'; - -export const CollateralChangeModal = () => { - const { type, close, args } = useModalContext() as ModalContextType<{ - underlyingAsset: string; - }>; - return ( - - Review tx} underlyingAsset={args.underlyingAsset}> - {(params) => ( - - {(user) => } - - )} - - - ); -}; diff --git a/src/components/transactions/CollateralChange/CollateralChangeModalContent.tsx b/src/components/transactions/CollateralChange/CollateralChangeModalContent.tsx deleted file mode 100644 index de11544c94..0000000000 --- a/src/components/transactions/CollateralChange/CollateralChangeModalContent.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { calculateHealthFactorFromBalancesBigUnits, valueToBigNumber } from '@aave/math-utils'; -import { Trans } from '@lingui/macro'; -import { Typography } from '@mui/material'; -import { useEffect, useState } from 'react'; -import { Warning } from 'src/components/primitives/Warning'; -import { ExtendedFormattedUser } from 'src/hooks/app-data-provider/useAppDataProvider'; -import { useAssetCaps } from 'src/hooks/useAssetCaps'; -import { useModalContext } from 'src/hooks/useModal'; -import { useZeroLTVBlockingWithdraw } from 'src/hooks/useZeroLTVBlockingWithdraw'; - -import { GasEstimationError } from '../FlowCommons/GasEstimationError'; -import { ModalWrapperProps } from '../FlowCommons/ModalWrapper'; -import { TxSuccessView } from '../FlowCommons/Success'; -import { DetailsHFLine, DetailsNumberLine, TxModalDetails } from '../FlowCommons/TxModalDetails'; -import { IsolationModeWarning } from '../Warnings/IsolationModeWarning'; -import { CollateralChangeActions } from './CollateralChangeActions'; - -export type CollateralChangeModalContentProps = { - underlyingAsset: string; -}; - -export enum ErrorType { - DO_NOT_HAVE_SUPPLIES_IN_THIS_CURRENCY, - CAN_NOT_USE_THIS_CURRENCY_AS_COLLATERAL, - CAN_NOT_SWITCH_USAGE_AS_COLLATERAL_MODE, - ZERO_LTV_WITHDRAW_BLOCKED, -} - -export const CollateralChangeModalContent = ({ - poolReserve, - userReserve, - isWrongNetwork, - symbol, - user, -}: ModalWrapperProps & { user: ExtendedFormattedUser }) => { - const { gasLimit, mainTxState: collateralChangeTxState, txError } = useModalContext(); - const { debtCeiling } = useAssetCaps(); - - const [collateralEnabled, setCollateralEnabled] = useState( - userReserve.usageAsCollateralEnabledOnUser - ); - - // Health factor calculations - const usageAsCollateralModeAfterSwitch = !userReserve.usageAsCollateralEnabledOnUser; - const currenttotalCollateralMarketReferenceCurrency = valueToBigNumber( - user.totalCollateralMarketReferenceCurrency - ); - - // Messages - const showEnableIsolationModeMsg = !poolReserve.isIsolated && usageAsCollateralModeAfterSwitch; - const showDisableIsolationModeMsg = !poolReserve.isIsolated && !usageAsCollateralModeAfterSwitch; - const showEnterIsolationModeMsg = poolReserve.isIsolated && usageAsCollateralModeAfterSwitch; - const showExitIsolationModeMsg = poolReserve.isIsolated && !usageAsCollateralModeAfterSwitch; - - const totalCollateralAfterSwitchETH = currenttotalCollateralMarketReferenceCurrency[ - usageAsCollateralModeAfterSwitch ? 'plus' : 'minus' - ](userReserve.underlyingBalanceMarketReferenceCurrency); - - const healthFactorAfterSwitch = calculateHealthFactorFromBalancesBigUnits({ - collateralBalanceMarketReferenceCurrency: totalCollateralAfterSwitchETH, - borrowBalanceMarketReferenceCurrency: user.totalBorrowsMarketReferenceCurrency, - currentLiquidationThreshold: user.currentLiquidationThreshold, - }); - - const assetsBlockingWithdraw = useZeroLTVBlockingWithdraw(); - - // error handling - let blockingError: ErrorType | undefined = undefined; - if (assetsBlockingWithdraw.length > 0 && !assetsBlockingWithdraw.includes(poolReserve.symbol)) { - blockingError = ErrorType.ZERO_LTV_WITHDRAW_BLOCKED; - } else if (valueToBigNumber(userReserve.underlyingBalance).eq(0)) { - blockingError = ErrorType.DO_NOT_HAVE_SUPPLIES_IN_THIS_CURRENCY; - } else if ( - (!userReserve.usageAsCollateralEnabledOnUser && - poolReserve.reserveLiquidationThreshold === '0') || - poolReserve.reserveLiquidationThreshold === '0' - ) { - blockingError = ErrorType.CAN_NOT_USE_THIS_CURRENCY_AS_COLLATERAL; - } else if ( - userReserve.usageAsCollateralEnabledOnUser && - user.totalBorrowsMarketReferenceCurrency !== '0' && - healthFactorAfterSwitch.lte('1') - ) { - blockingError = ErrorType.CAN_NOT_SWITCH_USAGE_AS_COLLATERAL_MODE; - } - - // error render handling - const BlockingError: React.FC = () => { - switch (blockingError) { - case ErrorType.DO_NOT_HAVE_SUPPLIES_IN_THIS_CURRENCY: - return You do not have supplies in this currency; - case ErrorType.CAN_NOT_USE_THIS_CURRENCY_AS_COLLATERAL: - return You can not use this currency as collateral; - case ErrorType.CAN_NOT_SWITCH_USAGE_AS_COLLATERAL_MODE: - return ( - - You can not switch usage as collateral mode for this currency, because it will cause - collateral call - - ); - case ErrorType.ZERO_LTV_WITHDRAW_BLOCKED: - return ( - - Assets with zero LTV ({assetsBlockingWithdraw.join(', ')}) must be withdrawn or disabled - as collateral to perform this action - - ); - default: - return null; - } - }; - - // Effect to handle changes in collateral mode after switch as polling is fetching reserve state different after successful tx - useEffect(() => { - if (collateralChangeTxState.success) { - setCollateralEnabled(usageAsCollateralModeAfterSwitch); - } - }, [collateralChangeTxState.success, collateralEnabled]); - - if (collateralChangeTxState.success) - return ; - - return ( - <> - {showEnableIsolationModeMsg && ( - - - Enabling this asset as collateral increases your borrowing power and Health Factor. - However, it can get liquidated if your health factor drops below 1. - - - )} - - {showDisableIsolationModeMsg && ( - - - Disabling this asset as collateral affects your borrowing power and Health Factor. - - - )} - - {showEnterIsolationModeMsg && } - - {showExitIsolationModeMsg && ( - - You will exit isolation mode and other tokens can now be used as collateral - - )} - - {poolReserve.isIsolated && debtCeiling.determineWarningDisplay({ debtCeiling })} - - - Supply balance} - value={userReserve.underlyingBalance} - /> - - - - {blockingError !== undefined && ( - - - - )} - - {txError && } - - - - ); -}; diff --git a/src/components/transactions/DebtSwitch/DebtSwitchActions.tsx b/src/components/transactions/DebtSwitch/DebtSwitchActions.tsx deleted file mode 100644 index d8a4cc8941..0000000000 --- a/src/components/transactions/DebtSwitch/DebtSwitchActions.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import { - ApproveDelegationType, - gasLimitRecommendations, - ProtocolAction, -} from '@aave/contract-helpers'; -import { valueToBigNumber } from '@aave/math-utils'; -import { SignatureLike } from '@ethersproject/bytes'; -import { Trans } from '@lingui/macro'; -import { BoxProps } from '@mui/material'; -import { useQueryClient } from '@tanstack/react-query'; -import { parseUnits } from 'ethers/lib/utils'; -import { useCallback, useEffect, useState } from 'react'; -import { MOCK_SIGNED_HASH } from 'src/helpers/useTransactionHandler'; -import { ComputedReserveData } from 'src/hooks/app-data-provider/useAppDataProvider'; -import { calculateSignedAmount, SwapTransactionParams } from 'src/hooks/paraswap/common'; -import { useModalContext } from 'src/hooks/useModal'; -import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; -import { useRootStore } from 'src/store/root'; -import { ApprovalMethod } from 'src/store/walletSlice'; -import { getErrorTextFromError, TxAction } from 'src/ui-config/errorMapping'; -import { queryKeysFactory } from 'src/ui-config/queries'; -import { useShallow } from 'zustand/shallow'; - -import { TxActionsWrapper } from '../TxActionsWrapper'; -import { APPROVE_DELEGATION_GAS_LIMIT, checkRequiresApproval } from '../utils'; - -interface DebtSwitchBaseProps extends BoxProps { - amountToSwap: string; - amountToReceive: string; - poolReserve: ComputedReserveData; - targetReserve: ComputedReserveData; - isWrongNetwork: boolean; - customGasPrice?: string; - symbol?: string; - blocked?: boolean; - isMaxSelected: boolean; - loading?: boolean; - signatureParams?: SignedParams; -} - -export interface DebtSwitchActionProps extends DebtSwitchBaseProps { - augustus: string; - txCalldata: string; -} - -interface SignedParams { - signature: SignatureLike; - deadline: string; - amount: string; -} - -export const DebtSwitchActions = ({ - amountToSwap, - amountToReceive, - isWrongNetwork, - sx, - poolReserve, - targetReserve, - isMaxSelected, - loading, - blocked, - buildTxFn, -}: DebtSwitchBaseProps & { buildTxFn: () => Promise }) => { - const [ - getCreditDelegationApprovedAmount, - currentMarketData, - generateApproveDelegation, - estimateGasLimit, - addTransaction, - debtSwitch, - walletApprovalMethodPreference, - generateCreditDelegationSignatureRequest, - ] = useRootStore( - useShallow((state) => [ - state.getCreditDelegationApprovedAmount, - state.currentMarketData, - state.generateApproveDelegation, - state.estimateGasLimit, - state.addTransaction, - state.debtSwitch, - state.walletApprovalMethodPreference, - state.generateCreditDelegationSignatureRequest, - ]) - ); - const { - approvalTxState, - mainTxState, - loadingTxns, - setMainTxState, - setTxError, - setGasLimit, - setLoadingTxns, - setApprovalTxState, - } = useModalContext(); - const { sendTx, signTxData } = useWeb3Context(); - const queryClient = useQueryClient(); - const [requiresApproval, setRequiresApproval] = useState(false); - const [approvedAmount, setApprovedAmount] = useState(); - const [useSignature, setUseSignature] = useState(false); - const [signatureParams, setSignatureParams] = useState(); - - const approvalWithSignatureAvailable = currentMarketData.v3; - - useEffect(() => { - const preferSignature = walletApprovalMethodPreference === ApprovalMethod.PERMIT; - setUseSignature(preferSignature); - }, [walletApprovalMethodPreference]); - - const approval = async () => { - try { - if (requiresApproval && approvedAmount) { - const approveDelegationAmount = calculateSignedAmount( - amountToReceive, - targetReserve.decimals, - 0.25 - ); - if (useSignature && approvalWithSignatureAvailable) { - const deadline = Math.floor(Date.now() / 1000 + 3600).toString(); - const signatureRequest = await generateCreditDelegationSignatureRequest({ - underlyingAsset: targetReserve.variableDebtTokenAddress, - deadline, - amount: approveDelegationAmount, - spender: currentMarketData.addresses.DEBT_SWITCH_ADAPTER ?? '', - }); - const response = await signTxData(signatureRequest); - setSignatureParams({ signature: response, deadline, amount: approveDelegationAmount }); - setApprovalTxState({ - txHash: MOCK_SIGNED_HASH, - loading: false, - success: true, - }); - } else { - let approveDelegationTxData = generateApproveDelegation({ - debtTokenAddress: targetReserve.variableDebtTokenAddress, - delegatee: currentMarketData.addresses.DEBT_SWITCH_ADAPTER ?? '', - amount: approveDelegationAmount, - }); - setApprovalTxState({ ...approvalTxState, loading: true }); - approveDelegationTxData = await estimateGasLimit(approveDelegationTxData); - const response = await sendTx(approveDelegationTxData); - await response.wait(1); - setApprovalTxState({ - txHash: response.hash, - loading: false, - success: true, - }); - addTransaction(response.hash, { - action: ProtocolAction.approval, - txState: 'success', - asset: targetReserve.variableDebtTokenAddress, - amount: approveDelegationAmount, - assetName: 'varDebt' + targetReserve.name, - spender: currentMarketData.addresses.DEBT_SWITCH_ADAPTER, - }); - setTxError(undefined); - fetchApprovedAmount(true); - } - } - } catch (error) { - const parsedError = getErrorTextFromError(error, TxAction.GAS_ESTIMATION, false); - setTxError(parsedError); - if (!approvalTxState.success) { - setApprovalTxState({ - txHash: undefined, - loading: false, - }); - } - } - }; - const action = async () => { - try { - setMainTxState({ ...mainTxState, loading: true }); - const route = await buildTxFn(); - let debtSwitchTxData = debtSwitch({ - poolReserve, - targetReserve, - amountToReceive: parseUnits(amountToReceive, targetReserve.decimals).toString(), - amountToSwap: parseUnits(amountToSwap, poolReserve.decimals).toString(), - isMaxSelected, - txCalldata: route.swapCallData, - augustus: route.augustus, - signatureParams, - isWrongNetwork, - }); - debtSwitchTxData = await estimateGasLimit(debtSwitchTxData); - const response = await sendTx(debtSwitchTxData); - await response.wait(1); - setMainTxState({ - txHash: response.hash, - loading: false, - success: true, - }); - addTransaction(response.hash, { - action: 'debtSwitch', - txState: 'success', - previousState: `${route.outputAmount} variable ${poolReserve.symbol}`, - newState: `${route.inputAmount} variable ${targetReserve.symbol}`, - amountUsd: valueToBigNumber(parseUnits(amountToSwap, poolReserve.decimals).toString()) - .multipliedBy(poolReserve.priceInUSD) - .toString(), - outAmountUsd: valueToBigNumber( - parseUnits(amountToReceive, targetReserve.decimals).toString() - ) - .multipliedBy(targetReserve.priceInUSD) - .toString(), - }); - - queryClient.invalidateQueries({ queryKey: queryKeysFactory.pool }); - queryClient.invalidateQueries({ queryKey: queryKeysFactory.gho }); - } catch (error) { - const parsedError = getErrorTextFromError(error, TxAction.GAS_ESTIMATION, false); - setTxError(parsedError); - setMainTxState({ - txHash: undefined, - loading: false, - }); - } - }; - - // callback to fetch approved credit delegation amount and determine execution path on dependency updates - const fetchApprovedAmount = useCallback( - async (forceApprovalCheck?: boolean) => { - // Check approved amount on-chain on first load or if an action triggers a re-check such as an approveDelegation being confirmed - let approval = approvedAmount; - if (approval === undefined || forceApprovalCheck) { - setLoadingTxns(true); - approval = await getCreditDelegationApprovedAmount({ - debtTokenAddress: targetReserve.variableDebtTokenAddress, - delegatee: currentMarketData.addresses.DEBT_SWITCH_ADAPTER ?? '', - }); - setApprovedAmount(approval); - } else { - setRequiresApproval(false); - setApprovalTxState({}); - } - - if (approval) { - const fetchedRequiresApproval = checkRequiresApproval({ - approvedAmount: approval.amount, - amount: amountToReceive, - signedAmount: '0', - }); - setRequiresApproval(fetchedRequiresApproval); - if (fetchedRequiresApproval) setApprovalTxState({}); - } - - setLoadingTxns(false); - }, - [ - approvedAmount, - setLoadingTxns, - getCreditDelegationApprovedAmount, - targetReserve.variableDebtTokenAddress, - currentMarketData.addresses.DEBT_SWITCH_ADAPTER, - setApprovalTxState, - amountToReceive, - ] - ); - - // Run on first load and when the target reserve changes - useEffect(() => { - if (amountToSwap === '0') return; - - if (!approvedAmount) { - fetchApprovedAmount(); - } else if (approvedAmount.debtTokenAddress !== targetReserve.variableDebtTokenAddress) { - fetchApprovedAmount(true); - } - }, [amountToSwap, approvedAmount, fetchApprovedAmount, targetReserve.variableDebtTokenAddress]); - - // Update gas estimation - useEffect(() => { - let switchGasLimit = 0; - switchGasLimit = Number(gasLimitRecommendations[ProtocolAction.borrow].recommended); - if (requiresApproval && !approvalTxState.success) { - switchGasLimit += Number(APPROVE_DELEGATION_GAS_LIMIT); - } - setGasLimit(switchGasLimit.toString()); - }, [requiresApproval, approvalTxState, setGasLimit]); - - return ( - approval()} - requiresApproval={requiresApproval} - actionText={Swap} - actionInProgressText={Swapping} - sx={sx} - fetchingData={loading} - errorParams={{ - loading: false, - disabled: blocked || !approvalTxState?.success, - content: Swap, - handleClick: action, - }} - blocked={blocked} - tryPermit={approvalWithSignatureAvailable} - /> - ); -}; diff --git a/src/components/transactions/DebtSwitch/DebtSwitchModal.tsx b/src/components/transactions/DebtSwitch/DebtSwitchModal.tsx deleted file mode 100644 index c1a44b58a5..0000000000 --- a/src/components/transactions/DebtSwitch/DebtSwitchModal.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Trans } from '@lingui/macro'; -import React from 'react'; -import { BasicModal } from 'src/components/primitives/BasicModal'; -import { UserAuthenticated } from 'src/components/UserAuthenticated'; -import { ModalContextType, ModalType, useModalContext } from 'src/hooks/useModal'; - -import { ModalWrapper } from '../FlowCommons/ModalWrapper'; -import { DebtSwitchModalContent } from './DebtSwitchModalContent'; - -export const DebtSwitchModal = () => { - const { type, close, args } = useModalContext() as ModalContextType<{ - underlyingAsset: string; - }>; - return ( - - Swap borrow position} - underlyingAsset={args.underlyingAsset} - hideTitleSymbol - > - {(params) => ( - - {(user) => } - - )} - - - ); -}; diff --git a/src/components/transactions/DebtSwitch/DebtSwitchModalContent.tsx b/src/components/transactions/DebtSwitch/DebtSwitchModalContent.tsx deleted file mode 100644 index 6e657a903b..0000000000 --- a/src/components/transactions/DebtSwitch/DebtSwitchModalContent.tsx +++ /dev/null @@ -1,374 +0,0 @@ -import { valueToBigNumber } from '@aave/math-utils'; -import { MaxUint256 } from '@ethersproject/constants'; -import { ArrowDownIcon } from '@heroicons/react/outline'; -import { ArrowNarrowRightIcon } from '@heroicons/react/solid'; -import { Trans } from '@lingui/macro'; -import { Box, ListItemText, ListSubheader, Stack, SvgIcon, Typography } from '@mui/material'; -import { BigNumber } from 'bignumber.js'; -import React, { useRef, useState } from 'react'; -import { PriceImpactTooltip } from 'src/components/infoTooltips/PriceImpactTooltip'; -import { FormattedNumber } from 'src/components/primitives/FormattedNumber'; -import { TokenIcon } from 'src/components/primitives/TokenIcon'; -import { Warning } from 'src/components/primitives/Warning'; -import { Asset, AssetInput } from 'src/components/transactions/AssetInput'; -import { TxModalDetails } from 'src/components/transactions/FlowCommons/TxModalDetails'; -import { maxInputAmountWithSlippage } from 'src/hooks/paraswap/common'; -import { useDebtSwitch } from 'src/hooks/paraswap/useDebtSwitch'; -import { useModalContext } from 'src/hooks/useModal'; -import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; -import { ListSlippageButton } from 'src/modules/dashboard/lists/SlippageList'; -import { useRootStore } from 'src/store/root'; -import { assetCanBeBorrowedByUser } from 'src/utils/getMaxAmountAvailableToBorrow'; - -import { - ComputedUserReserveData, - ExtendedFormattedUser, - useAppDataContext, -} from '../../../hooks/app-data-provider/useAppDataProvider'; -import { ModalWrapperProps } from '../FlowCommons/ModalWrapper'; -import { TxSuccessView } from '../FlowCommons/Success'; -import { ParaswapErrorDisplay } from '../Warnings/ParaswapErrorDisplay'; -import { DebtSwitchActions } from './DebtSwitchActions'; -import { DebtSwitchModalDetails } from './DebtSwitchModalDetails'; - -export type SupplyProps = { - underlyingAsset: string; -}; - -export interface GhoRange { - qualifiesForDiscount: boolean; - userBorrowApyAfterMaxSwitch: number; - ghoApyRange?: [number, number]; - userDiscountTokenBalance: number; - inputAmount: number; - targetAmount: number; - userCurrentBorrowApy: number; - ghoVariableBorrowApy: number; - userGhoAvailableToBorrowAtDiscount: number; - ghoBorrowAPYWithMaxDiscount: number; - userCurrentBorrowBalance: number; -} - -interface SwitchTargetAsset extends Asset { - variableApy: string; -} - -enum ErrorType { - INSUFFICIENT_LIQUIDITY, -} - -export const DebtSwitchModalContent = ({ - poolReserve, - userReserve, - isWrongNetwork, - user, -}: ModalWrapperProps & { user: ExtendedFormattedUser }) => { - const { reserves } = useAppDataContext(); - const currentChainId = useRootStore((store) => store.currentChainId); - const currentNetworkConfig = useRootStore((store) => store.currentNetworkConfig); - const { currentAccount } = useWeb3Context(); - const { gasLimit, mainTxState, txError, setTxError } = useModalContext(); - - let switchTargets = reserves - .filter( - (r) => - r.underlyingAsset !== poolReserve.underlyingAsset && - r.availableLiquidity !== '0' && - assetCanBeBorrowedByUser(r, user) - ) - .map((reserve) => ({ - address: reserve.underlyingAsset, - symbol: reserve.symbol, - iconSymbol: reserve.iconSymbol, - variableApy: reserve.variableBorrowAPY, - priceInUsd: reserve.priceInUSD, - decimals: reserve.decimals, - })); - - switchTargets = [ - ...switchTargets.filter((r) => r.symbol === 'GHO'), - ...switchTargets.filter((r) => r.symbol !== 'GHO'), - ]; - - // states - const [_amount, setAmount] = useState(''); - const amountRef = useRef(''); - const [targetReserve, setTargetReserve] = useState(switchTargets[0]); - const [maxSlippage, setMaxSlippage] = useState('0.1'); - - const switchTarget = user.userReservesData.find( - (r) => r.underlyingAsset === targetReserve.address - ) as ComputedUserReserveData; - - const maxAmountToSwitch = userReserve.variableBorrows; - - const isMaxSelected = _amount === '-1'; - const amount = isMaxSelected ? maxAmountToSwitch : _amount; - - const { - inputAmount, - outputAmount, - outputAmountUSD, - error, - loading: routeLoading, - buildTxFn, - } = useDebtSwitch({ - chainId: currentNetworkConfig.underlyingChainId || currentChainId, - userAddress: currentAccount, - swapOut: { ...poolReserve, amount: amountRef.current }, - swapIn: { ...switchTarget.reserve, amount: '0' }, - max: isMaxSelected, - skip: mainTxState.loading || false, - maxSlippage: Number(maxSlippage), - }); - - const loadingSkeleton = routeLoading && outputAmountUSD === '0'; - - const handleChange = (value: string) => { - const maxSelected = value === '-1'; - amountRef.current = maxSelected ? maxAmountToSwitch : value; - setAmount(value); - setTxError(undefined); - }; - - let availableBorrowCap = valueToBigNumber(MaxUint256.toString()); - let availableLiquidity: string | number = '0'; - availableBorrowCap = - switchTarget.reserve.borrowCap === '0' - ? valueToBigNumber(MaxUint256.toString()) - : valueToBigNumber(Number(switchTarget.reserve.borrowCap)).minus( - valueToBigNumber(switchTarget.reserve.totalDebt) - ); - availableLiquidity = switchTarget.reserve.formattedAvailableLiquidity; - - const availableLiquidityOfTargetReserve = BigNumber.max( - BigNumber.min(availableLiquidity, availableBorrowCap), - 0 - ); - - const poolReserveAmountUSD = Number(amount) * Number(poolReserve.priceInUSD); - const targetReserveAmountUSD = Number(inputAmount) * Number(targetReserve.priceInUsd); - - const priceImpactDifference: number = targetReserveAmountUSD - poolReserveAmountUSD; - const insufficientCollateral = - Number(user.availableBorrowsUSD) === 0 || - priceImpactDifference > Number(user.availableBorrowsUSD); - - let blockingError: ErrorType | undefined = undefined; - if (BigNumber(inputAmount).gt(availableLiquidityOfTargetReserve)) { - blockingError = ErrorType.INSUFFICIENT_LIQUIDITY; - } - - const BlockingError: React.FC = () => { - switch (blockingError) { - case ErrorType.INSUFFICIENT_LIQUIDITY: - return ( - - There is not enough liquidity for the target asset to perform the switch. Try lowering - the amount. - - ); - default: - return null; - } - }; - - const maxAmountToReceiveWithSlippage = maxInputAmountWithSlippage( - inputAmount, - maxSlippage, - targetReserve.decimals || 18 - ); - - if (mainTxState.success) - return ( - - - You've successfully swapped borrow position. - - - - - {poolReserve.symbol} - - - - - - {switchTarget.reserve.symbol} - - - } - /> - ); - - return ( - <> - Borrowed asset amount} - balanceText={ - - Borrow balance - - } - isMaxSelected={isMaxSelected} - /> - - - - - - {/** For debt switch, targetAmountUSD (input) > poolReserveAmountUSD (output) means that more is being borrowed to cover the current borrow balance as exactOut, so this should be treated as positive impact */} - - - - value={inputAmount} - onSelect={setTargetReserve} - usdValue={targetReserveAmountUSD.toString()} - symbol={targetReserve.symbol} - assets={switchTargets} - inputTitle={Swap to} - balanceText={Supply balance} - disableInput - loading={loadingSkeleton} - selectOptionHeader={} - selectOption={(asset) => } - /> - {error && !loadingSkeleton && ( - - {error} - - )} - {!error && blockingError !== undefined && ( - - - - )} - - { - setTxError(undefined); - setMaxSlippage(newMaxSlippage); - }} - slippageTooltipHeader={ - - Maximum amount received - - - - - - - - } - /> - } - > - - - - {txError && } - - {insufficientCollateral && ( - - - - Insufficient collateral to cover new borrow position. Wallet must have borrowing power - remaining to perform debt switch. - - - - )} - - - - ); -}; - -const SelectOptionListHeader = () => { - return ( - ({ borderBottom: `1px solid ${theme.palette.divider}`, mt: -1 })}> - - - Select an asset - - - Borrow APY - - - - ); -}; - -const SwitchTargetSelectOption = ({ asset }: { asset: SwitchTargetAsset }) => { - return ( - <> - - {asset.symbol} - - - - Variable rate - - - - ); -}; diff --git a/src/components/transactions/DebtSwitch/DebtSwitchModalDetails.tsx b/src/components/transactions/DebtSwitch/DebtSwitchModalDetails.tsx deleted file mode 100644 index d0e537aa70..0000000000 --- a/src/components/transactions/DebtSwitch/DebtSwitchModalDetails.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { valueToBigNumber } from '@aave/math-utils'; -import { ArrowNarrowRightIcon } from '@heroicons/react/solid'; -import { Trans } from '@lingui/macro'; -import { Box, Skeleton, SvgIcon } from '@mui/material'; -import React from 'react'; -import { FormattedNumber } from 'src/components/primitives/FormattedNumber'; -import { Row } from 'src/components/primitives/Row'; -import { TokenIcon } from 'src/components/primitives/TokenIcon'; -import { DetailsIncentivesLine } from 'src/components/transactions/FlowCommons/TxModalDetails'; - -import { ComputedUserReserveData } from '../../../hooks/app-data-provider/useAppDataProvider'; - -export type DebtSwitchModalDetailsProps = { - switchSource: ComputedUserReserveData; - switchTarget: ComputedUserReserveData; - toAmount: string; - fromAmount: string; - loading: boolean; - sourceBalance: string; - sourceBorrowAPY: string; - targetBorrowAPY: string; -}; -const ArrowRightIcon = ( - - - -); - -export const DebtSwitchModalDetails = ({ - switchSource, - switchTarget, - toAmount, - fromAmount, - loading, - sourceBalance, - sourceBorrowAPY, - targetBorrowAPY, -}: DebtSwitchModalDetailsProps) => { - const sourceAmountAfterSwap = valueToBigNumber(sourceBalance).minus(valueToBigNumber(fromAmount)); - - const targetAmountAfterSwap = valueToBigNumber(switchTarget.variableBorrows).plus( - valueToBigNumber(toAmount) - ); - - const skeleton: JSX.Element = ( - <> - - - - ); - - return ( - <> - Borrow apy} captionVariant="description" mb={4}> - - {loading ? ( - - ) : ( - <> - - {ArrowRightIcon} - - - )} - - - - - - Borrow balance after switch} - captionVariant="description" - mb={4} - align="flex-start" - > - - - {loading ? ( - skeleton - ) : ( - <> - - - - - - - )} - - - - {loading ? ( - skeleton - ) : ( - <> - - - - - - - )} - - - - - ); -}; diff --git a/src/components/transactions/FlowCommons/BaseSuccess.tsx b/src/components/transactions/FlowCommons/BaseSuccess.tsx index 689b6f8d78..a045d8249b 100644 --- a/src/components/transactions/FlowCommons/BaseSuccess.tsx +++ b/src/components/transactions/FlowCommons/BaseSuccess.tsx @@ -95,7 +95,7 @@ export const BaseSuccessView = ({ onClick={handleClose} variant="contained" size="large" - sx={{ minHeight: '50px', mb: '30px' }} + sx={{ minHeight: '50px', mb: '0px' }} data-cy="closeButton" > Ok, Close diff --git a/src/components/transactions/FlowCommons/PermitNonceInfo.tsx b/src/components/transactions/FlowCommons/PermitNonceInfo.tsx new file mode 100644 index 0000000000..358680be90 --- /dev/null +++ b/src/components/transactions/FlowCommons/PermitNonceInfo.tsx @@ -0,0 +1,32 @@ +import { Trans } from '@lingui/macro'; +import { Tooltip, Typography } from '@mui/material'; + +export const PermitNonceInfo = () => { + return ( + + There is an active order for the same sell asset (avoid nonce reuse). + + } + placement="left" + arrow + > + + Approval by signature not available + + + ); +}; diff --git a/src/components/transactions/FlowCommons/RightHelperText.tsx b/src/components/transactions/FlowCommons/RightHelperText.tsx index e401d04e65..64518dab3a 100644 --- a/src/components/transactions/FlowCommons/RightHelperText.tsx +++ b/src/components/transactions/FlowCommons/RightHelperText.tsx @@ -9,9 +9,12 @@ import { useRootStore } from 'src/store/root'; import { ApprovalMethod } from 'src/store/walletSlice'; import { useShallow } from 'zustand/shallow'; +import { PermitNonceInfo } from './PermitNonceInfo'; + export type RightHelperTextProps = { approvalHash?: string; tryPermit?: boolean; + permitInUse?: boolean; }; const ExtLinkIcon = () => ( @@ -20,7 +23,11 @@ const ExtLinkIcon = () => ( ); -export const RightHelperText = ({ approvalHash, tryPermit }: RightHelperTextProps) => { +export const RightHelperText = ({ + approvalHash, + tryPermit, + permitInUse = false, +}: RightHelperTextProps) => { const [ account, walletApprovalMethodPreference, @@ -61,6 +68,13 @@ export const RightHelperText = ({ approvalHash, tryPermit }: RightHelperTextProp /> ); + // When permit use is disabled by the flow, inform the user why + if (!tryPermit && permitInUse) + return ( + + + + ); if (approvalHash && !usingPermit) return ( Promise }) => { - const [paraswapRepayWithCollateral, currentMarketData] = useRootStore( - useShallow((state) => [state.paraswapRepayWithCollateral, state.currentMarketData]) - ); - - const { approval, action, loadingTxns, approvalTxState, mainTxState, requiresApproval } = - useParaSwapTransactionHandler({ - protocolAction: ProtocolAction.repayCollateral, - handleGetTxns: async (signature, deadline) => { - const route = await buildTxFn(); - return paraswapRepayWithCollateral({ - repayAllDebt, - repayAmount, - rateMode, - repayWithAmount, - fromAssetData, - poolReserve, - isWrongNetwork, - symbol, - useFlashLoan, - blocked, - swapCallData: route.swapCallData, - augustus: route.augustus, - signature, - deadline, - signedAmount: calculateSignedAmount(repayWithAmount, fromAssetData.decimals), - }); - }, - handleGetApprovalTxns: async () => { - return paraswapRepayWithCollateral({ - repayAllDebt, - repayAmount, - rateMode, - repayWithAmount, - fromAssetData, - poolReserve, - isWrongNetwork, - symbol, - useFlashLoan: false, - blocked, - swapCallData: '0x', - augustus: API_ETH_MOCK_ADDRESS, - }); - }, - gasLimitRecommendation: gasLimitRecommendations[ProtocolAction.repayCollateral].limit, - skip: loading || !repayAmount || parseFloat(repayAmount) === 0 || blocked, - spender: currentMarketData.addresses.REPAY_WITH_COLLATERAL_ADAPTER ?? '', - deps: [fromAssetData.symbol, repayWithAmount], - }); - - return ( - - approval({ - amount: calculateSignedAmount(repayWithAmount, fromAssetData.decimals), - underlyingAsset: fromAssetData.aTokenAddress, - }) - } - actionText={Repay {symbol}} - actionInProgressText={Repaying {symbol}} - fetchingData={loading} - errorParams={{ - loading: false, - disabled: blocked, - content: Repay {symbol}, - handleClick: action, - }} - tryPermit - /> - ); -}; diff --git a/src/components/transactions/Repay/CollateralRepayModalContent.tsx b/src/components/transactions/Repay/CollateralRepayModalContent.tsx deleted file mode 100644 index 2fc80232e3..0000000000 --- a/src/components/transactions/Repay/CollateralRepayModalContent.tsx +++ /dev/null @@ -1,399 +0,0 @@ -import { InterestRate } from '@aave/contract-helpers'; -import { valueToBigNumber } from '@aave/math-utils'; -import { ArrowDownIcon } from '@heroicons/react/outline'; -import { Trans } from '@lingui/macro'; -import { Box, Stack, SvgIcon, Typography } from '@mui/material'; -import { BigNumber } from 'bignumber.js'; -import { useRef, useState } from 'react'; -import { PriceImpactTooltip } from 'src/components/infoTooltips/PriceImpactTooltip'; -import { FormattedNumber } from 'src/components/primitives/FormattedNumber'; -import { TokenIcon } from 'src/components/primitives/TokenIcon'; -import { - ComputedReserveData, - ExtendedFormattedUser, - useAppDataContext, -} from 'src/hooks/app-data-provider/useAppDataProvider'; -import { - maxInputAmountWithSlippage, - minimumReceivedAfterSlippage, - SwapVariant, -} from 'src/hooks/paraswap/common'; -import { useCollateralRepaySwap } from 'src/hooks/paraswap/useCollateralRepaySwap'; -import { useModalContext } from 'src/hooks/useModal'; -import { useZeroLTVBlockingWithdraw } from 'src/hooks/useZeroLTVBlockingWithdraw'; -import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; -import { ListSlippageButton } from 'src/modules/dashboard/lists/SlippageList'; -import { useRootStore } from 'src/store/root'; -import { calculateHFAfterRepay } from 'src/utils/hfUtils'; -import { useShallow } from 'zustand/shallow'; - -import { Asset, AssetInput } from '../AssetInput'; -import { ModalWrapperProps } from '../FlowCommons/ModalWrapper'; -import { TxSuccessView } from '../FlowCommons/Success'; -import { - DetailsHFLine, - DetailsNumberLineWithSub, - TxModalDetails, -} from '../FlowCommons/TxModalDetails'; -import { ErrorType, useFlashloan } from '../utils'; -import { ParaswapErrorDisplay } from '../Warnings/ParaswapErrorDisplay'; -import { CollateralRepayActions } from './CollateralRepayActions'; - -export function CollateralRepayModalContent({ - poolReserve, - symbol, - debtType, - userReserve, - isWrongNetwork, - user, -}: ModalWrapperProps & { debtType: InterestRate; user: ExtendedFormattedUser }) { - const { reserves, userReserves } = useAppDataContext(); - const { gasLimit, txError, mainTxState } = useModalContext(); - const [currentChainId, currentNetworkConfig] = useRootStore( - useShallow((store) => [store.currentChainId, store.currentNetworkConfig]) - ); - const { currentAccount } = useWeb3Context(); - - // List of tokens eligble to repay with, ordered by USD value - const repayTokens = user.userReservesData - .filter( - (userReserve) => - userReserve.underlyingBalance !== '0' && - userReserve.underlyingAsset !== poolReserve.underlyingAsset && - userReserve.reserve.symbol !== 'stETH' - ) - .map((userReserve) => ({ - address: userReserve.underlyingAsset, - balance: userReserve.underlyingBalance, - balanceUSD: userReserve.underlyingBalanceUSD, - symbol: userReserve.reserve.symbol, - iconSymbol: userReserve.reserve.iconSymbol, - decimals: userReserve.reserve.decimals, - })) - .sort((a, b) => Number(b.balanceUSD) - Number(a.balanceUSD)); - const [tokenToRepayWith, setTokenToRepayWith] = useState(repayTokens[0]); - const tokenToRepayWithBalance = tokenToRepayWith.balance || '0'; - - // const [swapVariant, setSwapVariant] = useState('exactOut'); - const swapVariant: SwapVariant = 'exactOut'; - - const [amount, setAmount] = useState(''); - const [maxSlippage, setMaxSlippage] = useState('0.5'); - - const amountRef = useRef(''); - - const collateralReserveData = reserves.find( - (reserve) => reserve.underlyingAsset === tokenToRepayWith.address - ) as ComputedReserveData; - - const debt = userReserve?.variableBorrows || '0'; - - let safeAmountToRepayAll = valueToBigNumber(debt); - // Add in the approximate interest accrued over the next 30 minutes - safeAmountToRepayAll = safeAmountToRepayAll.plus( - safeAmountToRepayAll.multipliedBy(poolReserve.variableBorrowAPY).dividedBy(360 * 24 * 2) - ); - - const isMaxSelected = amount === '-1'; - const repayAmount = isMaxSelected ? safeAmountToRepayAll.toString() : amount; - const repayAmountUsdValue = valueToBigNumber(repayAmount) - .multipliedBy(poolReserve.priceInUSD) - .toString(); - - // The slippage is factored into the collateral amount because when we swap for 'exactOut', positive slippage is applied on the collateral amount. - const collateralAmountRequiredToCoverDebt = safeAmountToRepayAll - .multipliedBy(poolReserve.priceInUSD) - .multipliedBy(100 + Number(maxSlippage)) - .dividedBy(100) - .dividedBy(collateralReserveData.priceInUSD); - - const swapIn = { ...collateralReserveData, amount: tokenToRepayWithBalance }; - const swapOut = { ...poolReserve, amount: amountRef.current }; - // if (swapVariant === 'exactIn') { - // swapIn.amount = tokenToRepayWithBalance; - // swapOut.amount = '0'; - // } - - const repayAllDebt = - isMaxSelected && - valueToBigNumber(tokenToRepayWithBalance).gte(collateralAmountRequiredToCoverDebt); - - const { - inputAmountUSD, - inputAmount, - outputAmount, - outputAmountUSD, - loading: routeLoading, - error, - buildTxFn, - } = useCollateralRepaySwap({ - chainId: currentNetworkConfig.underlyingChainId || currentChainId, - userAddress: currentAccount, - swapVariant: swapVariant, - swapIn, - swapOut, - max: repayAllDebt, - skip: mainTxState.loading || false, - maxSlippage: Number(maxSlippage), - }); - - const loadingSkeleton = routeLoading && inputAmountUSD === '0'; - - const handleRepayAmountChange = (value: string) => { - const maxSelected = value === '-1'; - amountRef.current = maxSelected ? safeAmountToRepayAll.toString(10) : value; - setAmount(value); - - // if ( - // maxSelected && - // valueToBigNumber(tokenToRepayWithBalance).lt(collateralAmountRequiredToCoverDebt) - // ) { - // // The selected collateral amount is not enough to pay the full debt. We'll try to do a swap using the exact amount of collateral. - // // The amount won't be known until we fetch the swap data, so we'll clear it out. Once the swap data is fetched, we'll set the amount. - // amountRef.current = ''; - // setAmount(''); - // setSwapVariant('exactIn'); - // } else { - // amountRef.current = maxSelected ? safeAmountToRepayAll.toString(10) : value; - // setAmount(value); - // setSwapVariant('exactOut'); - // } - }; - - // for v3 we need hf after withdraw collateral, because when removing collateral to repay - // debt, hf could go under 1 then it would fail. If that is the case then we need - // to use flashloan path - const repayWithUserReserve = userReserves.find( - (userReserve) => userReserve.underlyingAsset === tokenToRepayWith.address - ); - const { hfAfterSwap, hfEffectOfFromAmount } = calculateHFAfterRepay({ - amountToReceiveAfterSwap: outputAmount, - amountToSwap: inputAmount, - fromAssetData: collateralReserveData, - user, - toAssetData: poolReserve, - repayWithUserReserve, - debt, - }); - - // If the selected collateral asset is frozen, a flashloan must be used. When a flashloan isn't used, - // the remaining amount after the swap is deposited into the pool, which will fail for frozen assets. - const shouldUseFlashloan = - useFlashloan(user.healthFactor, hfEffectOfFromAmount.toString()) || - collateralReserveData?.isFrozen; - - // we need to get the min as minimumReceived can be greater than debt as we are swapping - // a safe amount to repay all. When this happens amountAfterRepay would be < 0 and - // this would show as certain amount left to repay when we are actually repaying all debt - const amountAfterRepay = valueToBigNumber(debt).minus(BigNumber.min(outputAmount, debt)); - const displayAmountAfterRepayInUsd = amountAfterRepay.multipliedBy(poolReserve.priceInUSD); - const collateralAmountAfterRepay = tokenToRepayWithBalance - ? valueToBigNumber(tokenToRepayWithBalance).minus(inputAmount) - : valueToBigNumber('0'); - const collateralAmountAfterRepayUSD = collateralAmountAfterRepay.multipliedBy( - collateralReserveData.priceInUSD - ); - - const exactOutputAmount = repayAmount; // swapVariant === 'exactIn' ? outputAmount : repayAmount; - const exactOutputUsd = repayAmountUsdValue; // swapVariant === 'exactIn' ? outputAmountUSD : repayAmountUsdValue; - - const assetsBlockingWithdraw = useZeroLTVBlockingWithdraw(); - - let blockingError: ErrorType | undefined = undefined; - - if ( - assetsBlockingWithdraw.length > 0 && - !assetsBlockingWithdraw.includes(tokenToRepayWith.symbol) - ) { - blockingError = ErrorType.ZERO_LTV_WITHDRAW_BLOCKED; - } else if (valueToBigNumber(tokenToRepayWithBalance).lt(inputAmount)) { - blockingError = ErrorType.NOT_ENOUGH_COLLATERAL_TO_REPAY_WITH; - } else if (shouldUseFlashloan && !collateralReserveData.flashLoanEnabled) { - blockingError = ErrorType.FLASH_LOAN_NOT_AVAILABLE; - } - - const BlockingError: React.FC = () => { - switch (blockingError) { - case ErrorType.NOT_ENOUGH_COLLATERAL_TO_REPAY_WITH: - return Not enough collateral to repay this amount of debt with; - case ErrorType.ZERO_LTV_WITHDRAW_BLOCKED: - return ( - - Assets with zero LTV ({assetsBlockingWithdraw.join(', ')}) must be withdrawn or disabled - as collateral to perform this action - - ); - case ErrorType.FLASH_LOAN_NOT_AVAILABLE: - return ( - - Due to health factor impact, a flashloan is required to perform this transaction, but - Aave Governance has disabled flashloan availability for this asset. Try lowering the - amount or supplying additional collateral. - - ); - default: - return null; - } - }; - - const inputAmountWithSlippage = maxInputAmountWithSlippage( - inputAmount, - maxSlippage, - tokenToRepayWith.decimals || 18 - ); - - const outputAmountWithSlippage = minimumReceivedAfterSlippage( - outputAmount, - maxSlippage, - poolReserve.decimals - ); - - if (mainTxState.success) - return ( - Repaid} - amount={swapVariant === 'exactOut' ? outputAmount : outputAmountWithSlippage} - symbol={poolReserve.symbol} - /> - ); - - return ( - <> - Expected amount to repay} - balanceText={Borrow balance} - /> - - - - - - - - Collateral to repay with} - balanceText={Borrow balance} - maxValue={tokenToRepayWithBalance} - loading={loadingSkeleton} - disableInput - /> - {error && !loadingSkeleton && ( - - {error} - - )} - {blockingError !== undefined && ( - - - - )} - - - {false ? ( - <> - Minimum amount of debt to be repaid - - - - - - - - ) : ( - <> - Maximum collateral amount to use - - - - - - - - )} - - } - /> - } - > - - Borrow balance after repay} - futureValue={amountAfterRepay.toString()} - futureValueUSD={displayAmountAfterRepayInUsd.toString()} - symbol={symbol} - tokenIcon={poolReserve.iconSymbol} - loading={loadingSkeleton} - hideSymbolSuffix - /> - Collateral balance after repay} - futureValue={collateralAmountAfterRepay.toString()} - futureValueUSD={collateralAmountAfterRepayUSD.toString()} - symbol={tokenToRepayWith.symbol} - tokenIcon={tokenToRepayWith.iconSymbol} - loading={loadingSkeleton} - hideSymbolSuffix - /> - - - {txError && } - - - - ); -} diff --git a/src/components/transactions/Repay/RepayModal.tsx b/src/components/transactions/Repay/RepayModal.tsx index 972f30addd..0ae907beed 100644 --- a/src/components/transactions/Repay/RepayModal.tsx +++ b/src/components/transactions/Repay/RepayModal.tsx @@ -9,7 +9,7 @@ import { isFeatureEnabled } from 'src/utils/marketsAndNetworksConfig'; import { BasicModal } from '../../primitives/BasicModal'; import { ModalWrapper } from '../FlowCommons/ModalWrapper'; -import { CollateralRepayModalContent } from './CollateralRepayModalContent'; +import { RepayWithCollateralModalContent } from '../Swap/modals/request/RepayWithCollateralModalContent'; import { RepayModalContent } from './RepayModalContent'; import { RepayType, RepayTypeSelector } from './RepayTypeSelector'; @@ -53,10 +53,10 @@ export const RepayModal = () => { )} {repayType === RepayType.BALANCE && } {repayType === RepayType.COLLATERAL && ( - )} diff --git a/src/components/transactions/Swap/README.md b/src/components/transactions/Swap/README.md new file mode 100644 index 0000000000..ee50ebc025 --- /dev/null +++ b/src/components/transactions/Swap/README.md @@ -0,0 +1,89 @@ +## Swap module + +![Swap module architecture](./docs/swap-modal-architecture.png) + +### Goals +- **Consistent UI/UX** across all swap-related modals +- **Shared logic, no duplication** via a common base content and shared helpers +- **Multi‑provider** architecture (CoW Protocol, ParaSwap; easy to extend) +- **Composable and customizable** components + +### High‑level flow +1. A top‑level modal (`modals/*Modal.tsx`) renders a corresponding content component in `modals/request/*ModalContent.tsx`. +2. Each `*ModalContent` composes `BaseSwapModalContent`, which wires data, inputs, warnings, details, and actions. +3. Provider selection is automatic via `helpers/shared/provider.helpers.ts` (`getSwitchProvider`). +4. Quotes are fetched with `hooks/useSwapQuote` and refreshed periodically; flash‑loan and health‑factor logic is handled by `hooks/useFlowSelector`. +5. The UI is composed from shared inputs, pre/post warnings, per‑flow details, and provider‑specific actions. + +### Directory overview +- `modals/` + - Entry points displayed to users: `SwapModal.tsx`, `CollateralSwapModal.tsx`, `DebtSwapModal.tsx`, etc. + - `modals/request/` contains `*ModalContent.tsx` for each flow and a `BaseSwapModalContent` used by all. + - `modals/result/` shows success/receipt views. + +- `inputs/` + - `SwapInputs.tsx` orchestrates order inputs; `MarketOrderInputs.tsx` and `LimitOrderInputs.tsx` are the two modes. + - `inputs/shared/` and `inputs/primitives/` host reusable input UI. + +- `warnings/` + - Pre‑ and post‑input warnings: `SwapPreInputWarnings.tsx`, `SwapPostInputWarnings.tsx`. + - Flow‑ and rule‑specific implementations live under `warnings/preInputs/` and `warnings/postInputs/`. + +- `details/` + - Transaction overview blocks: `SwapDetails.tsx`, plus flow variants like `CollateralSwapDetails.tsx`, `DebtSwapDetails.tsx`, `RepayWithCollateralDetails.tsx`, `WithdrawAndSwapDetails.tsx`. + - Provider‑specific cost breakdowns, e.g. `CowCostsDetails.tsx`. + +- `actions/` + - The submit/CTA area and transaction execution. + - Flow containers: `SwapActions`, `CollateralSwapActions`, `DebtSwapActions`, `RepayWithCollateralActions`, `WithdrawAndSwapActions`. + - Provider adapters implement the same surface per flow, e.g. `SwapActionsViaCoW.tsx`, `SwapActionsViaParaswap.tsx`, and their flow equivalents under each subfolder. + - `approval/useSwapTokenApproval.ts` handles allowance flows when needed. + +- `hooks/` + - `useSwapQuote` retrieves and normalizes quotes (CoW/ParaSwap) and writes into `SwapState`. + - `useFlowSelector` computes health‑factor effects and decides when to use flash‑loans. + - Other utilities: `useSwapOrderAmounts`, `useSwapGasEstimation`, `useSlippageSelector`, `useMaxNativeAmount`, `useProtocolReserves`, `useUserContext`. + +- `helpers/` + - `shared/` contains provider‑agnostic logic (provider selection, formatting, misc) and `gasEstimation.helpers.ts`. + - `cow/` and `paraswap/` contain provider integrations (rates, order helpers, misc). + +- `errors/` + - UI components and mapping/helpers for error presentation; organized by provider and shared concerns. + +- `constants/` + - Provider and feature flags: `cow.constants.ts`, `paraswap.constants.ts`, `limitOrders.constants.ts`, `shared.constants.ts`. + +- `types/` + - Shared domain types: params, state, tokens, quotes, and re‑exports. + +- `shared/` + - Small UI pieces reused across modals (e.g., `OrderTypeSelector`, `SwapModalTitle`). + +- `analytics/` + - Analytics constants and hooks to track swap interactions. + +- `backup/` + - Legacy/previous implementation kept for reference during the migration to the new modular structure. + +### Extending to a new provider +1. Add provider constants in `constants/` and integration helpers under `helpers//`. +2. Plug the provider into `helpers/shared/provider.helpers.ts` so it can be selected. +3. Implement `*ActionsVia.tsx` for each supported flow under `actions/`. +4. Optionally add provider‑specific details/warnings and wire them in the `*Details.tsx`/warnings where appropriate. + +### Data model +- `types/state.types.ts` defines the authoritative `SwapState` used across the module. +- `types/params.types.ts` carries immutable parameters from the modal entry. +- Quotes unify to a `quote.types.ts` shape so the UI remains provider‑agnostic. + +### Notes +- `useSwapQuote` refreshes quotes every 30s by default, paused during action execution. +- Some flows invert the quote route (e.g., `DebtSwap`, `RepayWithCollateral`); this is encapsulated in `useSwapQuote` and consumers stay agnostic. + +### Core types (documented) +- State: see `types/state.types.ts` (`SwapState`, `TokensSwapState`, `ProtocolSwapState`). +- Params: see `types/params.types.ts` (`SwapParams`). +- Tokens and quotes: see `types/tokens.types.ts`, `types/quote.types.ts`, and shared enums in `types/shared.types.ts`. + + diff --git a/src/components/transactions/Swap/actions/ActionsBlocked.tsx b/src/components/transactions/Swap/actions/ActionsBlocked.tsx new file mode 100644 index 0000000000..83ac6dd4fc --- /dev/null +++ b/src/components/transactions/Swap/actions/ActionsBlocked.tsx @@ -0,0 +1,26 @@ +import { Trans } from '@lingui/macro'; +import { Button } from '@mui/material'; + +import { SwapState } from '../types'; + +type blockType = 'errors' | 'generic'; +const stateToBlockType = (state: SwapState): blockType => { + if (state.error) return 'errors'; + return 'generic'; +}; + +export const ActionsBlocked: React.FC<{ state: SwapState }> = ({ state }) => { + const blockType = stateToBlockType(state); + + return ( + + ); +}; diff --git a/src/components/transactions/Swap/actions/ActionsSkeleton.tsx b/src/components/transactions/Swap/actions/ActionsSkeleton.tsx new file mode 100644 index 0000000000..a1193552c7 --- /dev/null +++ b/src/components/transactions/Swap/actions/ActionsSkeleton.tsx @@ -0,0 +1,69 @@ +import { Trans } from '@lingui/macro'; +import { Button, CircularProgress } from '@mui/material'; +import React, { useEffect, useRef, useState } from 'react'; + +import { SwapState } from '../types'; + +export type LoadingType = 'quote' | 'actions' | 'other'; +const stateToLoadingType = (state: SwapState): LoadingType => { + if (state.ratesLoading) return 'quote'; + if (state.actionsLoading) return 'actions'; + return 'other'; +}; + +export const ActionsLoading: React.FC<{ state: SwapState }> = ({ state }) => { + const loadingType = stateToLoadingType(state); + const [quoteTimeElapsed, setQuoteTimeElapsed] = useState(false); + const timerRef = useRef(null); + + // Timer logic for updating the loading text after 2 seconds when loadingType is 'quote' + // Trick to change quote loading trick to make it feel more smooth + useEffect(() => { + if (loadingType === 'quote') { + setQuoteTimeElapsed(false); + if (timerRef.current) { + clearTimeout(timerRef.current); + } + timerRef.current = setTimeout(() => { + setQuoteTimeElapsed(true); + }, 2000); + } else { + // In case the loading type changes, clear timer and reset state + setQuoteTimeElapsed(false); + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + } + // Cleanup on unmount + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [loadingType]); + + return ( + + ); +}; diff --git a/src/components/transactions/Swap/actions/CollateralSwap/CollateralSwapActions.tsx b/src/components/transactions/Swap/actions/CollateralSwap/CollateralSwapActions.tsx new file mode 100644 index 0000000000..82ae9ad3e6 --- /dev/null +++ b/src/components/transactions/Swap/actions/CollateralSwap/CollateralSwapActions.tsx @@ -0,0 +1,65 @@ +import { Dispatch } from 'react'; + +import { TrackAnalyticsHandlers } from '../../analytics/useTrackAnalytics'; +import { ProtocolSwapParams, ProtocolSwapState, SwapProvider, SwapState } from '../../types'; +import { SwapActionsViaCoW } from '../SwapActions/SwapActionsViaCoW'; +import { SwapActionsViaParaswap } from '../SwapActions/SwapActionsViaParaswap'; +import { CollateralSwapActionsViaCowAdapters } from './CollateralSwapActionsViaCoWAdapters'; +import { CollateralSwapActionsViaParaswapAdapters } from './CollateralSwapActionsViaParaswapAdapters'; + +export const CollateralSwapActions = ({ + params, + state, + setState, + trackingHandlers, +}: { + params: ProtocolSwapParams; + state: ProtocolSwapState; + setState: Dispatch>; + trackingHandlers: TrackAnalyticsHandlers; +}) => { + switch (state.provider) { + case SwapProvider.COW_PROTOCOL: + if (state.useFlashloan) { + return ( + + ); + } else { + // Essentially traditional aTokens swap + return ( + + ); + } + case SwapProvider.PARASWAP: + if (state.useFlashloan) { + return ( + + ); + } else { + // Essentially traditional aTokens swap + return ( + + ); + } + } +}; diff --git a/src/components/transactions/Swap/actions/CollateralSwap/CollateralSwapActionsViaCoWAdapters.tsx b/src/components/transactions/Swap/actions/CollateralSwap/CollateralSwapActionsViaCoWAdapters.tsx new file mode 100644 index 0000000000..9fe42097da --- /dev/null +++ b/src/components/transactions/Swap/actions/CollateralSwap/CollateralSwapActionsViaCoWAdapters.tsx @@ -0,0 +1,352 @@ +import { normalize } from '@aave/math-utils'; +import { getOrderToSign, LimitTradeParameters, OrderKind, OrderStatus } from '@cowprotocol/cow-sdk'; +import { AaveFlashLoanType, HASH_ZERO } from '@cowprotocol/sdk-flash-loans'; +import { Trans } from '@lingui/macro'; +import { Dispatch, useEffect, useMemo, useState } from 'react'; +import { TxActionsWrapper } from 'src/components/transactions/TxActionsWrapper'; +import { calculateSignedAmount } from 'src/hooks/paraswap/common'; +import { useModalContext } from 'src/hooks/useModal'; +import { useSwapOrdersTracking } from 'src/hooks/useSwapOrdersTracking'; +import { useRootStore } from 'src/store/root'; +import { getErrorTextFromError, TxAction } from 'src/ui-config/errorMapping'; +import { saveCowOrderToUserHistory } from 'src/utils/swapAdapterHistory'; +import { useShallow } from 'zustand/react/shallow'; + +import { TrackAnalyticsHandlers } from '../../analytics/useTrackAnalytics'; +import { COW_PARTNER_FEE, FLASH_LOAN_FEE_BPS } from '../../constants/cow.constants'; +import { APP_CODE_PER_SWAP_TYPE } from '../../constants/shared.constants'; +import { + addOrderTypeToAppData, + getCowFlashLoanSdk, + getCowTradingSdkByChainIdAndAppCode, +} from '../../helpers/cow'; +import { calculateInstanceAddress } from '../../helpers/cow/adapters.helpers'; +import { useSwapGasEstimation } from '../../hooks/useSwapGasEstimation'; +import { + areActionsBlocked, + ExpiryToSecondsMap, + isCowProtocolRates, + OrderType, + SwapParams, + SwapState, +} from '../../types'; +import { useSwapTokenApproval } from '../approval/useSwapTokenApproval'; + +/** + * Collateral swap via CoW Protocol Flashloan Adapters. + * + * Flow summary: + * 1) Approve collateral aToken (permit supported) to the CoW flashloan adapter + * 2) Compute flashloan fee and sell amount to sign + * 3) Create a LIMIT order relative to the UI: collateral -> debt asset + * 4) Post order with adapter-provided swap settings; adapter orchestrates the swap + */ +export const CollateralSwapActionsViaCowAdapters = ({ + state, + setState, + trackingHandlers, +}: { + params: SwapParams; + state: SwapState; + setState: Dispatch>; + trackingHandlers: TrackAnalyticsHandlers; +}) => { + const [user] = useRootStore(useShallow((state) => [state.account])); + + const { + mainTxState, + loadingTxns, + approvalTxState, + setMainTxState, + setTxError, + setApprovalTxState, + } = useModalContext(); + + const [precalculatedInstanceAddress, setPrecalculatedInstanceAddress] = useState< + string | undefined + >(); + + const validTo = useMemo( + () => Math.floor(Date.now() / 1000) + ExpiryToSecondsMap[state.expiry], + [state.expiry] + ); + + // Pre-compute instance address + useEffect(() => { + calculateInstanceAddress({ + user, + validTo, + type: AaveFlashLoanType.CollateralSwap, + state, + }) + .catch((error) => { + console.error('calculateInstanceAddress error', error); + setTxError(getErrorTextFromError(error, TxAction.MAIN_ACTION, true)); + setMainTxState({ + txHash: undefined, + loading: false, + success: false, + }); + }) + .then((address) => { + if (address) setPrecalculatedInstanceAddress(address); + }); + }, [ + user, + validTo, + state.sellAmountBigInt, + state.buyAmountBigInt, + state.sellAmountToken, + state.buyAmountToken, + state.processedSide, + state.slippage, + state.orderType, + state.chainId, + ]); + + // Approval is aToken ERC20 Approval + const amountToApprove = useMemo(() => { + if (!state.sellAmountFormatted || !state.sellAmountToken) return '0'; + return calculateSignedAmount(state.sellAmountFormatted, state.sellAmountToken.decimals); + }, [state.sellAmountFormatted, state.sellAmountToken]); + + const { hasActiveOrderForSellToken, trackSwapOrderProgress } = useSwapOrdersTracking(); + const sellAssetAddress = + state.sellAmountToken?.underlyingAddress || state.sourceToken.addressToSwap; + const disablePermitDueToActiveOrder = hasActiveOrderForSellToken(state.chainId, sellAssetAddress); + + const { + requiresApproval, + approval, + tryPermit, + signatureParams, + loadingPermitData, + approvedAddress, + } = useSwapTokenApproval({ + chainId: state.chainId, + token: state.sourceToken.addressToSwap, + symbol: state.sourceToken.symbol, + amount: normalize(amountToApprove.toString(), state.sellAmountToken?.decimals ?? 18), + decimals: state.sourceToken.decimals, + spender: precalculatedInstanceAddress, + setState, + allowPermit: !disablePermitDueToActiveOrder, // CoW Adapters do support permit but avoid nonce reuse + trackingHandlers, + swapType: state.swapType, + }); + + // Use centralized gas estimation + useSwapGasEstimation({ + state, + setState, + requiresApproval, + requiresApprovalReset: state.requiresApprovalReset, + approvalTxState, + }); + + const action = async () => { + setMainTxState({ + txHash: undefined, + loading: true, + }); + setState({ + actionsLoading: false, + }); + + try { + if ( + !state.sellAmountBigInt || + !state.sellAmountToken || + !state.buyAmountBigInt || + !state.buyAmountToken + ) + return; + + const tradingSdk = await getCowTradingSdkByChainIdAndAppCode( + state.chainId, + APP_CODE_PER_SWAP_TYPE[state.swapType] + ); + const flashLoanSdk = await getCowFlashLoanSdk(state.chainId); + + const collateralPermit = signatureParams + ? { + amount: signatureParams?.amount, + deadline: Number(signatureParams?.deadline), + v: signatureParams?.splitedSignature.v, + r: signatureParams?.splitedSignature.r, + s: signatureParams?.splitedSignature.s, + } + : undefined; + + const { flashLoanFeeAmount, sellAmountToSign } = flashLoanSdk.calculateFlashLoanAmounts({ + flashLoanFeeBps: FLASH_LOAN_FEE_BPS, + sellAmount: state.sellAmountBigInt, + }); + + const limitOrder: LimitTradeParameters = { + sellToken: state.sellAmountToken.underlyingAddress, + sellTokenDecimals: state.sellAmountToken.decimals, + buyToken: state.buyAmountToken.underlyingAddress, + buyTokenDecimals: state.buyAmountToken.decimals, + sellAmount: sellAmountToSign.toString(), + quoteId: isCowProtocolRates(state.swapRate) ? state.swapRate?.quoteId : undefined, + buyAmount: state.buyAmountBigInt.toString(), + kind: state.processedSide === 'buy' ? OrderKind.BUY : OrderKind.SELL, + validTo, + slippageBps: state.orderType == OrderType.MARKET ? Number(state.slippage) * 100 : undefined, + partnerFee: COW_PARTNER_FEE(state.sellAmountToken.symbol, state.buyAmountToken.symbol), + }; + + const orderToSign = getOrderToSign( + { + chainId: state.chainId, + from: user, + networkCostsAmount: '0', + isEthFlow: false, + applyCostsSlippageAndFees: false, + }, + limitOrder, + HASH_ZERO + ); + + const orderPostParams = await flashLoanSdk.getOrderPostingSettings( + AaveFlashLoanType.CollateralSwap, + { + chainId: state.chainId, + validTo, + owner: user as `0x${string}`, + flashLoanFeeAmount, + }, + { + sellAmount: state.sellAmountBigInt, + buyAmount: state.buyAmountBigInt, + orderToSign, + collateralPermit, + } + ); + + orderPostParams.swapSettings.appData = addOrderTypeToAppData( + state.orderType, + orderPostParams.swapSettings.appData + ); + + // Safe-check in case any param changed between approval and order posting + const instanceAddress = orderPostParams.instanceAddress; + if (instanceAddress !== approvedAddress) { + console.error( + 'Some parameters changed between approval and order posting: instanceAddress !== approvedAddress, asking for a new approval', + instanceAddress, + approvedAddress + ); + // Force re-approve + setPrecalculatedInstanceAddress(instanceAddress); + setApprovalTxState({ + txHash: undefined, + loading: false, + success: false, + }); + setMainTxState({ txHash: undefined, loading: false, success: false }); + + return; + } + + const result = await tradingSdk.postLimitOrder(limitOrder, orderPostParams.swapSettings); + + trackingHandlers.trackSwap(); + setMainTxState({ + loading: false, + success: true, + txHash: result.orderId, + }); + // Save to local history and start tracking status + saveCowOrderToUserHistory({ + protocol: 'cow', + orderId: result.orderId, + status: OrderStatus.OPEN, + swapType: state.swapType, + chainId: state.chainId, + account: user, + timestamp: new Date().toISOString(), + srcToken: { + address: state.sellAmountToken.underlyingAddress, + symbol: state.sellAmountToken.symbol, + name: state.sellAmountToken.symbol, + decimals: state.sellAmountToken.decimals, + }, + destToken: { + address: state.buyAmountToken.underlyingAddress, + symbol: state.buyAmountToken.symbol, + name: state.buyAmountToken.symbol, + decimals: state.buyAmountToken.decimals, + }, + adapterInstanceAddress: instanceAddress, + usedAdapter: true, // CollateralSwap via adapters always uses adapter (flashloan) + srcAmount: state.sellAmountBigInt.toString(), + destAmount: state.buyAmountBigInt.toString(), + }); + trackSwapOrderProgress(result.orderId, state.chainId); + setState({ + actionsLoading: false, + }); + } catch (error) { + console.error('CollateralSwapActionsViaCoWAdapters error', error); + setTxError(getErrorTextFromError(error, TxAction.MAIN_ACTION, true)); + setMainTxState({ + txHash: undefined, + loading: false, + success: false, + }); + setState({ + actionsLoading: false, + }); + } + }; + + return ( + Checking approval + ) : ( + Swap {state.sourceToken.symbol} collateral + ) + } + actionInProgressText={ + approvalTxState.loading ? ( + Checking approval + ) : ( + Swapping {state.sourceToken.symbol} collateral + ) + } + errorParams={{ + loading: false, + disabled: + areActionsBlocked(state) || + approvalTxState.loading || + (!approvalTxState.success && requiresApproval), + content: approvalTxState.loading ? ( + Checking approval + ) : ( + Swap {state.sourceToken.symbol} collateral + ), + handleClick: action, + }} + fetchingData={state.actionsLoading || loadingPermitData} + blocked={areActionsBlocked(state) || !precalculatedInstanceAddress} + tryPermit={tryPermit} + permitInUse={disablePermitDueToActiveOrder} + /> + ); +}; diff --git a/src/components/transactions/Swap/actions/CollateralSwap/CollateralSwapActionsViaParaswapAdapters.tsx b/src/components/transactions/Swap/actions/CollateralSwap/CollateralSwapActionsViaParaswapAdapters.tsx new file mode 100644 index 0000000000..d532555929 --- /dev/null +++ b/src/components/transactions/Swap/actions/CollateralSwap/CollateralSwapActionsViaParaswapAdapters.tsx @@ -0,0 +1,309 @@ +import { normalize } from '@aave/math-utils'; +import { OrderStatus } from '@cowprotocol/cow-sdk'; +import { Trans } from '@lingui/macro'; +import { BigNumber, PopulatedTransaction } from 'ethers'; +import { Dispatch, useEffect, useMemo } from 'react'; +import { TxActionsWrapper } from 'src/components/transactions/TxActionsWrapper'; +import { calculateSignedAmount, ExactInSwapper, ExactOutSwapper } from 'src/hooks/paraswap/common'; +import { useModalContext } from 'src/hooks/useModal'; +import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; +import { useRootStore } from 'src/store/root'; +import { getErrorTextFromError, TxAction } from 'src/ui-config/errorMapping'; +import { saveParaswapTxToUserHistory } from 'src/utils/swapAdapterHistory'; +import { useShallow } from 'zustand/shallow'; + +import { TrackAnalyticsHandlers } from '../../analytics/useTrackAnalytics'; +import { useSwapGasEstimation } from '../../hooks/useSwapGasEstimation'; +import { + areActionsBlocked, + isParaswapRates, + isProtocolSwapState, + SwapParams, + SwapState, +} from '../../types'; +import { useSwapTokenApproval } from '../approval/useSwapTokenApproval'; +// import { normalizeBN } from '@aave/math-utils'; + +export const CollateralSwapActionsViaParaswapAdapters = ({ + params, + state, + setState, + trackingHandlers, +}: { + params: SwapParams; + state: SwapState; + setState: Dispatch>; + trackingHandlers: TrackAnalyticsHandlers; +}) => { + const { setTxError, setMainTxState, approvalTxState } = useModalContext(); + const { addTransaction, estimateGasLimit } = useRootStore(); + const { sendTx } = useWeb3Context(); + const [swapCollateral, currentMarketData] = useRootStore( + useShallow((state) => [state.swapCollateral, state.currentMarketData]) + ); + + // Approval is aToken ERC20 Approval + const amountToApprove = useMemo(() => { + if (!state.sellAmountFormatted || !state.sellAmountToken) return '0'; + return calculateSignedAmount(state.sellAmountFormatted, state.sellAmountToken.decimals); + }, [state.sellAmountFormatted, state.sellAmountToken]); + + const { + requiresApproval, + signatureParams, + approval, + tryPermit, + approvedAmount, + loadingPermitData, + } = useSwapTokenApproval({ + chainId: state.chainId, + token: state.sourceToken.addressToSwap, // aToken + symbol: state.sourceToken.symbol, + amount: normalize(amountToApprove.toString(), state.sourceToken?.decimals ?? 18), + decimals: state.sourceToken.decimals, + spender: currentMarketData.addresses.SWAP_COLLATERAL_ADAPTER, + setState, + trackingHandlers, + swapType: state.swapType, + }); + + // Use centralized gas estimation + useSwapGasEstimation({ + state, + setState, + requiresApproval, + requiresApprovalReset: state.requiresApprovalReset, + approvalTxState, + }); + + const action = async () => { + if (!state.swapRate || !isParaswapRates(state.swapRate)) + throw new Error('Route required to build transaction'); + + setMainTxState({ + txHash: undefined, + loading: true, + }); + const isMaxSelected = state.isMaxSelected; + const optimalRateData = state.swapRate.optimalRateData; + + // 1. Prepare internal swap call data + let swapCallData = ''; + let augustus = ''; + if (state.side === 'sell') { + const swapper = ExactInSwapper(state.chainId); + + const result = await swapper.getTransactionParams( + state.sourceToken.underlyingAddress, + state.sourceToken.decimals, + state.destinationToken.underlyingAddress, + state.destinationToken.decimals, + state.user, + optimalRateData, + Number(state.slippage) + ); + swapCallData = result.swapCallData; + augustus = result.augustus; + } else { + const swapper = ExactOutSwapper(state.chainId); + + const result = await swapper.getTransactionParams( + state.destinationToken.underlyingAddress, + state.destinationToken.decimals, + state.sourceToken.underlyingAddress, + state.sourceToken.decimals, + state.user, + optimalRateData, + Number(state.slippage) + ); + swapCallData = result.swapCallData; + augustus = result.augustus; + } + + if (!isProtocolSwapState(state)) throw new Error('State is not a protocol swap state'); + + const signedAmount = approvedAmount; + const amountToSwap = state.inputAmount; + const amountToReceive = state.buyAmountFormatted || '0'; + + let response; + try { + // 2. Prepare Tx + const txs = await swapCollateral({ + amountToSwap: amountToSwap, + amountToReceive: amountToReceive, + poolReserve: state.sourceReserve.reserve, + targetReserve: state.destinationReserve.reserve, + isWrongNetwork: state.isWrongNetwork, + symbol: state.sourceToken.symbol, + blocked: areActionsBlocked(state), + isMaxSelected: isMaxSelected, + useFlashLoan: true, + swapCallData: swapCallData, + augustus: augustus, + signature: signatureParams?.splitedSignature, + deadline: signatureParams?.deadline, + signedAmount, + }); + + const actionTx = txs.find((tx) => ['DLP_ACTION'].includes(tx.txType)); + if (!actionTx) throw new Error('Action tx not found'); + const tx = await actionTx.tx(); + const populatedTx: PopulatedTransaction = { + to: tx.to, + from: tx.from, + data: tx.data, + gasLimit: tx.gasLimit, + gasPrice: tx.gasPrice, + nonce: tx.nonce, + chainId: tx.chainId, + value: tx.value ? BigNumber.from(tx.value) : undefined, + }; + + // 3. Estimate gas limit and send tx + const txWithGasEstimation = await estimateGasLimit(populatedTx, state.chainId); + response = await sendTx(txWithGasEstimation); + await response.wait(1); + try { + saveParaswapTxToUserHistory({ + protocol: 'paraswap', + txHash: response.hash, + swapType: params.swapType, + chainId: state.chainId, + account: state.user, + timestamp: new Date().toISOString(), + status: OrderStatus.FULFILLED, + srcToken: { + address: state.sourceToken.addressToSwap, + symbol: state.sourceToken.symbol, + name: state.sourceToken.symbol, + decimals: state.sourceToken.decimals, + }, + destToken: { + address: state.destinationToken.addressToSwap, + symbol: state.destinationToken.symbol, + name: state.destinationToken.symbol, + decimals: state.destinationToken.decimals, + }, + srcAmount: state.sellAmountBigInt?.toString() ?? '0', + destAmount: state.buyAmountBigInt?.toString() ?? '0', + }); + } catch {} + addTransaction( + response.hash, + { + txState: 'success', + }, + { + chainId: state.chainId, + } + ); + trackingHandlers.trackSwap(); + params.invalidateAppState(); + setMainTxState({ + txHash: response.hash, + loading: false, + success: true, + }); + } catch (error) { + const parsedError = getErrorTextFromError(error, TxAction.MAIN_ACTION, false); + + // Check if this is a gas estimation error (from estimateGasLimit call) + // Gas estimation errors typically occur when estimateGasLimit fails + const errorMessage = parsedError.rawError?.message?.toLowerCase() || ''; + const isGasEstimationError = + errorMessage.includes('gas') || + errorMessage.includes('estimation') || + (errorMessage.includes('execution reverted') && errorMessage.includes('estimation')); + + // For gas estimation errors in Paraswap actions, show as warning instead of blocking error + if (isGasEstimationError) { + setState({ + actionsLoading: false, + warnings: [ + { + message: + 'Gas estimation error: The swap could not be estimated. Try increasing slippage or changing the amount.', + }, + ], + error: undefined, // Clear any existing errors + }); + } else { + // For other errors, handle normally + setTxError(parsedError); + setState({ + actionsLoading: false, + error: { + rawError: parsedError.rawError, + message: `Error: ${parsedError.error} on ${parsedError.txAction}`, + actionBlocked: parsedError.actionBlocked, + }, + }); + } + + setMainTxState({ + loading: false, + }); + + const reason = error instanceof Error ? error.message : 'Swap failed'; + trackingHandlers.trackSwapFailed(reason); + } + }; + + useEffect(() => { + if (state.mainTxState.success) { + trackingHandlers.trackSwap(); + params.invalidateAppState(); + + addTransaction( + state.mainTxState.txHash || '', + { + txState: 'success', + }, + { + chainId: state.chainId, + } + ); + + setMainTxState({ + txHash: state.mainTxState.txHash || '', + loading: false, + success: true, + }); + } + }, [state.mainTxState.success]); + + return ( + Checking approval + ) : ( + Swap {state.sourceToken.symbol} collateral + ) + } + actionInProgressText={Swapping {state.sourceToken.symbol} collateral} + fetchingData={state.actionsLoading || loadingPermitData} + errorParams={{ + loading: false, + disabled: areActionsBlocked(state), + content: Swap {state.sourceToken.symbol} collateral, + handleClick: action, + }} + tryPermit={tryPermit} + /> + ); +}; diff --git a/src/components/transactions/Swap/actions/DebtSwap/DebtSwapActions.tsx b/src/components/transactions/Swap/actions/DebtSwap/DebtSwapActions.tsx new file mode 100644 index 0000000000..20f90d8b90 --- /dev/null +++ b/src/components/transactions/Swap/actions/DebtSwap/DebtSwapActions.tsx @@ -0,0 +1,41 @@ +import { Dispatch } from 'react'; + +import { TrackAnalyticsHandlers } from '../../analytics/useTrackAnalytics'; +import { ProtocolSwapParams, ProtocolSwapState, SwapProvider, SwapState } from '../../types'; +import { DebtSwapActionsViaCoW } from './DebtSwapActionsViaCoW'; +import { DebtSwapActionsViaParaswap } from './DebtSwapActionsViaParaswap'; + +export const DebtSwapActions = ({ + params, + state, + setState, + trackingHandlers, +}: { + params: ProtocolSwapParams; + state: ProtocolSwapState; + setState: Dispatch>; + trackingHandlers: TrackAnalyticsHandlers; +}) => { + switch (state.provider) { + case SwapProvider.COW_PROTOCOL: + return ( + + ); + case SwapProvider.PARASWAP: + return ( + + ); + default: + return null; + } +}; diff --git a/src/components/transactions/Swap/actions/DebtSwap/DebtSwapActionsViaCoW.tsx b/src/components/transactions/Swap/actions/DebtSwap/DebtSwapActionsViaCoW.tsx new file mode 100644 index 0000000000..e4afce7d23 --- /dev/null +++ b/src/components/transactions/Swap/actions/DebtSwap/DebtSwapActionsViaCoW.tsx @@ -0,0 +1,368 @@ +import { normalize, valueToBigNumber } from '@aave/math-utils'; +import { getOrderToSign, LimitTradeParameters, OrderKind, OrderStatus } from '@cowprotocol/cow-sdk'; +import { AaveFlashLoanType, HASH_ZERO } from '@cowprotocol/sdk-flash-loans'; +import { Trans } from '@lingui/macro'; +import { Dispatch, useEffect, useMemo, useState } from 'react'; +import { TxActionsWrapper } from 'src/components/transactions/TxActionsWrapper'; +import { calculateSignedAmount } from 'src/hooks/paraswap/common'; +import { useModalContext } from 'src/hooks/useModal'; +import { useSwapOrdersTracking } from 'src/hooks/useSwapOrdersTracking'; +import { useRootStore } from 'src/store/root'; +import { getErrorTextFromError, TxAction } from 'src/ui-config/errorMapping'; +import { saveCowOrderToUserHistory } from 'src/utils/swapAdapterHistory'; +import { zeroAddress } from 'viem'; +import { useShallow } from 'zustand/react/shallow'; + +import { TrackAnalyticsHandlers } from '../../analytics/useTrackAnalytics'; +import { + COW_PARTNER_FEE, + DUST_PROTECTION_MULTIPLIER, + FLASH_LOAN_FEE_BPS, +} from '../../constants/cow.constants'; +import { APP_CODE_PER_SWAP_TYPE } from '../../constants/shared.constants'; +import { + addOrderTypeToAppData, + getCowFlashLoanSdk, + getCowTradingSdkByChainIdAndAppCode, +} from '../../helpers/cow'; +import { calculateInstanceAddress } from '../../helpers/cow/adapters.helpers'; +import { useSwapGasEstimation } from '../../hooks/useSwapGasEstimation'; +import { + areActionsBlocked, + ExpiryToSecondsMap, + isCowProtocolRates, + isProtocolSwapState, + OrderType, + SwapParams, + SwapState, +} from '../../types'; +import { useSwapTokenApproval } from '../approval/useSwapTokenApproval'; + +/** + * Debt swap via CoW Protocol Flashloan Adapters. + * + * Flow summary: + * 1) Approve delegation on the destination variable debt token (permit supported) + * 2) Compute flashloan fee and sell amount; we temporarily borrow to close existing debt + * 3) Create a LIMIT order INVERTED relative to the UI: new debt asset -> old debt asset + * 4) Post order with adapter swap settings; adapter executes the repay + reborrow atomically + */ +export const DebtSwapActionsViaCoW = ({ + state, + setState, + trackingHandlers, +}: { + params: SwapParams; + state: SwapState; + setState: Dispatch>; + trackingHandlers: TrackAnalyticsHandlers; +}) => { + const [user] = useRootStore(useShallow((state) => [state.account])); + + const { + mainTxState, + loadingTxns, + approvalTxState, + setMainTxState, + setTxError, + setApprovalTxState, + } = useModalContext(); + + const [precalculatedInstanceAddress, setPrecalculatedInstanceAddress] = useState< + string | undefined + >(); + + const validTo = useMemo( + () => Math.floor(Date.now() / 1000) + ExpiryToSecondsMap[state.expiry], + [state.expiry] + ); + + // Pre-compute instance address + useEffect(() => { + calculateInstanceAddress({ + user, + validTo, + type: AaveFlashLoanType.DebtSwap, + state, + }) + .catch((error) => { + console.error('calculateInstanceAddress error', error); + setTxError(getErrorTextFromError(error, TxAction.MAIN_ACTION, true)); + setMainTxState({ + txHash: undefined, + loading: false, + success: false, + }); + }) + .then((address) => { + if (address) setPrecalculatedInstanceAddress(address); + }); + }, [ + user, + validTo, + state.sellAmountBigInt, + state.buyAmountBigInt, + state.sellAmountToken, + state.buyAmountToken, + state.processedSide, + state.slippage, + state.orderType, + state.chainId, + APP_CODE_PER_SWAP_TYPE[state.swapType], + ]); + + const amountToApprove = useMemo(() => { + if (!state.sellAmountFormatted || !state.sellAmountToken) return '0'; + return calculateSignedAmount(state.sellAmountFormatted, state.sellAmountToken.decimals); + }, [state.sellAmountFormatted, state.sellAmountToken]); + + const { hasActiveOrderForSellToken, trackSwapOrderProgress } = useSwapOrdersTracking(); + const sellAssetAddress = + state.sellAmountToken?.underlyingAddress || state.sourceToken.addressToSwap; + const disablePermitDueToActiveOrder = hasActiveOrderForSellToken(state.chainId, sellAssetAddress); + + // Approval is to the destination token via delegation Approval + const { + requiresApproval, + approval, + tryPermit, + signatureParams, + loadingPermitData, + approvedAddress, + } = useSwapTokenApproval({ + chainId: state.chainId, + token: isProtocolSwapState(state) + ? state.destinationReserve.reserve.variableDebtTokenAddress + : zeroAddress, + symbol: state.destinationToken.symbol, + amount: normalize(amountToApprove, state.sellAmountToken?.decimals ?? 18), + decimals: state.destinationToken.decimals, + spender: precalculatedInstanceAddress, + setState, + allowPermit: !disablePermitDueToActiveOrder, // avoid nonce reuse if active order present + type: 'delegation', // Debt swap uses delegation + trackingHandlers, + swapType: state.swapType, + }); + + // Use centralized gas estimation + useSwapGasEstimation({ + state, + setState, + requiresApproval, + requiresApprovalReset: state.requiresApprovalReset, + approvalTxState, + }); + + const action = async () => { + setMainTxState({ + txHash: undefined, + loading: true, + }); + setState({ + actionsLoading: false, + }); + + try { + if ( + !state.sellAmountBigInt || + !state.sellAmountToken || + !state.buyAmountBigInt || + !state.buyAmountToken + ) + return; + + const tradingSdk = await getCowTradingSdkByChainIdAndAppCode( + state.chainId, + APP_CODE_PER_SWAP_TYPE[state.swapType] + ); + const flashLoanSdk = await getCowFlashLoanSdk(state.chainId); + + const buyAmountWithMarginForDustProtection = valueToBigNumber( + state.buyAmountBigInt.toString() + ) + .multipliedBy(DUST_PROTECTION_MULTIPLIER) + .toFixed(0); + + const delegationPermit = signatureParams + ? { + amount: signatureParams?.amount, + deadline: Number(signatureParams?.deadline), + v: signatureParams?.splitedSignature.v, + r: signatureParams?.splitedSignature.r, + s: signatureParams?.splitedSignature.s, + } + : undefined; + + const { flashLoanFeeAmount, sellAmountToSign } = flashLoanSdk.calculateFlashLoanAmounts({ + flashLoanFeeBps: FLASH_LOAN_FEE_BPS, + sellAmount: state.sellAmountBigInt, + }); + + // On Debt Swap, the side is inverted for the swap + const limitOrder: LimitTradeParameters = { + sellToken: state.sellAmountToken.underlyingAddress, + sellTokenDecimals: state.sellAmountToken.decimals, + buyToken: state.buyAmountToken.underlyingAddress, + buyTokenDecimals: state.buyAmountToken.decimals, + sellAmount: sellAmountToSign.toString(), + buyAmount: buyAmountWithMarginForDustProtection.toString(), + kind: state.processedSide === 'buy' ? OrderKind.BUY : OrderKind.SELL, + quoteId: isCowProtocolRates(state.swapRate) ? state.swapRate?.quoteId : undefined, + validTo, + slippageBps: state.orderType == OrderType.MARKET ? Number(state.slippage) * 100 : undefined, + partnerFee: COW_PARTNER_FEE(state.sellAmountToken.symbol, state.buyAmountToken.symbol), + }; + + const orderToSign = getOrderToSign( + { + chainId: state.chainId, + from: user, + networkCostsAmount: '0', + isEthFlow: false, + applyCostsSlippageAndFees: false, + }, + limitOrder, + HASH_ZERO + ); + + const orderPostParams = await flashLoanSdk.getOrderPostingSettings( + AaveFlashLoanType.DebtSwap, + { + chainId: state.chainId, + validTo, + owner: user as `0x${string}`, + flashLoanFeeAmount, + }, + { + sellAmount: state.sellAmountBigInt, + buyAmount: BigInt(buyAmountWithMarginForDustProtection), + orderToSign, + collateralPermit: delegationPermit, + } + ); + + // Safe-check in case any param changed between approval and order posting + const instanceAddress = orderPostParams.instanceAddress; + if (instanceAddress !== approvedAddress) { + console.error( + 'Some parameters changed between approval and order posting: instanceAddress !== approvedAddress, asking for a new approval', + instanceAddress, + approvedAddress + ); + // Force re-approve + setPrecalculatedInstanceAddress(instanceAddress); + setApprovalTxState({ + txHash: undefined, + loading: false, + success: false, + }); + setMainTxState({ txHash: undefined, loading: false, success: false }); + + return; + } + + orderPostParams.swapSettings.appData = addOrderTypeToAppData( + state.orderType, + orderPostParams.swapSettings.appData + ); + const result = await tradingSdk.postLimitOrder(limitOrder, orderPostParams.swapSettings); + + trackingHandlers.trackSwap(); + setMainTxState({ + loading: false, + success: true, + txHash: result.orderId, + }); + // Save to local history and start tracking status + saveCowOrderToUserHistory({ + protocol: 'cow', + orderId: result.orderId, + status: OrderStatus.OPEN, + swapType: state.swapType, + chainId: state.chainId, + account: user, + timestamp: new Date().toISOString(), + srcToken: { + address: state.sellAmountToken.underlyingAddress, + symbol: state.sellAmountToken.symbol, + name: state.sellAmountToken.symbol, + decimals: state.sellAmountToken.decimals, + }, + destToken: { + address: state.buyAmountToken.underlyingAddress, + symbol: state.buyAmountToken.symbol, + name: state.buyAmountToken.symbol, + decimals: state.buyAmountToken.decimals, + }, + adapterInstanceAddress: instanceAddress, + usedAdapter: true, // DebtSwap always uses adapter + srcAmount: state.sellAmountBigInt.toString(), + destAmount: state.buyAmountBigInt.toString(), + }); + trackSwapOrderProgress(result.orderId, state.chainId); + setState({ + actionsLoading: false, + }); + } catch (error) { + console.error('DebtSwapActionsViaCoW error', error); + setTxError(getErrorTextFromError(error, TxAction.MAIN_ACTION, true)); + setMainTxState({ + txHash: undefined, + loading: false, + success: false, + }); + setState({ + actionsLoading: false, + }); + } + }; + + return ( + Checking approval + ) : ( + Swap {state.sourceToken.symbol} debt + ) + } + actionInProgressText={ + approvalTxState.loading ? ( + Checking approval + ) : ( + Swapping {state.sourceToken.symbol} debt + ) + } + errorParams={{ + loading: false, + disabled: + areActionsBlocked(state) || + approvalTxState.loading || + (!approvalTxState.success && requiresApproval), + content: approvalTxState.loading ? ( + Checking approval + ) : ( + Swap {state.sourceToken.symbol} debt + ), + handleClick: action, + }} + fetchingData={state.actionsLoading || loadingPermitData} + blocked={areActionsBlocked(state) || !precalculatedInstanceAddress} + tryPermit={tryPermit} + permitInUse={disablePermitDueToActiveOrder} + /> + ); +}; diff --git a/src/components/transactions/Swap/actions/DebtSwap/DebtSwapActionsViaParaswap.tsx b/src/components/transactions/Swap/actions/DebtSwap/DebtSwapActionsViaParaswap.tsx new file mode 100644 index 0000000000..951fab2e26 --- /dev/null +++ b/src/components/transactions/Swap/actions/DebtSwap/DebtSwapActionsViaParaswap.tsx @@ -0,0 +1,260 @@ +import { valueToBigNumber } from '@aave/math-utils'; +import { OrderStatus } from '@cowprotocol/cow-sdk'; +import { Trans } from '@lingui/macro'; +import { parseUnits } from 'ethers/lib/utils'; +import { Dispatch } from 'react'; +import { TxActionsWrapper } from 'src/components/transactions/TxActionsWrapper'; +import { maxInputAmountWithSlippage } from 'src/hooks/paraswap/common'; +import { useModalContext } from 'src/hooks/useModal'; +import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; +import { useRootStore } from 'src/store/root'; +import { getErrorTextFromError, TxAction } from 'src/ui-config/errorMapping'; +import { saveParaswapTxToUserHistory } from 'src/utils/swapAdapterHistory'; +import { useShallow } from 'zustand/shallow'; + +import { TrackAnalyticsHandlers } from '../../analytics/useTrackAnalytics'; +import { getTransactionParams } from '../../helpers/paraswap'; +import { useSwapGasEstimation } from '../../hooks/useSwapGasEstimation'; +import { + areActionsBlocked, + isParaswapRates, + ProtocolSwapParams, + ProtocolSwapState, + SwapState, +} from '../../types'; +import { useSwapTokenApproval } from '../approval/useSwapTokenApproval'; + +/** + * Debt swap via ParaSwap Adapter. + * + * Flow summary: + * 1) Approve delegation on the destination variable debt token to the adapter + * 2) Build a ParaSwap route INVERTED relative to the UI: new debt asset -> old debt asset + * - Inversion is required because we're acquiring new debt to repay old debt + * 3) Call the Debt Switch adapter with swap calldata and permit/delegation signature + */ +export const DebtSwapActionsViaParaswap = ({ + state, + params, + setState, + trackingHandlers, +}: { + params: ProtocolSwapParams; + state: ProtocolSwapState; + setState: Dispatch>; + trackingHandlers: TrackAnalyticsHandlers; +}) => { + const [currentMarketData, estimateGasLimit, addTransaction, debtSwitch] = useRootStore( + useShallow((state) => [ + state.currentMarketData, + state.estimateGasLimit, + state.addTransaction, + state.debtSwitch, + ]) + ); + const { approvalTxState, mainTxState, loadingTxns, setMainTxState, setTxError } = + useModalContext(); + const { sendTx } = useWeb3Context(); + + // TODO: CHECK LIMIT ORDERS BUY ORDERS + + const amountToSwap = maxInputAmountWithSlippage( + state.buyAmountFormatted ?? '0', + (Number(state.slippage) * 100).toString(), + state.buyAmountToken?.decimals || 18 + ); + + const maxNewDebtAmountToReceiveWithSlippage = maxInputAmountWithSlippage( + state.sellAmountFormatted ?? '0', + (Number(state.slippage) * 100).toString(), + state.sellAmountToken?.decimals || 18 + ); + + const { requiresApproval, approval, tryPermit, signatureParams, loadingPermitData } = + useSwapTokenApproval({ + chainId: state.chainId, + token: state.destinationReserve.reserve.variableDebtTokenAddress, + symbol: state.destinationReserve.reserve.symbol, + amount: maxNewDebtAmountToReceiveWithSlippage, + decimals: state.destinationReserve.reserve.decimals, + spender: currentMarketData.addresses.DEBT_SWITCH_ADAPTER, + setState, + allowPermit: currentMarketData.v3, + margin: 0.25, + type: 'delegation', + trackingHandlers, + swapType: state.swapType, + }); + + // Use centralized gas estimation + useSwapGasEstimation({ + state, + setState, + requiresApproval, + requiresApprovalReset: state.requiresApprovalReset, + approvalTxState, + }); + + const action = async () => { + try { + setMainTxState({ ...mainTxState, loading: true }); + + if (!state.swapRate || !isParaswapRates(state.swapRate)) { + throw new Error('No swap rate found'); + } + + if (!signatureParams) { + throw new Error('Signature params not found'); + } + + const inferredKind = state.swapRate.optimalRateData.side === 'SELL' ? 'sell' : 'buy'; + + // CallData for ParaswapRoute, which is inversed to the actual swap (dest -> src) + const { swapCallData, augustus } = await getTransactionParams( + inferredKind, + state.chainId, + state.destinationToken.addressToSwap, + state.destinationToken.decimals, + state.sourceToken.addressToSwap, + state.sourceToken.decimals, + state.user, + state.swapRate.optimalRateData, + Number(state.slippage) + ); + + const amountToReceiveForDebtSwitch = state.buyAmountBigInt?.toString() ?? '0'; + const amountToSwapForDebtSwitch = state.sellAmountBigInt?.toString() ?? '0'; + + let debtSwitchTxData = debtSwitch({ + poolReserve: state.sourceReserve.reserve, + targetReserve: state.destinationReserve.reserve, + amountToReceive: amountToSwapForDebtSwitch, + amountToSwap: amountToReceiveForDebtSwitch, + isMaxSelected: state.isMaxSelected, + txCalldata: swapCallData, + augustus: augustus, + signatureParams: { + signature: signatureParams.signature, + deadline: signatureParams.deadline, + amount: signatureParams.amount, + }, + isWrongNetwork: state.isWrongNetwork, + }); + + debtSwitchTxData = await estimateGasLimit(debtSwitchTxData); + const response = await sendTx(debtSwitchTxData); + await response.wait(1); + try { + saveParaswapTxToUserHistory({ + protocol: 'paraswap', + txHash: response.hash, + swapType: state.swapType, + chainId: state.chainId, + account: state.user, + timestamp: new Date().toISOString(), + status: OrderStatus.FULFILLED, + srcToken: { + address: state.sourceToken.addressToSwap, + symbol: state.sourceToken.symbol, + name: state.sourceToken.symbol, + decimals: state.sourceToken.decimals, + }, + destToken: { + address: state.destinationToken.addressToSwap, + symbol: state.destinationToken.symbol, + name: state.destinationToken.symbol, + decimals: state.destinationToken.decimals, + }, + srcAmount: state.buyAmountBigInt?.toString() ?? '0', + destAmount: state.sellAmountBigInt?.toString() ?? '0', + }); + } catch {} + setMainTxState({ + txHash: response.hash, + loading: false, + success: true, + }); + addTransaction(response.hash, { + action: 'debtSwitch', + txState: 'success', + previousState: `${state.buyAmountFormatted} variable ${state.sourceReserve.reserve.symbol}`, + newState: `${state.inputAmount} variable ${state.destinationReserve.reserve.symbol}`, + amountUsd: valueToBigNumber( + parseUnits(amountToSwap, state.sourceReserve.reserve.decimals).toString() + ) + .multipliedBy(state.sourceReserve.reserve.priceInUSD) + .toString(), + outAmountUsd: valueToBigNumber( + parseUnits( + maxNewDebtAmountToReceiveWithSlippage, + state.destinationReserve.reserve.decimals + ).toString() + ) + .multipliedBy(state.destinationReserve.reserve.priceInUSD) + .toString(), + }); + + params.invalidateAppState(); + trackingHandlers.trackSwap(); + } catch (error) { + console.error('error', error); + const parsedError = getErrorTextFromError(error, TxAction.GAS_ESTIMATION, false); + + // For gas estimation errors in Paraswap actions, show as warning instead of blocking error + if (parsedError.txAction === TxAction.GAS_ESTIMATION) { + setState({ + actionsLoading: false, + warnings: [ + { + message: + 'Gas estimation error: The swap could not be estimated. Try increasing slippage or changing the amount.', + }, + ], + error: undefined, // Clear any existing errors + }); + } else { + // For other errors, handle normally + setTxError(parsedError); + setState({ + actionsLoading: false, + }); + } + + setMainTxState({ + txHash: undefined, + loading: false, + }); + + const reason = error instanceof Error ? error.message : 'Swap failed'; + trackingHandlers.trackSwapFailed(reason); + } + }; + + return ( + Swap} + actionInProgressText={Swapping} + fetchingData={state.ratesLoading || loadingPermitData} + errorParams={{ + loading: false, + disabled: areActionsBlocked(state) || !approvalTxState?.success, + content: Swap, + handleClick: action, + }} + blocked={areActionsBlocked(state)} + tryPermit={tryPermit} + /> + ); +}; diff --git a/src/components/transactions/Swap/actions/RepayWithCollateral/RepayWithCollateralActions.tsx b/src/components/transactions/Swap/actions/RepayWithCollateral/RepayWithCollateralActions.tsx new file mode 100644 index 0000000000..5db63b5e8f --- /dev/null +++ b/src/components/transactions/Swap/actions/RepayWithCollateral/RepayWithCollateralActions.tsx @@ -0,0 +1,41 @@ +import { Dispatch } from 'react'; + +import { TrackAnalyticsHandlers } from '../../analytics/useTrackAnalytics'; +import { ProtocolSwapParams, ProtocolSwapState, SwapProvider, SwapState } from '../../types'; +import { RepayWithCollateralActionsViaCoW } from './RepayWithCollateralActionsViaCoW'; +import { RepayWithCollateralActionsViaParaswap } from './RepayWithCollateralActionsViaParaswap'; + +export const RepayWithCollateralActions = ({ + params, + state, + setState, + trackingHandlers, +}: { + params: ProtocolSwapParams; + state: ProtocolSwapState; + setState: Dispatch>; + trackingHandlers: TrackAnalyticsHandlers; +}) => { + switch (state.provider) { + case SwapProvider.COW_PROTOCOL: + return ( + + ); + case SwapProvider.PARASWAP: + return ( + + ); + default: + return null; + } +}; diff --git a/src/components/transactions/Swap/actions/RepayWithCollateral/RepayWithCollateralActionsViaCoW.tsx b/src/components/transactions/Swap/actions/RepayWithCollateral/RepayWithCollateralActionsViaCoW.tsx new file mode 100644 index 0000000000..d6560d0008 --- /dev/null +++ b/src/components/transactions/Swap/actions/RepayWithCollateral/RepayWithCollateralActionsViaCoW.tsx @@ -0,0 +1,373 @@ +import { normalize, valueToBigNumber } from '@aave/math-utils'; +import { getOrderToSign, LimitTradeParameters, OrderKind, OrderStatus } from '@cowprotocol/cow-sdk'; +import { AaveFlashLoanType, HASH_ZERO } from '@cowprotocol/sdk-flash-loans'; +import { Trans } from '@lingui/macro'; +import { Dispatch, useEffect, useMemo, useState } from 'react'; +import { TxActionsWrapper } from 'src/components/transactions/TxActionsWrapper'; +import { calculateSignedAmount } from 'src/hooks/paraswap/common'; +import { useModalContext } from 'src/hooks/useModal'; +import { useSwapOrdersTracking } from 'src/hooks/useSwapOrdersTracking'; +import { useRootStore } from 'src/store/root'; +import { getErrorTextFromError, TxAction } from 'src/ui-config/errorMapping'; +import { saveCowOrderToUserHistory } from 'src/utils/swapAdapterHistory'; +import { useShallow } from 'zustand/react/shallow'; + +import { TrackAnalyticsHandlers } from '../../analytics/useTrackAnalytics'; +import { + COW_PARTNER_FEE, + DUST_PROTECTION_MULTIPLIER, + FLASH_LOAN_FEE_BPS, +} from '../../constants/cow.constants'; +import { APP_CODE_PER_SWAP_TYPE } from '../../constants/shared.constants'; +import { + addOrderTypeToAppData, + getCowFlashLoanSdk, + getCowTradingSdkByChainIdAndAppCode, +} from '../../helpers/cow'; +import { calculateInstanceAddress } from '../../helpers/cow/adapters.helpers'; +import { useSwapGasEstimation } from '../../hooks/useSwapGasEstimation'; +import { + areActionsBlocked, + ExpiryToSecondsMap, + isCowProtocolRates, + OrderType, + SwapParams, + SwapState, +} from '../../types'; +import { useSwapTokenApproval } from '../approval/useSwapTokenApproval'; + +/** + * Repay-with-collateral via CoW Protocol Flashloan Adapters. + * + * Flow summary: + * 1) Approve collateral aToken (permit supported) to the CoW flashloan adapter + * 2) Compute flashloan fee and sell amount to sign + * 3) Create a LIMIT order INVERTED relative to the UI: collateral -> debt asset + * - The order kind depends on processed side; inversion is required because + * we swap the available collateral to acquire the debt asset to repay + * 4) Post order with adapter-provided swap settings; adapter orchestrates repay + */ +export const RepayWithCollateralActionsViaCoW = ({ + state, + setState, + trackingHandlers, +}: { + params: SwapParams; + state: SwapState; + setState: Dispatch>; + trackingHandlers: TrackAnalyticsHandlers; +}) => { + const [user] = useRootStore(useShallow((state) => [state.account])); + + const { + mainTxState, + loadingTxns, + approvalTxState, + setMainTxState, + setTxError, + setApprovalTxState, + } = useModalContext(); + + const [precalculatedInstanceAddress, setPrecalculatedInstanceAddress] = useState< + string | undefined + >(); + + const validTo = useMemo( + () => Math.floor(Date.now() / 1000) + ExpiryToSecondsMap[state.expiry], + [state.expiry] + ); + + // Pre-compute instance address + useEffect(() => { + calculateInstanceAddress({ + user, + validTo, + type: AaveFlashLoanType.RepayCollateral, + state, + }) + .catch((error) => { + console.error('calculateInstanceAddress error', error); + setTxError(getErrorTextFromError(error, TxAction.MAIN_ACTION, true)); + setMainTxState({ + txHash: undefined, + loading: false, + success: false, + }); + }) + .then((address) => { + if (address) setPrecalculatedInstanceAddress(address); + }); + }, [ + user, + validTo, + state.sellAmountBigInt, + state.buyAmountBigInt, + state.sellAmountToken, + state.buyAmountToken, + state.processedSide, + state.slippage, + state.orderType, + state.chainId, + APP_CODE_PER_SWAP_TYPE[state.swapType], + ]); + + // Approval is aToken ERC20 Approval + const amountToApprove = useMemo(() => { + if (!state.sellAmountFormatted || !state.sellAmountToken) return '0'; + return calculateSignedAmount(state.sellAmountFormatted, state.sellAmountToken.decimals); + }, [state.sellAmountFormatted, state.sellAmountToken]); + + const { hasActiveOrderForSellToken, trackSwapOrderProgress } = useSwapOrdersTracking(); + const sellAssetAddress = + state.sellAmountToken?.underlyingAddress || state.sourceToken.addressToSwap; + const disablePermitDueToActiveOrder = hasActiveOrderForSellToken(state.chainId, sellAssetAddress); + + // Approval is aToken ERC20 Approval + const { + requiresApproval, + approval, + tryPermit, + signatureParams, + loadingPermitData, + approvedAddress, + } = useSwapTokenApproval({ + chainId: state.chainId, + token: state.destinationToken.addressToSwap, // aToken to repay with + symbol: state.destinationToken.symbol, + amount: normalize(amountToApprove.toString(), state.sellAmountToken?.decimals ?? 18), + decimals: state.destinationToken.decimals, + spender: precalculatedInstanceAddress, + setState, + allowPermit: !disablePermitDueToActiveOrder, // avoid nonce reuse if active order present + trackingHandlers, + swapType: state.swapType, + }); + + // Use centralized gas estimation + useSwapGasEstimation({ + state, + setState, + requiresApproval, + requiresApprovalReset: state.requiresApprovalReset, + approvalTxState, + }); + + const action = async () => { + setMainTxState({ + txHash: undefined, + loading: true, + }); + setState({ + actionsLoading: false, + }); + + try { + if ( + !state.sellAmountBigInt || + !state.sellAmountToken || + !state.buyAmountBigInt || + !state.buyAmountToken + ) + return; + + const tradingSdk = await getCowTradingSdkByChainIdAndAppCode( + state.chainId, + APP_CODE_PER_SWAP_TYPE[state.swapType] + ); + const flashLoanSdk = await getCowFlashLoanSdk(state.chainId); + + const buyAmountWithMarginForDustProtection = valueToBigNumber( + state.buyAmountBigInt.toString() + ) + .multipliedBy(DUST_PROTECTION_MULTIPLIER) + .toFixed(0); + + const collateralPermit = signatureParams + ? { + amount: signatureParams?.amount, + deadline: Number(signatureParams?.deadline), + v: signatureParams?.splitedSignature.v, + r: signatureParams?.splitedSignature.r, + s: signatureParams?.splitedSignature.s, + } + : undefined; + + const { flashLoanFeeAmount, sellAmountToSign } = flashLoanSdk.calculateFlashLoanAmounts({ + flashLoanFeeBps: FLASH_LOAN_FEE_BPS, + sellAmount: state.sellAmountBigInt, + }); + + // In Repay With Collateral, the order is inverted, we need to sell the collateral to repay with and do a BUY order to the repay amount + const limitOrder: LimitTradeParameters = { + sellToken: state.sellAmountToken.underlyingAddress, + sellTokenDecimals: state.sellAmountToken.decimals, + buyToken: state.buyAmountToken.underlyingAddress, + buyTokenDecimals: state.buyAmountToken.decimals, + sellAmount: sellAmountToSign.toString(), + buyAmount: buyAmountWithMarginForDustProtection.toString(), + kind: state.processedSide === 'buy' ? OrderKind.BUY : OrderKind.SELL, + quoteId: isCowProtocolRates(state.swapRate) ? state.swapRate?.quoteId : undefined, + validTo, + slippageBps: state.orderType == OrderType.MARKET ? Number(state.slippage) * 100 : undefined, + partnerFee: COW_PARTNER_FEE(state.sellAmountToken.symbol, state.buyAmountToken.symbol), + }; + + const orderToSign = getOrderToSign( + { + chainId: state.chainId, + from: user, + networkCostsAmount: '0', + isEthFlow: false, + applyCostsSlippageAndFees: false, + }, + limitOrder, + HASH_ZERO + ); + + const orderPostParams = await flashLoanSdk.getOrderPostingSettings( + AaveFlashLoanType.RepayCollateral, + { + chainId: state.chainId, + validTo, + owner: user as `0x${string}`, + flashLoanFeeAmount, + }, + { + sellAmount: state.sellAmountBigInt, + buyAmount: BigInt(buyAmountWithMarginForDustProtection), + orderToSign, + collateralPermit, + } + ); + + orderPostParams.swapSettings.appData = addOrderTypeToAppData( + state.orderType, + orderPostParams.swapSettings.appData + ); + + // Safe-check in case any param changed between approval and order posting + const instanceAddress = orderPostParams.instanceAddress; + if (instanceAddress !== approvedAddress) { + console.error( + 'Some parameters changed between approval and order posting: instanceAddress !== approvedAddress, asking for a new approval', + instanceAddress, + approvedAddress + ); + // Force re-approve + setPrecalculatedInstanceAddress(instanceAddress); + setApprovalTxState({ + txHash: undefined, + loading: false, + success: false, + }); + setMainTxState({ txHash: undefined, loading: false, success: false }); + + return; + } + + const result = await tradingSdk.postLimitOrder(limitOrder, orderPostParams.swapSettings); + + trackingHandlers.trackSwap(); + setMainTxState({ + loading: false, + success: true, + txHash: result.orderId, + }); + // Save to local history and start tracking status + saveCowOrderToUserHistory({ + protocol: 'cow', + orderId: result.orderId, + status: OrderStatus.OPEN, + swapType: state.swapType, + chainId: state.chainId, + account: user, + timestamp: new Date().toISOString(), + srcToken: { + address: state.sellAmountToken.underlyingAddress, + symbol: state.sellAmountToken.symbol, + name: state.sellAmountToken.symbol, + decimals: state.sellAmountToken.decimals, + }, + destToken: { + address: state.buyAmountToken.underlyingAddress, + symbol: state.buyAmountToken.symbol, + name: state.buyAmountToken.symbol, + decimals: state.buyAmountToken.decimals, + }, + adapterInstanceAddress: instanceAddress, + usedAdapter: true, // RepayWithCollateral always uses adapter + srcAmount: state.sellAmountBigInt.toString(), + destAmount: state.buyAmountBigInt.toString(), + }); + trackSwapOrderProgress(result.orderId, state.chainId); + setState({ + actionsLoading: false, + }); + } catch (error) { + console.error('RepayWithCollateralActionsViaCoW error', error); + setTxError(getErrorTextFromError(error, TxAction.MAIN_ACTION, true)); // TODO: Fix cannot copy error + setMainTxState({ + txHash: undefined, + loading: false, + success: false, + }); + setState({ + actionsLoading: false, + }); + } + }; + + return ( + Checking approval + ) : ( + + Repay {state.sourceToken.symbol} with {state.destinationToken.symbol} + + ) + } + actionInProgressText={ + approvalTxState.loading ? ( + Checking approval + ) : ( + + Repaying {state.sourceToken.symbol} with {state.destinationToken.symbol} + + ) + } + errorParams={{ + loading: false, + disabled: + areActionsBlocked(state) || + approvalTxState.loading || + (!approvalTxState.success && requiresApproval), + content: approvalTxState.loading ? ( + Checking approval + ) : ( + + Repay {state.sourceToken.symbol} with {state.destinationToken.symbol} + + ), + handleClick: action, + }} + fetchingData={state.actionsLoading || loadingPermitData} + blocked={areActionsBlocked(state) || !precalculatedInstanceAddress} + tryPermit={tryPermit} + permitInUse={disablePermitDueToActiveOrder} + /> + ); +}; diff --git a/src/components/transactions/Swap/actions/RepayWithCollateral/RepayWithCollateralActionsViaParaswap.tsx b/src/components/transactions/Swap/actions/RepayWithCollateral/RepayWithCollateralActionsViaParaswap.tsx new file mode 100644 index 0000000000..3f0d8bce67 --- /dev/null +++ b/src/components/transactions/Swap/actions/RepayWithCollateral/RepayWithCollateralActionsViaParaswap.tsx @@ -0,0 +1,296 @@ +import { normalize, normalizeBN, valueToBigNumber } from '@aave/math-utils'; +import { OrderStatus } from '@cowprotocol/cow-sdk'; +import { Trans } from '@lingui/macro'; +import { BigNumber, PopulatedTransaction } from 'ethers'; +import { Dispatch } from 'react'; +import { calculateSignedAmount } from 'src/hooks/paraswap/common'; +import { useModalContext } from 'src/hooks/useModal'; +import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; +import { useRootStore } from 'src/store/root'; +import { getErrorTextFromError, TxAction } from 'src/ui-config/errorMapping'; +import { saveParaswapTxToUserHistory } from 'src/utils/swapAdapterHistory'; +import { useShallow } from 'zustand/shallow'; + +import { TxActionsWrapper } from '../../../TxActionsWrapper'; +import { TrackAnalyticsHandlers } from '../../analytics/useTrackAnalytics'; +import { getTransactionParams } from '../../helpers/paraswap'; +import { useSwapGasEstimation } from '../../hooks/useSwapGasEstimation'; +import { + areActionsBlocked, + isParaswapRates, + ProtocolSwapParams, + ProtocolSwapState, + SwapState, +} from '../../types'; +import { useSwapTokenApproval } from '../approval/useSwapTokenApproval'; + +/** + * Repay-with-collateral via ParaSwap Adapter. + * + * Flow summary: + * 1) Approve aToken (or use permit) to the RepayWithCollateral adapter + * 2) Build a ParaSwap route INVERTED relative to the UI: collateral aToken -> debt token + * - We invert because the protocol action consumes collateral to acquire the debt asset + * 3) Compute repay amounts with slippage; detect `repayAllDebt` when balance covers max with margin + * 4) Call adapter with swap calldata + optional permit to execute repay and residual handling + */ +export const RepayWithCollateralActionsViaParaswap = ({ + params, + state, + setState, + trackingHandlers, +}: { + params: ProtocolSwapParams; + state: ProtocolSwapState; + setState: Dispatch>; + trackingHandlers: TrackAnalyticsHandlers; +}) => { + const { setTxError, setMainTxState, approvalTxState } = useModalContext(); + const { sendTx } = useWeb3Context(); + const [paraswapRepayWithCollateral, currentMarketData, estimateGasLimit] = useRootStore( + useShallow((state) => [ + state.paraswapRepayWithCollateral, + state.currentMarketData, + state.estimateGasLimit, + ]) + ); + + const toRepaySelectedAmountFormatted = state.inputAmount; + const collateralToRepayWithAmountFormatted = state.outputAmount; + const collateralToRepayAmountToApprove = normalize( + calculateSignedAmount( + normalizeBN( + collateralToRepayWithAmountFormatted, + -state.destinationToken.decimals + ).toString(), + 0 + // Adds margin to account future incremental so better ux + ), + state.destinationToken.decimals + ); + + // Approval is aToken ERC20 Approval + const { + requiresApproval, + signatureParams, + approval, + tryPermit, + approvedAmount, + loadingPermitData, + } = useSwapTokenApproval({ + chainId: state.chainId, + token: state.destinationToken.addressToSwap, // aToken + symbol: state.destinationToken.symbol, + decimals: state.destinationToken.decimals, + amount: collateralToRepayAmountToApprove.toString(), + spender: currentMarketData.addresses.REPAY_WITH_COLLATERAL_ADAPTER, + setState, + trackingHandlers, + swapType: state.swapType, + }); + + // Use centralized gas estimation + useSwapGasEstimation({ + state, + setState, + requiresApproval, + requiresApprovalReset: state.requiresApprovalReset, + approvalTxState, + }); + + const action = async () => { + if (!state.swapRate || !isParaswapRates(state.swapRate)) + throw new Error('Route required to build transaction'); + + setMainTxState({ + txHash: undefined, + loading: true, + }); + + try { + const tokenToRepayWithBalance = state.destinationToken.balance || '0'; + let safeAmountToRepayAll = valueToBigNumber(state.sourceReserve.variableBorrows || '0'); + // Add in the approximate interest accrued over the next 30 minutes + safeAmountToRepayAll = safeAmountToRepayAll.plus( + safeAmountToRepayAll + .multipliedBy(state.sourceReserve.reserve.variableBorrowAPY) + .dividedBy(360 * 24 * 2) + ); + + let repayAmount, repayWithAmount; + if (state.side === 'sell') { + // If sell order i want to repay exactly the input amount + repayAmount = state.isMaxSelected + ? safeAmountToRepayAll.toFixed(state.sourceToken.decimals) + : toRepaySelectedAmountFormatted; + + // Account slippage to make sure we have enough collateral to repay with + repayWithAmount = valueToBigNumber(state.outputAmount || '0') + .multipliedBy(1 + Number(state.slippage) / 100) + .toFixed(state.destinationToken.decimals); + } else { + // If buy order i want use exactly the collateral to repay with amount + repayWithAmount = state.outputAmount; + + // Account slippage to make sure we pay as much debt as possible + repayAmount = valueToBigNumber(state.inputAmount || '0') + .dividedBy(1 + Number(state.slippage) / 100) + .toFixed(state.sourceToken.decimals); + } + + // The slippage is factored into the collateral amount because when we swap for 'exactOut', positive slippage is applied on the collateral amount. + const collateralAmountRequiredToCoverDebt = safeAmountToRepayAll + .multipliedBy(state.sourceReserve.reserve.priceInUSD) + .multipliedBy(100 + Number(state.slippage)) + .dividedBy(100) + .dividedBy(state.destinationReserve.reserve.priceInUSD); + + const repayAllDebt = + state.isMaxSelected && + valueToBigNumber(tokenToRepayWithBalance).gte(collateralAmountRequiredToCoverDebt); + + const invertedSide = state.side === 'sell' ? 'buy' : 'sell'; + + // Prepare Swap (inversed, from the collateral asset to the debt to repay asset) + const { swapCallData, augustus } = await getTransactionParams( + invertedSide, + state.chainId, + state.destinationToken.underlyingAddress, + state.destinationToken.decimals, + state.sourceToken.underlyingAddress, + state.sourceToken.decimals, + state.user, + state.swapRate.optimalRateData, + Number(state.slippage) + ); + + const txs = await paraswapRepayWithCollateral({ + repayAllDebt, + repayAmount, + rateMode: params.interestMode, + repayWithAmount, + + fromAssetData: state.destinationReserve.reserve, + poolReserve: state.sourceReserve.reserve, + + symbol: state.sourceReserve.reserve.symbol, + isWrongNetwork: state.isWrongNetwork, + useFlashLoan: state.useFlashloan || false, + blocked: areActionsBlocked(state), + swapCallData, + augustus, + signature: signatureParams?.splitedSignature, + deadline: signatureParams?.deadline, + signedAmount: approvedAmount, + }); + + const actionTx = txs.find((tx) => ['DLP_ACTION'].includes(tx.txType)); + if (!actionTx) throw new Error('Action tx not found'); + const tx = await actionTx.tx(); + const populatedTx: PopulatedTransaction = { + to: tx.to, + from: tx.from, + data: tx.data, + gasLimit: tx.gasLimit, + gasPrice: tx.gasPrice, + nonce: tx.nonce, + chainId: tx.chainId, + value: tx.value ? BigNumber.from(tx.value) : undefined, + }; + + const txWithGasEstimation = await estimateGasLimit(populatedTx, state.chainId); + const response = await sendTx(txWithGasEstimation); + await response.wait(1); + try { + saveParaswapTxToUserHistory({ + protocol: 'paraswap', + txHash: response.hash, + swapType: state.swapType, + chainId: state.chainId, + status: OrderStatus.FULFILLED, + account: state.user, + timestamp: new Date().toISOString(), + srcToken: { + address: state.sourceToken.addressToSwap, + symbol: state.sourceToken.symbol, + name: state.sourceToken.symbol, + decimals: state.sourceToken.decimals, + }, + destToken: { + address: state.destinationToken.addressToSwap, + symbol: state.destinationToken.symbol, + name: state.destinationToken.symbol, + decimals: state.destinationToken.decimals, + }, + srcAmount: state.sellAmountBigInt?.toString() ?? '0', + destAmount: state.buyAmountBigInt?.toString() ?? '0', + }); + } catch {} + + trackingHandlers.trackSwap(); + params.invalidateAppState(); + setMainTxState({ + txHash: response.hash, + loading: false, + success: true, + }); + } catch (error) { + const parsedError = getErrorTextFromError(error, TxAction.GAS_ESTIMATION, false); + + // For gas estimation errors in Paraswap actions, show as warning instead of blocking error + if (parsedError.txAction === TxAction.GAS_ESTIMATION) { + setState({ + actionsLoading: false, + warnings: [ + { + message: + 'Gas estimation error: The swap could not be estimated. Try increasing slippage or changing the amount.', + }, + ], + error: undefined, // Clear any existing errors + }); + } else { + // For other errors, handle normally + setTxError(parsedError); + setState({ + actionsLoading: false, + }); + } + + setMainTxState({ + loading: false, + }); + + const reason = error instanceof Error ? error.message : undefined; + trackingHandlers.trackSwapFailed(reason); + } + }; + + return ( + Repay {state.sourceReserve.reserve.symbol}} + actionInProgressText={Repaying {state.sourceReserve.reserve.symbol}} + fetchingData={state.ratesLoading || loadingPermitData} + errorParams={{ + loading: false, + disabled: areActionsBlocked(state), + content: Repay {state.sourceReserve.reserve.symbol}, + handleClick: action, + }} + tryPermit={tryPermit} + /> + ); +}; diff --git a/src/components/transactions/Swap/actions/SwapActions/SwapActionsViaCoW.tsx b/src/components/transactions/Swap/actions/SwapActions/SwapActionsViaCoW.tsx new file mode 100644 index 0000000000..f6743a4abe --- /dev/null +++ b/src/components/transactions/Swap/actions/SwapActions/SwapActionsViaCoW.tsx @@ -0,0 +1,423 @@ +import { + calculateUniqueOrderId, + COW_PROTOCOL_VAULT_RELAYER_ADDRESS, + OrderKind, + SupportedChainId, +} from '@cowprotocol/cow-sdk'; +import { Trans } from '@lingui/macro'; +import { BigNumber } from 'ethers'; +import stringify from 'json-stringify-deterministic'; +import { Dispatch, useMemo } from 'react'; +import { TxActionsWrapper } from 'src/components/transactions/TxActionsWrapper'; +import { isSmartContractWallet } from 'src/helpers/provider'; +import { useModalContext } from 'src/hooks/useModal'; +import { useSwapOrdersTracking } from 'src/hooks/useSwapOrdersTracking'; +import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; +import { getEthersProvider } from 'src/libs/web3-data-provider/adapters/EthersAdapter'; +import { useRootStore } from 'src/store/root'; +import { getErrorTextFromError, TxAction } from 'src/ui-config/errorMapping'; +import { wagmiConfig } from 'src/ui-config/wagmiConfig'; +import { useShallow } from 'zustand/shallow'; + +import { TrackAnalyticsHandlers } from '../../analytics/useTrackAnalytics'; +import { COW_APP_DATA } from '../../constants/cow.constants'; +import { APP_CODE_PER_SWAP_TYPE } from '../../constants/shared.constants'; +import { + getPreSignTransaction, + getUnsignerOrder, + isNativeToken, + populateEthFlowTx, + sendOrder, + uploadAppData, +} from '../../helpers/cow'; +import { useSwapGasEstimation } from '../../hooks/useSwapGasEstimation'; +import { + areActionsBlocked, + ExpiryToSecondsMap, + isCowProtocolRates, + OrderType, + SwapParams, + SwapState, + TokenType, +} from '../../types'; +import { useSwapTokenApproval } from '../approval/useSwapTokenApproval'; + +/** + * Asset swap via CoW Protocol (Limit/Market orders). + * + * Process: + * 1) Ensure token approval (with permit when possible) for the CoW Relayer. Handles smart contract wallets and native token flows. + * 2) For tokens requiring approval, attempts onchain approval and reacts to possible failures or pending states. + * 3) For ERC-20s supporting permit, attempts signature path unless already approved. + * 4) Handles both normal EOA users and smart contract wallets (e.g. Gnosis Safe) with pre-sign or off-chain signatures as needed. + * 5) Posts order to the CoW API (off-chain) or submits on-chain pre-sign transaction, depending on user/wallet. + * 6) Tracks tx/analytics and updates transaction state accurately for UI. + * + * Automatically accounts for amount normalization, possible token decimal mismatches, + * error states in approvals or post order flow, and UI feedback for each path. + */ +export const SwapActionsViaCoW = ({ + params, + state, + setState, + trackingHandlers, +}: { + params: SwapParams; + state: SwapState; + setState: Dispatch>; + trackingHandlers: TrackAnalyticsHandlers; +}) => { + const [user, estimateGasLimit, addTransaction] = useRootStore( + useShallow((state) => [state.account, state.estimateGasLimit, state.addTransaction]) + ); + + const { mainTxState, loadingTxns, setMainTxState, setTxError, approvalTxState } = + useModalContext(); + + const { hasActiveOrderForSellToken } = useSwapOrdersTracking(); + + const disablePermitDueToActiveOrder = hasActiveOrderForSellToken( + state.chainId, + state.sourceToken.addressToSwap + ); + + const { + requiresApproval, + requiresApprovalReset, + approval, + tryPermit, + signatureParams, + loadingPermitData, + } = useSwapTokenApproval({ + chainId: state.chainId, + token: state.sourceToken.addressToSwap, + symbol: state.sourceToken.symbol, + amount: state.sellAmountFormatted ?? '0', + decimals: state.sourceToken.decimals, + spender: isCowProtocolRates(state.swapRate) + ? COW_PROTOCOL_VAULT_RELAYER_ADDRESS[state.chainId as SupportedChainId] + : undefined, + setState, + allowPermit: !disablePermitDueToActiveOrder, + trackingHandlers, + swapType: state.swapType, + }); + + // Use centralized gas estimation + useSwapGasEstimation({ + state, + setState, + requiresApproval, + requiresApprovalReset, + approvalTxState, + }); + + const validTo = useMemo( + () => Math.floor(Date.now() / 1000) + ExpiryToSecondsMap[state.expiry], + [state.expiry] + ); + + const { sendTx } = useWeb3Context(); + + const slippageInPercent = state.slippage; + + const sellAmountAccountingCosts = state.sellAmountBigInt; + const buyAmountAccountingCosts = state.buyAmountBigInt; + + const action = async () => { + if (!sellAmountAccountingCosts || !buyAmountAccountingCosts) { + return; + } + + if (state.orderType === OrderType.LIMIT) { + if (state.sourceToken.tokenType === TokenType.NATIVE) { + // Disallow native as sell token in ALL limit orders (would require eth-flow and locked funds) + setTxError( + getErrorTextFromError( + new Error( + 'Native sell token is not supported in limit orders. Please use the wrapped token.' + ), + TxAction.MAIN_ACTION, + true + ) + ); + setState({ actionsLoading: false }); + setMainTxState({ txHash: undefined, loading: false }); + return; + } + } + + setMainTxState({ ...mainTxState, loading: true }); + if (isCowProtocolRates(state.swapRate)) { + if (state.useFlashloan) { + setTxError( + getErrorTextFromError(new Error('Please use flashloan'), TxAction.MAIN_ACTION, true) + ); + setState({ + actionsLoading: false, + }); + setMainTxState({ + txHash: undefined, + loading: false, + }); + return; + } + + try { + const provider = await getEthersProvider(wagmiConfig, { chainId: state.chainId }); + const slippageBps = + state.orderType === OrderType.LIMIT ? 0 : Math.round(Number(slippageInPercent) * 100); // percent to bps + const smartSlippage = state.swapRate.suggestedSlippage == Number(slippageInPercent); + const appCode = APP_CODE_PER_SWAP_TYPE[params.swapType]; + + // If srcToken is native, we need to use the eth-flow instead of the orderbook + if (isNativeToken(state.sourceToken.addressToSwap)) { + const ethFlowTx = await populateEthFlowTx( + sellAmountAccountingCosts.toString(), + buyAmountAccountingCosts.toString(), + state.destinationToken.addressToSwap, + user, + validTo, + state.sourceToken.symbol, + state.destinationToken.symbol, + slippageBps, + smartSlippage, + appCode, + state.orderType, + state.swapRate.quoteId + ); + const txWithGasEstimation = await estimateGasLimit(ethFlowTx, state.chainId); + let response; + try { + response = await sendTx(txWithGasEstimation); + addTransaction( + response.hash, + { + txState: 'success', + }, + { + chainId: state.chainId, + } + ); + + setMainTxState({ + loading: false, + success: true, + }); + + const unsignerOrder = await getUnsignerOrder({ + sellAmount: sellAmountAccountingCosts.toString(), + buyAmount: buyAmountAccountingCosts.toString(), + dstToken: state.destinationToken.addressToSwap, + user, + chainId: state.chainId, + tokenFromSymbol: state.sourceToken.symbol, + tokenToSymbol: state.destinationToken.symbol, + slippageBps, + smartSlippage, + appCode, + orderType: state.orderType, + validTo, + }); + const calculatedOrderId = await calculateUniqueOrderId(state.chainId, unsignerOrder); + + await uploadAppData( + calculatedOrderId, + stringify( + COW_APP_DATA( + state.sourceToken.symbol, + state.destinationToken.symbol, + slippageBps, + smartSlippage, + state.orderType, + APP_CODE_PER_SWAP_TYPE[params.swapType] + ) + ), + state.chainId + ); + + // CoW takes some time to index the order for 'eth-flow' orders + setTimeout(() => { + setMainTxState({ + loading: false, + success: true, + txHash: calculatedOrderId, + }); + }, 1000 * 30); // 30 seconds - if we set less than 30 seconds, the order is not indexed yet and CoW explorer will not find the order + } catch (error) { + setTxError(getErrorTextFromError(error, TxAction.MAIN_ACTION, false)); + setMainTxState({ + txHash: response?.hash, + loading: false, + }); + setState({ + actionsLoading: false, + }); + if (response?.hash) { + addTransaction( + response?.hash, + { + txState: 'failed', + }, + { chainId: state.chainId } + ); + } + } + } else { + let orderId; + try { + if (await isSmartContractWallet(user, provider)) { + const preSignTransaction = await getPreSignTransaction({ + provider, + validTo, + tokenDest: state.destinationToken.addressToSwap, + chainId: state.chainId, + user, + sellAmount: sellAmountAccountingCosts.toString(), + buyAmount: buyAmountAccountingCosts.toString(), + tokenSrc: state.sourceToken.addressToSwap, + tokenSrcDecimals: state.sourceToken.decimals, + tokenDestDecimals: state.destinationToken.decimals, + slippageBps, + smartSlippage, + inputSymbol: state.sourceToken.symbol, + outputSymbol: state.destinationToken.symbol, + quote: state.swapRate?.order, + appCode, + orderBookQuote: state.swapRate?.orderBookQuote, + orderType: state.orderType, + kind: + state.orderType === OrderType.MARKET + ? OrderKind.SELL + : state.side === 'buy' + ? OrderKind.BUY + : OrderKind.SELL, + signatureParams, // TODO: Test permit for smart contract wallets? + estimateGasLimit, + }); + + const response = await sendTx({ + data: preSignTransaction.data, + to: preSignTransaction.to, + value: BigNumber.from(preSignTransaction.value), + gasLimit: BigNumber.from(preSignTransaction.gasLimit), + }); + + addTransaction( + response.hash, + { + txState: 'success', + }, + { + chainId: state.chainId, + } + ); + + setMainTxState({ + loading: false, + success: true, + txHash: preSignTransaction.orderId, + }); + } else { + orderId = await sendOrder({ + validTo, + tokenSrc: state.sourceToken.addressToSwap, + tokenSrcDecimals: state.sourceToken.decimals, + tokenDest: state.destinationToken.addressToSwap, + tokenDestDecimals: state.destinationToken.decimals, + quote: state.swapRate?.order, + sellAmount: sellAmountAccountingCosts.toString(), + buyAmount: buyAmountAccountingCosts.toString(), + slippageBps, + smartSlippage, + orderType: state.orderType, + kind: + state.orderType === OrderType.MARKET + ? OrderKind.SELL + : state.side === 'buy' + ? OrderKind.BUY + : OrderKind.SELL, + chainId: state.chainId, + user, + provider, + inputSymbol: state.sourceToken.symbol, + outputSymbol: state.destinationToken.symbol, + appCode, + orderBookQuote: state.swapRate?.orderBookQuote, + signatureParams, + estimateGasLimit, + }); + setMainTxState({ + loading: false, + success: true, + txHash: orderId ?? undefined, + }); + } + } catch (error) { + console.error('SwapActionsViaCoW error', error); + const parsedError = getErrorTextFromError(error, TxAction.MAIN_ACTION, false); + setTxError(parsedError); + setMainTxState({ + success: false, + loading: false, + }); + setState({ + actionsLoading: false, + }); + } + } + } catch (error) { + console.error(error); + const parsedError = getErrorTextFromError(error, TxAction.GAS_ESTIMATION, false); + setTxError(parsedError); + setMainTxState({ + txHash: undefined, + loading: false, + success: false, + }); + setState({ + actionsLoading: false, + }); + } + } else { + setTxError( + getErrorTextFromError(new Error('No sell rates found'), TxAction.MAIN_ACTION, true) + ); + setState({ + actionsLoading: false, + }); + } + + trackingHandlers.trackSwap(); + }; + + return ( + approval()} + requiresApproval={!areActionsBlocked(state) && requiresApproval} + actionText={Swap} + actionInProgressText={Swapping} + errorParams={{ + loading: false, + disabled: areActionsBlocked(state) || (!approvalTxState.success && requiresApproval), + content: Swap, + handleClick: action, + }} + fetchingData={state.actionsLoading || loadingPermitData} + blocked={areActionsBlocked(state)} + tryPermit={tryPermit} + permitInUse={disablePermitDueToActiveOrder} + /> + ); +}; diff --git a/src/components/transactions/Swap/actions/SwapActions/SwapActionsViaParaswap.tsx b/src/components/transactions/Swap/actions/SwapActions/SwapActionsViaParaswap.tsx new file mode 100644 index 0000000000..f218f1f583 --- /dev/null +++ b/src/components/transactions/Swap/actions/SwapActions/SwapActionsViaParaswap.tsx @@ -0,0 +1,243 @@ +import { OrderStatus } from '@cowprotocol/cow-sdk'; +import { Trans } from '@lingui/macro'; +import { Dispatch } from 'react'; +import { TxActionsWrapper } from 'src/components/transactions/TxActionsWrapper'; +import { useParaswapSellTxParams } from 'src/hooks/paraswap/useParaswapRates'; +import { useModalContext } from 'src/hooks/useModal'; +import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; +import { useRootStore } from 'src/store/root'; +import { getErrorTextFromError, TxAction } from 'src/ui-config/errorMapping'; +import { useShallow } from 'zustand/shallow'; + +import { TrackAnalyticsHandlers } from '../../analytics/useTrackAnalytics'; +import { APP_CODE_PER_SWAP_TYPE } from '../../constants/shared.constants'; +import { useSwapGasEstimation } from '../../hooks/useSwapGasEstimation'; +import { areActionsBlocked, isParaswapRates, SwapParams, SwapState } from '../../types'; +import { useSwapTokenApproval } from '../approval/useSwapTokenApproval'; + +/** + * Simple asset swap via ParaSwap Adapter (non-position flow). + * Prepares approval if needed and executes the route returned by useSwapQuote. + */ +export const SwapActionsViaParaswap = ({ + params, + state, + setState, + trackingHandlers, +}: { + params: SwapParams; + state: SwapState; + setState: Dispatch>; + trackingHandlers: TrackAnalyticsHandlers; +}) => { + const [user, estimateGasLimit, addTransaction] = useRootStore( + useShallow((state) => [state.account, state.estimateGasLimit, state.addTransaction]) + ); + + const { mainTxState, loadingTxns, setMainTxState, setTxError, approvalTxState } = + useModalContext(); + + const { sendTx } = useWeb3Context(); + const { mutateAsync: fetchParaswapTxParams } = useParaswapSellTxParams(state.chainId); + + const slippageInPercent = (Number(state.slippage) * 100).toString(); + + const { + requiresApproval, + requiresApprovalReset, + signatureParams, + approval, + tryPermit, + loadingPermitData, + } = useSwapTokenApproval({ + chainId: state.chainId, + token: state.sourceToken.addressToSwap, + symbol: state.sourceToken.symbol, + amount: state.inputAmount, + decimals: state.sourceToken.decimals, + spender: isParaswapRates(state.swapRate) + ? state?.swapRate?.optimalRateData?.tokenTransferProxy + : undefined, + setState, + trackingHandlers, + swapType: state.swapType, + }); + + // Use centralized gas estimation + useSwapGasEstimation({ + state, + setState, + requiresApproval, + requiresApprovalReset, + approvalTxState: { success: approvalTxState.success || false }, + }); + + const action = async () => { + setMainTxState({ ...mainTxState, loading: true }); + if (isParaswapRates(state.swapRate)) { + try { + const appCode = APP_CODE_PER_SWAP_TYPE[params.swapType]; + + // Normal switch using paraswap + const tx = await fetchParaswapTxParams({ + srcToken: state.sourceToken.addressToSwap, + srcDecimals: state.swapRate.srcDecimals, + destDecimals: state.swapRate.destDecimals, + destToken: state.destinationToken.addressToSwap, + route: state.swapRate.optimalRateData, + user, + maxSlippage: Number(slippageInPercent), + permit: signatureParams && signatureParams.signature, + deadline: signatureParams && signatureParams.deadline, + partner: appCode, + }); + tx.chainId = state.chainId; + const txWithGasEstimation = await estimateGasLimit(tx, state.chainId); + const response = await sendTx(txWithGasEstimation); + try { + await response.wait(1); + // Save Paraswap tx locally for history + try { + const { saveParaswapTxToUserHistory: addParaswapTx } = await import( + 'src/utils/swapAdapterHistory' + ); + addParaswapTx({ + protocol: 'paraswap', + txHash: response.hash, + swapType: params.swapType, + chainId: state.chainId, + account: user, + timestamp: new Date().toISOString(), + status: OrderStatus.FULFILLED, + srcToken: { + address: state.sourceToken.addressToSwap, + symbol: state.sourceToken.symbol, + name: state.sourceToken.symbol, + decimals: state.sourceToken.decimals, + }, + destToken: { + address: state.destinationToken.addressToSwap, + symbol: state.destinationToken.symbol, + name: state.destinationToken.symbol, + decimals: state.destinationToken.decimals, + }, + srcAmount: state.sellAmountBigInt?.toString() ?? '0', + destAmount: state.buyAmountBigInt?.toString() ?? '0', + }); + // ParaSwap is atomic onchain; no toast tracking required + } catch {} + addTransaction( + response.hash, + { + txState: 'success', + }, + { + chainId: state.chainId, + } + ); + setMainTxState({ + txHash: response.hash, + loading: false, + success: true, + }); + + params.invalidateAppState(); + trackingHandlers.trackSwap(); + } catch (error) { + // This is for transaction waiting errors, not gas estimation, so handle normally + const parsedError = getErrorTextFromError(error, TxAction.MAIN_ACTION, false); + setTxError(parsedError); + setMainTxState({ + txHash: response.hash, + loading: false, + }); + setState({ + actionsLoading: false, + }); + addTransaction( + response.hash, + { + txState: 'failed', + }, + { + chainId: state.chainId, + } + ); + } + } catch (error) { + const parsedError = getErrorTextFromError(error, TxAction.MAIN_ACTION, false); + + // Check if this is a gas estimation error (from estimateGasLimit call) + // Gas estimation errors typically occur when estimateGasLimit fails + const errorMessage = parsedError.rawError?.message?.toLowerCase() || ''; + const isGasEstimationError = + errorMessage.includes('gas') || + errorMessage.includes('estimation') || + (errorMessage.includes('execution reverted') && errorMessage.includes('estimation')); + + // For gas estimation errors in Paraswap actions, show as warning instead of blocking error + if (isGasEstimationError) { + setState({ + actionsLoading: false, + warnings: [ + { + message: + 'Gas estimation error: The swap could not be estimated. Try increasing slippage or changing the amount.', + }, + ], + error: undefined, // Clear any existing errors + }); + } else { + // For other errors, handle normally + setTxError(parsedError); + setState({ + actionsLoading: false, + }); + } + + setMainTxState({ + txHash: undefined, + loading: false, + }); + + const reason = error instanceof Error ? error.message : 'Swap failed'; + trackingHandlers.trackSwapFailed(reason); + } + } else { + setTxError( + getErrorTextFromError(new Error('No sell rates found'), TxAction.MAIN_ACTION, true) + ); + setState({ + actionsLoading: false, + }); + } + }; + + return ( + approval()} + requiresApproval={!areActionsBlocked(state) && requiresApproval} + actionText={Swap} + actionInProgressText={Swapping} + errorParams={{ + loading: false, + disabled: areActionsBlocked(state) || (!approvalTxState.success && requiresApproval), + content: Swap, + handleClick: action, + }} + fetchingData={state.actionsLoading || loadingPermitData} + blocked={areActionsBlocked(state)} + tryPermit={tryPermit} + /> + ); +}; diff --git a/src/components/transactions/Swap/actions/SwapActions/index.tsx b/src/components/transactions/Swap/actions/SwapActions/index.tsx new file mode 100644 index 0000000000..bb327a39a0 --- /dev/null +++ b/src/components/transactions/Swap/actions/SwapActions/index.tsx @@ -0,0 +1,41 @@ +import { Dispatch } from 'react'; + +import { TrackAnalyticsHandlers } from '../../analytics/useTrackAnalytics'; +import { SwapParams, SwapProvider, SwapState } from '../../types'; +import { SwapActionsViaCoW } from './SwapActionsViaCoW'; +import { SwapActionsViaParaswap } from './SwapActionsViaParaswap'; + +export const SwapActions = ({ + params, + state, + setState, + trackingHandlers, +}: { + params: SwapParams; + state: SwapState; + setState: Dispatch>; + trackingHandlers: TrackAnalyticsHandlers; +}) => { + switch (state.provider) { + case SwapProvider.COW_PROTOCOL: + return ( + + ); + case SwapProvider.PARASWAP: + return ( + + ); + default: + return null; + } +}; diff --git a/src/components/transactions/Swap/actions/WithdrawAndSwap/WithdrawAndSwapActions.tsx b/src/components/transactions/Swap/actions/WithdrawAndSwap/WithdrawAndSwapActions.tsx new file mode 100644 index 0000000000..5824f28dd4 --- /dev/null +++ b/src/components/transactions/Swap/actions/WithdrawAndSwap/WithdrawAndSwapActions.tsx @@ -0,0 +1,41 @@ +import { Dispatch } from 'react'; + +import { TrackAnalyticsHandlers } from '../../analytics/useTrackAnalytics'; +import { ProtocolSwapParams, ProtocolSwapState, SwapProvider, SwapState } from '../../types'; +import { WithdrawAndSwapActionsViaCoW } from './WithdrawAndSwapActionsViaCoW'; +import { WithdrawAndSwapActionsViaParaswap } from './WithdrawAndSwapActionsViaParaswap'; + +export const WithdrawAndSwapActions = ({ + params, + state, + setState, + trackingHandlers, +}: { + params: ProtocolSwapParams; + state: ProtocolSwapState; + setState: Dispatch>; + trackingHandlers: TrackAnalyticsHandlers; +}) => { + switch (state.provider) { + case SwapProvider.COW_PROTOCOL: + return ( + + ); + case SwapProvider.PARASWAP: + return ( + + ); + default: + return null; + } +}; diff --git a/src/components/transactions/Swap/actions/WithdrawAndSwap/WithdrawAndSwapActionsViaCoW.tsx b/src/components/transactions/Swap/actions/WithdrawAndSwap/WithdrawAndSwapActionsViaCoW.tsx new file mode 100644 index 0000000000..ebc1fe8518 --- /dev/null +++ b/src/components/transactions/Swap/actions/WithdrawAndSwap/WithdrawAndSwapActionsViaCoW.tsx @@ -0,0 +1,28 @@ +import { Dispatch } from 'react'; + +import { TrackAnalyticsHandlers } from '../../analytics/useTrackAnalytics'; +import { ProtocolSwapParams, ProtocolSwapState, SwapState } from '../../types'; +import { SwapActionsViaCoW } from '../SwapActions/SwapActionsViaCoW'; + +export const WithdrawAndSwapActionsViaCoW = ({ + params, + state, + setState, + trackingHandlers, +}: { + params: ProtocolSwapParams; + state: ProtocolSwapState; + setState: Dispatch>; + trackingHandlers: TrackAnalyticsHandlers; +}) => { + // Essentially an aToken to token swap without a flashloan + + return ( + + ); +}; diff --git a/src/components/transactions/Swap/actions/WithdrawAndSwap/WithdrawAndSwapActionsViaParaswap.tsx b/src/components/transactions/Swap/actions/WithdrawAndSwap/WithdrawAndSwapActionsViaParaswap.tsx new file mode 100644 index 0000000000..04ace480a6 --- /dev/null +++ b/src/components/transactions/Swap/actions/WithdrawAndSwap/WithdrawAndSwapActionsViaParaswap.tsx @@ -0,0 +1,236 @@ +import { normalize } from '@aave/math-utils'; +import { OrderStatus } from '@cowprotocol/cow-sdk'; +import { Trans } from '@lingui/macro'; +import { Dispatch, useEffect, useMemo } from 'react'; +import { TxActionsWrapper } from 'src/components/transactions/TxActionsWrapper'; +import { calculateSignedAmount } from 'src/hooks/paraswap/common'; +import { useModalContext } from 'src/hooks/useModal'; +import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; +import { useRootStore } from 'src/store/root'; +import { getErrorTextFromError, TxAction } from 'src/ui-config/errorMapping'; +import { saveParaswapTxToUserHistory } from 'src/utils/swapAdapterHistory'; +import { useShallow } from 'zustand/shallow'; + +import { TrackAnalyticsHandlers } from '../../analytics/useTrackAnalytics'; +import { getTransactionParams } from '../../helpers/paraswap'; +import { useSwapGasEstimation } from '../../hooks/useSwapGasEstimation'; +import { + areActionsBlocked, + isParaswapRates, + ProtocolSwapParams, + ProtocolSwapState, + SwapState, +} from '../../types'; +import { useSwapTokenApproval } from '../approval/useSwapTokenApproval'; + +export const WithdrawAndSwapActionsViaParaswap = ({ + state, + setState, + params, + trackingHandlers, +}: { + params: ProtocolSwapParams; + state: ProtocolSwapState; + setState: Dispatch>; + trackingHandlers: TrackAnalyticsHandlers; +}) => { + const [withdrawAndSwitch, currentMarketData, estimateGasLimit, addTransaction] = useRootStore( + useShallow((state) => [ + state.withdrawAndSwitch, + state.currentMarketData, + state.estimateGasLimit, + state.addTransaction, + ]) + ); + + const { approvalTxState, mainTxState, setMainTxState, setTxError } = useModalContext(); + + const { sendTx } = useWeb3Context(); + + // Approval is aToken ERC20 Approval + const amountToApprove = useMemo(() => { + if (!state.sellAmountFormatted || !state.sellAmountToken) return '0'; + return calculateSignedAmount(state.sellAmountFormatted, state.sellAmountToken.decimals); + }, [state.sellAmountFormatted, state.sellAmountToken]); + + const { requiresApproval, signatureParams, approval, tryPermit, loadingPermitData } = + useSwapTokenApproval({ + chainId: state.chainId, + token: state.sourceToken.addressToSwap, // aToken + symbol: state.sourceToken.symbol, + amount: normalize(amountToApprove.toString(), state.sourceToken?.decimals ?? 18), + decimals: state.sourceToken.decimals, + spender: currentMarketData.addresses.WITHDRAW_SWITCH_ADAPTER, + setState, + trackingHandlers, + swapType: state.swapType, + }); + + // Use centralized gas estimation + useSwapGasEstimation({ + state, + setState, + requiresApproval, + requiresApprovalReset: state.requiresApprovalReset, + approvalTxState, + }); + + const action = async () => { + if (!state.swapRate || !isParaswapRates(state.swapRate)) { + console.error('No swap rate found'); + return; + } + + try { + setMainTxState({ ...mainTxState, loading: true }); + const { swapCallData, augustus } = await getTransactionParams( + state.side, + state.chainId, + state.sourceToken.underlyingAddress, + state.sourceToken.decimals, + state.destinationToken.underlyingAddress, + state.destinationToken.decimals, + state.user, + state.swapRate.optimalRateData, + Number(state.slippage) + ); + + const tx = withdrawAndSwitch({ + poolReserve: state.sourceReserve.reserve, + targetReserve: state.destinationReserve.reserve, + isMaxSelected: state.isMaxSelected, + amountToSwap: state.sellAmountBigInt?.toString() ?? '0', + amountToReceive: state.buyAmountBigInt?.toString() ?? '0', + augustus: augustus, + txCalldata: swapCallData, + signatureParams: { + signature: signatureParams?.plain ?? '', + deadline: signatureParams?.deadline ?? '', + amount: signatureParams?.amount ?? '', + }, + }); + + const txDataWithGasEstimation = await estimateGasLimit(tx); + const response = await sendTx(txDataWithGasEstimation); + await response.wait(1); + + trackingHandlers.trackSwap(); + params.invalidateAppState(); + saveParaswapTxToUserHistory({ + protocol: 'paraswap', + txHash: response.hash, + swapType: state.swapType, + chainId: state.chainId, + account: state.user, + timestamp: new Date().toISOString(), + status: OrderStatus.FULFILLED, + srcToken: { + address: state.sourceToken.underlyingAddress, + symbol: state.sourceToken.symbol, + name: state.sourceToken.symbol, + decimals: state.sourceToken.decimals, + }, + destToken: { + address: state.destinationToken.underlyingAddress, + symbol: state.destinationToken.symbol, + name: state.destinationToken.symbol, + decimals: state.destinationToken.decimals, + }, + srcAmount: state.sellAmountBigInt?.toString() ?? '0', + destAmount: state.buyAmountBigInt?.toString() ?? '0', + }); + addTransaction( + response.hash, + { + txState: 'success', + }, + { + chainId: state.chainId, + } + ); + + setMainTxState({ + txHash: response.hash, + loading: false, + success: true, + }); + } catch (error) { + const parsedError = getErrorTextFromError(error, TxAction.GAS_ESTIMATION, false); + + // For gas estimation errors in Paraswap actions, show as warning instead of blocking error + if (parsedError.txAction === TxAction.GAS_ESTIMATION) { + setState({ + actionsLoading: false, + warnings: [ + { + message: + 'Gas estimation error: The swap could not be estimated. Try increasing slippage or changing the amount.', + }, + ], + error: undefined, // Clear any existing errors + }); + } else { + // For other errors, handle normally + setTxError(parsedError); + setState({ + actionsLoading: false, + }); + } + + setMainTxState({ + txHash: undefined, + loading: false, + }); + const reason = error instanceof Error ? error.message : undefined; + trackingHandlers.trackSwapFailed(reason); + } + }; + + useEffect(() => { + if (state.mainTxState.success) { + trackingHandlers.trackSwap(); + params.invalidateAppState(); + + addTransaction( + state.mainTxState.txHash || '', + { + txState: 'success', + }, + { + chainId: state.chainId, + } + ); + + setMainTxState({ + txHash: state.mainTxState.txHash || '', + loading: false, + success: true, + }); + } + }, [state.mainTxState.success]); + + return ( + Withdraw and Swap} + actionInProgressText={Withdrawing and Swapping} + errorParams={{ + loading: false, + disabled: areActionsBlocked(state) || !approvalTxState?.success, + content: Withdraw and Swap, + handleClick: action, + }} + fetchingData={state.actionsLoading || loadingPermitData} + blocked={areActionsBlocked(state)} + tryPermit={tryPermit} + /> + ); +}; diff --git a/src/components/transactions/Swap/actions/approval/useSwapTokenApproval.ts b/src/components/transactions/Swap/actions/approval/useSwapTokenApproval.ts new file mode 100644 index 0000000000..f982fef3af --- /dev/null +++ b/src/components/transactions/Swap/actions/approval/useSwapTokenApproval.ts @@ -0,0 +1,455 @@ +import { ERC20Service } from '@aave/contract-helpers'; +import { normalizeBN, valueToBigNumber } from '@aave/math-utils'; +import { ethers } from 'ethers'; +import { defaultAbiCoder, splitSignature } from 'ethers/lib/utils'; +import { Dispatch, useEffect, useMemo, useRef, useState } from 'react'; +import { MOCK_SIGNED_HASH } from 'src/helpers/useTransactionHandler'; +import { calculateSignedAmount } from 'src/hooks/paraswap/common'; +import { useModalContext } from 'src/hooks/useModal'; +import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; +import { useRootStore } from 'src/store/root'; +import { ApprovalMethod } from 'src/store/walletSlice'; +import { getErrorTextFromError, TxAction } from 'src/ui-config/errorMapping'; +import { isPermitSupportedWithFallback } from 'src/ui-config/permitConfig'; +import { getProvider } from 'src/utils/marketsAndNetworksConfig'; +import { needsUSDTApprovalReset } from 'src/utils/usdtHelpers'; +import { useShallow } from 'zustand/shallow'; + +import { TrackAnalyticsHandlers } from '../../analytics/useTrackAnalytics'; +import { isNativeToken } from '../../helpers/cow'; +import { SwapState, SwapType } from '../../types'; + +export type SwapTokenApprovalParams = { + chainId: number; + token: string; + decimals: number; + symbol: string; + amount: string; + spender?: string; + setState: Dispatch>; + allowPermit?: boolean; + margin?: number; + type?: 'approval' | 'delegation'; + trackingHandlers?: TrackAnalyticsHandlers; + swapType: SwapType; +}; + +export type SignatureLike = { + r: string; + s: string; + _vs: string; + recoveryParam: number; + v: number; +}; +export interface SignedParams { + plain: string; + signature: string; + splitedSignature: SignatureLike; + deadline: string; + amount: string; + approvedToken: string; +} + +/** + * Custom React hook to handle token approval flow for swaps. + * + * Handles both “traditional” ERC-20 approvals and permit signatures, depending on token and chain support. + * - Determines if approval or approval reset is required for a given token, amount, and spender. + * - Exposes functions and state for triggering approvals, permits, and tracking their status. + * - Integrates with the modal and global stores for transaction state management. + * - Handles token-specific quirks (e.g., USDT approval reset) and margin calculations for edge cases. + * + * @param {object} params - Hook parameters. + * @param {number} params.chainId - Current chain ID. + * @param {string} params.token - Address of the token to approve. + * @param {string} params.symbol - Symbol of the token. + * @param {string} params.amount - Amount, as string formatter like '1.234567890', for which approval is requested. + * @param {number} params.decimals - Token decimals. + * @param {string} [params.spender] - Spender address, smart contract requiring approval. + * @param {Dispatch>} params.setState - State setter for updating SwapState. + * @param {boolean} [params.allowPermit=true] - Whether to allow permit signature flow if supported. + * @param {number} [params.margin=0] - Optional margin for approval checks (in token units). + * @param {"approval"|"delegation"} [params.type="approval"] - Approval type; "approval" for typical ERC-20, "delegation" for credit delegation. + * + * @returns {{ + * requiresApproval: boolean; // Whether an approval transaction is needed. + * requiresApprovalReset: boolean; // Whether an approval "reset" to 0 is needed before the actual approval (e.g. for USDT). + * approval: () => Promise; // Function to trigger the approval transaction. + * tryPermit: () => Promise; // Function to attempt permit signature flow, if available. + * signatureParams?: SignedParams; // Details/signature object if permit is ready. + * }} + */ +export const useSwapTokenApproval = ({ + chainId, + token, + symbol, + amount, + decimals, + spender, + setState, + allowPermit = true, + margin = 0, + type = 'approval', + trackingHandlers, + swapType, +}: SwapTokenApprovalParams) => { + const [approvedAmount, setApprovedAmount] = useState(); + const [approvedAddress, setApprovedAddress] = useState(); + const [requiresApprovalReset, setRequiresApprovalReset] = useState(false); + const [signatureParams, setSignatureParams] = useState(); + // Keep track of last fetched approval key (token:spender) to avoid duplicate calls for same pair + const lastFetchedApprovalKeyRef = useRef(); + + const { approvalTxState, setLoadingTxns, setTxError, setApprovalTxState } = useModalContext(); + const { sendTx, signTxData } = useWeb3Context(); + const [loadingPermitData, setLoadingPermitData] = useState(true); + + const [ + user, + generateApproval, + estimateGasLimit, + walletApprovalMethodPreference, + generateSignatureRequest, + getCreditDelegationApprovedAmount, + generateApproveDelegation, + generateCreditDelegationSignatureRequest, + ] = useRootStore( + useShallow((state) => [ + state.account, + state.generateApproval, + state.estimateGasLimit, + state.walletApprovalMethodPreference, + state.generateSignatureRequest, + state.getCreditDelegationApprovedAmount, + state.generateApproveDelegation, + state.generateCreditDelegationSignatureRequest, + state.currentMarketData, + ]) + ); + + const requiresApproval = useMemo(() => { + if (isNativeToken(token)) { + return false; + } + + if (approvedAmount === undefined) { + return true; + } + + if (approvedAmount === '-1' || amount === '0') { + return false; + } + + return valueToBigNumber(approvedAmount).isLessThan(valueToBigNumber(amount)); + }, [approvedAmount, amount, signatureParams, decimals]); + + // Clear status if amount changes + useEffect(() => { + if (signatureParams || approvalTxState.success) { + setSignatureParams(undefined); + setApprovedAmount(undefined); + setApprovedAddress(undefined); + setApprovalTxState({ + txHash: undefined, + loading: false, + success: false, + }); + } + }, [amount]); + + // Reset approval-related state when token/spender context changes to ensure fresh checks + useEffect(() => { + setSignatureParams(undefined); + setApprovedAmount(undefined); + lastFetchedApprovalKeyRef.current = undefined; + setApprovedAddress(undefined); + setApprovalTxState({ txHash: undefined, loading: false, success: false }); + }, [token, spender, chainId, type]); + + // Warning for USDT on Ethereum approval reset + useEffect(() => { + const amountToApprove = calculateSignedAmount(normalizeBN(amount, -decimals).toString(), 0); + const currentApproved = calculateSignedAmount(approvedAmount?.toString() || '0', decimals, 0); + + let needsApprovalReset = false; + if ( + needsUSDTApprovalReset(symbol, chainId, currentApproved, amountToApprove) && + swapType == SwapType.Swap + ) { + needsApprovalReset = true; + setRequiresApprovalReset(true); + } else { + needsApprovalReset = false; + } + + setRequiresApprovalReset(needsApprovalReset); + setState({ requiresApprovalReset: needsApprovalReset }); + }, [symbol, chainId, approvedAmount, amount]); + + const fetchApprovedAmountFromContract = async () => { + if (!spender || signatureParams) { + return; + } + setApprovalTxState({ + txHash: undefined, + loading: false, + success: false, + }); + setLoadingTxns(true); + + const rpc = getProvider(chainId); + let approvedTargetAmount: string; + if (type === 'delegation') { + const creditDelegationApprovedAmount = await getCreditDelegationApprovedAmount({ + debtTokenAddress: token, + delegatee: spender ?? '', + }); + approvedTargetAmount = creditDelegationApprovedAmount.amount; + } else { + const erc20Service = new ERC20Service(rpc); + const erc20ApprovedAmount = await erc20Service.approvedAmount({ + user, + token, + spender, + }); + approvedTargetAmount = erc20ApprovedAmount.toString(); + } + + setApprovedAmount(approvedTargetAmount.toString()); + setApprovedAddress(spender); + setLoadingTxns(false); + setState({ + actionsLoading: false, + }); + }; + + useEffect(() => { + if (!spender) return; + if (signatureParams) return; // skip after permit path + if (approvalTxState.loading || approvalTxState.success) return; + + const approvalKey = `${token.toLowerCase()}:${spender.toLowerCase()}`; + if (lastFetchedApprovalKeyRef.current === approvalKey) return; // prevent duplicate fetches for same token/spender + + lastFetchedApprovalKeyRef.current = approvalKey; + fetchApprovedAmountFromContract(); + }, [token, spender, signatureParams, approvalTxState.loading, approvalTxState.success]); + + const [permitSupported, setPermitSupported] = useState(undefined); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + setLoadingPermitData(true); + const rpc = getProvider(chainId); + const supported = await isPermitSupportedWithFallback(chainId, token, rpc); + if (!cancelled) setPermitSupported(supported); + setLoadingPermitData(false); + } catch { + if (!cancelled) setPermitSupported(false); + setLoadingPermitData(false); + } + })(); + return () => { + cancelled = true; + }; + }, [chainId, token]); + + const tryPermit = allowPermit && permitSupported === true; + const usePermit = tryPermit && walletApprovalMethodPreference === ApprovalMethod.PERMIT; + + const approval = async () => { + if (!spender) { + return; + } + + const amountToApprove = calculateSignedAmount( + normalizeBN(amount, -decimals).toString(), + 0, + margin + ); + + // If requires approval reset, reset the approval first + if (requiresApprovalReset) { + try { + // Create direct ERC20 approval transaction for reset to 0 as ERC20Service requires positive amount + const abi = new ethers.utils.Interface([ + 'function approve(address spender, uint256 amount)', + ]); + const encodedData = abi.encodeFunctionData('approve', [spender, '0']); + const resetTx = { + data: encodedData, + to: token, + }; + const resetTxWithGasEstimation = await estimateGasLimit(resetTx, chainId); + setApprovalTxState({ ...approvalTxState, loading: true }); + const resetResponse = await sendTx(resetTxWithGasEstimation); + await resetResponse.wait(1); + setState({ requiresApprovalReset: false }); + } catch (error) { + const parsedError = getErrorTextFromError(error, TxAction.APPROVAL, false); + setTxError(parsedError); + setApprovalTxState({ + txHash: undefined, + loading: false, + }); + setState({ + actionsLoading: false, + }); + } + fetchApprovedAmountFromContract().then(() => { + setApprovalTxState({ + loading: false, + success: false, + }); + setState({ + actionsLoading: false, + }); + }); + + return; // Button will be updated to approve + } + + const approvalData = { + spender, + user, + token, + amount: amountToApprove, + }; + + if (usePermit) { + // Permit approval + try { + const deadline = Math.floor(Date.now() / 1000 + 3600).toString(); + let signatureRequest: string; + if (type === 'delegation') { + signatureRequest = await generateCreditDelegationSignatureRequest({ + underlyingAsset: token, + deadline, + amount: amountToApprove.toString(), + spender, + }); + } else { + signatureRequest = await generateSignatureRequest( + { + ...approvalData, + deadline, + }, + { chainId: chainId } + ); + } + setApprovalTxState({ ...approvalTxState, loading: true }); + const response = await signTxData(signatureRequest); + const splitedSignature = splitSignature(response); + const encodedSignature = + type === 'delegation' + ? response.toString() + : defaultAbiCoder.encode( + ['address', 'address', 'uint256', 'uint256', 'uint8', 'bytes32', 'bytes32'], + [ + approvalData.user, + approvalData.spender, + approvalData.amount, + deadline, + splitedSignature.v, + splitedSignature.r, + splitedSignature.s, + ] + ); + const newSignatureParams = { + plain: response.toString(), + signature: encodedSignature, + splitedSignature, + deadline, + amount: approvalData.amount, + approvedToken: approvalData.spender, + }; + setSignatureParams(newSignatureParams); + setState({ + actionsLoading: false, + }); + + setApprovedAmount(amountToApprove.toString()); + setApprovedAddress(spender); + setTxError(undefined); + setApprovalTxState({ + txHash: MOCK_SIGNED_HASH, + loading: false, + success: true, + }); + } catch (error) { + const parsedError = getErrorTextFromError(error, TxAction.APPROVAL, false); + setTxError(parsedError); + setApprovalTxState({ + txHash: undefined, + loading: false, + }); + setState({ + actionsLoading: false, + }); + } + } else { + // Direct ERC20 approval transaction + try { + let tx; + if (type === 'delegation') { + tx = generateApproveDelegation({ + debtTokenAddress: token, + delegatee: spender ?? '', + amount: amountToApprove.toString(), + }); + } else { + tx = generateApproval(approvalData, { + chainId: chainId, + amount: amountToApprove, + }); + } + const txWithGasEstimation = await estimateGasLimit(tx, chainId); + setApprovalTxState({ loading: true }); + const response = await sendTx(txWithGasEstimation); + await response.wait(1); + fetchApprovedAmountFromContract().then(() => { + setApprovalTxState({ + txHash: response.hash, + loading: false, + success: true, + }); + setTxError(undefined); + setState({ + actionsLoading: false, + }); + }); + } catch (error) { + const parsedError = getErrorTextFromError(error, TxAction.APPROVAL, false); + setTxError(parsedError); + setApprovalTxState({ + txHash: undefined, + loading: false, + }); + setState({ + actionsLoading: false, + }); + } + } + + // Stop loading quotes + setState({ + quoteRefreshPaused: true, + quoteTimerPausedAt: Date.now(), + }); + + trackingHandlers?.trackApproval(amountToApprove.toString(), usePermit); + }; + + return { + requiresApproval, + requiresApprovalReset, + loadingPermitData, + signatureParams, + approval, + tryPermit, + approvedAmount, + approvedAddress, + }; +}; diff --git a/src/components/transactions/Swap/actions/index.ts b/src/components/transactions/Swap/actions/index.ts new file mode 100644 index 0000000000..b676ca90a4 --- /dev/null +++ b/src/components/transactions/Swap/actions/index.ts @@ -0,0 +1,80 @@ +import React, { Dispatch } from 'react'; + +import { TrackAnalyticsHandlers } from '../analytics/useTrackAnalytics'; +import { + isProtocolSwapParams, + isProtocolSwapState, + isTokensSwapParams, + isTokensSwapState, + SwapParams, + SwapState, + SwapType, +} from '../types'; +import { ActionsBlocked } from './ActionsBlocked'; +import { ActionsLoading } from './ActionsSkeleton'; +import { CollateralSwapActions } from './CollateralSwap/CollateralSwapActions'; +import { DebtSwapActions } from './DebtSwap/DebtSwapActions'; +import { RepayWithCollateralActions } from './RepayWithCollateral/RepayWithCollateralActions'; +import { SwapActions } from './SwapActions'; +import { WithdrawAndSwapActions } from './WithdrawAndSwap/WithdrawAndSwapActions'; + +/** + * Decides which action component to render for the current swap type. + * Shows skeleton/blocked states based on `SwapState` and guards against + * invalid combinations of params/state. + */ +export const BaseSwapActions = ({ + params, + state, + setState, + trackingHandlers, +}: { + params: SwapParams; + state: SwapState; + setState: Dispatch>; + trackingHandlers: TrackAnalyticsHandlers; +}) => { + if (state.ratesLoading || state.actionsLoading || !state.isSwapFlowSelected) { + return React.createElement(ActionsLoading, { state }); + } + + if (state.error?.actionBlocked || !state.swapRate) { + return React.createElement(ActionsBlocked, { state }); + } + + if (params.swapType === SwapType.Swap && isTokensSwapParams(params) && isTokensSwapState(state)) { + return React.createElement(SwapActions, { params, state, setState, trackingHandlers }); + } else if (isProtocolSwapParams(params) && isProtocolSwapState(state)) { + switch (params.swapType) { + case SwapType.CollateralSwap: + return React.createElement(CollateralSwapActions, { + params, + state, + setState, + trackingHandlers, + }); + case SwapType.DebtSwap: + return React.createElement(DebtSwapActions, { params, state, setState, trackingHandlers }); + case SwapType.RepayWithCollateral: + return React.createElement(RepayWithCollateralActions, { + params, + state, + setState, + trackingHandlers, + }); + case SwapType.WithdrawAndSwap: + return React.createElement(WithdrawAndSwapActions, { + params, + state, + setState, + trackingHandlers, + }); + default: + console.error(`Unsupported swap type`); + return null; + } + } else { + console.error(`Invalid swap params or state in actions`); + return null; + } +}; diff --git a/src/components/transactions/Swap/analytics/constants.ts b/src/components/transactions/Swap/analytics/constants.ts new file mode 100644 index 0000000000..6de0ad76bf --- /dev/null +++ b/src/components/transactions/Swap/analytics/constants.ts @@ -0,0 +1,43 @@ +// Re-export the SWAP enum from the events file, for clarity within the analytics folder +export { SWAP } from 'src/utils/events'; + +export enum SwapInputChanges { + /// The user has changed the input amount + INPUT_AMOUNT = 'INPUT_AMOUNT', + + /// The user has changed the output amount + OUTPUT_AMOUNT = 'OUTPUT_AMOUNT', + + /// The user has changed the rate + RATE_CHANGE = 'RATE_CHANGE', + + /// The user has switched the reserves + SWITCH_RESERVES = 'SWITCH_RESERVES', + + /// The user has changed the slippage + SLIPPAGE = 'SLIPPAGE', + + /// The user has changed the network + NETWORK = 'NETWORK', + + /// The user has changed the input token + INPUT_TOKEN = 'INPUT_TOKEN', + + /// The user has added a custom token + ADD_CUSTOM_TOKEN = 'ADD_CUSTOM_TOKEN', + + /// The user has changed the output token + OUTPUT_TOKEN = 'OUTPUT_TOKEN', + + /// The user has changed the order type + ORDER_TYPE = 'ORDER_TYPE', + + /// The user has changed the expiry + EXPIRY = 'EXPIRY', + + /// The user has changed the gas limit + GAS_LIMIT = 'GAS_LIMIT', + + /// The user approved high price impact warning + HIGH_PRICE_IMPACT_CONFIRM = 'HIGH_PRICE_IMPACT_CONFIRM', +} diff --git a/src/components/transactions/Swap/analytics/state.helpers.ts b/src/components/transactions/Swap/analytics/state.helpers.ts new file mode 100644 index 0000000000..1479685163 --- /dev/null +++ b/src/components/transactions/Swap/analytics/state.helpers.ts @@ -0,0 +1,137 @@ +import { TrackEventProperties } from 'src/store/analyticsSlice'; + +import { isCowProtocolRates, SwapError, SwapQuoteType, SwapState } from '../types'; +import { SwapInputChanges } from './constants'; + +export const swapStateToAnalyticsEventParams = (state: SwapState): TrackEventProperties => { + return { + // UI inputs info + chainId: state.chainId, + inputSymbol: state.sourceToken.symbol, + outputSymbol: state.destinationToken.symbol, + inputAmount: state.inputAmount, + inputAmountUSD: state.swapRate?.srcSpotUSD, + outputAmount: state.outputAmount, + outputAmountUSD: state.swapRate?.destSpotUSD, + slippage: state.slippage, + + // Swap Order info + sellAmountFormatted: state.sellAmountFormatted, + sellAmountBigInt: state.sellAmountBigInt?.toString() ?? '', + sellAmountToken: state.sellAmountToken?.symbol ?? '', + buyAmountFormatted: state.buyAmountFormatted, + buyAmountBigInt: state.buyAmountBigInt?.toString() ?? '', + buyAmountToken: state.buyAmountToken?.symbol ?? '', + isInvertedSwap: state.isInvertedSwap, + + // Swap context info + provider: state.provider, + expiry: state.expiry, + orderType: state.orderType, + gasLimit: state.gasLimit, + shouldUseFlashloan: state.useFlashloan, + useFlashloan: state.useFlashloan, + swapType: state.swapType, + txHash: state.mainTxState.txHash, + isMaxSelected: state.isMaxSelected, + pair: `${state.sourceToken.symbol}-${state.destinationToken.symbol}`, + side: state.side, + userIsSmartContractWallet: state.userIsSmartContractWallet, + userIsSafeWallet: state.userIsSafeWallet, + }; +}; + +export const swapErrorToAnalyticsEventParams = (error: SwapError): TrackEventProperties => { + return { + errorMessage: error.message, + isActionBlocked: error.actionBlocked, + stage: error.stage, + }; +}; + +export const swapQuoteToAnalyticsEventParams = ( + state: SwapState, + swapQuote: SwapQuoteType +): TrackEventProperties => { + return { + ...swapStateToAnalyticsEventParams(state), + + quoteProvider: swapQuote.provider, + quoteSrcAmount: swapQuote.srcSpotAmount, + quoteSrcUSD: swapQuote.srcSpotUSD, + quoteDestAmount: swapQuote.destSpotAmount, + quoteDestUSD: swapQuote.destSpotUSD, + quoteSuggestedSlippage: swapQuote.suggestedSlippage, + ...(isCowProtocolRates(swapQuote) + ? { + quoteQuoteId: swapQuote.quoteId, + } + : { + // any? + }), + }; +}; + +export const swapInputChangeToAnalyticsEventParams = ( + state: SwapState, + fieldChange: SwapInputChanges, + newValue: string +): TrackEventProperties => { + return { + ...swapStateToAnalyticsEventParams(state), + fieldChange, + newValue, + }; +}; + +export const swapTrackApprovalToAnalyticsEventParams = ( + state: SwapState, + approvalAmount: string, + viaPermit: boolean +): TrackEventProperties => { + return { + ...swapStateToAnalyticsEventParams(state), + approvalAmount, + viaPermit, + }; +}; + +export const swapTrackSwapToAnalyticsEventParams = (state: SwapState): TrackEventProperties => { + return { + ...swapStateToAnalyticsEventParams(state), + }; +}; + +export const swapTrackSwapFilledToAnalyticsEventParams = ( + state: SwapState, + executedSellAmount: string, + executedBuyAmount: string +): TrackEventProperties => { + return { + ...swapStateToAnalyticsEventParams(state), + executedSellAmount, + executedSellAmountUSD: state.swapRate?.srcSpotUSD, + executedBuyAmount, + executedBuyAmountUSD: state.swapRate?.destSpotUSD, + }; +}; + +export const swapTrackSwapFailedToAnalyticsEventParams = ( + state: SwapState, + reason?: string +): TrackEventProperties => { + return { + ...swapStateToAnalyticsEventParams(state), + ...(reason + ? { errorReason: String(reason).slice(0, 160) } + : state.error?.message + ? { errorReason: String(state.error.message).slice(0, 160) } + : {}), + }; +}; + +export const swapUserDeniedToAnalyticsEventParams = (state: SwapState): TrackEventProperties => { + return { + ...swapStateToAnalyticsEventParams(state), + }; +}; diff --git a/src/components/transactions/Swap/analytics/useTrackAnalytics.ts b/src/components/transactions/Swap/analytics/useTrackAnalytics.ts new file mode 100644 index 0000000000..487b641180 --- /dev/null +++ b/src/components/transactions/Swap/analytics/useTrackAnalytics.ts @@ -0,0 +1,63 @@ +import { useRootStore } from 'src/store/root'; + +import { SwapError, SwapQuoteType, SwapState } from '../types'; +import { SWAP, SwapInputChanges } from './constants'; +import { + swapErrorToAnalyticsEventParams, + swapInputChangeToAnalyticsEventParams, + swapQuoteToAnalyticsEventParams, + swapTrackApprovalToAnalyticsEventParams, + swapTrackSwapFailedToAnalyticsEventParams, + swapTrackSwapFilledToAnalyticsEventParams, + swapTrackSwapToAnalyticsEventParams, + swapUserDeniedToAnalyticsEventParams, +} from './state.helpers'; + +export type TrackAnalyticsHandlers = { + trackSwapQuote: (isAutoRefreshed: boolean, swapQuote: SwapQuoteType) => void; + trackSwapError: (error: SwapError) => void; + trackUserDenied: () => void; + trackInputChange: (fieldChange: SwapInputChanges, newValue: string) => void; + trackApproval: (approvalAmount: string, viaPermit: boolean) => void; + trackSwap: () => void; + trackSwapFilled: (executedSellAmount: string, executedBuyAmount: string) => void; + trackSwapFailed: (reason?: string) => void; +}; + +/* + This hook handles all analytics for the swap component. + We track all the user journey through the swap component, including quote, input changes, errors, warnings, actions, etc. +*/ +export const useHandleAnalytics = ({ state }: { state: SwapState }) => { + const trackEvent = useRootStore((store) => store.trackEvent); + + return { + trackSwapQuote: (isAutoRefreshed: boolean, swapQuote: SwapQuoteType) => + trackEvent( + isAutoRefreshed ? SWAP.QUOTE_REFRESHED : SWAP.QUOTE, + swapQuoteToAnalyticsEventParams(state, swapQuote) + ), + trackSwapError: (error: SwapError) => + trackEvent(SWAP.ERROR, swapErrorToAnalyticsEventParams(error)), + trackUserDenied: () => + trackEvent(SWAP.USER_DENIED, swapUserDeniedToAnalyticsEventParams(state)), + trackInputChange: (fieldChange: SwapInputChanges, newValue: string) => + trackEvent( + SWAP.INPUT_CHANGES, + swapInputChangeToAnalyticsEventParams(state, fieldChange, newValue) + ), + trackApproval: (approvalAmount: string, viaPermit: boolean) => + trackEvent( + SWAP.APPROVAL, + swapTrackApprovalToAnalyticsEventParams(state, approvalAmount, viaPermit) + ), + trackSwap: () => trackEvent(SWAP.SWAP, swapTrackSwapToAnalyticsEventParams(state)), + trackSwapFilled: (executedSellAmount: string, executedBuyAmount: string) => + trackEvent( + SWAP.SWAP_FILLED, + swapTrackSwapFilledToAnalyticsEventParams(state, executedSellAmount, executedBuyAmount) + ), + trackSwapFailed: (reason?: string) => + trackEvent(SWAP.SWAP_FAILED, swapTrackSwapFailedToAnalyticsEventParams(state, reason)), + }; +}; diff --git a/src/components/transactions/Swap/constants/cow.constants.ts b/src/components/transactions/Swap/constants/cow.constants.ts new file mode 100644 index 0000000000..e79bb28f11 --- /dev/null +++ b/src/components/transactions/Swap/constants/cow.constants.ts @@ -0,0 +1,203 @@ +import { CowEnv, OrderClass, SupportedChainId } from '@cowprotocol/cow-sdk'; +import { AaveFlashLoanType } from '@cowprotocol/sdk-flash-loans'; + +import { getAssetGroup } from '../helpers/shared/assetCorrelation.helpers'; +import { OrderType, SwapType } from '../types'; + +export const HOOK_ADAPTER_PER_TYPE: Record> = { + [AaveFlashLoanType.CollateralSwap]: { + [SupportedChainId.MAINNET]: '0x029d584E847373B6373b01dfaD1a0C9BfB916382', + [SupportedChainId.GNOSIS_CHAIN]: '0x029d584E847373B6373b01dfaD1a0C9BfB916382', + [SupportedChainId.ARBITRUM_ONE]: '0x029d584E847373B6373b01dfaD1a0C9BfB916382', + [SupportedChainId.AVALANCHE]: '0x029d584E847373B6373b01dfaD1a0C9BfB916382', + [SupportedChainId.BNB]: '0x029d584E847373B6373b01dfaD1a0C9BfB916382', + [SupportedChainId.POLYGON]: '0x029d584E847373B6373b01dfaD1a0C9BfB916382', + [SupportedChainId.BASE]: '0x029d584E847373B6373b01dfaD1a0C9BfB916382', + [SupportedChainId.SEPOLIA]: '', + [SupportedChainId.LENS]: '', + [SupportedChainId.LINEA]: '', + [SupportedChainId.PLASMA]: '', + }, + [AaveFlashLoanType.DebtSwap]: { + [SupportedChainId.MAINNET]: '0x73e7aF13Ef172F13d8FEfEbfD90C7A6530096344', + [SupportedChainId.GNOSIS_CHAIN]: '0x73e7aF13Ef172F13d8FEfEbfD90C7A6530096344', + [SupportedChainId.ARBITRUM_ONE]: '0x73e7aF13Ef172F13d8FEfEbfD90C7A6530096344', + [SupportedChainId.AVALANCHE]: '0x73e7aF13Ef172F13d8FEfEbfD90C7A6530096344', + [SupportedChainId.BNB]: '0x73e7aF13Ef172F13d8FEfEbfD90C7A6530096344', + [SupportedChainId.POLYGON]: '0x73e7aF13Ef172F13d8FEfEbfD90C7A6530096344', + [SupportedChainId.BASE]: '0x73e7aF13Ef172F13d8FEfEbfD90C7A6530096344', + [SupportedChainId.SEPOLIA]: '', + [SupportedChainId.LENS]: '', + [SupportedChainId.LINEA]: '', + [SupportedChainId.PLASMA]: '', + }, + [AaveFlashLoanType.RepayCollateral]: { + [SupportedChainId.MAINNET]: '0xAc27F3f86e78B14721d07C4f9CE999285f9AAa06', + [SupportedChainId.GNOSIS_CHAIN]: '0xAc27F3f86e78B14721d07C4f9CE999285f9AAa06', + [SupportedChainId.ARBITRUM_ONE]: '0xAc27F3f86e78B14721d07C4f9CE999285f9AAa06', + [SupportedChainId.AVALANCHE]: '0xAc27F3f86e78B14721d07C4f9CE999285f9AAa06', + [SupportedChainId.BNB]: '0xAc27F3f86e78B14721d07C4f9CE999285f9AAa06', + [SupportedChainId.POLYGON]: '0xAc27F3f86e78B14721d07C4f9CE999285f9AAa06', + [SupportedChainId.BASE]: '0xAc27F3f86e78B14721d07C4f9CE999285f9AAa06', + [SupportedChainId.SEPOLIA]: '', + [SupportedChainId.LENS]: '', + [SupportedChainId.LINEA]: '', + [SupportedChainId.PLASMA]: '', + }, +}; + +export const ADAPTER_FACTORY: Record = { + [SupportedChainId.MAINNET]: '0xdeCC46a4b09162F5369c5C80383AAa9159bCf192', + [SupportedChainId.GNOSIS_CHAIN]: '0xdeCC46a4b09162F5369c5C80383AAa9159bCf192', + [SupportedChainId.ARBITRUM_ONE]: '0xdeCC46a4b09162F5369c5C80383AAa9159bCf192', + [SupportedChainId.AVALANCHE]: '0xdeCC46a4b09162F5369c5C80383AAa9159bCf192', + [SupportedChainId.BNB]: '0xdeCC46a4b09162F5369c5C80383AAa9159bCf192', + [SupportedChainId.POLYGON]: '0xdeCC46a4b09162F5369c5C80383AAa9159bCf192', + [SupportedChainId.BASE]: '0xdeCC46a4b09162F5369c5C80383AAa9159bCf192', + [SupportedChainId.LENS]: '', + [SupportedChainId.LINEA]: '', + [SupportedChainId.PLASMA]: '', + [SupportedChainId.SEPOLIA]: '', +}; + +export const DUST_PROTECTION_MULTIPLIER = 1.001; + +export const COW_UNSUPPORTED_ASSETS: Partial< + Record>> +> = { + // // For adapters we start supporting only base + // [SwapType.DebtSwap]: { + // [SupportedChainId.ARBITRUM_ONE]: 'ALL', + // [SupportedChainId.AVALANCHE]: 'ALL', + // [SupportedChainId.BNB]: 'ALL', + // [SupportedChainId.GNOSIS_CHAIN]: 'ALL', + // [SupportedChainId.MAINNET]: 'ALL', + // [SupportedChainId.POLYGON]: 'ALL', + // [SupportedChainId.SEPOLIA]: 'ALL', + // // Base is supported + // }, + // [SwapType.CollateralSwap]: { + // [SupportedChainId.ARBITRUM_ONE]: 'ALL', + // [SupportedChainId.AVALANCHE]: 'ALL', + // [SupportedChainId.BNB]: 'ALL', + // [SupportedChainId.GNOSIS_CHAIN]: 'ALL', + // [SupportedChainId.MAINNET]: 'ALL', + // [SupportedChainId.POLYGON]: 'ALL', + // [SupportedChainId.SEPOLIA]: 'ALL', + // // Base is supported + // }, + // [SwapType.RepayWithCollateral]: { + // [SupportedChainId.ARBITRUM_ONE]: 'ALL', + // [SupportedChainId.AVALANCHE]: 'ALL', + // [SupportedChainId.BNB]: 'ALL', + // [SupportedChainId.GNOSIS_CHAIN]: 'ALL', + // [SupportedChainId.MAINNET]: 'ALL', + // [SupportedChainId.POLYGON]: 'ALL', + // [SupportedChainId.SEPOLIA]: 'ALL', + // // Base is supported + // }, + // // Specific assets that are not supported for certain chains across all swap types + // ['ALL']: { + // [SupportedChainId.POLYGON]: [ + // '0x8eb270e296023e9d92081fdf967ddd7878724424'.toLowerCase(), // aPOLGHST not supported + // '0x38d693ce1df5aadf7bc62595a37d667ad57922e5'.toLowerCase(), // aPolEURS not supported + // '0xea1132120ddcdda2f119e99fa7a27a0d036f7ac9'.toLowerCase(), // aPolSTMATIC not supported + // '0x6533afac2e7bccb20dca161449a13a32d391fb00'.toLowerCase(), // aPolJEUR not supported + // '0x513c7e3a9c69ca3e22550ef58ac1c0088e918fff'.toLowerCase(), // aPolCRV not supported + // '0xebe517846d0f36eced99c735cbf6131e1feb775d'.toLowerCase(), // aPolMIMATIC not supported + // '0xc45a479877e1e9dfe9fcd4056c699575a1045daa'.toLowerCase(), // aPolSUSHI not supported + // '0x8437d7c167dfb82ed4cb79cd44b7a32a1dd95c77'.toLowerCase(), // aPolAGEUR not supported + // '0x724dc807b04555b71ed48a6896b6f41593b8c637'.toLowerCase(), // aPolDPI not supported + // '0x8ffdf2de812095b1d19cb146e4c004587c0a0692'.toLowerCase(), // aPolBAL not supported + // ], + // [SupportedChainId.AVALANCHE]: [ + // '0x8eb270e296023e9d92081fdf967ddd7878724424'.toLowerCase(), // AVaMAI not supported + // '0x078f358208685046a11c85e8ad32895ded33a249'.toLowerCase(), // aVaWBTC not supported + // '0xc45a479877e1e9dfe9fcd4056c699575a1045daa'.toLowerCase(), // aVaFRAX not supported + // ], + // [SupportedChainId.GNOSIS_CHAIN]: [ + // '0xedbc7449a9b594ca4e053d9737ec5dc4cbccbfb2'.toLowerCase(), // EURe USD Price not supported + // ], + // [SupportedChainId.ARBITRUM_ONE]: [ + // '0x62fC96b27a510cF4977B59FF952Dc32378Cc221d'.toLowerCase(), // atBTC does not have good solver liquidity + // ], + // [SupportedChainId.BASE]: [ + // '0x90072A4aA69B5Eb74984Ab823EFC5f91e90b3a72'.toLowerCase(), // alBTC does not have good solver liquidity + // ], + // [SupportedChainId.MAINNET]: [ + // '0x00907f9921424583e7ffBfEdf84F92B7B2Be4977'.toLowerCase(), // aGHO not supported + // '0x18eFE565A5373f430e2F809b97De30335B3ad96A'.toLowerCase(), // aGHO not supported + // ], + // [SupportedChainId.SEPOLIA]: [ + // '0xd190eF37dB51Bb955A680fF1A85763CC72d083D4'.toLowerCase(), // aGHO not supported + // ], + // }, +}; + +export const CoWProtocolSupportedNetworks = [ + SupportedChainId.MAINNET, + SupportedChainId.GNOSIS_CHAIN, + SupportedChainId.ARBITRUM_ONE, + SupportedChainId.BASE, + SupportedChainId.SEPOLIA, + SupportedChainId.AVALANCHE, + SupportedChainId.POLYGON, + SupportedChainId.BNB, +] as const; + +export const isChainIdSupportedByCoWProtocol = (chainId: number): chainId is SupportedChainId => { + return CoWProtocolSupportedNetworks.includes(chainId); +}; + +export const COW_EVM_RECIPIENT = '0xC542C2F197c4939154017c802B0583C596438380'; +// export const COW_LENS_RECIPIENT = '0xce4eB8a1f6Bd0e0B9282102DC056B11E9D83b7CA'; +export const COW_PROTOCOL_ETH_FLOW_ADDRESS = '0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC'; +export const COW_PROTOCOL_ETH_FLOW_ADDRESS_STAGING = '0x04501b9b1D52e67f6862d157E00D13419D2D6E95'; + +export const COW_PROTOCOL_ETH_FLOW_ADDRESS_BY_ENV = (env: CowEnv) => { + return env === 'staging' ? COW_PROTOCOL_ETH_FLOW_ADDRESS_STAGING : COW_PROTOCOL_ETH_FLOW_ADDRESS; +}; + +export const COW_CREATE_ORDER_ABI = + 'function createOrder((address,address,uint256,uint256,bytes32,uint256,uint32,bool,int64)) returns (bytes32)'; + +export const COW_PARTNER_FEE = (tokenFromSymbol: string, tokenToSymbol: string) => ({ + volumeBps: getAssetGroup(tokenFromSymbol) == getAssetGroup(tokenToSymbol) ? 15 : 25, + recipient: COW_EVM_RECIPIENT, +}); + +export const FLASH_LOAN_FEE_BPS = 5; +export const VALID_TO_HALF_HOUR = Math.floor(Date.now() / 1000) + 60 * 30; // 30 minutes + +export const COW_APP_DATA = ( + tokenFromSymbol: string, + tokenToSymbol: string, + slippageBips: number, + smartSlippage: boolean, + orderType: OrderType, + appCode: string, + hooks?: Record +) => ({ + appCode: appCode, + version: '1.4.0', + metadata: { + orderClass: { + orderClass: orderType === OrderType.LIMIT ? OrderClass.LIMIT : OrderClass.MARKET, + }, // for CoW Swap UI & Analytics + ...(orderType === OrderType.MARKET + ? { quote: { slippageBips, smartSlippage } } + : // Slippage is not used in limit orders + {}), + partnerFee: COW_PARTNER_FEE(tokenFromSymbol, tokenToSymbol), + hooks, + }, +}); + +// TODO: Optimize CoW Values +export const COW_PROTOCOL_GAS_LIMITS: Record = { + [SwapType.Swap]: 1000000, // only eth-flow and smart contract wallets + [SwapType.CollateralSwap]: 1000000, // only if non-flashloan + [SwapType.DebtSwap]: 0, + [SwapType.RepayWithCollateral]: 0, + [SwapType.WithdrawAndSwap]: 0, +}; diff --git a/src/components/transactions/Swap/constants/limitOrders.constants.ts b/src/components/transactions/Swap/constants/limitOrders.constants.ts new file mode 100644 index 0000000000..5e6f6d09aa --- /dev/null +++ b/src/components/transactions/Swap/constants/limitOrders.constants.ts @@ -0,0 +1,26 @@ +const ONE_MINUTE_IN_SECONDS = 60; +const ONE_HOUR_IN_SECONDS = 3600; +const ONE_DAY_IN_SECONDS = 86400; +const ONE_MONTH_IN_SECONDS = 2592000; + +export enum Expiry { + TEN_MINUTES = '10 minutes', + HALF_HOUR = 'Half hour', + ONE_HOUR = 'One hour', + ONE_DAY = 'One day', + ONE_WEEK = 'One week', + ONE_MONTH = 'One month', + THREE_MONTHS = 'Three months', + ONE_YEAR = 'One year', +} + +export const ExpiryToSecondsMap = { + [Expiry.TEN_MINUTES]: ONE_MINUTE_IN_SECONDS * 10, + [Expiry.HALF_HOUR]: ONE_HOUR_IN_SECONDS / 2, + [Expiry.ONE_HOUR]: ONE_HOUR_IN_SECONDS, + [Expiry.ONE_DAY]: ONE_DAY_IN_SECONDS, + [Expiry.ONE_WEEK]: 7 * ONE_DAY_IN_SECONDS, + [Expiry.ONE_MONTH]: ONE_MONTH_IN_SECONDS, + [Expiry.THREE_MONTHS]: 3 * ONE_MONTH_IN_SECONDS, + [Expiry.ONE_YEAR]: 12 * ONE_MONTH_IN_SECONDS, +}; diff --git a/src/components/transactions/Swap/constants/paraswap.constants.ts b/src/components/transactions/Swap/constants/paraswap.constants.ts new file mode 100644 index 0000000000..78bbd165b2 --- /dev/null +++ b/src/components/transactions/Swap/constants/paraswap.constants.ts @@ -0,0 +1,27 @@ +import { ChainId } from '@aave/contract-helpers'; + +import { SwapType } from '../types'; + +export const ParaswapSupportedNetworks = [ + ChainId.mainnet, + ChainId.polygon, + ChainId.avalanche, + ChainId.sepolia, + ChainId.base, + ChainId.arbitrum_one, + ChainId.optimism, + ChainId.xdai, + ChainId.bnb, + ChainId.sonic, +]; + +export const PARASWAP_FLASH_LOAN_FEE_BPS = 5; + +// TODO: Optimize Paraswap Values +export const PARASWAP_GAS_LIMITS: Record = { + [SwapType.Swap]: 1000000, + [SwapType.CollateralSwap]: 1000000, + [SwapType.DebtSwap]: 400000, + [SwapType.RepayWithCollateral]: 700000, + [SwapType.WithdrawAndSwap]: 1000000, +}; diff --git a/src/components/transactions/Swap/constants/shared.constants.ts b/src/components/transactions/Swap/constants/shared.constants.ts new file mode 100644 index 0000000000..3bf8a2aa45 --- /dev/null +++ b/src/components/transactions/Swap/constants/shared.constants.ts @@ -0,0 +1,25 @@ +import { SwapType } from '../types'; + +export const SAFETY_MODULE_TOKENS = [ + 'stkgho', + 'stkaave', + 'stkaavewstethbptv2', + 'stkbptv2', + 'stkbpt', + 'stkabpt', +]; + +export const LIQUIDATION_SAFETY_THRESHOLD = 1.05; +export const LIQUIDATION_DANGER_THRESHOLD = 1.01; +export const SESSION_STORAGE_EXPIRY_MS = 15 * 60 * 1000; + +// TODO: Do we want one per swap type to analyze analytics? +export const APP_CODE_PER_SWAP_TYPE: Record = { + [SwapType.Swap]: 'aave-v3-interface-widget', + [SwapType.CollateralSwap]: 'aave-v3-interface-collateral-swap', + [SwapType.DebtSwap]: 'aave-v3-interface-debt-swap', + [SwapType.RepayWithCollateral]: 'aave-v3-interface-repay-with-collateral', + [SwapType.WithdrawAndSwap]: 'aave-v3-interface-withdraw-and-swap', +}; + +export const APP_CODE_VALUES = Object.values(APP_CODE_PER_SWAP_TYPE); diff --git a/src/components/transactions/Swap/details/CollateralSwapDetails.tsx b/src/components/transactions/Swap/details/CollateralSwapDetails.tsx new file mode 100644 index 0000000000..320ad90153 --- /dev/null +++ b/src/components/transactions/Swap/details/CollateralSwapDetails.tsx @@ -0,0 +1,313 @@ +import { valueToBigNumber } from '@aave/math-utils'; +import { ArrowNarrowRightIcon } from '@heroicons/react/outline'; +import { Trans } from '@lingui/macro'; +import { Box, Skeleton, SvgIcon, Typography } from '@mui/material'; +import React from 'react'; +import { DarkTooltip } from 'src/components/infoTooltips/DarkTooltip'; +import { FormattedNumber } from 'src/components/primitives/FormattedNumber'; +import { Row } from 'src/components/primitives/Row'; +import { TokenIcon } from 'src/components/primitives/TokenIcon'; +import { CollateralType } from 'src/helpers/types'; +import { + ComputedReserveData, + ComputedUserReserveData, + useAppDataContext, +} from 'src/hooks/app-data-provider/useAppDataProvider'; +import { getDebtCeilingData } from 'src/hooks/useAssetCaps'; +import { calculateHFAfterSwap } from 'src/utils/hfUtils'; + +import { + CollateralState, + DetailsHFLine, + DetailsIncentivesLine, + DetailsNumberLine, + TxModalDetails, +} from '../../FlowCommons/TxModalDetails'; +import { getAssetCollateralType } from '../../utils'; +import { SwapParams, SwapProvider, SwapState } from '../types'; +import { CowCostsDetails } from './CowCostsDetails'; +import { ParaswapCostsDetails } from './ParaswapCostsDetails'; + +export const ColalteralSwapDetails = ({ state }: { params: SwapParams; state: SwapState }) => { + const { user, reserves } = useAppDataContext(); + + if (!state.swapRate || !user) { + return null; + } + + // Map selected tokens to reserves and user reserves + const poolReserve = reserves.find( + (r) => r.underlyingAsset.toLowerCase() === state.sourceToken.underlyingAddress.toLowerCase() + ) as ComputedReserveData | undefined; + const targetReserve = reserves.find( + (r) => + r.underlyingAsset.toLowerCase() === state.destinationToken.underlyingAddress.toLowerCase() + ) as ComputedReserveData | undefined; + + if (!poolReserve || !targetReserve || !user) { + console.error( + 'Pool reserve or target reserve or user not found', + state.sourceToken.underlyingAddress, + state.destinationToken.underlyingAddress + ); + return null; + } + + const userReserve = user.userReservesData.find( + (ur) => ur.underlyingAsset.toLowerCase() === poolReserve.underlyingAsset.toLowerCase() + ) as ComputedUserReserveData | undefined; + const userTargetReserve = user.userReservesData.find( + (ur) => ur.underlyingAsset.toLowerCase() === targetReserve.underlyingAsset.toLowerCase() + ) as ComputedUserReserveData | undefined; + + if (!userReserve || !userTargetReserve) { + return null; + } + + // Show HF only when there are borrows and source reserve is collateralizable + const showHealthFactor = + user.totalBorrowsMarketReferenceCurrency !== '0' && + poolReserve.reserveLiquidationThreshold !== '0'; + + const fromAmount = state.sellAmountFormatted ?? '0'; + const toAmount = state.buyAmountFormatted ?? '0'; + + // Compute collateral types + const { debtCeilingReached: sourceDebtCeiling } = getDebtCeilingData(targetReserve); + const swapSourceCollateralType: CollateralType = getAssetCollateralType( + userReserve, + user.totalCollateralUSD, + user.isInIsolationMode, + sourceDebtCeiling + ); + const { debtCeilingReached: targetDebtCeiling } = getDebtCeilingData(targetReserve); + const swapTargetCollateralType: CollateralType = getAssetCollateralType( + userTargetReserve, + user.totalCollateralUSD, + user.isInIsolationMode, + targetDebtCeiling + ); + + // Health factor after swap using slippage-adjusted output amount + const { hfAfterSwap } = calculateHFAfterSwap({ + fromAmount, + fromAssetData: poolReserve, + fromAssetUserData: userReserve, + user, + toAmountAfterSlippage: valueToBigNumber(toAmount || '0'), + toAssetData: targetReserve, + fromAssetType: 'collateral', + toAssetType: 'collateral', + }); + + const sourceAmountAfterSwap = valueToBigNumber(userReserve.underlyingBalance).minus( + valueToBigNumber(fromAmount) + ); + + const targetAmountAfterSwap = valueToBigNumber(userTargetReserve.underlyingBalance).plus( + valueToBigNumber(toAmount || '0') + ); + + const skeleton: JSX.Element = ( + <> + + + + ); + + const showBalance = true; + + return ( + + {state.provider === SwapProvider.COW_PROTOCOL && } + {state.provider === SwapProvider.PARASWAP && } + + {swapSourceCollateralType !== swapTargetCollateralType && ( + Collateralization} captionVariant="description" mb={4}> + + {state.ratesLoading ? ( + + ) : ( + <> + + + + + + + + + + + )} + + + )} + {hfAfterSwap && ( + + )} + Supply apy} + value={userReserve.reserve.supplyAPY} + futureValue={userTargetReserve.reserve.supplyAPY} + percent + loading={state.ratesLoading} + /> + + Liquidation threshold} + value={userReserve.reserve.formattedReserveLiquidationThreshold} + futureValue={userTargetReserve.reserve.formattedReserveLiquidationThreshold} + percent + visibleDecimals={0} + loading={state.ratesLoading} + /> + + {showBalance && ( + Supply balance after switch} + captionVariant="description" + mb={4} + align="flex-start" + > + + + {state.ratesLoading ? ( + skeleton + ) : ( + <> + + + + {sourceAmountAfterSwap.toString()} {userReserve.reserve.symbol} + + } + arrow + placement="top" + enterTouchDelay={100} + leaveTouchDelay={500} + > + + + + + + + + )} + + + + {state.ratesLoading ? ( + skeleton + ) : ( + <> + + + + {targetAmountAfterSwap.toString()} {userTargetReserve.reserve.symbol} + + } + arrow + placement="top" + enterTouchDelay={100} + leaveTouchDelay={500} + > + + + + + + + + )} + + + + )} + + ); +}; diff --git a/src/components/transactions/Swap/details/CowCostsDetails.tsx b/src/components/transactions/Swap/details/CowCostsDetails.tsx new file mode 100644 index 0000000000..4b4e35c827 --- /dev/null +++ b/src/components/transactions/Swap/details/CowCostsDetails.tsx @@ -0,0 +1,256 @@ +import { normalize, valueToBigNumber } from '@aave/math-utils'; +import { Trans } from '@lingui/macro'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { Accordion, AccordionDetails, AccordionSummary, Box } from '@mui/material'; +import { useState } from 'react'; +import { EstimatedCostsForLimitSwapTooltip } from 'src/components/infoTooltips/EstimatedCostsForLimitSwap'; +import { ExecutionFeeTooltip } from 'src/components/infoTooltips/ExecutionFeeTooltip'; +import { NetworkCostTooltip } from 'src/components/infoTooltips/NetworkCostTooltip'; +import { SwapFeeTooltip } from 'src/components/infoTooltips/SwapFeeTooltip'; +import { FormattedNumber } from 'src/components/primitives/FormattedNumber'; +import { Row } from 'src/components/primitives/Row'; +import { ExternalTokenIcon } from 'src/components/primitives/TokenIcon'; + +import { calculateFlashLoanAmounts } from '../helpers/cow/adapters.helpers'; +import { isCowProtocolRates, OrderType, SwapState } from '../types'; + +export const CowCostsDetails = ({ state }: { state: SwapState }) => { + const [costBreakdownExpanded, setCostBreakdownExpanded] = useState(false); + + if (!state.swapRate || !isCowProtocolRates(state.swapRate)) return null; + + // Prefer unified values exported on state; fall back to raw swapRate if missing + const networkFeeFormatted = state.networkFeeAmountInSellFormatted || '0'; + + const networkFeeUsd = + Number(networkFeeFormatted) * + (!state.isInvertedSwap ? state.swapRate.srcTokenPriceUsd : state.swapRate.destTokenPriceUsd); + const networkFeeToken = !state.isInvertedSwap ? state.sourceToken : state.destinationToken; + + // If using flash-loan via CoW we need to account for the flash-loan fee + const flashloanFeeFormatted = normalize( + calculateFlashLoanAmounts(state).flashLoanFeeAmount.toString(), + state.sellAmountToken?.decimals ?? 18 + ); + const flashLoanFeeTokenPriceUnitUsd = valueToBigNumber(state.sellAmountUSD ?? '0') + .dividedBy(valueToBigNumber(state.sellAmountFormatted ?? '0')) + .toNumber(); + const flashloanFeeUsd = Number(flashloanFeeFormatted) * flashLoanFeeTokenPriceUnitUsd; + const flashloanFeeToken = state.sellAmountToken; + + if (!state.buyAmountToken || !state.sellAmountToken) return null; + + // Partner fee is applied to the surplus token: + // - For sell orders: fee in buy token (destinationToken), deducted from buy amount + // - For buy orders: fee in sell token (sourceToken), added to sell amount + // For Debt and Repay with collateral, the swap is inverted to our UI + const invertedSide = state.processedSide; + let partnerFeeFormatted: string, + partnerFeeUsd: number, + partnerFeeToken: typeof state.buyAmountToken | typeof state.sellAmountToken; + if (invertedSide === 'buy') { + // Fee in destination token (buy token) + partnerFeeFormatted = state.partnerFeeAmountFormatted ?? '0'; + const partnerFeeAmountPriceUnitUsd = + state.sellAmountFormatted == '0' + ? 0 + : valueToBigNumber(state.sellAmountUSD ?? '0') + .dividedBy(valueToBigNumber(state.sellAmountFormatted ?? '0')) + .toNumber(); + partnerFeeUsd = Number(partnerFeeFormatted) * partnerFeeAmountPriceUnitUsd; + partnerFeeToken = state.sellAmountToken; + } else { + // Fee in source token (sell token) + partnerFeeFormatted = state.partnerFeeAmountFormatted || '0'; + + const partnerFeeAmountPriceUnitUsd = + state.buyAmountFormatted == '0' + ? 0 + : valueToBigNumber(state.buyAmountUSD ?? '0') + .dividedBy(valueToBigNumber(state.buyAmountFormatted ?? '0')) + .toNumber(); + + partnerFeeUsd = Number(partnerFeeFormatted) * partnerFeeAmountPriceUnitUsd; + partnerFeeToken = state.buyAmountToken; + } + + const totalCostsInUsd = networkFeeUsd + partnerFeeUsd + (flashloanFeeUsd ?? 0); // + costs.slippageInUsd; + + return ( + { + setCostBreakdownExpanded(expanded); + }} + > + } + sx={{ + margin: 0, + padding: 0, + minHeight: '24px', + maxHeight: '24px', + height: '24px', + '&.Mui-expanded': { + minHeight: '24px', + maxHeight: '24px', + height: '24px', + }, + '.MuiAccordionSummary-content': { + margin: 0, + alignItems: !costBreakdownExpanded ? 'center' : undefined, + display: !costBreakdownExpanded ? 'flex' : undefined, + }, + '& .MuiAccordionSummary-content.Mui-expanded': { + margin: 0, + }, + }} + > + + ) : ( + Costs & Fees + ) + } + captionVariant="description" + align="flex-start" + width="100%" + minHeight="24px" + maxHeight="24px" + sx={{ + margin: 0, + display: 'flex', + alignItems: !costBreakdownExpanded ? 'center' : undefined, // center only if not expanded + }} + > + {!costBreakdownExpanded && ( + + )} + + + + } + captionVariant="caption" + align="flex-start" + > + + + + + + + + + {!!(flashloanFeeFormatted && flashloanFeeToken && flashloanFeeUsd) && ( + } + captionVariant="caption" + align="flex-start" + > + + + + + + + + + )} + } captionVariant="caption" align="flex-start"> + + + + + + + + + + + ); +}; diff --git a/src/components/transactions/Swap/details/DebtSwapDetails.tsx b/src/components/transactions/Swap/details/DebtSwapDetails.tsx new file mode 100644 index 0000000000..f014bfee75 --- /dev/null +++ b/src/components/transactions/Swap/details/DebtSwapDetails.tsx @@ -0,0 +1,201 @@ +import { valueToBigNumber } from '@aave/math-utils'; +import { ArrowNarrowRightIcon } from '@heroicons/react/solid'; +import { Trans } from '@lingui/macro'; +import { Box, Skeleton, SvgIcon, Typography } from '@mui/material'; +import React from 'react'; +import { DarkTooltip } from 'src/components/infoTooltips/DarkTooltip'; +import { FormattedNumber } from 'src/components/primitives/FormattedNumber'; +import { Row } from 'src/components/primitives/Row'; +import { TokenIcon } from 'src/components/primitives/TokenIcon'; +import { + DetailsIncentivesLine, + TxModalDetails, +} from 'src/components/transactions/FlowCommons/TxModalDetails'; + +import { ProtocolSwapParams, ProtocolSwapState, SwapProvider } from '../types'; +import { CowCostsDetails } from './CowCostsDetails'; +import { ParaswapCostsDetails } from './ParaswapCostsDetails'; + +export const DebtSwapDetails = ({ + state, +}: { + params: ProtocolSwapParams; + state: ProtocolSwapState; +}) => { + const sourceAmountAfterSwap = valueToBigNumber(state.sourceReserve.variableBorrows).minus( + valueToBigNumber(state.buyAmountFormatted ?? '0') + ); + const targetAmountAfterSwap = valueToBigNumber(state.destinationReserve.variableBorrows).plus( + valueToBigNumber(state.sellAmountFormatted ?? '0') + ); + + const sourceAmountAfterSwapUSD = sourceAmountAfterSwap.multipliedBy( + valueToBigNumber(state.sourceReserve.reserve.priceInUSD) + ); + const targetAmountAfterSwapUSD = targetAmountAfterSwap.multipliedBy( + valueToBigNumber(state.destinationReserve.reserve.priceInUSD) + ); + + const skeleton: JSX.Element = ( + <> + + + + ); + + return ( + + {state.provider === SwapProvider.COW_PROTOCOL && } + {state.provider === SwapProvider.PARASWAP && } + + Borrow apy} captionVariant="description" mb={4}> + + {state.ratesLoading ? ( + + ) : ( + <> + + {ArrowRightIcon} + + + )} + + + + + + Borrow balance after switch} + captionVariant="description" + mb={4} + align="flex-start" + > + + + {state.ratesLoading ? ( + skeleton + ) : ( + <> + + + + {sourceAmountAfterSwap.toString()} {state.sourceReserve.reserve.symbol} + + } + arrow + placement="top" + enterTouchDelay={100} + leaveTouchDelay={500} + > + + + + + + + + )} + + + + {state.ratesLoading ? ( + skeleton + ) : ( + <> + + + + {targetAmountAfterSwap.toString()} {state.destinationReserve.reserve.symbol} + + } + arrow + placement="top" + enterTouchDelay={100} + leaveTouchDelay={500} + > + + + + + + + + )} + + + + + ); +}; + +const ArrowRightIcon = ( + + + +); diff --git a/src/components/transactions/Swap/details/DetailsSkeleton.tsx b/src/components/transactions/Swap/details/DetailsSkeleton.tsx new file mode 100644 index 0000000000..357e437ec4 --- /dev/null +++ b/src/components/transactions/Swap/details/DetailsSkeleton.tsx @@ -0,0 +1,37 @@ +import { Box, Skeleton } from '@mui/material'; + +import { TxModalDetails } from '../../FlowCommons/TxModalDetails'; +import { SwapState } from '../types'; + +export const DetailsSkeleton: React.FC<{ state: SwapState }> = ({ + state, +}: { + state: SwapState; +}) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/components/transactions/Swap/details/ParaswapCostsDetails.tsx b/src/components/transactions/Swap/details/ParaswapCostsDetails.tsx new file mode 100644 index 0000000000..ae0f632b10 --- /dev/null +++ b/src/components/transactions/Swap/details/ParaswapCostsDetails.tsx @@ -0,0 +1,64 @@ +import { valueToBigNumber } from '@aave/math-utils'; +import { Box } from '@mui/material'; +import { ExecutionFeeTooltip } from 'src/components/infoTooltips/ExecutionFeeTooltip'; +import { FormattedNumber } from 'src/components/primitives/FormattedNumber'; +import { Row } from 'src/components/primitives/Row'; +import { ExternalTokenIcon } from 'src/components/primitives/TokenIcon'; + +import { calculateParaswapFlashLoanFee } from '../helpers/paraswap/flashloan.helpers'; +import { isParaswapRates, SwapState } from '../types'; + +export const ParaswapCostsDetails = ({ state }: { state: SwapState }) => { + if (!state.swapRate || !isParaswapRates(state.swapRate)) return null; + + // Calculate flashloan fee if using flashloan + const { flashLoanFeeFormatted } = calculateParaswapFlashLoanFee(state); + + // Calculate flashloan fee in USD + const flashLoanFeeUsd = + state.sellAmountUSD && state.sellAmountFormatted && Number(flashLoanFeeFormatted) > 0 + ? valueToBigNumber(state.sellAmountUSD) + .dividedBy(valueToBigNumber(state.sellAmountFormatted)) + .multipliedBy(valueToBigNumber(flashLoanFeeFormatted)) + .toNumber() + : 0; + + const flashloanFeeToken = state.sellAmountToken; + + // Only show if there's a flashloan fee + if (!flashLoanFeeFormatted || Number(flashLoanFeeFormatted) === 0 || !flashloanFeeToken) { + return null; + } + + return ( + } captionVariant="description" align="flex-start" mb={4}> + + + + + + + + + ); +}; diff --git a/src/components/transactions/Swap/details/RepayWithCollateralDetails.tsx b/src/components/transactions/Swap/details/RepayWithCollateralDetails.tsx new file mode 100644 index 0000000000..2ae9f1245a --- /dev/null +++ b/src/components/transactions/Swap/details/RepayWithCollateralDetails.tsx @@ -0,0 +1,103 @@ +import { valueToBigNumber } from '@aave/math-utils'; +import { Trans } from '@lingui/macro'; +import { useMemo } from 'react'; +import { useAppDataContext } from 'src/hooks/app-data-provider/useAppDataProvider'; +import { calculateHFAfterRepay } from 'src/utils/hfUtils'; + +import { + DetailsHFLine, + DetailsNumberLineWithSub, + TxModalDetails, +} from '../../FlowCommons/TxModalDetails'; +import { ProtocolSwapParams, ProtocolSwapState, SwapProvider } from '../types'; +import { CowCostsDetails } from './CowCostsDetails'; +import { ParaswapCostsDetails } from './ParaswapCostsDetails'; + +export const RepayWithCollateralDetails = ({ + state, +}: { + params: ProtocolSwapParams; + state: ProtocolSwapState; +}) => { + const { user } = useAppDataContext(); + + const currentDebt = state.sourceReserve.variableBorrows; + + // // If the selected collateral asset is frozen, a flashloan must be used. When a flashloan isn't used, + // // the remaining amount after the swap is deposited into the pool, which will fail for frozen assets. + // const shouldUseFlashloan = + // useFlashloan(user.healthFactor, hfEffectOfFromAmount.toString()) || + // state.destinationReserve.reserve.isFrozen; + + // we need to get the min as minimumReceived can be greater than debt as we are swapping + // a safe amount to repay all. When this happens amountAfterRepay would be < 0 and + // this would show as certain amount left to repay when we are actually repaying all debt + const tokenToRepayWithBalance = state.destinationReserve.underlyingBalance; + const debtAmountAfterRepay = useMemo(() => { + if (!state.buyAmountFormatted || !currentDebt) return valueToBigNumber('0'); + + return valueToBigNumber(currentDebt).minus( + valueToBigNumber(state.buyAmountFormatted) < valueToBigNumber(currentDebt) + ? valueToBigNumber(state.buyAmountFormatted) + : valueToBigNumber(currentDebt) + ); + }, [currentDebt, state.buyAmountFormatted]); + + if (!user || !state.buyAmountFormatted) { + return null; + } + + const { hfAfterSwap } = calculateHFAfterRepay({ + amountToReceiveAfterSwap: state.buyAmountFormatted, + amountToSwap: state.sellAmountFormatted ?? '0', + fromAssetData: state.destinationReserve.reserve, // used as collateral + user, + toAssetData: state.sourceReserve.reserve, + repayWithUserReserve: state.destinationReserve, + debt: currentDebt, + }); + + const displayAmountAfterRepayInUsd = debtAmountAfterRepay.multipliedBy( + state.sourceReserve.reserve.priceInUSD + ); + const rawCollateralAmountAfterRepay = tokenToRepayWithBalance + ? valueToBigNumber(tokenToRepayWithBalance).minus(state.sellAmountFormatted ?? '0') + : valueToBigNumber('0'); + const collateralAmountAfterRepay = rawCollateralAmountAfterRepay.isNegative() + ? valueToBigNumber('0') + : rawCollateralAmountAfterRepay; + const collateralAmountAfterRepayUSD = collateralAmountAfterRepay.multipliedBy( + state.destinationReserve.reserve.priceInUSD + ); + + return ( + + {state.provider === SwapProvider.COW_PROTOCOL && } + {state.provider === SwapProvider.PARASWAP && } + + + Borrow balance after repay} + futureValue={debtAmountAfterRepay.toString()} + futureValueUSD={displayAmountAfterRepayInUsd.toString()} + symbol={state.sourceReserve.reserve.symbol} + tokenIcon={state.sourceReserve.reserve.iconSymbol} + loading={state.ratesLoading} + hideSymbolSuffix + /> + Collateral balance after repay} + futureValue={collateralAmountAfterRepay.toString()} + futureValueUSD={collateralAmountAfterRepayUSD.toString()} + symbol={state.destinationReserve.reserve.symbol} + tokenIcon={state.destinationReserve.reserve.iconSymbol} + loading={state.ratesLoading} + hideSymbolSuffix + /> + + ); +}; diff --git a/src/components/transactions/Swap/details/SwapDetails.tsx b/src/components/transactions/Swap/details/SwapDetails.tsx new file mode 100644 index 0000000000..f5e41c6cc5 --- /dev/null +++ b/src/components/transactions/Swap/details/SwapDetails.tsx @@ -0,0 +1,250 @@ +import { valueToBigNumber } from '@aave/math-utils'; +import { Trans } from '@lingui/macro'; +import { Box, Typography } from '@mui/material'; +import { DarkTooltip } from 'src/components/infoTooltips/DarkTooltip'; +import { FormattedNumber } from 'src/components/primitives/FormattedNumber'; +import { Row } from 'src/components/primitives/Row'; +import { ExternalTokenIcon } from 'src/components/primitives/TokenIcon'; + +import { TxModalDetails } from '../../FlowCommons/TxModalDetails'; +import { SwappableToken, SwapParams, SwapProvider, SwapState } from '../types'; +import { CowCostsDetails } from './CowCostsDetails'; + +export const SwapDetails = ({ params, state }: { params: SwapParams; state: SwapState }) => { + if ( + !state.swapRate || + !state.sellAmountToken || + !state.buyAmountToken || + !state.sellAmountUSD || + !state.buyAmountUSD || + !state.sellAmountFormatted || + !state.buyAmountFormatted + ) + return null; + + return ( + + + + ); +}; + +export const SwapModalTxDetails = ({ + provider, + buyToken, + buyAmount, + buyAmountUSD, + sellAmountUSD, + safeSlippage, + customReceivedTitle, + sellToken, + state, +}: { + provider: SwapProvider; + safeSlippage: number; + customReceivedTitle?: React.ReactNode; + sellToken: SwappableToken; + buyToken: SwappableToken; + sellAmount: string; + buyAmount: string; + buyAmountUSD: string; + sellAmountUSD: string; + state: SwapState; +}) => { + return provider === SwapProvider.COW_PROTOCOL ? ( + + ) : ( + + ); +}; + +export const IntentTxDetails = ({ + state, + buyToken, + customReceivedTitle, + sellAmountUSD, + buyAmount, + buyAmountUSD, +}: { + state: SwapState; + buyToken: SwappableToken; + sellToken: SwappableToken; + safeSlippage: number; + customReceivedTitle?: React.ReactNode; + sellAmountUSD: string; + buyAmount: string; + buyAmountUSD: string; +}) => { + const receivingInUsd = valueToBigNumber(buyAmountUSD); + const sendingInUsd = valueToBigNumber(sellAmountUSD); + + const priceImpact = (1 - receivingInUsd.dividedBy(sendingInUsd).toNumber()) * 100; + + return ( + <> + {state.provider === SwapProvider.COW_PROTOCOL && } + + {`Minimum ${buyToken.symbol} received`}} + captionVariant="description" + align="flex-start" + > + + + + + {buyAmount} {buyToken.symbol} + + } + arrow + placement="top" + enterTouchDelay={100} + leaveTouchDelay={500} + > + + + + + + + + {priceImpact && priceImpact > 0 && priceImpact < 100 && ( + 10 ? 'error' : priceImpact > 5 ? 'warning' : 'text.secondary'} + > + (-{priceImpact.toFixed(priceImpact > 3 ? 0 : priceImpact > 1 ? 1 : 2)}%) + + )} + + + + + ); +}; + +const MarketOrderTxDetails = ({ + buyToken, + customReceivedTitle, + buyAmount, + buyAmountUSD, +}: { + buyToken: SwappableToken; + safeSlippage: number; + customReceivedTitle?: React.ReactNode; + buyAmount: string; + buyAmountUSD: string; +}) => { + return ( + <> + {`Minimum ${buyToken.symbol} received`}} + captionVariant="description" + align="flex-start" + > + + + + + {buyAmount} {buyToken.symbol} + + } + arrow + placement="top" + enterTouchDelay={100} + leaveTouchDelay={500} + > + + + + + + + + + + ); +}; diff --git a/src/components/transactions/Swap/details/WithdrawAndSwapDetails.tsx b/src/components/transactions/Swap/details/WithdrawAndSwapDetails.tsx new file mode 100644 index 0000000000..7c0914081e --- /dev/null +++ b/src/components/transactions/Swap/details/WithdrawAndSwapDetails.tsx @@ -0,0 +1,71 @@ +import { valueToBigNumber } from '@aave/math-utils'; +import { Trans } from '@lingui/macro'; +import { useAppDataContext } from 'src/hooks/app-data-provider/useAppDataProvider'; +import { useRootStore } from 'src/store/root'; +import { calculateHFAfterWithdraw } from 'src/utils/hfUtils'; +import { useShallow } from 'zustand/shallow'; + +import { DetailsHFLine, DetailsNumberLine, TxModalDetails } from '../../FlowCommons/TxModalDetails'; +import { ProtocolSwapParams, ProtocolSwapState } from '../types'; +import { SwapModalTxDetails } from './SwapDetails'; + +export const WithdrawAndSwapDetails = ({ + state, + params, +}: { + params: ProtocolSwapParams; + state: ProtocolSwapState; +}) => { + const { user } = useAppDataContext(); + const { currentNetworkConfig } = useRootStore( + useShallow((store) => ({ currentNetworkConfig: store.currentNetworkConfig })) + ); + + const underlyingBalance = valueToBigNumber(state.sourceReserve.underlyingBalance); + const withdrawAmount = state.inputAmount; + const poolReserve = state.sourceReserve.reserve; + + if ( + !user || + !state.buyAmountFormatted || + !state.buyAmountUSD || + !state.sellAmountFormatted || + !state.sellAmountUSD + ) + return null; + const healthFactorAfterWithdraw = calculateHFAfterWithdraw({ + user, + userReserve: state.sourceReserve, + poolReserve, + withdrawAmount, + }); + + return ( + + + Remaining supply} + value={underlyingBalance.minus(withdrawAmount || '0').toString(10)} + symbol={ + poolReserve.isWrappedBaseAsset ? currentNetworkConfig.baseAssetSymbol : poolReserve.symbol + } + /> + + + ); +}; diff --git a/src/components/transactions/Swap/details/index.ts b/src/components/transactions/Swap/details/index.ts new file mode 100644 index 0000000000..1b109e157e --- /dev/null +++ b/src/components/transactions/Swap/details/index.ts @@ -0,0 +1,52 @@ +import React from 'react'; + +import { + isProtocolSwapParams, + isProtocolSwapState, + isTokensSwapParams, + isTokensSwapState, + SwapParams, + SwapState, + SwapType, +} from '../types'; +import { ColalteralSwapDetails } from './CollateralSwapDetails'; +import { DebtSwapDetails } from './DebtSwapDetails'; +import { DetailsSkeleton } from './DetailsSkeleton'; +import { RepayWithCollateralDetails } from './RepayWithCollateralDetails'; +import { SwapDetails } from './SwapDetails'; +import { WithdrawAndSwapDetails } from './WithdrawAndSwapDetails'; + +/** + * Decides which details component to show given the swap type. + * Renders a skeleton while rates load and hides the section if no quote is present. + */ +export const BaseSwapDetails = ({ params, state }: { params: SwapParams; state: SwapState }) => { + if (state.ratesLoading) { + return React.createElement(DetailsSkeleton, { state }); + } + + if (!state.swapRate) { + return null; + } + + if (params.swapType === SwapType.Swap && isTokensSwapParams(params) && isTokensSwapState(state)) { + return React.createElement(SwapDetails, { params, state }); + } else if (isProtocolSwapParams(params) && isProtocolSwapState(state)) { + switch (params.swapType) { + case SwapType.CollateralSwap: + return React.createElement(ColalteralSwapDetails, { params, state }); + case SwapType.DebtSwap: + return React.createElement(DebtSwapDetails, { params, state }); + case SwapType.RepayWithCollateral: + return React.createElement(RepayWithCollateralDetails, { params, state }); + case SwapType.WithdrawAndSwap: + return React.createElement(WithdrawAndSwapDetails, { params, state }); + default: + console.error(`Unsupported swap type`); + return null; + } + } else { + console.error(`Invalid swap params or state in details`); + return null; + } +}; diff --git a/src/components/transactions/Swap/docs/.gitkeep b/src/components/transactions/Swap/docs/.gitkeep new file mode 100644 index 0000000000..d7e5d7c40d --- /dev/null +++ b/src/components/transactions/Swap/docs/.gitkeep @@ -0,0 +1,3 @@ +# Placeholder to keep docs directory in git. Add `swap-modal-architecture.png` here. + + diff --git a/src/components/transactions/Swap/docs/swap-modal-architecture.png b/src/components/transactions/Swap/docs/swap-modal-architecture.png new file mode 100644 index 0000000000..99e71f34db Binary files /dev/null and b/src/components/transactions/Swap/docs/swap-modal-architecture.png differ diff --git a/src/components/transactions/Swap/errors/SwapErrors.tsx b/src/components/transactions/Swap/errors/SwapErrors.tsx new file mode 100644 index 0000000000..f90f0478c6 --- /dev/null +++ b/src/components/transactions/Swap/errors/SwapErrors.tsx @@ -0,0 +1,150 @@ +import React, { Dispatch, useEffect } from 'react'; + +import { useModalContext } from '../../../../hooks/useModal'; +import { TrackAnalyticsHandlers } from '../analytics/useTrackAnalytics'; +import { SwapError, SwapParams, SwapState } from '../types'; +import { isProtocolSwapState } from '../types/state.types'; +import { errorToConsole } from './shared/console.helpers'; +import { + FlashLoanDisabledBlockingGuard, + hasFlashLoanDisabled, +} from './shared/FlashLoanDisabledBlockingGuard'; +import { GasEstimationError } from './shared/GasEstimationError'; +import { GenericError } from './shared/GenericError'; +import { + hasInsufficientBalance, + InsufficientBalanceGuard, +} from './shared/InsufficientBalanceGuard'; +import { + hasInsufficientLiquidity, + InsufficientLiquidityBlockingGuard, +} from './shared/InsufficientLiquidityBlockingGuard'; +import { ProviderError } from './shared/ProviderError'; +import { hasSupplyCapBlocking, SupplyCapBlockingGuard } from './shared/SupplyCapBlockingGuard'; +import { hasUserDenied, UserDenied } from './shared/UserDenied'; +import { hasZeroLTVBlocking, ZeroLTVBlockingGuard } from './shared/ZeroLTVBlockingGuard'; + +export const SwapErrors = ({ + state, + setState, + trackingHandlers, +}: { + params: SwapParams; + state: SwapState; + setState: Dispatch>; + trackingHandlers: TrackAnalyticsHandlers; +}) => { + const { txError } = useModalContext(); + + useEffect(() => { + if (txError) { + const swapError: SwapError = { + rawError: txError.rawError, + message: `Error: ${txError.error} on ${txError.txAction}`, + actionBlocked: txError.actionBlocked || txError.blocking, + }; + + setState({ + error: swapError, + }); + trackingHandlers.trackSwapError(swapError); + + // Human readable error for user to share with support team + // Avoid wrapping in console.error to prevent dev overlay "undefined Error" noise + errorToConsole(state, { + rawError: txError.rawError, + message: `Error: ${txError.error} on ${txError.txAction}`, + actionBlocked: txError.actionBlocked || txError.blocking, + }); + } + }, [txError]); + + // Track user denied + useEffect(() => { + if (state.error && hasUserDenied(state.error)) { + trackingHandlers.trackUserDenied(); + } + }, [state.error]); + + if (hasInsufficientBalance(state)) { + return ( + + ); + } + + if (hasZeroLTVBlocking(state, [])) { + return ( + + ); + } + + if (hasFlashLoanDisabled(state) && isProtocolSwapState(state)) { + return ( + + ); + } + + if (isProtocolSwapState(state) && hasSupplyCapBlocking(state)) { + return ( + + ); + } + + if (isProtocolSwapState(state) && hasInsufficientLiquidity(state)) { + return ( + + ); + } + + if (!state.error) { + return null; + } + + if (hasUserDenied(state.error)) { + return ; + } + + const provider = state.provider; + if (!provider) { + return ( + + ); + } + + const providerError = React.createElement(ProviderError, { + error: state.error, + state, + provider, + sx: { mb: !state.isSwapFlowSelected ? 0 : 4 }, + key: `provider-error`, + }); + + if (providerError) { + return providerError; + } + + return ; +}; diff --git a/src/components/transactions/Switch/cowprotocol/cowprotocol.errors.ts b/src/components/transactions/Swap/errors/cow/quote.helpers.ts similarity index 82% rename from src/components/transactions/Switch/cowprotocol/cowprotocol.errors.ts rename to src/components/transactions/Swap/errors/cow/quote.helpers.ts index 287c45882b..9077d5283a 100644 --- a/src/components/transactions/Switch/cowprotocol/cowprotocol.errors.ts +++ b/src/components/transactions/Swap/errors/cow/quote.helpers.ts @@ -1,10 +1,10 @@ -const MESSAGE_MAP: { [key: string]: string } = { +export const MESSAGE_MAP: { [key: string]: string } = { NoLiquidity: 'No liquidity found for the given amount and asset pair.', NoRoutesFound: 'No routes found with enough liquidity.', SellAmountDoesNotCoverFee: 'Sell amount is too small to cover the fee.', }; -const MESSAGE_REGEX_MAP: Array<{ regex: RegExp; message: string }> = [ +export const MESSAGE_REGEX_MAP: Array<{ regex: RegExp; message: string }> = [ { regex: /^Source and destination tokens cannot be the same$/, message: 'Source and destination tokens cannot be the same', diff --git a/src/components/transactions/Swap/errors/paraswap/quote.helpers.ts b/src/components/transactions/Swap/errors/paraswap/quote.helpers.ts new file mode 100644 index 0000000000..537e4a21ea --- /dev/null +++ b/src/components/transactions/Swap/errors/paraswap/quote.helpers.ts @@ -0,0 +1,27 @@ +export const MESSAGE_MAP: { [key: string]: string } = { + ESTIMATED_LOSS_GREATER_THAN_MAX_IMPACT: + 'Price impact too high. Please try a different amount or asset pair.', + // not sure why this error-code is not upper-cased + 'No routes found with enough liquidity': 'No routes found with enough liquidity.', +}; + +export const MESSAGE_REGEX_MAP: Array<{ regex: RegExp; message: string }> = [ + { + regex: /^Amount \d+ is too small to proceed$/, + message: 'Amount is too small. Please try larger amount.', + }, +]; + +/** + * Converts Paraswap error message to message for displaying in interface + * @param message Paraswap error message + * @returns Message for displaying in interface + */ +export function convertParaswapErrorMessage(message: string): string | undefined { + if (message in MESSAGE_MAP) { + return MESSAGE_MAP[message]; + } + + const newMessage = MESSAGE_REGEX_MAP.find((mapping) => mapping.regex.test(message))?.message; + return newMessage; +} diff --git a/src/components/transactions/Swap/errors/shared/BalanceLowerThanInput.tsx b/src/components/transactions/Swap/errors/shared/BalanceLowerThanInput.tsx new file mode 100644 index 0000000000..63224ece6f --- /dev/null +++ b/src/components/transactions/Swap/errors/shared/BalanceLowerThanInput.tsx @@ -0,0 +1,18 @@ +import { Trans } from '@lingui/macro'; +import { SxProps, Typography } from '@mui/material'; +import { Warning } from 'src/components/primitives/Warning'; + +import { SwapType } from '../../types/shared.types'; + +export const BalanceLowerThanInput = ({ sx, swapType }: { sx?: SxProps; swapType: SwapType }) => { + return ( + + + + Your {swapType === SwapType.RepayWithCollateral ? 'collateral' : ''} balance is lower than + the selected amount. + + + + ); +}; diff --git a/src/components/transactions/Swap/errors/shared/FlashLoanDisabledBlockingError.tsx b/src/components/transactions/Swap/errors/shared/FlashLoanDisabledBlockingError.tsx new file mode 100644 index 0000000000..9764a06b74 --- /dev/null +++ b/src/components/transactions/Swap/errors/shared/FlashLoanDisabledBlockingError.tsx @@ -0,0 +1,13 @@ +import { Trans } from '@lingui/macro'; +import { SxProps, Typography } from '@mui/material'; +import { Warning } from 'src/components/primitives/Warning'; + +export const FlashLoanDisabledBlockingError = ({ sx }: { sx?: SxProps }) => { + return ( + + + Position swaps are disabled for this asset due to security reasons. + + + ); +}; diff --git a/src/components/transactions/Swap/errors/shared/FlashLoanDisabledBlockingGuard.tsx b/src/components/transactions/Swap/errors/shared/FlashLoanDisabledBlockingGuard.tsx new file mode 100644 index 0000000000..c201669c11 --- /dev/null +++ b/src/components/transactions/Swap/errors/shared/FlashLoanDisabledBlockingGuard.tsx @@ -0,0 +1,83 @@ +import { SxProps } from '@mui/material'; +import { Dispatch, useEffect } from 'react'; + +import { ActionsBlockedReason, SwapError, SwapProvider, SwapState } from '../../types'; +import { isProtocolSwapState, ProtocolSwapState } from '../../types/state.types'; +import { FlashLoanDisabledBlockingError } from './FlashLoanDisabledBlockingError'; + +export const hasFlashLoanDisabled = (state: SwapState): boolean => { + if (!isProtocolSwapState(state)) { + return false; + } + + // Check if provider is Paraswap, using flashloan, and sourceReserve exists + if ( + state.provider === SwapProvider.PARASWAP && + state.useFlashloan === true && + state.sourceReserve?.reserve && + !state.sourceReserve.reserve.flashLoanEnabled + ) { + return true; + } + + return false; +}; + +export const FlashLoanDisabledBlockingGuard = ({ + state, + setState, + sx, + isSwapFlowSelected, +}: { + state: ProtocolSwapState; + setState: Dispatch>; + sx?: SxProps; + isSwapFlowSelected: boolean; +}) => { + useEffect(() => { + const isBlocking = hasFlashLoanDisabled(state); + + if (isBlocking) { + const isAlreadyBlockingError = + state.error?.rawError instanceof Error && + state.error.rawError.message === 'FlashLoanDisabledError'; + + if (!isAlreadyBlockingError) { + const blockingError: SwapError = { + rawError: new Error('FlashLoanDisabledError'), + message: 'Position Swaps disabled for this asset', + actionBlocked: true, + }; + setState({ + error: blockingError, + actionsBlocked: { + [ActionsBlockedReason.FLASH_LOAN_DISABLED]: true, + }, + }); + } + } else { + const isBlockingError = + state.error?.rawError instanceof Error && + state.error.rawError.message === 'FlashLoanDisabledError'; + if (isBlockingError) { + setState({ + error: undefined, + actionsBlocked: { + [ActionsBlockedReason.FLASH_LOAN_DISABLED]: undefined, + }, + }); + } + } + }, [ + state.provider, + state.useFlashloan, + state.sourceReserve?.reserve?.flashLoanEnabled, + state.error, + ]); + + if (hasFlashLoanDisabled(state)) { + return ; + } + + return null; +}; diff --git a/src/components/transactions/Swap/errors/shared/GasEstimationError.tsx b/src/components/transactions/Swap/errors/shared/GasEstimationError.tsx new file mode 100644 index 0000000000..b2c87cbc75 --- /dev/null +++ b/src/components/transactions/Swap/errors/shared/GasEstimationError.tsx @@ -0,0 +1,43 @@ +import { Trans } from '@lingui/macro'; +import { Box, Typography } from '@mui/material'; +import { Warning } from 'src/components/primitives/Warning'; +import { TxAction, TxErrorType } from 'src/ui-config/errorMapping'; + +import { GasEstimationError as GasEstimationErrorComponent } from '../../../FlowCommons/GasEstimationError'; + +interface ErrorProps { + error?: Error; + isLimitOrder?: boolean; +} + +export const GasEstimationError: React.FC = ({ error, isLimitOrder }) => { + if (!error) { + return null; + } + + const txErrorType: TxErrorType = { + blocking: false, + actionBlocked: false, + rawError: error, + error: Gas estimation error, + txAction: TxAction.GAS_ESTIMATION, + }; + + return ( + + + + + + {' '} + {isLimitOrder ? ( + Tip: Try increasing slippage or reduce input amount + ) : ( + Tip: Try improving your order parameters + )} + + + + + ); +}; diff --git a/src/components/transactions/Swap/errors/shared/GenericError.tsx b/src/components/transactions/Swap/errors/shared/GenericError.tsx new file mode 100644 index 0000000000..89fd15b888 --- /dev/null +++ b/src/components/transactions/Swap/errors/shared/GenericError.tsx @@ -0,0 +1,48 @@ +import { Trans } from '@lingui/macro'; +import { ContentCopy } from '@mui/icons-material'; +import { IconButton, SxProps, Tooltip, Typography } from '@mui/material'; +import React, { useState } from 'react'; +import { Warning } from 'src/components/primitives/Warning'; + +interface GenericErrorProps { + sx?: SxProps; + message: string; + copyText?: string; +} + +export const GenericError = ({ sx, message, copyText }: GenericErrorProps) => { + const [copyTooltip, setCopyTooltip] = useState<'Copy' | 'Copied!'>('Copy'); + + const handleCopy = async () => { + if (copyText) { + try { + await navigator.clipboard.writeText(copyText); + setCopyTooltip('Copied!'); + setTimeout(() => setCopyTooltip('Copy'), 1200); + } catch (e) { + setCopyTooltip('Copy'); + setTimeout(() => setCopyTooltip('Copy'), 1200); + } + } + }; + + return ( + + + {message} + {copyText ? ( + + + + + + ) : null} + + + ); +}; diff --git a/src/components/transactions/Swap/errors/shared/InsufficientBalanceGuard.tsx b/src/components/transactions/Swap/errors/shared/InsufficientBalanceGuard.tsx new file mode 100644 index 0000000000..3de76d3383 --- /dev/null +++ b/src/components/transactions/Swap/errors/shared/InsufficientBalanceGuard.tsx @@ -0,0 +1,92 @@ +import { valueToBigNumber } from '@aave/math-utils'; +import { SxProps } from '@mui/material'; +import React, { Dispatch, useEffect } from 'react'; + +import { ActionsBlockedReason, SwapError, SwapState, SwapType } from '../../types'; +import { BalanceLowerThanInput } from './BalanceLowerThanInput'; + +export const hasInsufficientBalance = (state: SwapState) => { + // Determine which token pays and which amount to compare. + // - Default: sell side pays. + // - Inverted flows (e.g., RepayWithCollateral) use destination token. + // - DebtSwap is special: the buy side pays (repaying with the bought debt token). + const paysOnBuySide = state.swapType === SwapType.DebtSwap; + const payingToken = paysOnBuySide ? state.buyAmountToken : state.sellAmountToken; + + const requiredAmount = paysOnBuySide ? state.buyAmountFormatted : state.sellAmountFormatted; + + return valueToBigNumber(requiredAmount || 0).isGreaterThan( + valueToBigNumber(payingToken?.balance || 0) + ); +}; + +export const InsufficientBalanceGuard = ({ + state, + setState, + sx, + isSwapFlowSelected, +}: { + state: SwapState; + setState: Dispatch>; + sx?: SxProps; + isSwapFlowSelected: boolean; +}) => { + useEffect(() => { + const insufficient = hasInsufficientBalance(state); + + if (insufficient) { + const isAlreadyBalanceError = + state.error?.rawError instanceof Error && + state.error.rawError.message === 'BalanceLowerThanInput'; + + if (!isAlreadyBalanceError) { + const balanceError: SwapError = { + rawError: new Error('BalanceLowerThanInput'), + message: 'Your balance is lower than the selected amount.', + actionBlocked: true, + }; + setState({ + error: balanceError, + actionsBlocked: { + [ActionsBlockedReason.INSUFFICIENT_BALANCE]: true, + }, + }); + } + } else { + const isBalanceError = + state.error?.rawError instanceof Error && + state.error.rawError.message === 'BalanceLowerThanInput'; + + if (isBalanceError) { + setState({ + error: undefined, + actionsBlocked: { + [ActionsBlockedReason.INSUFFICIENT_BALANCE]: undefined, + }, + }); + } + } + }, [ + state.debouncedInputAmount, + state.debouncedOutputAmount, + state.sourceToken.balance, + state.destinationToken.balance, + state.sellAmountFormatted, + state.isInvertedSwap, + state.side, + state.swapType, + state.buyAmountFormatted, + state.sellAmountFormatted, + ]); + + if (hasInsufficientBalance(state)) { + return ( + + ); + } + + return null; +}; diff --git a/src/components/transactions/Swap/errors/shared/InsufficientLiquidityBlockingError.tsx b/src/components/transactions/Swap/errors/shared/InsufficientLiquidityBlockingError.tsx new file mode 100644 index 0000000000..2569c85027 --- /dev/null +++ b/src/components/transactions/Swap/errors/shared/InsufficientLiquidityBlockingError.tsx @@ -0,0 +1,21 @@ +import { Trans } from '@lingui/macro'; +import { SxProps, Typography } from '@mui/material'; +import { Warning } from 'src/components/primitives/Warning'; + +export const InsufficientLiquidityBlockingError = ({ + symbol, + sx, +}: { + symbol: string; + sx?: SxProps; +}) => { + return ( + + + + There is not enough liquidity in {symbol} to complete this swap. Try lowering the amount. + + + + ); +}; diff --git a/src/components/transactions/Swap/errors/shared/InsufficientLiquidityBlockingGuard.tsx b/src/components/transactions/Swap/errors/shared/InsufficientLiquidityBlockingGuard.tsx new file mode 100644 index 0000000000..775f95f817 --- /dev/null +++ b/src/components/transactions/Swap/errors/shared/InsufficientLiquidityBlockingGuard.tsx @@ -0,0 +1,96 @@ +import { valueToBigNumber } from '@aave/math-utils'; +import { SxProps } from '@mui/material'; +import { BigNumber } from 'bignumber.js'; +import { ethers } from 'ethers'; +import { Dispatch, useEffect } from 'react'; + +import { ActionsBlockedReason, ProtocolSwapState, SwapError, SwapState } from '../../types'; +import { isProtocolSwapState } from '../../types/state.types'; +import { InsufficientLiquidityBlockingError } from './InsufficientLiquidityBlockingError'; + +export const hasInsufficientLiquidity = (state: SwapState) => { + if (!isProtocolSwapState(state)) return false; + const reserve = state.isInvertedSwap + ? state.sourceReserve?.reserve + : state.destinationReserve?.reserve; + const buyAmount = state.buyAmountFormatted; + if (!reserve || !buyAmount) return false; + + const availableBorrowCap = + reserve.borrowCap === '0' + ? valueToBigNumber(ethers.constants.MaxUint256.toString()) + : valueToBigNumber(reserve.borrowCap).minus(valueToBigNumber(reserve.totalDebt)); + const availableLiquidity = BigNumber.max( + BigNumber.min(valueToBigNumber(reserve.formattedAvailableLiquidity), availableBorrowCap), + 0 + ); + + return valueToBigNumber(buyAmount).gt(availableLiquidity); +}; + +export const InsufficientLiquidityBlockingGuard = ({ + state, + setState, + sx, + isSwapFlowSelected, +}: { + state: ProtocolSwapState; + setState: Dispatch>; + sx?: SxProps; + isSwapFlowSelected: boolean; +}) => { + useEffect(() => { + const isBlocking = hasInsufficientLiquidity(state); + + if (isBlocking) { + const isAlreadyBlockingError = + state.error?.rawError instanceof Error && + state.error.rawError.message === 'InsufficientLiquidityError'; + + if (!isAlreadyBlockingError) { + const blockingError: SwapError = { + rawError: new Error('InsufficientLiquidityError'), + message: 'Not enough liquidity in target asset to complete the swap.', + actionBlocked: true, + }; + setState({ + error: blockingError, + actionsBlocked: { + [ActionsBlockedReason.INSUFFICIENT_LIQUIDITY]: true, + }, + }); + } + } else { + const isBlockingError = + state.error?.rawError instanceof Error && + state.error.rawError.message === 'InsufficientLiquidityError'; + if (isBlockingError) { + setState({ + error: undefined, + actionsBlocked: { + [ActionsBlockedReason.INSUFFICIENT_LIQUIDITY]: undefined, + }, + }); + } + } + }, [ + state.buyAmountFormatted, + state.destinationReserve?.reserve?.formattedAvailableLiquidity, + state.sourceReserve?.reserve?.formattedAvailableLiquidity, + state.isInvertedSwap, + ]); + + if (hasInsufficientLiquidity(state)) { + const symbol = state.isInvertedSwap + ? state.sourceReserve?.reserve?.symbol + : state.destinationReserve?.reserve?.symbol; + return ( + + ); + } + + return null; +}; diff --git a/src/components/transactions/Swap/errors/shared/ProviderError.tsx b/src/components/transactions/Swap/errors/shared/ProviderError.tsx new file mode 100644 index 0000000000..83d31fa712 --- /dev/null +++ b/src/components/transactions/Swap/errors/shared/ProviderError.tsx @@ -0,0 +1,47 @@ +import { SxProps, Typography } from '@mui/material'; +import { Warning } from 'src/components/primitives/Warning'; + +import { SwapError, SwapProvider, SwapState } from '../../types'; +import { convertCowProtocolErrorMessage } from '../cow/quote.helpers'; +import { convertParaswapErrorMessage } from '../paraswap/quote.helpers'; +import { errorToConsoleString } from '../shared/console.helpers'; +import { GenericError } from './GenericError'; + +interface QuoteErrorProps { + error: SwapError; + provider: SwapProvider; + sx?: SxProps; + state: SwapState; +} + +export const ProviderError = ({ error, sx, provider, state }: QuoteErrorProps) => { + let customErrorMessage; + + switch (provider) { + case SwapProvider.PARASWAP: + customErrorMessage = convertParaswapErrorMessage(error.message); + break; + case SwapProvider.COW_PROTOCOL: + customErrorMessage = convertCowProtocolErrorMessage(error.message); + break; + default: + console.error('No provider error mapping found for', provider, error); + break; + } + + if (!customErrorMessage) { + const errorToCopy = errorToConsoleString(state, error); + return ( + + ); + } + + return ( + + {customErrorMessage} + + ); +}; diff --git a/src/components/transactions/Swap/errors/shared/SupplyCapBlockingError.tsx b/src/components/transactions/Swap/errors/shared/SupplyCapBlockingError.tsx new file mode 100644 index 0000000000..f4b2f778d1 --- /dev/null +++ b/src/components/transactions/Swap/errors/shared/SupplyCapBlockingError.tsx @@ -0,0 +1,15 @@ +import { Trans } from '@lingui/macro'; +import { SxProps, Typography } from '@mui/material'; +import { Warning } from 'src/components/primitives/Warning'; + +export const SupplyCapBlockingError = ({ symbol, sx }: { symbol: string; sx?: SxProps }) => { + return ( + + + + Supply cap reached for {symbol}. Reduce the amount or choose a different asset. + + + + ); +}; diff --git a/src/components/transactions/Swap/errors/shared/SupplyCapBlockingGuard.tsx b/src/components/transactions/Swap/errors/shared/SupplyCapBlockingGuard.tsx new file mode 100644 index 0000000000..236919dc10 --- /dev/null +++ b/src/components/transactions/Swap/errors/shared/SupplyCapBlockingGuard.tsx @@ -0,0 +1,89 @@ +import { valueToBigNumber } from '@aave/math-utils'; +import { SxProps } from '@mui/material'; +import { Dispatch, useEffect } from 'react'; + +import { ActionsBlockedReason, ProtocolSwapState, SwapError, SwapState } from '../../types'; +import { isProtocolSwapState } from '../../types/state.types'; +import { SupplyCapBlockingError } from './SupplyCapBlockingError'; + +export const hasSupplyCapBlocking = (state: SwapState) => { + if (!isProtocolSwapState(state)) return false; + const reserve = state.isInvertedSwap + ? state.sourceReserve?.reserve + : state.destinationReserve?.reserve; + const buyAmount = state.buyAmountFormatted; + if (!reserve || !buyAmount) return false; + + if (reserve.supplyCap === '0') return false; + + const remainingCap = valueToBigNumber(reserve.supplyCap).minus( + valueToBigNumber(reserve.totalLiquidity) + ); + + // If remaining cap is exhausted or the intended buy exceeds remaining, block + return remainingCap.lte(0) || valueToBigNumber(buyAmount).gt(remainingCap); +}; + +export const SupplyCapBlockingGuard = ({ + state, + setState, + sx, + isSwapFlowSelected, +}: { + state: ProtocolSwapState; + setState: Dispatch>; + sx?: SxProps; + isSwapFlowSelected: boolean; +}) => { + useEffect(() => { + const isBlocking = hasSupplyCapBlocking(state); + + if (isBlocking) { + const isAlreadyBlockingError = + state.error?.rawError instanceof Error && + state.error.rawError.message === 'SupplyCapBlockingError'; + + if (!isAlreadyBlockingError) { + const blockingError: SwapError = { + rawError: new Error('SupplyCapBlockingError'), + message: 'Supply cap reached for target asset.', + actionBlocked: true, + }; + setState({ + error: blockingError, + actionsBlocked: { + [ActionsBlockedReason.SUPPLY_CAP_BLOCKING]: true, + }, + }); + } + } else { + const isBlockingError = + state.error?.rawError instanceof Error && + state.error.rawError.message === 'SupplyCapBlockingError'; + if (isBlockingError) { + setState({ + error: undefined, + actionsBlocked: { + [ActionsBlockedReason.SUPPLY_CAP_BLOCKING]: undefined, + }, + }); + } + } + }, [ + state.buyAmountFormatted, + state.destinationReserve?.reserve?.totalLiquidity, + state.sourceReserve?.reserve?.totalLiquidity, + state.isInvertedSwap, + ]); + + if (hasSupplyCapBlocking(state)) { + const symbol = state.isInvertedSwap + ? state.sourceReserve?.reserve?.symbol + : state.destinationReserve?.reserve?.symbol; + return ( + + ); + } + + return null; +}; diff --git a/src/components/transactions/Swap/errors/shared/UserDenied.tsx b/src/components/transactions/Swap/errors/shared/UserDenied.tsx new file mode 100644 index 0000000000..e5b362c943 --- /dev/null +++ b/src/components/transactions/Swap/errors/shared/UserDenied.tsx @@ -0,0 +1,95 @@ +import { Trans } from '@lingui/macro'; +import { Box, CircularProgress, Typography } from '@mui/material'; +import React, { Dispatch, useEffect, useState } from 'react'; +import { Warning } from 'src/components/primitives/Warning'; + +import { SwapError, SwapState } from '../../types'; + +const USER_DENIED_MESSAGES = [ + 'user denied message signature', + 'user denied message', + 'user denied transaction signature', + 'user denied transaction', + 'user denied the request', + 'user denied request', + 'user rejected the request', + 'user rejected request', + 'user rejected the transaction', + 'user rejected transaction', + 'you cancelled the transaction', +]; + +export const hasUserDenied = (txError: SwapError) => { + return USER_DENIED_MESSAGES.some((message) => + txError.rawError.message.toLowerCase().includes(message.toLowerCase()) + ); +}; + +export const UserDenied = ({ + state, + setState, +}: { + state: SwapState; + setState: Dispatch>; +}) => { + // Show info message for 10 seconds with progress circle at the end, then remove + const [visible, setVisible] = useState(true); + const [progress, setProgress] = useState(0); + + useEffect(() => { + // Progress increments every 50ms (100 * 50ms = 5s to reach 100) + const interval = setInterval(() => { + setProgress((prevProgress) => { + if (prevProgress >= 100) { + clearInterval(interval); + + if (state.actionsLoading) { + setState({ + actionsLoading: false, + }); + } + + // Hide after short delay to allow circle to show full (e.g. 250ms) + setTimeout(() => { + setVisible(false); + setProgress(0); + setState({ + error: undefined, + }); + }, 100); + + return 100; + } + return prevProgress + 1; + }); + }, 50); + + return () => { + clearInterval(interval); + }; + }, []); + + return ( + + + } + > + + User denied the operation. + + + + ); +}; diff --git a/src/components/transactions/Swap/errors/shared/ZeroLTVBlockingError.tsx b/src/components/transactions/Swap/errors/shared/ZeroLTVBlockingError.tsx new file mode 100644 index 0000000000..426d0ae562 --- /dev/null +++ b/src/components/transactions/Swap/errors/shared/ZeroLTVBlockingError.tsx @@ -0,0 +1,16 @@ +import { Trans } from '@lingui/macro'; +import { SxProps, Typography } from '@mui/material'; +import { Warning } from 'src/components/primitives/Warning'; + +export const ZeroLTVBlockingError = ({ sx }: { sx?: SxProps }) => { + return ( + + + + You have assets with zero LTV that are blocking this operation. Please disable them as + collateral first. + + + + ); +}; diff --git a/src/components/transactions/Swap/errors/shared/ZeroLTVBlockingGuard.tsx b/src/components/transactions/Swap/errors/shared/ZeroLTVBlockingGuard.tsx new file mode 100644 index 0000000000..ab07d55f23 --- /dev/null +++ b/src/components/transactions/Swap/errors/shared/ZeroLTVBlockingGuard.tsx @@ -0,0 +1,67 @@ +import { SxProps } from '@mui/material'; +import React, { Dispatch, useEffect } from 'react'; +import { useZeroLTVBlockingWithdraw } from 'src/hooks/useZeroLTVBlockingWithdraw'; + +import { ActionsBlockedReason, SwapError, SwapState } from '../../types'; +import { ZeroLTVBlockingError } from './ZeroLTVBlockingError'; + +export const hasZeroLTVBlocking = (state: SwapState, blockingAssets: string[]) => { + return blockingAssets.length > 0 && !blockingAssets.includes(state.sourceToken.symbol); +}; + +export const ZeroLTVBlockingGuard = ({ + state, + setState, + sx, + isSwapFlowSelected, +}: { + state: SwapState; + setState: Dispatch>; + sx?: SxProps; + isSwapFlowSelected: boolean; +}) => { + const assetsBlockingWithdraw = useZeroLTVBlockingWithdraw(); + + useEffect(() => { + const isBlocking = hasZeroLTVBlocking(state, assetsBlockingWithdraw); + + if (isBlocking) { + const isAlreadyBlockingError = + state.error?.rawError instanceof Error && + state.error.rawError.message === 'ZeroLTVBlockingError'; + + if (!isAlreadyBlockingError) { + const blockingError: SwapError = { + rawError: new Error('ZeroLTVBlockingError'), + message: + 'You have assets with zero LTV that are blocking this operation. Please disable them as collateral first.', + actionBlocked: true, + }; + setState({ + error: blockingError, + actionsBlocked: { + [ActionsBlockedReason.ZERO_LTV_BLOCKING]: true, + }, + }); + } + } else { + const isBlockingError = + state.error?.rawError instanceof Error && + state.error.rawError.message === 'ZeroLTVBlockingError'; + if (isBlockingError) { + setState({ + error: undefined, + actionsBlocked: { + [ActionsBlockedReason.ZERO_LTV_BLOCKING]: undefined, + }, + }); + } + } + }, [assetsBlockingWithdraw, state.sourceToken.symbol]); + + if (hasZeroLTVBlocking(state, assetsBlockingWithdraw)) { + return ; + } + + return null; +}; diff --git a/src/components/transactions/Swap/errors/shared/console.helpers.ts b/src/components/transactions/Swap/errors/shared/console.helpers.ts new file mode 100644 index 0000000000..3962a2bf90 --- /dev/null +++ b/src/components/transactions/Swap/errors/shared/console.helpers.ts @@ -0,0 +1,63 @@ +import { SwapError, SwapState } from '../../types'; + +function serializeError(raw: unknown) { + if (!raw) return undefined; + try { + const err = raw as Record; + const base: Record = { + name: err?.name, + message: err?.message, + stack: err?.stack, + }; + const props: Record = {}; + try { + for (const key of Object.getOwnPropertyNames(err)) { + if (!(key in base)) props[key] = err[key]; + } + } catch (_) { + // ignore + } + return { ...base, ...props }; + } catch (_) { + return { message: String(raw) }; + } +} + +function buildErrorPayload(state: SwapState, error: SwapError) { + return { + timestamp: new Date().toISOString(), + chainId: state.chainId, + provider: state.provider, + useFlashloan: state.useFlashloan ?? false, + side: state.side, + orderType: state.orderType, + swapType: state.swapType, + slippage: state.slippage, + input: { + token: state.sourceToken.symbol, + amount: state.inputAmount, + usd: state.swapRate?.srcSpotUSD, + }, + output: { + token: state.destinationToken.symbol, + amount: state.outputAmount, + usd: state.swapRate?.destSpotUSD, + }, + error: { + message: error.message, + actionBlocked: error.actionBlocked, + stage: error.stage, + raw: serializeError(error.rawError), + }, + }; +} + +export const errorToConsoleString = (state: SwapState, error: SwapError): string => { + const payload = buildErrorPayload(state, error); + return JSON.stringify(payload, null, 2); +}; + +export const errorToConsole = (state: SwapState, error: SwapError) => { + const pretty = errorToConsoleString(state, error); + console.error('Aave Swap Error\n' + pretty); +}; diff --git a/src/components/transactions/Swap/helpers/cow/adapters.helpers.ts b/src/components/transactions/Swap/helpers/cow/adapters.helpers.ts new file mode 100644 index 0000000000..d6e73ede08 --- /dev/null +++ b/src/components/transactions/Swap/helpers/cow/adapters.helpers.ts @@ -0,0 +1,304 @@ +import { valueToBigNumber } from '@aave/math-utils'; +import { + AppDataParams, + getOrderToSign, + LimitTradeParameters, + OrderKind, + OrderSigningUtils, + SupportedChainId, +} from '@cowprotocol/cow-sdk'; +import { + AaveCollateralSwapSdk, + AaveFlashLoanType, + EncodedOrder, + FlashLoanHookAmounts, + HASH_ZERO, +} from '@cowprotocol/sdk-flash-loans'; + +import { + COW_PARTNER_FEE, + DUST_PROTECTION_MULTIPLIER, + FLASH_LOAN_FEE_BPS, +} from '../../constants/cow.constants'; +import { isCowProtocolRates, OrderType, SwapProvider, SwapState, SwapType } from '../../types'; +import { getCowFlashLoanSdk } from './env.helpers'; + +export const calculateInstanceAddress = async ({ + user, + validTo, + type, + state, +}: { + user: string; + validTo: number; + type: AaveFlashLoanType; + state: SwapState; +}) => { + if (!user) return; + if ( + !state.sellAmountBigInt || + !state.buyAmountBigInt || + !state.sellAmountToken || + !state.buyAmountToken + ) + return; + + const flashLoanSdk = await getCowFlashLoanSdk(state.chainId); + const { + sellAmount, + buyAmountWithMarginForDustProtection, + buyAmount, + sellToken, + buyToken, + quoteId, + side, + slippageBps, + partnerFee, + } = { + sellAmount: state.sellAmountBigInt, + // @note: We wont have dust for borrow side, but we may have dust in collateral swaps + buyAmountWithMarginForDustProtection: + state.swapType !== SwapType.CollateralSwap + ? valueToBigNumber(state.buyAmountBigInt.toString()) + .multipliedBy(DUST_PROTECTION_MULTIPLIER) + .toFixed(0) + : state.buyAmountBigInt, + sellToken: state.sellAmountToken, + buyAmount: state.buyAmountBigInt, + buyToken: state.buyAmountToken, + quoteId: isCowProtocolRates(state.swapRate) ? state.swapRate?.quoteId : undefined, + side: state.processedSide, + slippageBps: state.orderType == OrderType.MARKET ? Number(state.slippage) * 100 : undefined, + partnerFee: COW_PARTNER_FEE(state.sellAmountToken.symbol, state.buyAmountToken.symbol), + }; + + const { flashLoanFeeAmount, sellAmountToSign } = flashLoanSdk.calculateFlashLoanAmounts({ + flashLoanFeeBps: FLASH_LOAN_FEE_BPS, + sellAmount: sellAmount, + }); + + const limitOrder: LimitTradeParameters = { + sellToken: sellToken.underlyingAddress, + sellTokenDecimals: sellToken.decimals, + buyToken: buyToken.underlyingAddress, + buyTokenDecimals: buyToken.decimals, + sellAmount: sellAmountToSign.toString(), + buyAmount: buyAmount.toString(), + kind: side === 'buy' ? OrderKind.BUY : OrderKind.SELL, + quoteId, + validTo, + slippageBps, + partnerFee, + }; + + const orderToSign = getOrderToSign( + { + chainId: state.chainId, + from: user, + networkCostsAmount: '0', + isEthFlow: false, + applyCostsSlippageAndFees: false, + }, + limitOrder, + HASH_ZERO + ); + + const encodedOrder: EncodedOrder = { + ...OrderSigningUtils.encodeUnsignedOrder(orderToSign), + appData: HASH_ZERO, + validTo, + }; + + const hookAmounts: FlashLoanHookAmounts = { + flashLoanAmount: sellAmount.toString(), + flashLoanFeeAmount: flashLoanFeeAmount.toString(), + sellAssetAmount: sellAmount.toString(), + buyAssetAmount: buyAmountWithMarginForDustProtection.toString(), + }; + + return await flashLoanSdk.getExpectedInstanceAddress( + type, + state.chainId, + user as `0x${string}`, + hookAmounts, + encodedOrder + ); +}; + +export const calculateFlashLoanAmounts = ( + state: SwapState +): { + flashLoanFeeAmount: bigint; + finalSellAmount: bigint; +} => { + const flashLoanSdk = new AaveCollateralSwapSdk(); + const sellAmount = state.sellAmountBigInt; + + if (!sellAmount) + return { + flashLoanFeeAmount: BigInt(0), + finalSellAmount: BigInt(0), + }; + + if (state.swapType === SwapType.Swap || state.provider !== SwapProvider.COW_PROTOCOL) { + return { + flashLoanFeeAmount: BigInt(0), + finalSellAmount: sellAmount, + }; + } + + const { flashLoanFeeAmount, sellAmountToSign } = flashLoanSdk.calculateFlashLoanAmounts({ + sellAmount: sellAmount, + flashLoanFeeBps: FLASH_LOAN_FEE_BPS, + }); + + return { + flashLoanFeeAmount, + finalSellAmount: sellAmountToSign, + }; +}; + +/** + * This helper function is used to get the app data for a quote from the CowSwap API when adapters are used + * The goal is to let the solvers know that a flash loan is being used so the quote contemplates the higher gas costs + * and therefore the quote is more precise and more chances of being executed + * It's important to send the hooks and flashloan hint but not the exact amounts that will be used in the final + */ +export const getAppDataForQuote = async ({}: // user, +// type, +// amount, +// chainId, +// srcToken, +// srcDecimals, +// destToken, +// destDecimals, +{ + user: string; + type: SwapType; + amount: string; + chainId: SupportedChainId; + srcToken: string; + srcDecimals: number; + destToken: string; + destDecimals: number; +}): Promise => { + return undefined; + + // NOTE: This function is prepared to add solver hooks for accurate network cost estimation, + // but such estimations are not currently supported so solvers are absorbing some costs. + // Disabled for now; enable when proper support becomes available. + + // if (type === SwapType.Swap || type === SwapType.WithdrawAndSwap) { + // return undefined; // no flashloan needed - plain swap + // } + + // const factory = + // AAVE_ADAPTER_FACTORY[chainId].length > 0 ? AAVE_ADAPTER_FACTORY[chainId] : API_ETH_MOCK_ADDRESS; + // const pool = + // AAVE_POOL_ADDRESS[chainId].length > 0 ? AAVE_POOL_ADDRESS[chainId] : API_ETH_MOCK_ADDRESS; + // const AAVE_SWAP_TYPE_TO_COW_TYPE: Partial> = { + // [SwapType.CollateralSwap]: AaveFlashLoanType.CollateralSwap, + // [SwapType.DebtSwap]: AaveFlashLoanType.DebtSwap, + // [SwapType.RepayWithCollateral]: AaveFlashLoanType.RepayCollateral, + // } as const; + // const dappId = + // AAVE_DAPP_ID_PER_TYPE[AAVE_SWAP_TYPE_TO_COW_TYPE[type] ?? AaveFlashLoanType.CollateralSwap]; + + // // const flashLoanSdk = new AaveCollateralSwapSdk(); + // // const { flashLoanFeeAmount, sellAmountToSign } = flashLoanSdk.calculateFlashLoanAmounts({ + // // sellAmount: BigInt(normalize(amount, -srcDecimals)), + // // flashLoanFeeBps: FLASH_LOAN_FEE_BPS, + // // }); + + // // let cowType: AaveFlashLoanType; + // // if (type === SwapType.CollateralSwap) { + // // cowType = AaveFlashLoanType.CollateralSwap; + // // } else if (type === SwapType.DebtSwap) { + // // cowType = AaveFlashLoanType.DebtSwap; + // // } else if(type === SwapType.RepayWithCollateral) { + // // cowType = AaveFlashLoanType.RepayCollateral; + // // } else { + // // throw new Error('Invalid swap type'); + // // } + + // // const hookAmounts: FlashLoanHookAmounts = { + // // flashLoanAmount: amount, + // // flashLoanFeeAmount: flashLoanFeeAmount.toString(), + // // sellAssetAmount: sellAmountToSign.toString(), + // // buyAssetAmount: amount, + // // } + + // const flashloan: FlashLoanHint = { + // amount, // this is actually in UNDERLYING but aave tokens are 1:1 + // receiver: factory, + // liquidityProvider: pool, + // protocolAdapter: factory, + // token: srcToken, + // }; + + // // const limitOrder: LimitTradeParameters = { + // // kind: OrderKind.SELL, + // // sellToken: srcToken, + // // sellTokenDecimals: srcDecimals, + // // buyToken: destToken, + // // buyTokenDecimals: destDecimals, + // // sellAmount: normalize(amount, -srcDecimals).toString(), + // // buyAmount: amount, + // // } + + // // const orderToSign = getOrderToSign( + // // { + // // chainId, + // // from: user, + // // networkCostsAmount: '0', + // // isEthFlow: false, + // // applyCostsSlippageAndFees: false, + // // }, + // // limitOrder, + // // HASH_ZERO + // // ); + + // // const encodedOrder: EncodedOrder = { + // // ...OrderSigningUtils.encodeUnsignedOrder(orderToSign), + // // appData: HASH_ZERO, + // // } + + // // const hooks = await getOrderHooks( + // // cowType, + // // chainId, + // // user as `0x${string}`, + // // zeroAddress, + // // hookAmounts, + // // { + // // ...encodedOrder, + // // receiver: zeroAddress, + // // }, + // // ); + + // // TODO: send proper calldatas when available so solvers can properly simulate + // const hooks = { + // pre: [ + // { + // target: factory, + // callData: '0x', + // gasLimit: 160k DEFAULT_HOOK_GAS_LIMIT.pre.toString(), + // dappId, + // }, + // ], + // post: [ + // { + // target: 0x, + // callData: '0x', + // gasLimit: 160k DEFAULT_HOOK_GAS_LIMIT.post.toString(), + // dappId, + // }, + // ], + // }; + + // return { + // metadata: { + // flashloan, + // hooks, + // }, + // }; +}; diff --git a/src/components/transactions/Swap/helpers/cow/env.helpers.ts b/src/components/transactions/Swap/helpers/cow/env.helpers.ts new file mode 100644 index 0000000000..f17ed809b9 --- /dev/null +++ b/src/components/transactions/Swap/helpers/cow/env.helpers.ts @@ -0,0 +1,55 @@ +import { CowEnv, setGlobalAdapter, TradingSdk } from '@cowprotocol/cow-sdk'; +import { AaveCollateralSwapSdk } from '@cowprotocol/sdk-flash-loans'; +import { ViemAdapter } from '@cowprotocol/sdk-viem-adapter'; +import { wagmiConfig } from 'src/ui-config/wagmiConfig'; +import { getPublicClient, getWalletClient } from 'wagmi/actions'; + +import { ADAPTER_FACTORY, HOOK_ADAPTER_PER_TYPE } from '../../constants/cow.constants'; +import { APP_CODE_PER_SWAP_TYPE } from '../../constants/shared.constants'; +import { SwapState } from '../../types'; +import { COW_ENV } from './orders.helpers'; + +export const getCowTradingSdk = async (state: SwapState, env: CowEnv = 'prod') => { + return getCowTradingSdkByChainIdAndAppCode( + state.chainId, + APP_CODE_PER_SWAP_TYPE[state.swapType], + env + ); +}; + +export const getCowTradingSdkByChainIdAndAppCode = async ( + chainId: number, + appCode: string, + env: CowEnv = COW_ENV +) => { + const adapter = await getCowAdapter(chainId); + return new TradingSdk( + { + chainId, + appCode, + env, + signer: adapter.signer, + }, + {}, + adapter + ); +}; + +export const getCowFlashLoanSdk = async (chainId: number) => { + setGlobalAdapter(await getCowAdapter(chainId)); + return new AaveCollateralSwapSdk({ + hookAdapterPerType: HOOK_ADAPTER_PER_TYPE, + aaveAdapterFactory: ADAPTER_FACTORY, + }); +}; + +export const getCowAdapter = async (chainId: number) => { + const walletClient = await getWalletClient(wagmiConfig, { chainId }); + const publicClient = getPublicClient(wagmiConfig, { chainId }); + + if (!publicClient || !walletClient) { + throw new Error('Wallet not connected'); + } + + return new ViemAdapter({ provider: publicClient, walletClient }); +}; diff --git a/src/components/transactions/Swap/helpers/cow/index.ts b/src/components/transactions/Swap/helpers/cow/index.ts new file mode 100644 index 0000000000..494c0a7fb1 --- /dev/null +++ b/src/components/transactions/Swap/helpers/cow/index.ts @@ -0,0 +1,4 @@ +export * from './adapters.helpers'; +export * from './env.helpers'; +export * from './orders.helpers'; +export * from './rates.helpers'; diff --git a/src/components/transactions/Swap/helpers/cow/orders.helpers.ts b/src/components/transactions/Swap/helpers/cow/orders.helpers.ts new file mode 100644 index 0000000000..4d271d702a --- /dev/null +++ b/src/components/transactions/Swap/helpers/cow/orders.helpers.ts @@ -0,0 +1,541 @@ +import { API_ETH_MOCK_ADDRESS } from '@aave/contract-helpers'; +import { + BuyTokenDestination, + CowEnv, + OrderBookApi, + OrderClass, + OrderKind, + OrderParameters, + OrderStatus, + QuoteAndPost, + SellTokenSource, + SigningScheme, + SlippageToleranceRequest, + SlippageToleranceResponse, + SupportedChainId, + WRAPPED_NATIVE_CURRENCIES, +} from '@cowprotocol/cow-sdk'; +import { AnyAppDataDocVersion, AppDataParams, MetadataApi } from '@cowprotocol/sdk-app-data'; +import { JsonRpcProvider } from '@ethersproject/providers'; +import { BigNumber, ethers, PopulatedTransaction } from 'ethers'; +import { isSmartContractWallet } from 'src/helpers/provider'; + +import { SignedParams } from '../../actions/approval/useSwapTokenApproval'; +import { + COW_APP_DATA, + COW_CREATE_ORDER_ABI, + COW_PROTOCOL_ETH_FLOW_ADDRESS_BY_ENV, + isChainIdSupportedByCoWProtocol, +} from '../../constants/cow.constants'; +import { OrderType } from '../../types'; +import { getCowTradingSdkByChainIdAndAppCode } from './env.helpers'; + +export const COW_ENV: CowEnv = 'prod'; + +const EIP_2612_PERMIT_ABI = [ + { + constant: false, + inputs: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + { name: 'v', type: 'uint8' }, + { name: 'r', type: 'bytes32' }, + { name: 's', type: 'bytes32' }, + ], + name: 'permit', + outputs: [], + payable: false, + stateMutability: 'nonpayable', + type: 'function', + }, +]; + +export type CowProtocolActionParams = { + orderType: OrderType; + quote?: OrderParameters; + provider: JsonRpcProvider; + chainId: number; + user: string; + sellAmount: string; + buyAmount: string; + tokenDest: string; + tokenSrc: string; + tokenSrcDecimals: number; + tokenDestDecimals: number; + inputSymbol: string; + outputSymbol: string; + slippageBps: number; + smartSlippage: boolean; + appCode: string; + kind: OrderKind; + orderBookQuote?: QuoteAndPost; + signatureParams?: SignedParams; + estimateGasLimit?: (tx: PopulatedTransaction, chainId?: number) => Promise; + validTo: number; +}; + +export const getPreSignTransaction = async ({ + provider, + chainId, + user, + slippageBps, + smartSlippage, + inputSymbol, + outputSymbol, + appCode, + orderType, + sellAmount, + buyAmount, + tokenSrc, + tokenDest, + tokenSrcDecimals, + tokenDestDecimals, + kind, + validTo, +}: CowProtocolActionParams) => { + if (!isChainIdSupportedByCoWProtocol(chainId)) { + throw new Error('Chain not supported.'); + } + + const tradingSdk = await getCowTradingSdkByChainIdAndAppCode(chainId, appCode); + const isSmartContract = await isSmartContractWallet(user, provider); + if (!isSmartContract) { + throw new Error('Only smart contract wallets should use presign.'); + } + + const orderResult = await tradingSdk.postLimitOrder( + { + sellAmount, + buyAmount, + kind: kind == OrderKind.SELL ? OrderKind.SELL : OrderKind.BUY, + sellToken: tokenSrc, + buyToken: tokenDest, + sellTokenDecimals: tokenSrcDecimals, + buyTokenDecimals: tokenDestDecimals, + validTo, + owner: user as `0x${string}`, + env: COW_ENV, + }, + { + appData: COW_APP_DATA( + inputSymbol, + outputSymbol, + slippageBps, + smartSlippage, + orderType, + appCode + ), + additionalParams: { + signingScheme: SigningScheme.PRESIGN, + }, + } + ); + + const preSignTransaction = await tradingSdk.getPreSignTransaction({ + orderUid: orderResult.orderId, + signer: provider?.getSigner(), + }); + + return { + ...preSignTransaction, + orderId: orderResult.orderId, + }; +}; + +// Only for EOA wallets +export const sendOrder = async ({ + provider, + chainId, + user, + slippageBps, + inputSymbol, + outputSymbol, + smartSlippage, + appCode, + orderType, + sellAmount, + buyAmount, + tokenSrc, + tokenDest, + tokenSrcDecimals, + tokenDestDecimals, + kind, + signatureParams, + estimateGasLimit, + validTo, +}: CowProtocolActionParams) => { + const signer = provider?.getSigner(); + + if (!isChainIdSupportedByCoWProtocol(chainId)) { + throw new Error('Chain not supported.'); + } + + if (!signer) { + throw new Error('No signer found in provider'); + } + + const isSmartContract = await isSmartContractWallet(user, provider); + if (isSmartContract) { + throw new Error('Smart contract wallets should use presign.'); + } + + const permitHook = + signatureParams && estimateGasLimit + ? await getPermitHook({ tokenAddress: tokenSrc, signatureParams, estimateGasLimit, chainId }) + : undefined; + + const hooks = permitHook + ? { + pre: [permitHook], + } + : undefined; + + const appData = COW_APP_DATA( + inputSymbol, + outputSymbol, + slippageBps, + smartSlippage, + orderType, + appCode, + hooks + ); + + const tradingSdk = await getCowTradingSdkByChainIdAndAppCode(chainId, appCode); + + return tradingSdk + .postLimitOrder( + { + sellAmount, + buyAmount, + kind: kind == OrderKind.SELL ? OrderKind.SELL : OrderKind.BUY, + sellToken: tokenSrc, + buyToken: tokenDest, + sellTokenDecimals: tokenSrcDecimals, + buyTokenDecimals: tokenDestDecimals, + validTo, + owner: user as `0x${string}`, + env: COW_ENV, + }, + { + appData, + additionalParams: { + applyCostsSlippageAndFees: false, + }, + } + ) + .then((orderResult) => orderResult.orderId); +}; + +export const getOrderStatus = async (orderId: string, chainId: number) => { + const orderBookApi = new OrderBookApi({ chainId: chainId, env: COW_ENV }); + const status = await orderBookApi.getOrderCompetitionStatus(orderId, { + chainId, + }); + return status.type; +}; + +export const getOrder = async (orderId: string, chainId: number) => { + const orderBookApi = new OrderBookApi({ chainId, env: COW_ENV }); + const order = await orderBookApi.getOrder(orderId, { + chainId, + }); + return order; +}; + +export const getOrders = async (chainId: number, account: string) => { + const orderBookApi = new OrderBookApi({ chainId, env: COW_ENV }); + const orders = await orderBookApi.getOrders({ + owner: account, + }); + + return orders; +}; + +export const isOrderLoading = (status: OrderStatus) => { + return status === OrderStatus.OPEN || status === OrderStatus.PRESIGNATURE_PENDING; +}; + +export const isOrderFilled = (status: OrderStatus) => { + return status === OrderStatus.FULFILLED; +}; + +export const isOrderExpired = (status: OrderStatus) => { + return status === OrderStatus.EXPIRED; +}; + +export const isOrderCancelled = (status: OrderStatus) => { + return status === OrderStatus.CANCELLED; +}; + +export const isNativeToken = (token?: string) => { + return token?.toLowerCase() === API_ETH_MOCK_ADDRESS.toLowerCase(); +}; + +// TODO: make object param +export const getUnsignerOrder = async ({ + sellAmount, + buyAmount, + dstToken, + user, + chainId, + tokenFromSymbol, + tokenToSymbol, + slippageBps, + smartSlippage, + appCode, + orderType, + validTo, + srcToken, + receiver, +}: { + sellAmount: string; + buyAmount: string; + dstToken: string; + user: string; + chainId: number; + tokenFromSymbol: string; + tokenToSymbol: string; + slippageBps: number; + smartSlippage: boolean; + appCode: string; + orderType: OrderType; + validTo: number; + srcToken?: string; + receiver?: string; +}) => { + const metadataApi = new MetadataApi(); + const { appDataHex } = await metadataApi.getAppDataInfo( + COW_APP_DATA(tokenFromSymbol, tokenToSymbol, slippageBps, smartSlippage, orderType, appCode) + ); + + return { + buyToken: dstToken, + receiver: receiver || user, + sellAmount, + buyAmount, + appData: appDataHex, + feeAmount: '0', + validTo: validTo, + partiallyFillable: false, + kind: OrderKind.SELL, + sellToken: srcToken + ? srcToken.toLowerCase() + : WRAPPED_NATIVE_CURRENCIES[chainId as SupportedChainId].address.toLowerCase(), + buyTokenBalance: BuyTokenDestination.ERC20, + sellTokenBalance: SellTokenSource.ERC20, + }; +}; + +export const hashAppData = async (appData: AnyAppDataDocVersion) => { + const metadataApi = new MetadataApi(); + const { appDataHex } = await metadataApi.getAppDataInfo(appData); + return appDataHex; +}; + +export const populateEthFlowTx = async ( + sellAmount: string, + buyAmount: string, + dstToken: string, + user: string, + validTo: number, + tokenFromSymbol: string, + tokenToSymbol: string, + slippageBps: number, + smartSlippage: boolean, + appCode: string, + orderType: OrderType, + quoteId?: number +): Promise => { + const appDataHex = await hashAppData( + COW_APP_DATA(tokenFromSymbol, tokenToSymbol, slippageBps, smartSlippage, orderType, appCode) + ); + + const orderData = { + buyToken: dstToken, + receiver: user, + sellAmount, + buyAmount, + appData: appDataHex, + feeAmount: '0', + validTo, + partiallyFillable: false, + quoteId: quoteId || 0, + }; + + const value = BigNumber.from(sellAmount); + + // Create the contract interface + const iface = new ethers.utils.Interface([COW_CREATE_ORDER_ABI]); + + // Encode the function call + const data = iface.encodeFunctionData('createOrder', [ + [ + orderData.buyToken, + orderData.receiver, + orderData.sellAmount, + orderData.buyAmount, + orderData.appData, + orderData.feeAmount, + orderData.validTo, + orderData.partiallyFillable, + orderData.quoteId, + ], + ]); + + return { + to: COW_PROTOCOL_ETH_FLOW_ADDRESS_BY_ENV(COW_ENV), + value, + data, + }; +}; + +export const getRecommendedSlippage = (srcUSD: string) => { + try { + if (Number(srcUSD) <= 0) { + return Number(0.5); + } + + if (Number(srcUSD) <= 1) { + return Number(5.0); + } else if (Number(srcUSD) <= 5) { + return Number(2.5); + } else if (Number(srcUSD) <= 10) { + return Number(1.5); + } else { + return Number(0.5); + } + } catch (e) { + return Number(0.5); + } +}; + +export const uploadAppData = async (orderId: string, appDataHex: string, chainId: number) => { + const orderBookApi = new OrderBookApi({ chainId, env: COW_ENV }); + + return orderBookApi.uploadAppData(orderId, appDataHex); +}; + +export const generateCoWExplorerLink = (chainId: SupportedChainId, orderId?: string) => { + if (!orderId) { + return undefined; + } + + const base = 'https://explorer.cow.fi'; + switch (chainId) { + case SupportedChainId.MAINNET: + return `${base}/orders/${orderId}`; + case SupportedChainId.GNOSIS_CHAIN: + return `${base}/gc/orders/${orderId}`; + case SupportedChainId.BASE: + return `${base}/base/orders/${orderId}`; + case SupportedChainId.ARBITRUM_ONE: + return `${base}/arb1/orders/${orderId}`; + case SupportedChainId.SEPOLIA: + return `${base}/sepolia/orders/${orderId}`; + case SupportedChainId.AVALANCHE: + return `${base}/avax/orders/${orderId}`; + case SupportedChainId.POLYGON: + return `${base}/pol/orders/${orderId}`; + case SupportedChainId.BNB: + return `${base}/bnb/orders/${orderId}`; + default: + throw new Error('Define explorer link for chainId: ' + chainId); + } +}; + +export const adjustedBps = (sdkFeeBps: number) => { + const f = sdkFeeBps / 10000; + const effective = f / (1 + f); + return effective * 10000; +}; + +export const getPermitHook = async ({ + tokenAddress, + signatureParams, + estimateGasLimit, + chainId, +}: { + tokenAddress: string; + signatureParams: SignedParams; + estimateGasLimit: (tx: PopulatedTransaction, chainId?: number) => Promise; + chainId: number; +}) => { + // Decode the owner from the stored encoded signature payload if needed + const [owner] = ethers.utils.defaultAbiCoder.decode( + ['address', 'address', 'uint256', 'uint256', 'uint8', 'bytes32', 'bytes32'], + signatureParams.signature + ); + + const iface = new ethers.utils.Interface(EIP_2612_PERMIT_ABI); + const { v, r, s } = signatureParams.splitedSignature; + const spender = signatureParams.approvedToken; // Vault Relayer / adapter address + const value = signatureParams.amount; + const deadline = signatureParams.deadline; + + const callData = iface.encodeFunctionData('permit', [owner, spender, value, deadline, v, r, s]); + + const PERMIT_HOOK_DAPP_ID = 'cow.fi'; + const gasLimit = '80000'; + + const tx: PopulatedTransaction = { + to: tokenAddress, + data: callData, + gasLimit: BigNumber.from(gasLimit), + }; + + const txWithGasEstimation = await estimateGasLimit(tx, chainId); + + return { + target: txWithGasEstimation.to, + callData: txWithGasEstimation.data, + gasLimit: txWithGasEstimation.gasLimit?.toString(), + dappId: PERMIT_HOOK_DAPP_ID, + }; +}; + +// This function is used to get the slippage suggestion for a token pair on the respective chain based on the pair volatility. +export const getSlippageSuggestion = async ( + request: SlippageToleranceRequest +): Promise => { + const { sellToken, buyToken } = request; + + try { + if (request.chainId && sellToken && buyToken) { + const chainSlug = request.chainId; // e.g., 42161 for Arbitrum + const sell = sellToken.toLowerCase(); + const buy = buyToken.toLowerCase(); + const url = `https://bff.cow.fi/${chainSlug}/markets/${sell}-${buy}/slippageTolerance`; + + const res = await fetch(url); + + if (res.ok) { + const result = await res.json(); + // The endpoint returns { slippageBps: number } + // This is expected by the CoW SDK within the Slippage logic. + return result; + } + } + } catch (e) { + console.error('Error fetching slippage suggestion:', e); + return { slippageBps: 0 }; + } + + return { slippageBps: 0 }; +}; + +export const addOrderTypeToAppData = ( + orderType: OrderType, + appData?: AppDataParams +): AppDataParams => { + return { + ...appData, + metadata: { + ...appData?.metadata, + orderClass: { + orderClass: orderType === OrderType.LIMIT ? OrderClass.LIMIT : OrderClass.MARKET, + }, + }, + }; +}; diff --git a/src/components/transactions/Swap/helpers/cow/rates.helpers.ts b/src/components/transactions/Swap/helpers/cow/rates.helpers.ts new file mode 100644 index 0000000000..695ba80609 --- /dev/null +++ b/src/components/transactions/Swap/helpers/cow/rates.helpers.ts @@ -0,0 +1,275 @@ +import { ChainId } from '@aave/contract-helpers'; +import { OrderKind, QuoteAndPost, WRAPPED_NATIVE_CURRENCIES } from '@cowprotocol/cow-sdk'; +import { BigNumber } from 'bignumber.js'; +import { getEthersProvider } from 'src/libs/web3-data-provider/adapters/EthersAdapter'; +import { CoWProtocolPricesService } from 'src/services/CoWProtocolPricesService'; +import { FamilyPricesService } from 'src/services/FamilyPricesService'; +import { getErrorTextFromError, TxAction } from 'src/ui-config/errorMapping'; +import { wagmiConfig } from 'src/ui-config/wagmiConfig'; +import { getNetworkConfig } from 'src/utils/marketsAndNetworksConfig'; + +import { COW_PARTNER_FEE, isChainIdSupportedByCoWProtocol } from '../../constants/cow.constants'; +import { isNativeToken } from '../../helpers/cow'; +import { CowProtocolRatesType, ProviderRatesParams, SwapProvider } from '../../types'; +import { getAppDataForQuote } from './adapters.helpers'; +import { getCowTradingSdkByChainIdAndAppCode } from './env.helpers'; +import { getSlippageSuggestion } from './orders.helpers'; + +export const getTokenUsdPrice = async ( + chainId: number, + tokenAddress: string, + isTokenCustom: boolean, + isMainnet: boolean +) => { + const cowProtocolPricesService = new CoWProtocolPricesService(); + const familyPricesService = new FamilyPricesService(); + + try { + let price; + + if (!isTokenCustom && isMainnet) { + price = await familyPricesService.getTokenUsdPrice(chainId, tokenAddress); + } + + if (price) { + return price; + } + + return await cowProtocolPricesService.getTokenUsdPrice(chainId, tokenAddress); + } catch (familyError) { + console.error(familyError); + return undefined; + } +}; + +export async function getCowProtocolSellRates({ + chainId, + amount, + srcToken, + srcDecimals, + destToken, + destDecimals, + user, + swapType, + inputSymbol, + outputSymbol, + isInputTokenCustom, + isOutputTokenCustom, + appCode, + setError, + side = 'sell', + invertedQuoteRoute = false, +}: ProviderRatesParams): Promise { + const tradingSdk = await getCowTradingSdkByChainIdAndAppCode(chainId, appCode); + let orderBookQuote: QuoteAndPost | undefined; + let srcTokenPriceUsd: string | undefined; + let destTokenPriceUsd: string | undefined; + try { + if (!isChainIdSupportedByCoWProtocol(chainId)) { + throw new Error('Chain not supported by CowProtocol'); + } + + // If srcToken is native, we need to use the wrapped token for the quote + let srcTokenWrapped = srcToken; + if (isNativeToken(srcToken)) { + srcTokenWrapped = WRAPPED_NATIVE_CURRENCIES[chainId].address; + } + + let destTokenWrapped = destToken; + if (isNativeToken(destToken)) { + destTokenWrapped = WRAPPED_NATIVE_CURRENCIES[chainId].address; + } + + const provider = await getEthersProvider(wagmiConfig, { chainId }); + const signer = provider?.getSigner(); + const isMainnet = + !getNetworkConfig(chainId as unknown as ChainId).isTestnet && + !getNetworkConfig(chainId as unknown as ChainId).isFork; + + if (!inputSymbol || !outputSymbol) { + throw new Error('No input or output symbol provided'); + } + + [orderBookQuote, srcTokenPriceUsd, destTokenPriceUsd] = await Promise.all([ + tradingSdk + .getQuote( + { + owner: user as `0x${string}`, + kind: side === 'buy' ? OrderKind.BUY : OrderKind.SELL, + amount, + sellToken: srcTokenWrapped, + sellTokenDecimals: srcDecimals, + buyToken: destTokenWrapped, + buyTokenDecimals: destDecimals, + signer, + appCode: appCode, + partnerFee: COW_PARTNER_FEE(inputSymbol, outputSymbol), + }, + { + // Price Quality is set to OPTIMAL by default + appData: await getAppDataForQuote({ + user, + type: swapType, + chainId, + amount, + srcToken, + srcDecimals, + destToken, + destDecimals, + }), + getSlippageSuggestion, + } + ) + .catch((cowError) => { + console.error(cowError); + throw new Error(cowError?.body?.errorType); + }), + getTokenUsdPrice(chainId, srcTokenWrapped, isInputTokenCustom ?? false, isMainnet), + getTokenUsdPrice(chainId, destTokenWrapped, isOutputTokenCustom ?? false, isMainnet), + ]); + + if (!srcTokenPriceUsd || !destTokenPriceUsd) { + console.error('No price found for token'); + const error = getErrorTextFromError( + new Error('No price found for token, please try another token'), + TxAction.MAIN_ACTION, + true + ); + setError?.(error); + console.error(error); + throw new Error('No price found for token, please try another token'); + } + } catch (error) { + console.error('generate error', error); + setError?.({ + error, + blocking: true, + actionBlocked: true, + rawError: error, + txAction: TxAction.MAIN_ACTION, + }); + + throw error; + } + + if (!orderBookQuote.quoteResults.suggestedSlippageBps) { + console.error('No suggested slippage found'); + const error = getErrorTextFromError( + new Error('No suggested slippage found'), + TxAction.MAIN_ACTION, + true + ); + setError?.(error); + console.error(error); + throw new Error('No suggested slippage found'); + } + + if (!orderBookQuote.quoteResults.amountsAndCosts.afterPartnerFees.buyAmount) { + console.error('No buy amount found'); + const error = getErrorTextFromError( + new Error('No buy amount found'), + TxAction.MAIN_ACTION, + true + ); + setError?.(error); + console.error(error); + throw new Error('No buy amount found'); + } + + let suggestedSlippage = (orderBookQuote.quoteResults.suggestedSlippageBps ?? 100) / 100; // E.g. 100 bps -> 1% 100 / 100 = 1 + + if (isNativeToken(srcToken)) { + // Recommended by CoW for potential reimbursments + if (chainId == 1 && suggestedSlippage < 2) { + suggestedSlippage = 2; + } else if (chainId != 1 && suggestedSlippage < 0.5) { + suggestedSlippage = 0.5; + } + } + + if (invertedQuoteRoute) { + // Calculate Amounts + const srcSpotAmount = + side === 'sell' + ? orderBookQuote.quoteResults.amountsAndCosts.beforeNetworkCosts.buyAmount.toString() + : orderBookQuote.quoteResults.amountsAndCosts.afterNetworkCosts.buyAmount.toString(); + const srcSpotUSD = BigNumber(destTokenPriceUsd) + .multipliedBy(BigNumber(srcSpotAmount).dividedBy(10 ** destDecimals)) + .toString(); + const destSpotAmount = + side === 'sell' + ? orderBookQuote.quoteResults.amountsAndCosts.beforeNetworkCosts.sellAmount.toString() + : orderBookQuote.quoteResults.amountsAndCosts.afterNetworkCosts.sellAmount.toString(); + const destSpotUSD = BigNumber(srcTokenPriceUsd) + .multipliedBy(BigNumber(destSpotAmount).dividedBy(10 ** srcDecimals)) + .toString(); + const afterFeesAmount = + orderBookQuote.quoteResults.amountsAndCosts.afterPartnerFees.sellAmount.toString(); + const afterFeesUSD = BigNumber(srcTokenPriceUsd) + .multipliedBy(BigNumber(afterFeesAmount).dividedBy(10 ** srcDecimals)) + .toString(); + + return { + srcToken: destToken, + srcSpotUSD, + srcSpotAmount: srcSpotAmount, + srcDecimals: destDecimals, + destToken: srcToken, + destSpotAmount, + destSpotUSD, + afterFeesUSD, + afterFeesAmount, + destDecimals: srcDecimals, + orderBookQuote, + provider: SwapProvider.COW_PROTOCOL, + order: orderBookQuote.quoteResults.orderToSign, + quoteId: orderBookQuote.quoteResults.quoteResponse.id, + suggestedSlippage, + amountAndCosts: orderBookQuote.quoteResults.amountsAndCosts, + srcTokenPriceUsd: Number(destTokenPriceUsd), + destTokenPriceUsd: Number(srcTokenPriceUsd), + }; + } else { + // Calculate Amounts + const srcSpotAmount = + orderBookQuote.quoteResults.orderToSign.kind === OrderKind.SELL + ? orderBookQuote.quoteResults.amountsAndCosts.afterNetworkCosts.sellAmount.toString() + : orderBookQuote.quoteResults.amountsAndCosts.beforeNetworkCosts.sellAmount.toString(); + const srcSpotUSD = BigNumber(srcTokenPriceUsd) + .multipliedBy(BigNumber(srcSpotAmount).dividedBy(10 ** srcDecimals)) + .toString(); + const destSpotAmount = + orderBookQuote.quoteResults.orderToSign.kind === OrderKind.SELL + ? orderBookQuote.quoteResults.amountsAndCosts.beforeNetworkCosts.buyAmount.toString() + : orderBookQuote.quoteResults.amountsAndCosts.afterNetworkCosts.buyAmount.toString(); + const destSpotUSD = BigNumber(destTokenPriceUsd) + .multipliedBy(BigNumber(destSpotAmount).dividedBy(10 ** destDecimals)) + .toString(); + const afterFeesAmount = + orderBookQuote.quoteResults.amountsAndCosts.afterPartnerFees.buyAmount.toString(); + const afterFeesUSD = BigNumber(destTokenPriceUsd) + .multipliedBy(BigNumber(afterFeesAmount).dividedBy(10 ** destDecimals)) + .toString(); + + return { + srcToken, + srcSpotUSD, + srcSpotAmount, + srcDecimals, + destToken, + destSpotAmount, + destSpotUSD, + afterFeesUSD, + afterFeesAmount, + destDecimals, + orderBookQuote, + provider: SwapProvider.COW_PROTOCOL, + order: orderBookQuote.quoteResults.orderToSign, + quoteId: orderBookQuote.quoteResults.quoteResponse.id, + suggestedSlippage, + amountAndCosts: orderBookQuote.quoteResults.amountsAndCosts, + srcTokenPriceUsd: Number(srcTokenPriceUsd), + destTokenPriceUsd: Number(destTokenPriceUsd), + }; + } +} diff --git a/src/components/transactions/Swap/helpers/gasEstimation.helpers.ts b/src/components/transactions/Swap/helpers/gasEstimation.helpers.ts new file mode 100644 index 0000000000..d347b67066 --- /dev/null +++ b/src/components/transactions/Swap/helpers/gasEstimation.helpers.ts @@ -0,0 +1,151 @@ +import { APPROVAL_GAS_LIMIT } from 'src/components/transactions/utils'; +import { TxStateType } from 'src/hooks/useModal'; + +import { COW_PROTOCOL_GAS_LIMITS } from '../constants/cow.constants'; +import { PARASWAP_GAS_LIMITS } from '../constants/paraswap.constants'; +import { SwapProvider, SwapType, TokenType } from '../types'; + +// Helper function to check if token is native +const isNativeToken = (address: string): boolean => { + return ( + address === '0x0000000000000000000000000000000000000000' || + address === '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' + ); +}; + +export interface GasEstimationParams { + swapType: SwapType; + provider: SwapProvider; + sourceToken: { addressToSwap: string; tokenType: TokenType }; + userIsSmartContractWallet: boolean; + requiresApproval: boolean; + requiresApprovalReset: boolean; + approvalTxState: TxStateType; + useFlashloan: boolean; + usePermit: boolean; +} + +export interface GasEstimationResult { + gasLimit: string; + showGasStation: boolean; + breakdown: { + baseGas: number; + approvalGas: number; + resetApprovalGas: number; + total: number; + }; +} + +/** + * Centralized gas estimation logic for all swap types and providers + */ +export const estimateSwapGas = (params: GasEstimationParams): GasEstimationResult => { + const { + swapType, + provider, + sourceToken, + userIsSmartContractWallet, + requiresApproval, + requiresApprovalReset, + approvalTxState, + useFlashloan, + usePermit, + } = params; + + let baseGas = 0; + let approvalGas = 0; + let resetApprovalGas = 0; + let showGasStation = false; + // Drastically reduced version of base gas estimation + if (provider === SwapProvider.PARASWAP) { + baseGas = PARASWAP_GAS_LIMITS[swapType] ?? 0; + showGasStation = true; + } else if (provider === SwapProvider.COW_PROTOCOL) { + const isEthNativeSwap = isNativeToken(sourceToken.addressToSwap); + if ( + (swapType === SwapType.Swap && (isEthNativeSwap || userIsSmartContractWallet)) || + (swapType === SwapType.CollateralSwap && !useFlashloan) + ) { + baseGas = COW_PROTOCOL_GAS_LIMITS[swapType] ?? 0; + showGasStation = true; + } else { + baseGas = 0; + showGasStation = false; + } + } else { + baseGas = 0; + showGasStation = false; + } + + // Add approval gas if needed + if (requiresApproval && !approvalTxState.success && !usePermit) { + approvalGas = Number(APPROVAL_GAS_LIMIT); + showGasStation = true; + } + + // Add reset approval gas if needed + if (requiresApprovalReset && !usePermit) { + resetApprovalGas = Number(APPROVAL_GAS_LIMIT); + showGasStation = true; + } + + const total = baseGas + approvalGas + resetApprovalGas; + + return { + gasLimit: total.toString(), + showGasStation, + breakdown: { + baseGas, + approvalGas, + resetApprovalGas, + total, + }, + }; +}; + +/** + * Determines if a swap requires gas based on provider and token types + */ +export const shouldShowGasStation = ( + provider: SwapProvider, + sourceToken: { addressToSwap: string; tokenType: TokenType }, + userIsSmartContractWallet: boolean, + requiresApproval: boolean +): boolean => { + // Always show gas station for Paraswap + if (provider === SwapProvider.PARASWAP) { + return true; + } + + // For CoW Protocol, only show gas station for ETH-native swaps or smart contract wallets + if (provider === SwapProvider.COW_PROTOCOL) { + const isEthNativeSwap = isNativeToken(sourceToken.addressToSwap); + return isEthNativeSwap || userIsSmartContractWallet || requiresApproval; + } + + // For other providers, show gas station if approval is required + return requiresApproval; +}; + +/** + * Gets gas estimation for native token swaps (ETH, MATIC, etc.) + */ +export const getNativeTokenGasEstimation = ( + chainId: number, + tokenType: TokenType +): { gasRequired: string; showWarning: boolean } => { + // Different gas requirements for different chains + const gasRequirements = { + 1: '0.01', // Ethereum mainnet + 137: '0.001', // Polygon + 42161: '0.001', // Arbitrum + 10: '0.001', // Optimism + 56: '0.001', // BSC + 43114: '0.001', // Avalanche + }; + + const gasRequired = gasRequirements[chainId as keyof typeof gasRequirements] || '0.001'; + const showWarning = tokenType === TokenType.NATIVE; + + return { gasRequired, showWarning }; +}; diff --git a/src/components/transactions/Swap/helpers/paraswap/flashloan.helpers.ts b/src/components/transactions/Swap/helpers/paraswap/flashloan.helpers.ts new file mode 100644 index 0000000000..1bb122358c --- /dev/null +++ b/src/components/transactions/Swap/helpers/paraswap/flashloan.helpers.ts @@ -0,0 +1,46 @@ +import { valueToBigNumber } from '@aave/math-utils'; + +import { PARASWAP_FLASH_LOAN_FEE_BPS } from '../../constants/paraswap.constants'; +import { SwapProvider, SwapState, SwapType } from '../../types'; + +/** + * Calculate flashloan fee amount for Paraswap adapter swaps. + * The fee is 0.05% (5 bps) of the flashloan amount, which is the sell amount. + * + * @param state - Swap state + * @returns Object containing flashloan fee amount in bigint and formatted string + */ +export const calculateParaswapFlashLoanFee = ( + state: SwapState +): { + flashLoanFeeAmount: bigint; + flashLoanFeeFormatted: string; +} => { + // Only calculate fee for protocol swaps using Paraswap with flashloan + if ( + state.swapType === SwapType.Swap || + state.provider !== SwapProvider.PARASWAP || + !state.useFlashloan || + !state.sellAmountBigInt + ) { + return { + flashLoanFeeAmount: BigInt(0), + flashLoanFeeFormatted: '0', + }; + } + + // Calculate fee: flashloan amount * fee bps / 10000 + // The flashloan amount is the sell amount (collateral being swapped) + const flashLoanFeeAmount = + (state.sellAmountBigInt * BigInt(PARASWAP_FLASH_LOAN_FEE_BPS)) / BigInt(10000); + + // Format the fee amount + const flashLoanFeeFormatted = valueToBigNumber(flashLoanFeeAmount.toString()) + .dividedBy(valueToBigNumber(10).pow(state.sellAmountToken?.decimals ?? 18)) + .toString(); + + return { + flashLoanFeeAmount, + flashLoanFeeFormatted, + }; +}; diff --git a/src/components/transactions/Swap/helpers/paraswap/index.ts b/src/components/transactions/Swap/helpers/paraswap/index.ts new file mode 100644 index 0000000000..3b4e920c50 --- /dev/null +++ b/src/components/transactions/Swap/helpers/paraswap/index.ts @@ -0,0 +1,3 @@ +export * from './misc.helpers'; +export * from './order.helpers'; +export * from './rates.helpers'; diff --git a/src/components/transactions/Swap/helpers/paraswap/misc.helpers.ts b/src/components/transactions/Swap/helpers/paraswap/misc.helpers.ts new file mode 100644 index 0000000000..b7a2cb7793 --- /dev/null +++ b/src/components/transactions/Swap/helpers/paraswap/misc.helpers.ts @@ -0,0 +1,23 @@ +import { SwapType } from '../../types/shared.types'; +import { getAssetGroup } from '../shared/assetCorrelation.helpers'; + +export const getParaswapSlippage = ( + inputSymbol: string, + outputSymbol: string, + swapType: SwapType +): string => { + const inputGroup = getAssetGroup(inputSymbol); + const outputGroup = getAssetGroup(outputSymbol); + + const baseSlippage = inputGroup === outputGroup ? '0.10' : '0.20'; + + if (swapType === SwapType.DebtSwap) { + return (Number(baseSlippage) * 2).toString(); + } + + if (swapType === SwapType.RepayWithCollateral) { + return (Number(baseSlippage) * 5).toString(); + } + + return baseSlippage; +}; diff --git a/src/components/transactions/Swap/helpers/paraswap/order.helpers.ts b/src/components/transactions/Swap/helpers/paraswap/order.helpers.ts new file mode 100644 index 0000000000..25e3fa0b4e --- /dev/null +++ b/src/components/transactions/Swap/helpers/paraswap/order.helpers.ts @@ -0,0 +1,80 @@ +import { SignatureLike } from '@ethersproject/bytes'; +import { BoxProps } from '@mui/material'; +import { OptimalRate, TransactionParams } from '@paraswap/sdk'; +import { ComputedReserveData } from 'src/hooks/app-data-provider/useAppDataProvider'; +import { getParaswap } from 'src/hooks/paraswap/common'; + +import { SwapKind } from '../../types'; + +export const getTransactionParams = async ( + kind: SwapKind, + chainId: number, + srcToken: string, + srcDecimals: number, + destToken: string, + destDecimals: number, + user: string, + route: OptimalRate, + maxSlippage: number +) => { + const { paraswap, feeTarget } = getParaswap(chainId); + + try { + const params = await paraswap.buildTx( + { + srcToken, + destToken, + ...(kind === 'buy' ? { destAmount: route.destAmount } : { srcAmount: route.srcAmount }), + slippage: maxSlippage * 100, + priceRoute: route, + userAddress: user, + partnerAddress: feeTarget, + srcDecimals, + destDecimals, + isDirectFeeTransfer: true, + takeSurplus: true, + }, + { ignoreChecks: true } + ); + + return { + swapCallData: (params as TransactionParams).data, + augustus: (params as TransactionParams).to, + }; + } catch (e) { + console.error(e, { + srcToken, + destToken, + ...(kind === 'buy' ? { destAmount: route.destAmount } : { srcAmount: route.srcAmount }), + priceRoute: route, + userAddress: user, + partnerAddress: feeTarget, + takeSurplus: true, + slippage: maxSlippage * 100, + srcDecimals, + }); + throw new Error('Error building transaction parameters'); + } +}; + +export interface SwapBaseProps extends BoxProps { + amountToSwap: string; + amountToReceive: string; + poolReserve: ComputedReserveData; + targetReserve: ComputedReserveData; + isWrongNetwork: boolean; + customGasPrice?: string; + symbol: string; + blocked: boolean; + isMaxSelected: boolean; + useFlashLoan: boolean; + loading?: boolean; + signature?: SignatureLike; + deadline?: string; + signedAmount?: string; +} + +export interface SwapActionProps extends SwapBaseProps { + swapCallData: string; + augustus: string; +} diff --git a/src/components/transactions/Swap/helpers/paraswap/rates.helpers.ts b/src/components/transactions/Swap/helpers/paraswap/rates.helpers.ts new file mode 100644 index 0000000000..251c952130 --- /dev/null +++ b/src/components/transactions/Swap/helpers/paraswap/rates.helpers.ts @@ -0,0 +1,98 @@ +import { valueToBigNumber } from '@aave/math-utils'; +import { OptimalRate, SwapSide } from '@paraswap/sdk'; +import { constants } from 'ethers'; + +import { getParaswap } from '../../../../../hooks/paraswap/common'; +import { ParaswapRatesType, ProviderRatesParams, SwapProvider } from '../../types'; + +export async function getParaswapSellRates({ + chainId, + amount, + srcToken, + srcDecimals, + destToken, + destDecimals, + user, + side = 'sell', + options = {}, + invertedQuoteRoute = false, +}: ProviderRatesParams): Promise { + const { paraswap } = getParaswap(chainId); + return paraswap + .getRate({ + amount, + srcToken, + srcDecimals, + destToken, + destDecimals, + userAddress: user ? user : constants.AddressZero, + side: side === 'buy' ? SwapSide.BUY : SwapSide.SELL, + options: { + ...options, + includeContractMethods: [ + // side === "buy" ? ContractMethod.swapExactAmountIn : ContractMethod.swapExactAmountOut, + ], + excludeDEXS: [ + 'ParaSwapPool', + 'ParaSwapLimitOrders', + 'SwaapV2', + 'Hashflow', + 'Dexalot', + 'Bebop', + ], + }, + }) + .then((paraSwapResponse: OptimalRate) => { + if (invertedQuoteRoute) { + return { + srcToken: destToken, + srcSpotUSD: paraSwapResponse.destUSD, + srcSpotAmount: paraSwapResponse.destAmount, + srcTokenPriceUsd: Number( + valueToBigNumber(paraSwapResponse.destUSD) + .dividedBy(paraSwapResponse.destAmount) + .toString() + ), + srcDecimals: destDecimals, + destToken: srcToken, + destSpotUSD: paraSwapResponse.srcUSD, + destSpotAmount: paraSwapResponse.srcAmount, + destTokenPriceUsd: Number( + valueToBigNumber(paraSwapResponse.srcUSD) + .dividedBy(paraSwapResponse.srcAmount) + .toString() + ), + afterFeesUSD: paraSwapResponse.srcUSD, + afterFeesAmount: paraSwapResponse.srcAmount, + destDecimals: srcDecimals, + provider: SwapProvider.PARASWAP, + optimalRateData: paraSwapResponse, + }; + } else { + return { + srcToken, + srcSpotUSD: paraSwapResponse.srcUSD, + srcSpotAmount: paraSwapResponse.srcAmount, + srcTokenPriceUsd: Number( + valueToBigNumber(paraSwapResponse.srcUSD) + .dividedBy(paraSwapResponse.srcAmount) + .toString() + ), + srcDecimals, + destToken, + destSpotUSD: paraSwapResponse.destUSD, + destSpotAmount: paraSwapResponse.destAmount, + destTokenPriceUsd: Number( + valueToBigNumber(paraSwapResponse.destUSD) + .dividedBy(paraSwapResponse.destAmount) + .toString() + ), + afterFeesUSD: paraSwapResponse.destUSD, + afterFeesAmount: paraSwapResponse.destAmount, + destDecimals, + provider: SwapProvider.PARASWAP, + optimalRateData: paraSwapResponse, + }; + } + }); +} diff --git a/src/components/transactions/Switch/assetCorrelation.helpers.ts b/src/components/transactions/Swap/helpers/shared/assetCorrelation.helpers.ts similarity index 100% rename from src/components/transactions/Switch/assetCorrelation.helpers.ts rename to src/components/transactions/Swap/helpers/shared/assetCorrelation.helpers.ts diff --git a/src/components/transactions/Swap/helpers/shared/index.ts b/src/components/transactions/Swap/helpers/shared/index.ts new file mode 100644 index 0000000000..6e1c7b38f7 --- /dev/null +++ b/src/components/transactions/Swap/helpers/shared/index.ts @@ -0,0 +1,5 @@ +export * from './assetCorrelation.helpers'; +export * from './invalidation.helpers'; +export * from './misc.helpers'; +export * from './provider.helpers'; +export * from './slippage.helpers'; diff --git a/src/components/transactions/Swap/helpers/shared/invalidation.helpers.ts b/src/components/transactions/Swap/helpers/shared/invalidation.helpers.ts new file mode 100644 index 0000000000..406018bf13 --- /dev/null +++ b/src/components/transactions/Swap/helpers/shared/invalidation.helpers.ts @@ -0,0 +1,89 @@ +import { QueryClient } from '@tanstack/react-query'; +import { findByChainId, MarketDataType } from 'src/ui-config/marketsConfig'; +import { queryKeysFactory } from 'src/ui-config/queries'; + +import { SwapType } from '../../types'; + +export const invalidateAppStateForSwap = ({ + swapType, + chainId, + account, + queryClient, +}: { + swapType: SwapType; + chainId: number; + account: string; + queryClient: QueryClient; +}) => { + const marketDataType = findByChainId(chainId); + + if (!marketDataType) { + return; + } + + switch (swapType) { + case SwapType.Swap: + invalidateUserBalances({ account, queryClient, marketDataType }); + invalidateTransactionHistory({ account, queryClient, marketDataType }); + break; + case SwapType.CollateralSwap: + invalidateUserPoolBalances({ account, queryClient, marketDataType }); + invalidateTransactionHistory({ account, queryClient, marketDataType }); + break; + case SwapType.DebtSwap: + invalidateUserPoolBalances({ account, queryClient, marketDataType }); + invalidateTransactionHistory({ account, queryClient, marketDataType }); + break; + case SwapType.RepayWithCollateral: + invalidateUserPoolBalances({ account, queryClient, marketDataType }); + invalidateTransactionHistory({ account, queryClient, marketDataType }); + break; + case SwapType.WithdrawAndSwap: + invalidateUserBalances({ account, queryClient, marketDataType }); + invalidateUserPoolBalances({ account, queryClient, marketDataType }); + invalidateTransactionHistory({ account, queryClient, marketDataType }); + break; + } +}; + +const invalidateUserBalances = ({ + account, + queryClient, + marketDataType, +}: { + account: string; + queryClient: QueryClient; + marketDataType: MarketDataType; +}) => { + queryClient.invalidateQueries({ + queryKey: queryKeysFactory.poolTokens(account, marketDataType), + }); +}; + +const invalidateUserPoolBalances = ({ + account, + queryClient, + marketDataType, +}: { + account: string; + queryClient: QueryClient; + marketDataType: MarketDataType; +}) => { + queryClient.invalidateQueries({ + queryKey: queryKeysFactory.userPoolReservesDataHumanized(account, marketDataType), + }); +}; + +const invalidateTransactionHistory = ({ + account, + queryClient, + marketDataType, +}: { + account: string; + queryClient: QueryClient; + marketDataType: MarketDataType; +}) => { + queryClient.invalidateQueries({ + queryKey: queryKeysFactory.transactionHistory(account, marketDataType), + }); +}; diff --git a/src/components/transactions/Switch/common.ts b/src/components/transactions/Swap/helpers/shared/misc.helpers.ts similarity index 89% rename from src/components/transactions/Switch/common.ts rename to src/components/transactions/Swap/helpers/shared/misc.helpers.ts index 2f173d7ab2..45631d7535 100644 --- a/src/components/transactions/Switch/common.ts +++ b/src/components/transactions/Swap/helpers/shared/misc.helpers.ts @@ -15,9 +15,11 @@ export const supportedNetworksConfig: SupportedNetworkWithChainId[] = getSupport chainId, }) ); + +// TODO: join and make sure at least one provider supports it export const supportedNetworksWithEnabledMarket = supportedNetworksConfig.filter((elem) => Object.values(marketsData).find( - (market) => market.chainId === elem.chainId && market.enabledFeatures?.switch + (market) => market.chainId === elem.chainId && market.enabledFeatures?.switch // TODO: change to swap ) ); diff --git a/src/components/transactions/Swap/helpers/shared/provider.helpers.ts b/src/components/transactions/Swap/helpers/shared/provider.helpers.ts new file mode 100644 index 0000000000..181c5ab2d9 --- /dev/null +++ b/src/components/transactions/Swap/helpers/shared/provider.helpers.ts @@ -0,0 +1,76 @@ +import { + COW_UNSUPPORTED_ASSETS, + isChainIdSupportedByCoWProtocol, +} from '../../constants/cow.constants'; +import { SwapProvider, SwapType } from '../../types'; + +/** + * Returns whether CoW Protocol can handle the given pair/swapType on the chain. + * Checks chain support and a per-flow unsupported assets list. + */ +export const isSwapSupportedByCowProtocol = ( + chainId: number, + assetFrom: string, + assetTo: string, + swapType: SwapType, + useFlashloan: boolean +) => { + if (!isChainIdSupportedByCoWProtocol(chainId)) return false; + + let swapTypeToUse = swapType; + if (useFlashloan == false && swapType === SwapType.CollateralSwap) { + swapTypeToUse = SwapType.Swap; + } + + // Helper to normalize values that can be string[] or 'ALL' to always be an array + const normalizeToArray = (value: string[] | 'ALL' | undefined): string[] => { + if (!value) return []; + if (value === 'ALL') return ['ALL']; + return value; + }; + + const unsupportedAssetsPerChainAndModalType = [ + ...normalizeToArray(COW_UNSUPPORTED_ASSETS['ALL']?.[chainId]), + ...normalizeToArray(COW_UNSUPPORTED_ASSETS[swapTypeToUse]?.[chainId]), + ]; + + if (unsupportedAssetsPerChainAndModalType.length === 0) return true; // No unsupported assets for this chain and modal type + + if (unsupportedAssetsPerChainAndModalType.includes('ALL')) return false; // All assets are unsupported + + if ( + unsupportedAssetsPerChainAndModalType.includes(assetFrom.toLowerCase()) || + unsupportedAssetsPerChainAndModalType.includes(assetTo.toLowerCase()) + ) + return false; + + return true; +}; + +/** + * Picks the provider for the current swap based on chain, assets and flow. + * + * Notes: + * - CoW is preferred when supported; fallback to ParaSwap + */ +export const getSwitchProvider = ({ + chainId, + assetFrom, + assetTo, + shouldUseFlashloan, + swapType, +}: { + chainId: number; + assetFrom: string; + assetTo: string; + shouldUseFlashloan?: boolean; + swapType: SwapType; +}): SwapProvider | undefined => { + if ( + isSwapSupportedByCowProtocol(chainId, assetFrom, assetTo, swapType, shouldUseFlashloan ?? false) + ) { + return SwapProvider.COW_PROTOCOL; + } + + return SwapProvider.PARASWAP; +}; diff --git a/src/components/transactions/Switch/validation.helpers.ts b/src/components/transactions/Swap/helpers/shared/slippage.helpers.ts similarity index 96% rename from src/components/transactions/Switch/validation.helpers.ts rename to src/components/transactions/Swap/helpers/shared/slippage.helpers.ts index fa3e5d3d21..d0c9d3f6df 100644 --- a/src/components/transactions/Switch/validation.helpers.ts +++ b/src/components/transactions/Swap/helpers/shared/slippage.helpers.ts @@ -1,4 +1,4 @@ -import { SwitchProvider } from './switch.types'; +import { SwapProvider } from '../../types'; export enum ValidationSeverity { ERROR = 'error', @@ -14,7 +14,7 @@ export const validateSlippage = ( slippage: string, chainId: number, isNativeToken = false, - provider?: SwitchProvider + provider?: SwapProvider ): ValidationData | undefined => { try { const numberSlippage = Number(slippage); diff --git a/src/components/transactions/Swap/hooks/useFlowSelector.ts b/src/components/transactions/Swap/hooks/useFlowSelector.ts new file mode 100644 index 0000000000..0330d32c5f --- /dev/null +++ b/src/components/transactions/Swap/hooks/useFlowSelector.ts @@ -0,0 +1,228 @@ +import { ComputedUserReserve, valueToBigNumber } from '@aave/math-utils'; +import { Dispatch, useEffect } from 'react'; +import { + ComputedReserveData, + ExtendedFormattedUser, + useAppDataContext, +} from 'src/hooks/app-data-provider/useAppDataProvider'; +import { calculateHFAfterSwap, CalculateHFAfterSwapProps } from 'src/utils/hfUtils'; + +import { + LIQUIDATION_DANGER_THRESHOLD, + LIQUIDATION_SAFETY_THRESHOLD, +} from '../constants/shared.constants'; +import { + ActionsBlockedReason, + isProtocolSwapState, + SwapParams, + SwapProvider, + SwapState, + SwapType, +} from '../types'; + +/** + * React hook that decides the execution flow (simple vs flashloan) and + * computes health-factor effects for protocol-aware swaps. + * + * Writes derived flags into SwapState: isHFLow, isLiquidatable, useFlashloan, + * and marks the flow as selected once enough context is present. + */ +export const useFlowSelector = ({ + params, + state, + setState, +}: { + params: SwapParams; + state: SwapState; + setState: Dispatch>; +}) => { + const { user: extendedUser, reserves } = useAppDataContext(); + const requiresInvertedQuote = state.isInvertedSwap; + + useEffect(() => { + if (params.swapType === SwapType.Swap) { + // For non positions swaps, set isSwapFlowSelected to true + setState({ isSwapFlowSelected: true }); + } else { + return healthFactorSensibleSwapFlowSelector({ + params, + state, + setState, + extendedUser, + reserves, + requiresInvertedQuote, + }); + } + }, [ + params.swapType, + state.sourceToken, + state.destinationToken, + state.inputAmount, + state.outputAmount, + state.sellAmountFormatted, + state.buyAmountFormatted, + extendedUser, + reserves, + state.swapRate, + ]); +}; + +/** + * Pure helper that computes HF and determines whether to force flashloan. + */ +export const healthFactorSensibleSwapFlowSelector = ({ + state, + setState, + extendedUser, +}: { + params: SwapParams; + state: SwapState; + setState: Dispatch>; + extendedUser: ExtendedFormattedUser | undefined; + reserves: ComputedReserveData[]; + requiresInvertedQuote: boolean; +}) => { + const fromAssetUserReserve = extendedUser?.userReservesData.find( + (ur) => ur.underlyingAsset.toLowerCase() === state.sourceToken?.underlyingAddress.toLowerCase() + ); + const toAssetUserReserve = extendedUser?.userReservesData.find( + (ur) => + ur.underlyingAsset.toLowerCase() === state.destinationToken?.underlyingAddress.toLowerCase() + ); + + if ( + !fromAssetUserReserve || + !toAssetUserReserve || + !extendedUser || + !state.swapRate || + !state.sellAmountFormatted || + !state.buyAmountFormatted + ) + return; + + if (!isProtocolSwapState(state)) { + return; + } + + // Compute HF effect of withdrawing inputAmount (copied from SwitchModalTxDetails) + const calculateHfEffectOfFromAmount = () => { + try { + if (!state.swapRate) return { hfEffectOfFromAmount: '0', hfAfterSwap: undefined }; + + const params = getHFAfterSwapParamsFromSwapType( + state, + fromAssetUserReserve, + toAssetUserReserve, + extendedUser + ); + + if (!params) return { hfEffectOfFromAmount: '0', hfAfterSwap: undefined }; + + const { hfEffectOfFromAmount, hfAfterSwap } = calculateHFAfterSwap(params); + + return { + hfEffectOfFromAmount: hfEffectOfFromAmount.toString(), + hfAfterSwap: hfAfterSwap.toString(), + }; + } catch { + return { hfEffectOfFromAmount: '0', hfAfterSwap: undefined }; + } + }; + + const { hfEffectOfFromAmount, hfAfterSwap } = calculateHfEffectOfFromAmount(); + + const isHFLow = () => { + if (!hfAfterSwap) return false; + + const hfNumber = valueToBigNumber(hfAfterSwap); + + if (hfNumber.lt(0)) return false; + + return hfNumber.lt(LIQUIDATION_SAFETY_THRESHOLD) && hfNumber.gte(LIQUIDATION_DANGER_THRESHOLD); + }; + + const isLiquidatable = + hfAfterSwap && hfAfterSwap !== '-1' + ? valueToBigNumber(hfAfterSwap).lt(LIQUIDATION_DANGER_THRESHOLD) + : false; + + const forceFlashloanFlow = + state.provider === SwapProvider.COW_PROTOCOL && + (state.swapType === SwapType.RepayWithCollateral || state.swapType === SwapType.DebtSwap); + const useFlashloan = + forceFlashloanFlow || + (extendedUser?.healthFactor !== '-1' && + valueToBigNumber(extendedUser?.healthFactor || 0) + .minus(valueToBigNumber(hfEffectOfFromAmount || 0)) + .lt(LIQUIDATION_SAFETY_THRESHOLD)); + + if (!state.ratesLoading && !!state.provider) { + setState({ + isHFLow: isHFLow(), + isLiquidatable, + hfAfterSwap: Number(hfAfterSwap || '0'), + useFlashloan, + isSwapFlowSelected: true, + actionsBlocked: { + [ActionsBlockedReason.IS_LIQUIDATABLE]: isLiquidatable ? true : undefined, + }, + }); + } +}; + +const getHFAfterSwapParamsFromSwapType = ( + state: SwapState, + fromAssetUserReserve: ComputedUserReserve, + toAssetUserReserve: ComputedUserReserve, + user: ExtendedFormattedUser +): CalculateHFAfterSwapProps | undefined => { + if (!state.sellAmountFormatted || !state.buyAmountFormatted) return undefined; + switch (state.swapType) { + case SwapType.CollateralSwap: + return { + fromAmount: state.sellAmountFormatted.toString(), + toAmountAfterSlippage: state.buyAmountFormatted.toString(), + fromAssetData: state.sourceReserve.reserve, + toAssetData: state.destinationReserve.reserve, + fromAssetUserData: fromAssetUserReserve, + fromAssetType: 'collateral', + toAssetType: 'collateral', + user, + }; + case SwapType.DebtSwap: + return { + fromAmount: state.sellAmountFormatted.toString(), + toAmountAfterSlippage: state.buyAmountFormatted.toString(), + fromAssetData: state.destinationReserve.reserve, + toAssetData: state.sourceReserve.reserve, + fromAssetUserData: toAssetUserReserve, + user, + fromAssetType: 'debt', + toAssetType: 'debt', + }; + case SwapType.RepayWithCollateral: + return { + fromAmount: state.sellAmountFormatted.toString(), + toAmountAfterSlippage: state.buyAmountFormatted.toString(), + fromAssetData: state.destinationReserve.reserve, + toAssetData: state.sourceReserve.reserve, + fromAssetUserData: toAssetUserReserve, + user, + fromAssetType: 'collateral', + toAssetType: 'debt', + }; + case SwapType.WithdrawAndSwap: + return { + fromAmount: state.sellAmountFormatted.toString(), + toAmountAfterSlippage: state.buyAmountFormatted.toString(), + fromAssetData: state.sourceReserve.reserve, + toAssetData: state.destinationReserve.reserve, + fromAssetUserData: fromAssetUserReserve, + fromAssetType: 'collateral', + toAssetType: 'none', + user, + }; + default: + return undefined; + } +}; diff --git a/src/components/transactions/Swap/hooks/useMaxNativeAmount.ts b/src/components/transactions/Swap/hooks/useMaxNativeAmount.ts new file mode 100644 index 0000000000..2a78639c7e --- /dev/null +++ b/src/components/transactions/Swap/hooks/useMaxNativeAmount.ts @@ -0,0 +1,45 @@ +import { normalize } from '@aave/math-utils'; +import { parseUnits } from 'ethers/lib/utils'; +import { Dispatch, useEffect } from 'react'; + +import { SwapParams, SwapState, SwapType, TokenType } from '../types'; + +/** + * Computes the max selectable amount for native-asset sells, leaving gas headroom. + * Applies only to simple token swaps for EOAs; SCWs/Safe and protocol flows ignore it. + */ +export const useMaxNativeAmount = ({ + params, + state, + setState, +}: { + params: SwapParams; + state: SwapState; + setState: Dispatch>; +}) => { + // Eth-Flow requires to leave some assets for gas + const nativeDecimals = 18; + const gasRequiredForEthFlow = + state.chainId === 1 ? parseUnits('0.01', nativeDecimals) : parseUnits('0.0001', nativeDecimals); // TODO: Ask for better value coming from the SDK + + const requiredAssetsLeftForGas = + state.sourceToken.tokenType === TokenType.NATIVE && + !state.userIsSmartContractWallet && + params.swapType === SwapType.Swap + ? gasRequiredForEthFlow + : undefined; + + const maxAmount = (() => { + const balance = parseUnits(state.sourceToken.balance, nativeDecimals); + if (!requiredAssetsLeftForGas) return balance; + return balance.gt(requiredAssetsLeftForGas) ? balance.sub(requiredAssetsLeftForGas) : balance; + })(); + + const maxAmountFormatted = maxAmount + ? normalize(maxAmount.toString(), nativeDecimals).toString() + : undefined; + + useEffect(() => { + setState({ forcedMaxValue: maxAmountFormatted }); + }, [maxAmountFormatted]); +}; diff --git a/src/components/transactions/Swap/hooks/useProtocolReserves.ts b/src/components/transactions/Swap/hooks/useProtocolReserves.ts new file mode 100644 index 0000000000..d01337f21f --- /dev/null +++ b/src/components/transactions/Swap/hooks/useProtocolReserves.ts @@ -0,0 +1,45 @@ +import { Dispatch, useEffect } from 'react'; +import { useAppDataContext } from 'src/hooks/app-data-provider/useAppDataProvider'; + +import { isProtocolSwapParams, SwapParams, SwapState } from '../types'; + +/** + * Resolves `sourceReserve` and `destinationReserve` from the connected user's data + * for protocol-aware flows. Keeps state in sync with token selection changes. + */ +export const useProtocolReserves = ({ + state, + params, + setState, +}: { + state: SwapState; + params: SwapParams; + setState: Dispatch>; +}) => { + const { user } = useAppDataContext(); + + const userReserves = user?.userReservesData; + + useEffect(() => { + if (state.sourceToken && isProtocolSwapParams(params)) { + const reserve = userReserves?.find( + (r) => r.underlyingAsset.toLowerCase() === state.sourceToken.underlyingAddress.toLowerCase() + ); + if (reserve) { + setState({ sourceReserve: reserve }); + } + } + }, [state.sourceToken, userReserves]); + + useEffect(() => { + if (state.destinationToken && isProtocolSwapParams(params)) { + const reserve = userReserves?.find( + (r) => + r.underlyingAsset.toLowerCase() === state.destinationToken.underlyingAddress.toLowerCase() + ); + if (reserve) { + setState({ destinationReserve: reserve }); + } + } + }, [state.destinationToken, userReserves]); +}; diff --git a/src/components/transactions/Swap/hooks/useSlippageSelector.ts b/src/components/transactions/Swap/hooks/useSlippageSelector.ts new file mode 100644 index 0000000000..b4265bed24 --- /dev/null +++ b/src/components/transactions/Swap/hooks/useSlippageSelector.ts @@ -0,0 +1,78 @@ +import { Dispatch, useEffect, useRef } from 'react'; + +import { validateSlippage, ValidationSeverity } from '../helpers/shared/slippage.helpers'; +import { isCowProtocolRates, OrderType, SwapParams, SwapState, TokenType } from '../types'; + +/** +/** + * Hook responsibilities: + * - Synchronizes the slippage value in state with the selected order type (MARKET or LIMIT), restoring previous market slippage as needed. + * - Tracks the last non-zero market slippage to allow restoration when toggling between order types. + * - Triggers slippage warnings for the user if their input value is below the provider's suggested minimum for CoW Protocol swaps. + * - Validates the slippage value and updates related state/validation UI. + * - Keeps UI/validation in sync with both user input and provider hints or requirements. + */ +export const useSlippageSelector = ({ + state, + setState, +}: { + params: SwapParams; + state: SwapState; + setState: Dispatch>; +}) => { + // Track last non-zero market slippage to restore when switching back from LIMIT + const lastMarketSlippageRef = useRef(null); + + // Keep slippage aligned with order type globally + useEffect(() => { + if (state.orderType === OrderType.LIMIT) { + // Remember current market slippage if non-zero before forcing to 0 for limit + if (state.slippage && Number(state.slippage) !== 0) { + lastMarketSlippageRef.current = state.slippage; + } + if (state.slippage !== '0') { + setState({ slippage: '0' }); + } + } else if (state.orderType === OrderType.MARKET) { + // Restore to suggested slippage if available, otherwise last known market slippage, else default 0.10% + const target = lastMarketSlippageRef.current || state.autoSlippage || '0.10'; + if (state.slippage !== target) { + setState({ slippage: target }); + } + } + }, [state.orderType]); + + useEffect(() => { + // Debounce to avoid race condition + const timeout = setTimeout(() => { + setState({ + showSlippageWarning: + isCowProtocolRates(state.swapRate) && + Number(state.slippage) < state.swapRate?.suggestedSlippage, + }); + }, 500); + + return () => clearTimeout(timeout); + }, [state.slippage]); + + useEffect(() => { + if (!state.swapRate) return; + + const slippageValidation = validateSlippage( + state.slippage, + state.chainId, + state.sourceToken.tokenType === TokenType.NATIVE, + state.provider + ); + + const safeSlippage = + slippageValidation && slippageValidation.severity === ValidationSeverity.ERROR + ? 0 + : Number(state.slippage) / 100; + + setState({ + slippageValidation, + safeSlippage, + }); + }, [state.slippage, state.swapRate]); +}; diff --git a/src/components/transactions/Swap/hooks/useSwapGasEstimation.ts b/src/components/transactions/Swap/hooks/useSwapGasEstimation.ts new file mode 100644 index 0000000000..627eb940b8 --- /dev/null +++ b/src/components/transactions/Swap/hooks/useSwapGasEstimation.ts @@ -0,0 +1,86 @@ +import { Dispatch, useEffect, useMemo, useRef } from 'react'; +import { TxStateType } from 'src/hooks/useModal'; +import { useRootStore } from 'src/store/root'; +import { ApprovalMethod } from 'src/store/walletSlice'; +import { useShallow } from 'zustand/react/shallow'; + +import { estimateSwapGas, GasEstimationParams } from '../helpers/gasEstimation.helpers'; +import { SwapState, TokenType } from '../types'; + +/** + * Centralized gas estimation for swap actions. + * + * Normalizes inputs required by provider/flow specific estimators and writes + * only when values change to avoid render loops. + */ +export const useSwapGasEstimation = ({ + state, + setState, + requiresApproval, + requiresApprovalReset, + approvalTxState, +}: { + state: SwapState; + setState: Dispatch>; + requiresApproval: boolean; + requiresApprovalReset: boolean; + approvalTxState: TxStateType; +}) => { + const walletApprovalMethodPreference = useRootStore( + useShallow((store) => store.walletApprovalMethodPreference) + ); + const usePermit = walletApprovalMethodPreference === ApprovalMethod.PERMIT; + + // Memoize gas estimation parameters to prevent unnecessary recalculations + const gasEstimationParams: GasEstimationParams = useMemo( + () => ({ + swapType: state.swapType, + provider: state.provider, + sourceToken: { + addressToSwap: state.sourceToken.addressToSwap, + tokenType: state.sourceToken.tokenType || TokenType.ERC20, + }, + userIsSmartContractWallet: state.userIsSmartContractWallet, + requiresApproval, + requiresApprovalReset, + approvalTxState, + useFlashloan: state.useFlashloan ?? false, + usePermit, + }), + [ + state.swapType, + state.provider, + state.sourceToken.addressToSwap, + state.sourceToken.tokenType, + state.userIsSmartContractWallet, + requiresApproval, + requiresApprovalReset, + approvalTxState.success, + state.useFlashloan, + usePermit, + ] + ); + + // Memoize gas estimation result + const gasEstimation = useMemo(() => estimateSwapGas(gasEstimationParams), [gasEstimationParams]); + + // Use ref to track previous values and prevent unnecessary updates + const previousGasEstimation = useRef<{ gasLimit: string; showGasStation: boolean } | null>(null); + + useEffect(() => { + const currentGasEstimation = { + gasLimit: gasEstimation.gasLimit, + showGasStation: gasEstimation.showGasStation, + }; + + // Only update if the values have actually changed + if ( + !previousGasEstimation.current || + previousGasEstimation.current.gasLimit !== currentGasEstimation.gasLimit || + previousGasEstimation.current.showGasStation !== currentGasEstimation.showGasStation + ) { + setState(currentGasEstimation); + previousGasEstimation.current = currentGasEstimation; + } + }, [gasEstimation.gasLimit, gasEstimation.showGasStation, setState]); +}; diff --git a/src/components/transactions/Swap/hooks/useSwapOrderAmounts.ts b/src/components/transactions/Swap/hooks/useSwapOrderAmounts.ts new file mode 100644 index 0000000000..4696e77cc2 --- /dev/null +++ b/src/components/transactions/Swap/hooks/useSwapOrderAmounts.ts @@ -0,0 +1,327 @@ +import { normalize, normalizeBN, valueToBigNumber } from '@aave/math-utils'; +import { OrderKind } from '@cowprotocol/cow-sdk'; +import { Dispatch, useEffect } from 'react'; + +import { COW_PARTNER_FEE } from '../constants/cow.constants'; +import { + isCowProtocolRates, + OrderType, + SwapParams, + SwapProvider, + SwapState, + SwapType, +} from '../types'; +import { swapTypesThatRequiresInvertedQuote } from './useSwapQuote'; + +const marketOrderKindPerSwapType: Record = { + [SwapType.Swap]: OrderKind.SELL, + [SwapType.CollateralSwap]: OrderKind.SELL, + [SwapType.DebtSwap]: OrderKind.BUY, + [SwapType.RepayWithCollateral]: OrderKind.BUY, + [SwapType.WithdrawAndSwap]: OrderKind.SELL, +}; + +/** + * Computes normalized sell/buy amounts used to build transactions. + * + * Responsibilities: + * - Applies partner fee and user slippage depending on order side and type + * - Handles flows that require inverted quoting (DebtSwap, RepayWithCollateral) + * by swapping token roles: UI(source,destination) -> swap order request(sell,buy) + * - Derives bigint amounts and USD values for details and execution + * - Chooses the correct OrderKind for market orders per swap type + */ +export const useSwapOrderAmounts = ({ + state, + setState, +}: { + params: SwapParams; + state: SwapState; + setState: Dispatch>; +}) => { + useEffect(() => { + if ( + !state.swapRate?.afterFeesAmount || + state.outputAmount == '' || + state.outputAmount == 'NaN' || + state.inputAmount == '' || + state.inputAmount == 'NaN' || + (state.orderType === OrderType.MARKET && state.slippage == undefined) + ) + return; + + // On some swaps, the order is inverted, the required swap behind the operation is from our second input to our first. + // e.g. repay with collateral, we have input Repay and output Available collateral, the required swap is from Available collateral to Repay. + // So we need to invert the order of the tokens and the amounts. + const isInvertedSwap = swapTypesThatRequiresInvertedQuote.includes(state.swapType); + const processedSide = isInvertedSwap ? (state.side === 'sell' ? 'buy' : 'sell') : state.side; + // The default order kind for market order is not always SELL, it depends on the swap type + // e.g. for collateral swap, the default order kind is SELL, for debt swap, the default order kind is BUY + const marketOrderKind = marketOrderKindPerSwapType[state.swapType]; + + let buyAmountFormatted, + sellAmountFormatted, + buyAmountToken, + sellAmountToken, + buyTokenPriceUsd, + sellTokenPriceUsd; + // Track costs to expose them in state (unified across details views) + let networkFeeAmountInSellFormatted = '0'; + let networkFeeAmountInBuyFormatted = '0'; + const partnetFeeBps = + state.provider === SwapProvider.COW_PROTOCOL + ? COW_PARTNER_FEE(state.sourceToken.symbol, state.destinationToken.symbol).volumeBps + : 0; + const partnerFeeAmount = + state.side === 'sell' + ? valueToBigNumber(state.outputAmount).multipliedBy(partnetFeeBps).dividedBy(10000) + : valueToBigNumber(state.inputAmount).multipliedBy(partnetFeeBps).dividedBy(10000); + // const partnerFeeToken = state.side === 'sell' ? state.destinationToken : state.sourceToken; + + if (!isInvertedSwap) { + // on classic swaps, minimum is calculated from the output token and sent amount is from the input token + sellAmountToken = state.sourceToken; + buyAmountToken = state.destinationToken; + sellTokenPriceUsd = valueToBigNumber(state.inputAmountUSD) + .dividedBy(valueToBigNumber(state.inputAmount)) + .toNumber(); + buyTokenPriceUsd = valueToBigNumber(state.outputAmountUSD) + .dividedBy(valueToBigNumber(state.outputAmount)) + .toNumber(); + + let networkFeeAmountFormattedInSell = isCowProtocolRates(state.swapRate) + ? normalize( + state.swapRate.amountAndCosts.costs.networkFee.amountInSellCurrency.toString(), + sellAmountToken.decimals + ) + : '0'; + let networkFeeAmountFormattedInBuy = isCowProtocolRates(state.swapRate) + ? normalize( + state.swapRate.amountAndCosts.costs.networkFee.amountInBuyCurrency.toString(), + buyAmountToken.decimals + ) + : '0'; + + // Trick waiting for CoW solvers precise hook simulation - TODO: remove once it's solved on CoW's BFF + if ( + state.swapType === SwapType.RepayWithCollateral || + state.swapType === SwapType.DebtSwap || + state.swapType === SwapType.CollateralSwap + ) { + networkFeeAmountFormattedInSell = valueToBigNumber(networkFeeAmountFormattedInSell) + .multipliedBy(3) + .toFixed(); + networkFeeAmountFormattedInBuy = valueToBigNumber(networkFeeAmountFormattedInBuy) + .multipliedBy(3) + .toFixed(); + } + networkFeeAmountInSellFormatted = networkFeeAmountFormattedInSell; + networkFeeAmountInBuyFormatted = networkFeeAmountFormattedInBuy; + + if (state.orderType === OrderType.MARKET) { + // On a classic sell market order, we send the input amount and receive the amount after partner fees and slippage + + if (marketOrderKind === OrderKind.SELL) { + sellAmountFormatted = state.inputAmount; + + const outputAmountAfterNetworkFees = valueToBigNumber(state.outputAmount).minus( + networkFeeAmountFormattedInBuy + ); + const outputAmountAfterPartnerFees = valueToBigNumber(outputAmountAfterNetworkFees).minus( + partnerFeeAmount + ); + const outputAmountAfterSlippage = valueToBigNumber( + outputAmountAfterPartnerFees + ).multipliedBy(1 - Number(state.slippage) / 100); + buyAmountFormatted = outputAmountAfterSlippage.toFixed(); + } else { + // TODO: check if this is correct + buyAmountFormatted = state.inputAmount; + + const sellAmountAfterNetworkFees = valueToBigNumber(state.outputAmount).plus( + networkFeeAmountFormattedInSell + ); + const sellAmountAfterPartnerFees = valueToBigNumber(sellAmountAfterNetworkFees).plus( + partnerFeeAmount + ); + const sellAmountAfterSlippage = valueToBigNumber(sellAmountAfterPartnerFees).multipliedBy( + 1 + Number(state.slippage) / 100 + ); + sellAmountFormatted = sellAmountAfterSlippage.toFixed(); + } + } else if (state.orderType === OrderType.LIMIT) { + if (state.side === 'sell') { + // on a sell limit order, we send the input amount and receive the amount after partner fees (no slippage applied) + sellAmountFormatted = state.inputAmount; + + // Do not apply network costs on limit orders + buyAmountFormatted = valueToBigNumber(state.outputAmount) + .minus(partnerFeeAmount) + .toFixed(); + } else { + // on a buy limit order, we receive exactly the output amount and send the input amount after partner fees (no slippage applied) + // Do not apply network costs on limit orders + sellAmountFormatted = valueToBigNumber(state.inputAmount) + .plus(partnerFeeAmount) + .toFixed(); + + buyAmountFormatted = state.outputAmount; + } + } + } else { + // if the swap is inverted (from the UI perspective, e.g. in a repay with collateral our second input is the sell token), + // the minimum received is from the input token and sent is from the output token + sellAmountToken = state.destinationToken; + buyAmountToken = state.sourceToken; + sellTokenPriceUsd = valueToBigNumber(state.outputAmountUSD) + .dividedBy(valueToBigNumber(state.outputAmount)) + .toNumber(); + buyTokenPriceUsd = valueToBigNumber(state.inputAmountUSD) + .dividedBy(valueToBigNumber(state.inputAmount)) + .toNumber(); + + let networkFeeAmountFormattedInSell = isCowProtocolRates(state.swapRate) + ? normalize( + state.swapRate.amountAndCosts.costs.networkFee.amountInSellCurrency.toString(), + sellAmountToken.decimals + ) + : '0'; + let networkFeeAmountFormattedInBuy = isCowProtocolRates(state.swapRate) + ? normalize( + state.swapRate.amountAndCosts.costs.networkFee.amountInBuyCurrency.toString(), + buyAmountToken.decimals + ) + : '0'; + + // console.debug('networkFeeAmountFormattedInSell', networkFeeAmountFormattedInSell); + // console.debug('networkFeeAmountFormattedInBuy', networkFeeAmountFormattedInBuy); + + // Trick waiting for CoW solvers precise hook simulation - TODO: remove once it's solved on CoW's BFF + if ( + state.swapType === SwapType.RepayWithCollateral || + state.swapType === SwapType.DebtSwap || + state.swapType === SwapType.CollateralSwap + ) { + networkFeeAmountFormattedInSell = valueToBigNumber(networkFeeAmountFormattedInSell) + .multipliedBy(3) + .toFixed(); + networkFeeAmountFormattedInBuy = valueToBigNumber(networkFeeAmountFormattedInBuy) + .multipliedBy(3) + .toFixed(); + } + + // console.debug('networkFeeAmountFormattedInSell after trick', networkFeeAmountFormattedInSell); + // console.debug('networkFeeAmountFormattedInBuy after trick', networkFeeAmountFormattedInBuy); + networkFeeAmountInSellFormatted = networkFeeAmountFormattedInSell; + networkFeeAmountInBuyFormatted = networkFeeAmountFormattedInBuy; + + if (state.orderType === OrderType.MARKET) { + // on a classic inverted sell market order, we send the output amount and receive the input amount after partner fees and slippage + if (marketOrderKind === OrderKind.SELL) { + sellAmountFormatted = state.outputAmount; + + const inputAmountAfterNetworkFees = valueToBigNumber(state.inputAmount).minus( + networkFeeAmountFormattedInBuy + ); + const inputAmountAfterPartnerFees = valueToBigNumber(inputAmountAfterNetworkFees) + .minus(partnerFeeAmount) + .toFixed(); + const inputAmountAfterSlippage = valueToBigNumber(inputAmountAfterPartnerFees) + .multipliedBy(1 + Number(state.slippage) / 100) + .toFixed(); + buyAmountFormatted = inputAmountAfterSlippage; + } else { + buyAmountFormatted = state.inputAmount; + + const sellAmountAfterNetworkFees = valueToBigNumber(state.outputAmount).plus( + networkFeeAmountFormattedInSell + ); + const sellAmountAfterPartnerFees = valueToBigNumber(sellAmountAfterNetworkFees).plus( + partnerFeeAmount + ); + const sellAmountAfterSlippage = valueToBigNumber(sellAmountAfterPartnerFees).multipliedBy( + 1 + Number(state.slippage) / 100 + ); + sellAmountFormatted = sellAmountAfterSlippage.toFixed(); + } + } else { + if (processedSide === 'buy') { + // on an inverted buy limit order, we buy the input amount and sell the output amount after partner fees (no slippage applied) + buyAmountFormatted = state.inputAmount; + + // Do not apply network costs on limit orders + sellAmountFormatted = valueToBigNumber(state.outputAmount) + .plus(partnerFeeAmount) + .toFixed(); + } else { + // on an inverted sell limit order, we sell the output amount and buy the input amount after partner fees (no slippage applied) + sellAmountFormatted = state.outputAmount; + + // Do not apply network costs on limit orders + buyAmountFormatted = valueToBigNumber(state.inputAmount) + .minus(partnerFeeAmount) + .toFixed(); + } + } + } + + if ( + buyAmountFormatted == undefined || + sellAmountFormatted == undefined || + sellAmountToken == undefined || + buyAmountToken == undefined || + sellTokenPriceUsd == undefined || + buyTokenPriceUsd == undefined + ) + return; + + // Avoid negative amounts + sellAmountFormatted = valueToBigNumber(sellAmountFormatted ?? '0').lt(0) + ? '0' + : sellAmountFormatted; + buyAmountFormatted = valueToBigNumber(buyAmountFormatted ?? '0').lt(0) + ? '0' + : buyAmountFormatted; + + const sellAmountUSD = valueToBigNumber(sellAmountFormatted) + .multipliedBy(sellTokenPriceUsd) + .toFixed(); + const buyAmountUSD = valueToBigNumber(buyAmountFormatted) + .multipliedBy(buyTokenPriceUsd) + .toFixed(); + + const sellAmountBigInt = BigInt( + normalizeBN(sellAmountFormatted, -sellAmountToken.decimals).toFixed(0) + ); + + const buyAmountBigInt = BigInt( + normalizeBN(buyAmountFormatted, -buyAmountToken.decimals).toFixed(0) + ); + + setState({ + buyAmountFormatted, + buyAmountUSD, + sellAmountFormatted, + sellAmountUSD, + sellAmountToken, + buyAmountToken, + isInvertedSwap, + sellAmountBigInt, + buyAmountBigInt, + processedSide, + networkFeeAmountInSellFormatted, + networkFeeAmountInBuyFormatted, + partnerFeeAmountFormatted: partnerFeeAmount.toFixed(), + partnerFeeBps: partnetFeeBps, + }); + }, [ + state.inputAmount, + state.outputAmount, + state.slippage, + state.sourceToken, + state.destinationToken, + state.side, + state.swapType, + state.orderType, + ]); +}; diff --git a/src/components/transactions/Swap/hooks/useSwapQuote.ts b/src/components/transactions/Swap/hooks/useSwapQuote.ts new file mode 100644 index 0000000000..bcc5bf66e2 --- /dev/null +++ b/src/components/transactions/Swap/hooks/useSwapQuote.ts @@ -0,0 +1,462 @@ +import { normalizeBN } from '@aave/math-utils'; +import { useQuery } from '@tanstack/react-query'; +import { Dispatch, useEffect, useMemo } from 'react'; +import { useModalContext } from 'src/hooks/useModal'; +import { isTxErrorType, TxErrorType } from 'src/ui-config/errorMapping'; +import { queryKeysFactory } from 'src/ui-config/queries'; + +import { TrackAnalyticsHandlers } from '../analytics/useTrackAnalytics'; +import { APP_CODE_PER_SWAP_TYPE } from '../constants/shared.constants'; +import { hasFlashLoanDisabled } from '../errors/shared/FlashLoanDisabledBlockingGuard'; +import { hasInsufficientBalance } from '../errors/shared/InsufficientBalanceGuard'; +import { getCowProtocolSellRates } from '../helpers/cow'; +import { getParaswapSellRates, getParaswapSlippage } from '../helpers/paraswap'; +import { getSwitchProvider } from '../helpers/shared/provider.helpers'; +import { + SwapParams, + SwapProvider, + SwapQuoteType as SwapQuoteType, + SwapState, + SwapType, + TokenType, +} from '../types'; + +interface TokenSelectionParams { + srcToken: string; + destToken: string; + srcDecimals: number; + destDecimals: number; + inputSymbol: string; + outputSymbol: string; + isInputTokenCustom: boolean; + isOutputTokenCustom: boolean; + side: 'buy' | 'sell'; +} + +export const swapTypesThatRequiresInvertedQuote: SwapType[] = [ + SwapType.DebtSwap, + SwapType.RepayWithCollateral, +]; + +const getTokenSelectionForQuote = ( + invertedQuoteRoute: boolean, + state: SwapState +): TokenSelectionParams => { + // Note: Consider the quote an approximation, we prefer underlying address for better support while aTokens value should always match + const srcTokenObj = invertedQuoteRoute ? state.destinationToken : state.sourceToken; + const srcToken = + state.useFlashloan == false && + state.provider === SwapProvider.PARASWAP && + state.swapType !== SwapType.WithdrawAndSwap && + state.swapType !== SwapType.RepayWithCollateral + ? srcTokenObj.addressToSwap + : srcTokenObj.underlyingAddress; + const destTokenObj = invertedQuoteRoute ? state.sourceToken : state.destinationToken; + const destToken = + state.useFlashloan == false && + state.provider === SwapProvider.PARASWAP && + state.swapType !== SwapType.WithdrawAndSwap && + state.swapType !== SwapType.RepayWithCollateral + ? destTokenObj.addressToSwap + : destTokenObj.underlyingAddress; + + const srcDecimals = invertedQuoteRoute + ? state.destinationToken.decimals + : state.sourceToken.decimals; + const destDecimals = invertedQuoteRoute + ? state.sourceToken.decimals + : state.destinationToken.decimals; + const inputSymbol = invertedQuoteRoute ? state.destinationToken.symbol : state.sourceToken.symbol; + const outputSymbol = invertedQuoteRoute + ? state.sourceToken.symbol + : state.destinationToken.symbol; + const isInputTokenCustom = invertedQuoteRoute + ? state.destinationToken.tokenType === TokenType.USER_CUSTOM + : state.sourceToken.tokenType === TokenType.USER_CUSTOM; + const isOutputTokenCustom = invertedQuoteRoute + ? state.sourceToken.tokenType === TokenType.USER_CUSTOM + : state.destinationToken.tokenType === TokenType.USER_CUSTOM; + const side = invertedQuoteRoute ? (state.side === 'buy' ? 'sell' : 'buy') : state.side; + + return { + srcToken, + destToken, + srcDecimals, + destDecimals, + inputSymbol, + outputSymbol, + isInputTokenCustom, + isOutputTokenCustom, + side, + }; +}; + +export const QUOTE_REFETCH_INTERVAL = 30000; // 30 seconds + +/** + * React hook that orchestrates quoting logic across providers. + * + * - Selects provider via getSwitchProvider + * - Builds provider-agnostic params from SwapState/SwapParams + * - Periodically refetches quotes and writes normalized values into SwapState + */ +export const useSwapQuote = ({ + params, + state, + setState, + trackingHandlers, +}: { + params: SwapParams; + state: SwapState; + setState: Dispatch>; + trackingHandlers?: TrackAnalyticsHandlers; +}) => { + // Once transaction succeeds, lock the provider to prevent recalculation + // (useFlashloan or other dependencies might change after invalidateAppState) + const provider = useMemo(() => { + // If transaction already succeeded, use the existing provider from state + if (state.mainTxState.success && state.provider !== SwapProvider.NONE) { + return state.provider; + } + // Otherwise, calculate provider based on current state + return getSwitchProvider({ + chainId: state.chainId, + assetFrom: state.sourceToken.addressToSwap, + assetTo: state.destinationToken.addressToSwap, + swapType: params.swapType, + shouldUseFlashloan: state.useFlashloan, + }); + }, [ + state.mainTxState.success, + state.provider, + state.chainId, + state.sourceToken.addressToSwap, + state.destinationToken.addressToSwap, + params.swapType, + state.useFlashloan, + ]); + + const requiresQuoteInverted = useMemo( + () => swapTypesThatRequiresInvertedQuote.includes(params.swapType), + [provider, params.swapType] + ); + + const { + data: swapQuote, + isLoading: ratesLoading, + error: ratesError, + } = useMultiProviderSwapQuoteQuery({ + provider: provider ?? SwapProvider.NONE, + params, + state, + setState, + requiresQuoteInverted, + }); + + const quoteToState = (quote: SwapQuoteType | null | undefined) => { + if (!quote) return; + + const nextInputAmount = normalizeBN(quote.srcSpotAmount, quote.srcDecimals).toFixed(); + const nextOutputAmount = normalizeBN(quote.destSpotAmount, quote.destDecimals).toFixed(); + const nextInputAmountUSD = quote.srcSpotUSD; + const nextOutputAmountUSD = quote.destSpotUSD; + + // Skip update if nothing changed to avoid re-render loops + if ( + state.provider == quote.provider && + state.swapRate?.srcSpotAmount == quote.srcSpotAmount && + state.swapRate?.destSpotAmount == quote.destSpotAmount && + state.inputAmount == nextInputAmount && + state.outputAmount == nextOutputAmount && + state.inputAmountUSD == nextInputAmountUSD && + state.outputAmountUSD == nextOutputAmountUSD + ) { + return; + } + + let slippage = state.slippage; + let autoSlippage = state.autoSlippage; + if (quote.provider === 'cowprotocol' && quote?.suggestedSlippage !== undefined) { + slippage = quote.suggestedSlippage.toString(); + autoSlippage = quote.suggestedSlippage.toString(); + } else if (quote.provider === 'paraswap') { + const paraswapSlippage = getParaswapSlippage( + state.sourceToken.symbol || '', + state.destinationToken.symbol || '', + state.swapType + ); + slippage = paraswapSlippage; + autoSlippage = paraswapSlippage; + } + + return { + swapRate: quote, + inputAmount: nextInputAmount, + outputAmount: nextOutputAmount, + inputAmountUSD: nextInputAmountUSD, + outputAmountUSD: nextOutputAmountUSD, + slippage, + autoSlippage, + }; + }; + + useEffect(() => { + if (provider) { + setState({ + provider, + swapRate: undefined, // Clear the old swap rate to force new quote + autoSlippage: '', // Clear suggested slippage until a new quote arrives + quoteRefreshPaused: false, // Ensure quotes can be fetched + }); + } + }, [provider]); + + useEffect(() => { + if (ratesLoading != state.ratesLoading) { + setState({ ratesLoading: ratesLoading }); + } + }, [ratesLoading]); + + useEffect(() => { + if (ratesError) { + setState({ + error: { rawError: ratesError, message: ratesError.message, actionBlocked: true }, + ratesLoading: false, + swapRate: undefined, + }); + } + }, [ratesError]); + + useEffect(() => { + if (swapQuote) { + const isAutoRefreshed = Boolean(state.quoteLastUpdatedAt); + trackingHandlers?.trackSwapQuote(isAutoRefreshed, swapQuote); + + setState({ + provider: swapQuote.provider, + ...quoteToState(swapQuote), + quoteLastUpdatedAt: Date.now(), + // Reset pause bookkeeping on new quote + quoteTimerPausedAt: null, + quoteTimerPausedAccumMs: 0, + + error: undefined, + actionsBlocked: {}, + warnings: [], + actionsLoading: false, + }); + } + }, [swapQuote]); + + // Pause/resume timer bookkeeping when actions are loading + useEffect(() => { + if (state.actionsLoading) { + if (!state.quoteTimerPausedAt) { + setState({ quoteTimerPausedAt: Date.now() }); + } + } else { + if (state.quoteTimerPausedAt) { + const pausedDelta = Date.now() - state.quoteTimerPausedAt; + setState({ + quoteTimerPausedAt: null, + quoteTimerPausedAccumMs: (state.quoteTimerPausedAccumMs || 0) + pausedDelta, + }); + } + } + }, [state.actionsLoading]); +}; + +/** + * Low-level function used by useSwapQuote to query the selected provider. + * Converts state into provider params and returns a normalized `SwapQuoteType`. + */ +const useMultiProviderSwapQuoteQuery = ({ + params, + state, + setState, + provider, + requiresQuoteInverted, +}: { + params: SwapParams; + state: SwapState; + setState: Dispatch>; + provider: SwapProvider; + requiresQuoteInverted: boolean; +}) => { + const { approvalTxState } = useModalContext(); + + // Amount to quote depends on side (sell uses input amount, buy uses output amount) + const amount = useMemo(() => { + if (state.side === 'sell') { + return normalizeBN(state.debouncedInputAmount, -1 * state.sourceToken.decimals).toFixed(0); + } else { + return normalizeBN(state.debouncedOutputAmount, -1 * state.destinationToken.decimals).toFixed( + 0 + ); + } + }, [ + state.debouncedInputAmount, + state.debouncedOutputAmount, + requiresQuoteInverted, + state.side, + state.sourceToken.decimals, + state.destinationToken.decimals, + ]); + + const appCode = APP_CODE_PER_SWAP_TYPE[params.swapType]; + + const { + srcToken, + destToken, + srcDecimals, + destDecimals, + inputSymbol, + outputSymbol, + isInputTokenCustom, + isOutputTokenCustom, + side, + } = useMemo( + () => getTokenSelectionForQuote(requiresQuoteInverted, state), + [ + state.provider, + state.sourceToken, + state.destinationToken, + state.side, + requiresQuoteInverted, + state.useFlashloan, + ] + ); + + return useQuery({ + queryFn: async () => { + if (!provider) { + setState({ + error: { + rawError: new Error('No swap provider found in the selected chain for this pair'), + message: 'No swap provider found in the selected chain for this pair', + actionBlocked: true, + }, + }); + return null; + } + + if (state.sourceToken.addressToSwap === state.destinationToken.addressToSwap) { + setState({ + error: { + rawError: new Error('Source and destination tokens cannot be the same'), + message: 'Source and destination tokens cannot be the same', + actionBlocked: true, + }, + }); + return null; + } + + const setError = (error: Error | TxErrorType) => { + setState({ + error: { + rawError: isTxErrorType(error) ? error.rawError : error, + message: isTxErrorType(error) ? 'Error in Swap Quote' : error.message, + actionBlocked: true, + }, + }); + }; + + switch (provider) { + case SwapProvider.COW_PROTOCOL: + return await getCowProtocolSellRates({ + swapType: state.swapType, + chainId: state.chainId, + amount, + srcToken, + destToken, + user: state.user, + srcDecimals, + destDecimals, + inputSymbol, + outputSymbol, + isInputTokenCustom, + isOutputTokenCustom, + appCode, + setError, + side, + invertedQuoteRoute: requiresQuoteInverted, + }); + case SwapProvider.PARASWAP: + return await getParaswapSellRates({ + swapType: state.swapType, + chainId: state.chainId, + amount, + srcToken, + destToken, + user: state.user, + srcDecimals, + destDecimals, + side, + appCode, + options: { + partner: appCode, + }, + invertedQuoteRoute: requiresQuoteInverted, + }); + default: + // Error + setError(new Error('No swap provider found in the selected chain for this pair')); + return null; + } + }, + queryKey: queryKeysFactory.swapQuote( + state.chainId, + provider, + amount, + requiresQuoteInverted, + srcToken, + destToken, + state.user + ), + enabled: (() => { + // Allow fetch when user has entered a positive amount, even if normalization rounded to '0' + const hasPositiveUserAmount = + state.side === 'sell' + ? Number(state.debouncedInputAmount || '0') > 0 + : Number(state.debouncedOutputAmount || '0') > 0; + + // Basic pre-blockers to avoid provider requests + const isSameTokenPair = + state.sourceToken.addressToSwap === state.destinationToken.addressToSwap; + const isFlashloanDisabled = hasFlashLoanDisabled(state); + + return ( + hasPositiveUserAmount && + !isSameTokenPair && + !isFlashloanDisabled && + !state.mainTxState.success && + !state.mainTxState.txHash && // Don't fetch quotes once transaction is sent + !state.mainTxState.loading && // Don't fetch quotes while transaction is processing + !approvalTxState?.loading && // Don't fetch quotes while approval is processing + !approvalTxState?.success && // Don't fetch quotes while approval is successful + provider !== SwapProvider.NONE && + !state.quoteRefreshPaused && + !state.isWrongNetwork + ); + })(), + retry: 0, + throwOnError: false, + refetchOnWindowFocus: (query) => (query.state.error ? false : true), + refetchInterval: (() => { + const isInsufficientBalance = hasInsufficientBalance(state); + const isFlashloanDisabled = hasFlashLoanDisabled(state); + + return !state.actionsLoading && + !state.quoteRefreshPaused && + !state.mainTxState.success && + !state.mainTxState.txHash && + !state.mainTxState.loading && + !approvalTxState?.loading && + !approvalTxState?.success && + !isInsufficientBalance && + !isFlashloanDisabled + ? QUOTE_REFETCH_INTERVAL + : false; + })(), + }); +}; diff --git a/src/components/transactions/Swap/hooks/useUserContext.ts b/src/components/transactions/Swap/hooks/useUserContext.ts new file mode 100644 index 0000000000..25ba020976 --- /dev/null +++ b/src/components/transactions/Swap/hooks/useUserContext.ts @@ -0,0 +1,31 @@ +import { Dispatch, useEffect } from 'react'; +import { isSafeWallet, isSmartContractWallet } from 'src/helpers/provider'; +import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; +import { getEthersProvider } from 'src/libs/web3-data-provider/adapters/EthersAdapter'; +import { useRootStore } from 'src/store/root'; +import { wagmiConfig } from 'src/ui-config/wagmiConfig'; + +import { SwapState } from '../types'; + +export const useUserContext = ({ setState }: { setState: Dispatch> }) => { + const user = useRootStore((store) => store.account); + const { chainId: connectedChainId } = useWeb3Context(); + + useEffect(() => { + try { + if (user && connectedChainId) { + setState({ user }); + getEthersProvider(wagmiConfig, { chainId: connectedChainId }).then((provider) => { + Promise.all([isSmartContractWallet(user, provider), isSafeWallet(user, provider)]).then( + ([isSmartContract, isSafe]) => { + setState({ userIsSmartContractWallet: isSmartContract }); + setState({ userIsSafeWallet: isSafe }); + } + ); + }); + } + } catch (error) { + console.error(error); + } + }, [user, connectedChainId]); +}; diff --git a/src/components/transactions/Swap/inputs/LimitOrderInputs.tsx b/src/components/transactions/Swap/inputs/LimitOrderInputs.tsx new file mode 100644 index 0000000000..dc5ddf9beb --- /dev/null +++ b/src/components/transactions/Swap/inputs/LimitOrderInputs.tsx @@ -0,0 +1,281 @@ +import { ArrowDownIcon, SwitchVerticalIcon } from '@heroicons/react/outline'; +import { Box, IconButton, SvgIcon, Typography } from '@mui/material'; +import { Dispatch } from 'react'; + +import { SwapInputChanges } from '../analytics/constants'; +import { TrackAnalyticsHandlers } from '../analytics/useTrackAnalytics'; +import { QUOTE_REFETCH_INTERVAL } from '../hooks/useSwapQuote'; +import { Expiry, OrderType, SwapParams, SwapProvider, SwapState } from '../types'; +import { SwitchAssetInput } from './primitives/SwapAssetInput'; +import { ExpirySelector } from './shared/ExpirySelector'; +import { NetworkSelector } from './shared/NetworkSelector'; +import { PriceInput } from './shared/PriceInput'; +import { QuoteProgressRing } from './shared/QuoteProgressRing'; +import { SwapInputState } from './SwapInputs'; + +export type SwapInputsCustomProps = { + canSwitchTokens: boolean; +}; + +export const LimitOrderInputs = ({ + params, + state, + swapState, + setState, + customProps, + trackingHandlers, +}: { + params: SwapParams; + state: SwapState; + swapState: SwapInputState; + setState: Dispatch>; + customProps?: SwapInputsCustomProps; + trackingHandlers: TrackAnalyticsHandlers; +}) => { + // Prioritize Limit Order specific input/output titles + let inputInputTitle; + let outputInputTitle; + + if (state.orderType === OrderType.LIMIT) { + if (!inputInputTitle) { + inputInputTitle = + state.processedSide === 'sell' ? params.inputInputTitleSell : params.inputInputTitleBuy; + } + if (!outputInputTitle) { + outputInputTitle = + state.processedSide === 'buy' ? params.outputInputTitleBuy : params.outputInputTitleSell; + } + } + // Fallback to global input/output titles + if (!inputInputTitle) { + inputInputTitle = params.inputInputTitle; + } + if (!outputInputTitle) { + outputInputTitle = params.outputInputTitle; + } + + return ( + <> + + {(inputInputTitle || swapState.showNetworkSelector) && ( + + {inputInputTitle && ( + + {inputInputTitle} + + )} + {swapState.showNetworkSelector && ( + + )} + + )} + + { + setState({ expiry }); + trackingHandlers.trackInputChange(SwapInputChanges.EXPIRY, expiry.toString()); + }} + /> + + + + { + setState({ + inputAmount: '', + debouncedInputAmount: '', + inputAmountUSD: '', + quoteRefreshPaused: true, + quoteLastUpdatedAt: undefined, + }); + if (state.outputAmount === '') { + // Both reset to listen quotes + setState({ + swapRate: undefined, + quoteRefreshPaused: false, + quoteLastUpdatedAt: undefined, + }); + } + }} + usdValue={state.inputAmountUSD || '0'} + onSelect={swapState.handleSelectedInputToken} + selectedAsset={state.sourceToken} + forcedMaxValue={state.forcedMaxValue} + allowCustomTokens={params.allowCustomTokens} + swapType={params.swapType} + side="input" + /> + + {params.showSwitchInputAndOutputAssetsButton ? ( + + + + + + + {!state.quoteRefreshPaused && ( + + )} + + ) : ( + !outputInputTitle && ( + + + + + + + {!state.quoteRefreshPaused && ( + + )} + + ) + )} + + { + swapState.handleOutputChange(value); + }} + onClear={() => { + setState({ + outputAmount: '', + debouncedOutputAmount: '', + outputAmountUSD: '', + quoteRefreshPaused: true, + quoteLastUpdatedAt: undefined, + }); + if (state.inputAmount === '') { + // Both reset to listen quotes + setState({ + swapRate: undefined, + quoteRefreshPaused: false, + quoteLastUpdatedAt: undefined, + }); + } + }} + onSelect={swapState.handleSelectedOutputToken} + disableInput={false} + selectedAsset={state.destinationToken} + showBalance={false} + allowCustomTokens={params.allowCustomTokens} + swapType={params.swapType} + side="output" + /> + + + + + ); +}; diff --git a/src/components/transactions/Swap/inputs/MarketOrderInputs.tsx b/src/components/transactions/Swap/inputs/MarketOrderInputs.tsx new file mode 100644 index 0000000000..241fd3a85a --- /dev/null +++ b/src/components/transactions/Swap/inputs/MarketOrderInputs.tsx @@ -0,0 +1,220 @@ +import { ArrowDownIcon, SwitchVerticalIcon } from '@heroicons/react/outline'; +import { Box, IconButton, SvgIcon, Typography } from '@mui/material'; +import { Dispatch } from 'react'; + +import { QUOTE_REFETCH_INTERVAL } from '../hooks/useSwapQuote'; +import { isCowProtocolRates, SwapParams, SwapProvider, SwapState } from '../types'; +import { SwitchAssetInput } from './primitives/SwapAssetInput'; +import { NetworkSelector } from './shared/NetworkSelector'; +import { QuoteProgressRing } from './shared/QuoteProgressRing'; +import { SwitchRates } from './shared/SwitchRates'; +import { SwitchSlippageSelector } from './shared/SwitchSlippageSelector'; +import { SwapInputState } from './SwapInputs'; + +export type SwapInputsCustomProps = { + canSwitchTokens: boolean; +}; + +export const MarketOrderInputs = ({ + params, + state, + swapState, + setState, + customProps, +}: { + params: SwapParams; + state: SwapState; + swapState: SwapInputState; + setState: Dispatch>; + customProps?: SwapInputsCustomProps; +}) => { + return ( + <> + + {(params.inputInputTitle || swapState.showNetworkSelector) && ( + + {params.inputInputTitle && ( + + {params.inputInputTitle} + + )} + {swapState.showNetworkSelector && ( + + )} + + )} + + + + + + + setState({ + inputAmount: '', + debouncedInputAmount: '', + inputAmountUSD: '', + outputAmount: '', + debouncedOutputAmount: '', + outputAmountUSD: '', + swapRate: undefined, + ratesLoading: false, + error: undefined, + warnings: [], + quoteRefreshPaused: true, + quoteLastUpdatedAt: undefined, + autoSlippage: '', + }) + } + usdValue={state.inputAmountUSD.toString() || '0'} + onSelect={swapState.handleSelectedInputToken} + selectedAsset={state.sourceToken} + forcedMaxValue={state.forcedMaxValue} + allowCustomTokens={params.allowCustomTokens} + swapType={params.swapType} + side="input" + /> + + {params.showSwitchInputAndOutputAssetsButton ? ( + + + + + + + {!state.quoteRefreshPaused && ( + + )} + + ) : ( + !params.outputInputTitle && ( + + + + + + + {!state.quoteRefreshPaused && ( + + )} + + ) + )} + + + + + {state.swapRate && state.isSwapFlowSelected && ( + <> + + + )} + + ); +}; diff --git a/src/components/transactions/Swap/inputs/SwapInputs.tsx b/src/components/transactions/Swap/inputs/SwapInputs.tsx new file mode 100644 index 0000000000..b64f8d1f8d --- /dev/null +++ b/src/components/transactions/Swap/inputs/SwapInputs.tsx @@ -0,0 +1,722 @@ +import { BigNumberValue, valueToBigNumber } from '@aave/math-utils'; +import { WRAPPED_NATIVE_CURRENCIES } from '@cowprotocol/cow-sdk'; +import { useQueryClient } from '@tanstack/react-query'; +import { Dispatch, useEffect, useMemo } from 'react'; +import { useRootStore } from 'src/store/root'; +import { queryKeysFactory } from 'src/ui-config/queries'; + +import { SwapInputChanges } from '../analytics/constants'; +import { TrackAnalyticsHandlers } from '../analytics/useTrackAnalytics'; +import { SESSION_STORAGE_EXPIRY_MS } from '../constants/shared.constants'; +import { + OrderType, + SwappableToken, + swappableTokenToTokenInfo, + SwapParams, + SwapState, + SwapType, + TokenType, +} from '../types'; +import { LimitOrderInputs } from './LimitOrderInputs'; +import { MarketOrderInputs } from './MarketOrderInputs'; + +export type SwapInputState = { + handleSelectedInputToken: (token: SwappableToken) => void; + handleSelectedOutputToken: (token: SwappableToken) => void; + handleSelectedNetworkChange: (value: number) => void; + setSlippage: (value: string) => void; + showNetworkSelector: boolean; + inputAssets: SwappableToken[]; + outputAssets: SwappableToken[]; + handleInputChange: (value: string) => void; + handleOutputChange: (value: string) => void; + handleRateChange: (rateFromAsset: SwappableToken, newRate: BigNumberValue) => void; + onSwitchReserves: () => void; +}; + +/** + * Input surface for both market and limit orders. + * + * Responsibilities: + * - Manage input/output amount edits, max selection, and switching tokens + * - Pause automatic quote refresh when user makes manual price/amount edits + * - Persist last token selection per swap type + chain in sessionStorage (with expiry) + * - Filter token lists to avoid wrapping paths and native token pitfalls for SCWs + */ +export const SwapInputs = ({ + params, + state, + setState, + trackingHandlers, +}: { + params: SwapParams; + state: SwapState; + setState: Dispatch>; + trackingHandlers: TrackAnalyticsHandlers; +}) => { + const resetErrorsAndWarnings = () => { + setState({ + error: undefined, + warnings: [], + actionsBlocked: {}, + actionsLoading: false, + }); + }; + + const handleInputChange = (value: string) => { + resetErrorsAndWarnings(); + + // Calculate USD per token unit if possible + const usdPerToken = state.swapRate?.srcTokenPriceUsd; + + const computeUSD = (amt: string) => + usdPerToken ? valueToBigNumber(amt).multipliedBy(usdPerToken).toString(10) : ''; + + if (state.orderType === OrderType.LIMIT && state.swapRate) { + // Manual edit should pause quote refresh + setState({ quoteRefreshPaused: true }); + } + + if (value === '-1') { + const maxAmount = state.sourceToken.balance; + setState({ + ...(state.orderType === OrderType.LIMIT && state.swapRate + ? { + quoteRefreshPaused: true, + quoteLastUpdatedAt: undefined, + quoteTimerPausedAt: undefined, + quoteTimerPausedAccumMs: undefined, + } + : {}), + inputAmount: maxAmount, + inputAmountUSD: computeUSD(maxAmount), + isMaxSelected: true, + side: 'sell', + }); + } else { + setState({ + ...(state.orderType === OrderType.LIMIT && state.swapRate + ? { + quoteRefreshPaused: true, + quoteLastUpdatedAt: undefined, + quoteTimerPausedAt: undefined, + quoteTimerPausedAccumMs: undefined, + } + : {}), + inputAmount: value, + inputAmountUSD: computeUSD(value), + isMaxSelected: value === state.forcedMaxValue, + side: 'sell', + }); + } + + trackingHandlers.trackInputChange(SwapInputChanges.INPUT_AMOUNT, value); + resetErrorsAndWarnings(); + }; + + const handleOutputChange = (value: string) => { + // Calculate USD per token unit if possible, same as in handleInputChange + const usdPerToken = state.swapRate?.destTokenPriceUsd; + + const computeUSD = (amt: string) => + usdPerToken ? valueToBigNumber(amt).multipliedBy(usdPerToken).toString(10) : ''; + + if (state.swapRate) { + // Block quote refreshs if user is changing the output amount after getting quotes + setState({ quoteRefreshPaused: true }); + } + + if (value === '-1') { + const maxAmount = state.destinationToken.balance; + setState({ + ...(state.orderType === OrderType.LIMIT && state.swapRate + ? { + quoteRefreshPaused: true, + quoteLastUpdatedAt: undefined, + quoteTimerPausedAt: undefined, + quoteTimerPausedAccumMs: undefined, + } + : {}), + outputAmount: maxAmount, + outputAmountUSD: computeUSD(maxAmount), + isMaxSelected: true, + side: 'buy', + }); + } else { + setState({ + ...(state.orderType === OrderType.LIMIT && state.swapRate + ? { + quoteRefreshPaused: true, + quoteLastUpdatedAt: undefined, + quoteTimerPausedAt: undefined, + quoteTimerPausedAccumMs: undefined, + } + : {}), + outputAmount: value, + outputAmountUSD: computeUSD(value), + isMaxSelected: false, + side: 'buy', + }); + } + + trackingHandlers.trackInputChange(SwapInputChanges.OUTPUT_AMOUNT, value); + resetErrorsAndWarnings(); + }; + + const handleRateChange = (rateFromAsset: SwappableToken, newRate: BigNumberValue) => { + if (newRate.toString() === '0') return; + + // User changed custom price, pause quote refresh in limit orders + setState({ quoteRefreshPaused: true }); + + // Normalize the rate to always be dest per 1 source, then recompute output deterministically + const isBaseSource = rateFromAsset.addressToSwap === state.sourceToken.addressToSwap; + const rateDestPerSrc = isBaseSource + ? valueToBigNumber(newRate) + : valueToBigNumber(1).dividedBy(newRate); + const newOutputAmount = valueToBigNumber(state.inputAmount || '0') + .multipliedBy(rateDestPerSrc) + .toString(); + + setState({ + quoteRefreshPaused: true, + quoteLastUpdatedAt: undefined, + quoteTimerPausedAt: undefined, + quoteTimerPausedAccumMs: undefined, + outputAmount: newOutputAmount, + isMaxSelected: false, + side: 'sell', + }); + + trackingHandlers.trackInputChange(SwapInputChanges.RATE_CHANGE, newRate.toString()); + resetErrorsAndWarnings(); + }; + + const onSwitchReserves = () => { + const fromToken = state.sourceToken; + const toToken = state.destinationToken; + + setState({ + quoteRefreshPaused: false, + quoteLastUpdatedAt: undefined, + quoteTimerPausedAt: undefined, + quoteTimerPausedAccumMs: undefined, + sourceToken: toToken, + destinationToken: fromToken, + inputAmount: '', + debouncedInputAmount: '', + outputAmount: '', + debouncedOutputAmount: '', + inputAmountUSD: '', + outputAmountUSD: '', + ratesLoading: false, + swapRate: undefined, + actionsLoading: false, + slippage: '0.1', + autoSlippage: '', + actionsBlocked: {}, + error: undefined, + warnings: [], + }); + + trackingHandlers.trackInputChange(SwapInputChanges.SWITCH_RESERVES, 'switched'); + resetErrorsAndWarnings(); + resetSwap('both'); + }; + + const queryClient = useQueryClient(); + const user = useRootStore((store) => store.account); + + const addNewToken = async (token: SwappableToken) => { + queryClient.setQueryData( + queryKeysFactory.tokensBalance( + state.sourceTokens.concat(state.destinationTokens).map(swappableTokenToTokenInfo) ?? [], + state.chainId, + user + ), + (oldData) => { + if (oldData) + return [...oldData, token].sort((a, b) => Number(b.balance) - Number(a.balance)); + return [token]; + } + ); + const customTokens = localStorage.getItem('customTokens'); + const newTokenInfo: SwappableToken = { + addressToSwap: token.addressToSwap, + addressForUsdPrice: token.addressForUsdPrice, + underlyingAddress: token.underlyingAddress, + name: token.name, + symbol: token.symbol, + decimals: token.decimals, + chainId: token.chainId, + balance: token.balance, + logoURI: token.logoURI, + tokenType: TokenType.USER_CUSTOM, + }; + if (customTokens) { + const parsedCustomTokens: SwappableToken[] = JSON.parse(customTokens); + parsedCustomTokens.push(newTokenInfo); + localStorage.setItem('customTokens', JSON.stringify(parsedCustomTokens)); + } else { + localStorage.setItem('customTokens', JSON.stringify([newTokenInfo])); + } + trackingHandlers.trackInputChange(SwapInputChanges.ADD_CUSTOM_TOKEN, token.symbol); + }; + + const handleSelectedInputToken = (token: SwappableToken) => { + if (!state.sourceTokens?.find((t) => t.addressToSwap === token.addressToSwap)) { + addNewToken(token).then(() => { + setState({ + sourceToken: token, + inputAmount: '', + debouncedInputAmount: '', + inputAmountUSD: '', + quoteRefreshPaused: false, + quoteTimerPausedAt: undefined, + quoteTimerPausedAccumMs: undefined, + autoSlippage: '', + error: undefined, + warnings: [], + actionsBlocked: {}, + actionsLoading: false, + }); + saveTokenSelection(token, state.destinationToken); + saveRecentToken('input', token); + resetErrorsAndWarnings(); + }); + } else { + setState({ + sourceToken: token, + inputAmount: '', + debouncedInputAmount: '', + inputAmountUSD: '', + quoteRefreshPaused: false, + quoteTimerPausedAt: undefined, + quoteTimerPausedAccumMs: undefined, + autoSlippage: '', + error: undefined, + warnings: [], + actionsBlocked: {}, + actionsLoading: false, + }); + saveTokenSelection(token, state.destinationToken); + saveRecentToken('input', token); + resetErrorsAndWarnings(); + } + trackingHandlers.trackInputChange(SwapInputChanges.INPUT_TOKEN, token.symbol); + }; + + // Persist selected tokens in session storage to retain them on modal close/open but differentiating by modalType + const getStorageKey = (swapType: SwapType, chainId: number) => { + // if (SwapType.CollateralSwap === swapType) { + // return `aave_switch_tokens_${swapType}_${chainId}_${state.sourceToken?.addressToSwap?.toLowerCase()}`; + // } else { + return `aave_switch_tokens_${swapType}_${chainId}`; + // } + }; + + const handleSelectedOutputToken = (token: SwappableToken) => { + if (!state.destinationTokens?.find((t) => t.addressToSwap === token.addressToSwap)) { + addNewToken(token).then(() => { + setState({ + destinationToken: token, + outputAmount: '', + debouncedOutputAmount: '', + outputAmountUSD: '', + quoteRefreshPaused: false, + quoteTimerPausedAt: undefined, + quoteTimerPausedAccumMs: undefined, + autoSlippage: '', + error: undefined, + warnings: [], + actionsBlocked: {}, + actionsLoading: false, + }); + saveTokenSelection(state.sourceToken, token); + saveRecentToken('output', token); + resetErrorsAndWarnings(); + }); + } else { + setState({ + destinationToken: token, + outputAmount: '', + debouncedOutputAmount: '', + outputAmountUSD: '', + quoteRefreshPaused: false, + quoteTimerPausedAt: undefined, + quoteTimerPausedAccumMs: undefined, + autoSlippage: '', + error: undefined, + warnings: [], + actionsBlocked: {}, + actionsLoading: false, + }); + saveTokenSelection(state.sourceToken, token); + saveRecentToken('output', token); + resetErrorsAndWarnings(); + } + trackingHandlers.trackInputChange(SwapInputChanges.OUTPUT_TOKEN, token.symbol); + }; + + const saveTokenSelection = (inputToken: SwappableToken, outputToken: SwappableToken) => { + try { + sessionStorage.setItem( + getStorageKey(params.swapType, state.chainId), + JSON.stringify({ + inputToken: params.forcedInputToken ? null : inputToken, + outputToken: params.forcedOutputToken ? null : outputToken, + timestamp: Date.now(), + }) + ); + } catch (e) { + console.error('Error saving token selection', e); + } + }; + + const getRecentStorageKey = (swapType: SwapType, chainId: number, side: 'input' | 'output') => + `aave_recent_tokens_${swapType}_${chainId}_${side}`; + + const saveRecentToken = (side: 'input' | 'output', token: SwappableToken) => { + try { + const key = getRecentStorageKey(params.swapType, state.chainId, side); + const raw = localStorage.getItem(key); + const list: string[] = raw ? JSON.parse(raw) : []; + const addr = token.addressToSwap.toLowerCase(); + const next = [addr, ...list.filter((a) => a.toLowerCase() !== addr)]; + localStorage.setItem(key, JSON.stringify(next.slice(0, 8))); + } catch (e) { + // ignore storage errors + } + }; + + const loadTokenSelection = () => { + try { + const savedTokenSelection = sessionStorage.getItem( + getStorageKey(params.swapType, state.chainId) + ); + if (!savedTokenSelection) return null; + + const parsedTokenSelection = JSON.parse(savedTokenSelection); + if ( + parsedTokenSelection.timestamp && + Date.now() - parsedTokenSelection.timestamp > SESSION_STORAGE_EXPIRY_MS + ) { + sessionStorage.removeItem(getStorageKey(params.swapType, state.chainId)); + return null; + } + return parsedTokenSelection; + } catch (e) { + return null; + } + }; + + // TODO: Can we simplify? + const { defaultInputToken: fallbackInputToken, defaultOutputToken: fallbackOutputToken } = + useMemo(() => { + let auxInputToken = params.forcedInputToken || params.suggestedDefaultInputToken; + let auxOutputToken = params.forcedOutputToken || params.suggestedDefaultOutputToken; + + const fromList = params.sourceTokens; + const toList = params.destinationTokens; + + if (!auxInputToken) { + auxInputToken = fromList.find( + (token) => + (token.balance !== '0' || token.tokenType === TokenType.NATIVE) && + token.symbol !== 'GHO' + ); + } + + if (!auxOutputToken) { + auxOutputToken = toList.find((token) => token.symbol === 'GHO'); + } + + return { + defaultInputToken: auxInputToken ?? fromList[0], + defaultOutputToken: auxOutputToken ?? toList[1], + }; + }, [params.sourceTokens, params.destinationTokens]); + + // Helper to check if two tokens are the same (by addressToSwap, underlyingAddress, or symbol) + const areTokensEqual = ( + token1: SwappableToken | undefined, + token2: SwappableToken | undefined + ): boolean => { + if (!token1 || !token2) return false; + return ( + token1.addressToSwap.toLowerCase() === token2.addressToSwap.toLowerCase() || + token1.underlyingAddress.toLowerCase() === token2.underlyingAddress.toLowerCase() || + token1.symbol === token2.symbol + ); + }; + + // Update selected tokens when defaults change (e.g., after network change) + useEffect(() => { + // Guard: do not auto-adjust tokens after user interaction (amounts entered or Max selected) + if (state.inputAmount || state.outputAmount || state.isMaxSelected) return; + const saved = loadTokenSelection(); + + let inputToken: SwappableToken | undefined; + let outputToken: SwappableToken | undefined; + + // Determine input token first (prioritize forced, then saved if valid, else fallback) + if (params.forcedInputToken) { + inputToken = params.forcedInputToken; + } else if (saved?.inputToken) { + // Only use saved input token if it doesn't match the intended output + const intendedOutput = params.forcedOutputToken || saved.outputToken || fallbackOutputToken; + if (!areTokensEqual(saved.inputToken, intendedOutput)) { + inputToken = saved.inputToken; + } else { + inputToken = fallbackInputToken; + } + } else { + inputToken = fallbackInputToken; + } + + // Determine output token (prioritize forced, then saved if valid, else fallback) + if (params.forcedOutputToken) { + outputToken = params.forcedOutputToken; + } else if (saved?.outputToken) { + // Only use saved output token if it doesn't match the input token + if (!areTokensEqual(saved.outputToken, inputToken)) { + outputToken = saved.outputToken; + } else { + outputToken = fallbackOutputToken; + } + } else { + outputToken = fallbackOutputToken; + } + + // Final safety check: if input and output tokens still match, reset output to fallback + if (areTokensEqual(inputToken, outputToken)) { + outputToken = fallbackOutputToken; + } + + setState({ + sourceToken: inputToken ?? fallbackInputToken, + destinationToken: outputToken ?? fallbackOutputToken, + }); + }, [ + params.forcedInputToken, + params.forcedOutputToken, + fallbackInputToken, + fallbackOutputToken, + state.chainId, + state.inputAmount, + state.outputAmount, + state.isMaxSelected, + ]); + + const resetSwap = (side: 'source' | 'destination' | 'both') => { + setState({ error: undefined }); + // Reset input amount when changing networks + if (side === 'source') { + setState({ inputAmount: '' }); + setState({ debouncedInputAmount: '' }); + } else if (side === 'destination') { + setState({ outputAmount: '' }); + setState({ debouncedOutputAmount: '' }); + } else { + setState({ debouncedInputAmount: '' }); + setState({ debouncedOutputAmount: '' }); + } + resetErrorsAndWarnings(); + }; + + const handleSelectedNetworkChange = (value: number) => { + setState({ chainId: value }); + resetSwap('both'); + + params.refreshTokens(value); + trackingHandlers.trackInputChange(SwapInputChanges.NETWORK, value.toString()); + }; + + const setSlippage = (value: string) => { + setState({ slippage: value }); + if (state.slippage !== state.autoSlippage) { + // Pause automatic quote refresh only in market orders when slippage is edited by user + setState({ quoteRefreshPaused: true }); + } + trackingHandlers.trackInputChange(SwapInputChanges.SLIPPAGE, value); + }; + + const showNetworkSelector = params.showNetworkSelector && !!params.supportedNetworks.length; + + // Debounce input and output amounts before triggering quote logic + useEffect(() => { + const t = setTimeout(() => { + setState({ debouncedInputAmount: state.inputAmount }); + }, 400); + return () => clearTimeout(t); + }, [state.inputAmount]); + + useEffect(() => { + const t = setTimeout(() => { + setState({ debouncedOutputAmount: state.outputAmount }); + }, 400); + return () => clearTimeout(t); + }, [state.outputAmount]); + + const filterInputAssets = (allowOwn = false, orderType: OrderType = OrderType.MARKET) => + state.sourceTokens.filter( + (token) => + (allowOwn || + // Filter out tokens that match the destination token by addressToSwap OR underlyingAddress + // This prevents the same asset from appearing in both lists (e.g., USDT in CollateralSwap) + (token.addressToSwap.toLowerCase() !== + state.destinationToken.addressToSwap.toLowerCase() && + token.underlyingAddress.toLowerCase() !== + state.destinationToken.underlyingAddress.toLowerCase())) && + Number(token.balance) !== 0 && + // Remove native when limit order, but only for classic swaps (CollateralSwap allows native limit orders) + !( + orderType === OrderType.LIMIT && + token.tokenType === TokenType.NATIVE && + params.swapType === SwapType.Swap + ) && + // Remove native tokens for non-Safe smart contract wallets + !( + state.userIsSmartContractWallet && + !state.userIsSafeWallet && + token.tokenType === TokenType.NATIVE + ) && + // Avoid wrapping + !( + state.destinationToken.tokenType === TokenType.NATIVE && + typeof state.chainId === 'number' && + token.addressToSwap.toLowerCase() === + WRAPPED_NATIVE_CURRENCIES[ + state.chainId as keyof typeof WRAPPED_NATIVE_CURRENCIES + ]?.address.toLowerCase() + ) && + !( + state.destinationToken.addressToSwap.toLowerCase() === + WRAPPED_NATIVE_CURRENCIES[ + state.chainId as keyof typeof WRAPPED_NATIVE_CURRENCIES + ]?.address.toLowerCase() && token.tokenType === TokenType.NATIVE + ) + ); + + const inputAssets = useMemo( + () => filterInputAssets(false, state.orderType), + [ + state.sourceTokens, + state.destinationToken.addressToSwap, + state.destinationToken.underlyingAddress, + state.destinationToken.tokenType, + state.userIsSmartContractWallet, + state.userIsSafeWallet, + state.chainId, + state.orderType, + ] + ); + + const filterOutputAssets = (allowOwn = false) => + state.destinationTokens.filter( + (token) => + (allowOwn || + // Filter out tokens that match the source token by addressToSwap OR underlyingAddress + // This prevents the same asset from appearing in both lists (e.g., USDT in CollateralSwap) + (token.addressToSwap.toLowerCase() !== state.sourceToken.addressToSwap.toLowerCase() && + token.underlyingAddress.toLowerCase() !== + state.sourceToken.underlyingAddress.toLowerCase())) && + // Avoid wrapping + !( + state.sourceToken.tokenType === TokenType.NATIVE && + typeof state.chainId === 'number' && + token.addressToSwap.toLowerCase() === + WRAPPED_NATIVE_CURRENCIES[ + state.chainId as keyof typeof WRAPPED_NATIVE_CURRENCIES + ]?.address.toLowerCase() + ) && + !( + state.sourceToken.addressToSwap.toLowerCase() === + WRAPPED_NATIVE_CURRENCIES[ + state.chainId as keyof typeof WRAPPED_NATIVE_CURRENCIES + ]?.address.toLowerCase() && token.tokenType === TokenType.NATIVE + ) + ); + + const outputAssets = useMemo( + () => filterOutputAssets(false), + [ + state.destinationTokens, + state.sourceToken.addressToSwap, + state.sourceToken.underlyingAddress, + state.sourceToken.tokenType, + state.chainId, + state.orderType, + ] + ); + + const allowSwitchTokens = useMemo(() => { + const newInputAsset = state.destinationToken; + const newOutputAsset = state.sourceToken; + + const newInputAssetExists = filterInputAssets(true, state.orderType).find( + (token) => token.addressToSwap.toLowerCase() === newInputAsset.addressToSwap.toLowerCase() + ); + const newOutputAssetExists = filterOutputAssets(true).find( + (token) => token.addressToSwap.toLowerCase() === newOutputAsset.addressToSwap.toLowerCase() + ); + + return !!newInputAssetExists && !!newOutputAssetExists; + }, [state.sourceToken, state.destinationToken, state.orderType]); + + // Hook to disable limits order based on specific assets conditions + useEffect(() => { + const inputsInLimitsOrder = filterInputAssets(true, OrderType.LIMIT); + const outputsInLimitsOrder = filterOutputAssets(true); + + const canLimitSupportCurrentInputAsset = inputsInLimitsOrder.find( + (token) => token.addressToSwap.toLowerCase() === state.sourceToken.addressToSwap.toLowerCase() + ); + const canLimitSupportCurrentOutputAsset = outputsInLimitsOrder.find( + (token) => + token.addressToSwap.toLowerCase() === state.destinationToken.addressToSwap.toLowerCase() + ); + + const limitsOrderButtonBlocked = + !canLimitSupportCurrentInputAsset || !canLimitSupportCurrentOutputAsset; + + setState({ limitsOrderButtonBlocked }); + }, [state.sourceToken, state.destinationToken]); + + const swapState: SwapInputState = { + handleSelectedInputToken, + handleSelectedOutputToken, + handleSelectedNetworkChange, + setSlippage, + showNetworkSelector, + inputAssets, + outputAssets, + handleInputChange, + handleOutputChange, + handleRateChange, + onSwitchReserves, + }; + + if (state.orderType === OrderType.MARKET) { + return ( + + ); + } else if (state.orderType === OrderType.LIMIT) { + return ( + + ); + } +}; diff --git a/src/components/transactions/Swap/inputs/primitives/SwapAssetInput.tsx b/src/components/transactions/Swap/inputs/primitives/SwapAssetInput.tsx new file mode 100644 index 0000000000..da26df4645 --- /dev/null +++ b/src/components/transactions/Swap/inputs/primitives/SwapAssetInput.tsx @@ -0,0 +1,877 @@ +import { valueToBigNumber } from '@aave/math-utils'; +import { isAddress } from '@ethersproject/address'; +import { formatUnits } from '@ethersproject/units'; +import { ExclamationIcon } from '@heroicons/react/outline'; +import { XCircleIcon } from '@heroicons/react/solid'; +import { Trans } from '@lingui/macro'; +import { ExpandMore } from '@mui/icons-material'; +import LaunchIcon from '@mui/icons-material/Launch'; +import { + Box, + Button, + CircularProgress, + IconButton, + InputBase, + MenuItem, + Popover, + SvgIcon, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography, + useTheme, +} from '@mui/material'; +import React, { useEffect, useRef, useState } from 'react'; +import NumberFormat, { NumberFormatProps } from 'react-number-format'; +import { MarketLogo } from 'src/components/MarketSwitcher'; +import { Link } from 'src/components/primitives/Link'; +import { textCenterEllipsis } from 'src/helpers/text-center-ellipsis'; +import { useRootStore } from 'src/store/root'; +import { useSharedDependencies } from 'src/ui-config/SharedDependenciesProvider'; +import { getNetworkConfig } from 'src/utils/marketsAndNetworksConfig'; + +import { COMMON_SWAPS } from '../../../../../ui-config/TokenList'; +import { BasicModal } from '../../../../primitives/BasicModal'; +import { FormattedNumber } from '../../../../primitives/FormattedNumber'; +import { ExternalTokenIcon } from '../../../../primitives/TokenIcon'; +import { SearchInput } from '../../../../SearchInput'; +import { SwappableToken, SwapType, TokenType } from '../../types'; + +interface CustomProps { + onChange: (event: { target: { name: string; value: string } }) => void; + name: string; + value: string; +} + +export const NumberFormatCustom = React.forwardRef( + function NumberFormatCustom(props, ref) { + const { onChange, ...other } = props; + + return ( + { + if (values.value !== props.value) + onChange({ + target: { + name: props.name, + value: values.value || '', + }, + }); + }} + thousandSeparator + isNumericString + allowNegative={false} + /> + ); + } +); + +export interface AssetInputProps { + value: string; + usdValue: string; + chainId: number; + onChange?: (value: string) => void; + onClear?: () => void; + enableHover?: boolean; + disabled?: boolean; + disableInput?: boolean; + onSelect?: (asset: SwappableToken) => void; + assets: SwappableToken[]; + maxValue?: string; + forcedMaxValue?: string; + loading?: boolean; + selectedAsset: SwappableToken; + balanceTitle?: string; + showBalance?: boolean; + allowCustomTokens?: boolean; + title?: string; + swapType: SwapType; + side: 'input' | 'output'; +} + +export const SwitchAssetInput = ({ + value, + usdValue, + onChange, + onClear, + enableHover = false, + disabled, + disableInput, + onSelect, + assets, + maxValue, + forcedMaxValue, + loading = false, + chainId, + selectedAsset, + balanceTitle, + showBalance = true, + allowCustomTokens = true, + title, + swapType, + side, +}: AssetInputProps) => { + const theme = useTheme(); + const networkConfig = getNetworkConfig(chainId); + const networkName = networkConfig.displayName || networkConfig.name; + const getApyInfo = (asset: SwappableToken, swapType: SwapType, side: 'input' | 'output') => { + switch (swapType) { + case SwapType.RepayWithCollateral: + return side === 'input' + ? asset.variableBorrowAPY + ? { label: 'Borrow APY', value: Number(asset.variableBorrowAPY) } + : undefined + : asset.supplyAPY + ? { label: 'Supply APY', value: Number(asset.supplyAPY) } + : undefined; + case SwapType.WithdrawAndSwap: + return side === 'input' && asset.supplyAPY + ? { label: 'Supply APY', value: Number(asset.supplyAPY) } + : undefined; + case SwapType.CollateralSwap: + return asset.supplyAPY + ? { label: 'Supply APY', value: Number(asset.supplyAPY) } + : undefined; + case SwapType.DebtSwap: + return asset.variableBorrowAPY + ? { label: 'Borrow APY', value: Number(asset.variableBorrowAPY) } + : undefined; + case SwapType.Swap: + default: + return undefined; + } + }; + const handleSelect = (asset: SwappableToken) => { + onSelect && onSelect(asset); + onChange && onChange(''); + handleClose(); + }; + + const { erc20Service } = useSharedDependencies(); + + const [openModal, setOpenModal] = useState(false); + const inputRef = useRef(null); + const [pickerHeight, setPickerHeight] = useState(undefined); + + const handleClick = () => { + if (assets.length === 1) return; + setOpenModal(true); + }; + + const handleClose = () => { + setOpenModal(false); + handleCleanSearch(); + }; + + // Match token picker height to the current swap modal paper height + useEffect(() => { + const paper = inputRef.current?.closest('.MuiPaper-root') as HTMLElement | null; + if (paper) { + setPickerHeight(paper.clientHeight); + } + }, [openModal]); + + const [filteredAssets, setFilteredAssets] = useState(assets); + const [loadingNewAsset, setLoadingNewAsset] = useState(false); + const user = useRootStore((store) => store.account); + + useEffect(() => { + setFilteredAssets(assets); + }, [assets]); + + const popularAssets = assets.filter((asset) => COMMON_SWAPS.includes(asset.symbol)); + + const getRecentStorageKey = (swapType: SwapType, chainId: number, side: 'input' | 'output') => + `aave_recent_tokens_${swapType}_${chainId}_${side}`; + + const recentAddresses: string[] = (() => { + try { + const raw = localStorage.getItem(getRecentStorageKey(swapType, chainId, side)); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } + })(); + + const recentAssets = recentAddresses + .map((addr) => assets.find((a) => a.addressToSwap.toLowerCase() === String(addr).toLowerCase())) + .filter(Boolean) as SwappableToken[]; + + const seen = new Set(); + const mergedPopular = [...recentAssets, ...popularAssets].filter((asset) => { + const key = asset.addressToSwap.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + const popularSectionTitle = recentAssets.length > 0 ? 'Recently used & Popular' : 'Popular'; + const handleSearchAssetChange = (value: string) => { + const searchQuery = value.trim().toLowerCase(); + const matchingAssets = assets.filter( + (asset) => + asset.symbol.toLowerCase().includes(searchQuery) || + asset.name.toLowerCase().includes(searchQuery) || + asset.addressToSwap.toLowerCase() === searchQuery + ); + if (matchingAssets.length === 0) { + // If custom tokens are not allowed, do not attempt to import by address + if (!allowCustomTokens) { + setLoadingNewAsset(false); + setFilteredAssets([]); + return; + } + + if (isAddress(value)) { + setLoadingNewAsset(true); + Promise.all([ + erc20Service.getTokenInfo(value, chainId), + erc20Service.getBalance(value, user, chainId), + ]) + .then(([tokenMetadata, userBalance]) => { + const tokenInfo = { + chainId: chainId, + balance: formatUnits(userBalance, tokenMetadata.decimals), + addressToSwap: tokenMetadata.address, + addressForUsdPrice: tokenMetadata.address, + underlyingAddress: tokenMetadata.address, + decimals: tokenMetadata.decimals, + symbol: tokenMetadata.symbol, + name: tokenMetadata.name, + tokenType: TokenType.USER_CUSTOM, + }; + setFilteredAssets([tokenInfo]); + }) + .catch(() => setFilteredAssets([])) + .finally(() => setLoadingNewAsset(false)); + return; + } + + setFilteredAssets([]); + } else { + setFilteredAssets(matchingAssets); + } + }; + + const handleCleanSearch = () => { + setFilteredAssets(assets); + setLoadingNewAsset(false); + }; + + return ( + + {title && ( + + {title} + + )} + ({ + border: `1px solid ${theme.palette.divider}`, + borderRadius: '6px', + overflow: 'hidden', + px: 3, + py: 2, + width: '100%', + ...(enableHover + ? { + transition: 'background-color 0.15s ease', + '&:hover': { + backgroundColor: 'background.surface', + }, + } + : {}), + })} + > + + {loading ? ( + + + + ) : ( + { + if (!onChange) return; + if (Number(e.target.value) > Number(maxValue)) { + onChange('-1'); + } else { + onChange(e.target.value); + } + }} + inputProps={{ + 'aria-label': 'amount input', + style: { + width: '100%', + fontSize: '21px', + lineHeight: '28,01px', + padding: 0, + height: '28px', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + }, + }} + // eslint-disable-next-line + inputComponent={NumberFormatCustom as any} + /> + )} + {value !== '' && !disableInput && ( + { + if (onClear) { + onClear(); + } else { + onChange && onChange(''); + } + }} + disabled={disabled} + > + + + )} + + + 3 ? 600 : 0 + )} + > + + + + + Select token + + + + + {networkName} + + + + + + 3 && mergedPopular.length > 0 + ? `1px solid ${theme.palette.divider}` + : 'none', + position: 'sticky', + top: 0, + zIndex: 2, + mb: 3, + pb: 3, + backgroundColor: theme.palette.background.paper, + boxShadow: '0px 4px 6px -6px rgba(0, 0, 0, 0.1)', + marginTop: -3, + paddingTop: 3, + }} + > + + {assets.length > 3 && ( + + + {popularSectionTitle} + + {mergedPopular.map((asset) => ( + handleSelect(asset)} + > + + + {asset.symbol} + + + ))} + + )} + + + {loadingNewAsset ? ( + + + + ) : filteredAssets.length > 0 ? ( + filteredAssets.map((asset) => ( + handleSelect(asset)} + > + + + + {asset.name || asset.symbol} + + + { + e.stopPropagation(); + }} + sx={{ + display: + asset.tokenType === TokenType.NATIVE ? 'none' : 'inline-flex', + alignItems: 'center', + textDecoration: 'none', + '&:hover .launch-icon-text': { + color: + theme.palette.mode === 'dark' + ? theme.palette.primary.light + : theme.palette.primary.main, + }, + '&:hover .launch-icon-svg': { + color: + theme.palette.mode === 'dark' + ? theme.palette.primary.light + : theme.palette.primary.main, + }, + }} + > + + {textCenterEllipsis( + (asset.underlyingAddress || asset.addressToSwap) ?? '', + 6, + 4 + )} + + + + + + {(() => { + const apy = getApyInfo(asset, swapType, side); + if (!apy) return null; + return ( + <> + + {' • '} + + + + + + + + ); + })()} + + + {asset.tokenType === TokenType.USER_CUSTOM && ( + + + + )} + + {asset.balance && ( + + )} + {asset.usdPrice && ( + + + + )} + + + )) + ) : ( + + {allowCustomTokens ? ( + + No results found. You can import a custom token with a contract address + + ) : ( + No results found. + )} + + )} + + + + + + + {loading ? ( + + ) : ( + + )} + + {showBalance && selectedAsset.balance && ( + <> + + {balanceTitle || 'Balance'} + + + {!disableInput && ( + { + const maxBase = forcedMaxValue || selectedAsset.balance || '0'; + const next = valueToBigNumber(maxBase).multipliedBy(fraction).toString(); + onChange && onChange(next); + }} + /> + )} + {!disableInput && ( + + )} + + )} + + + + ); +}; + +const PercentSelector = ({ + disabled, + onSelectPercent, +}: { + disabled?: boolean; + onSelectPercent: (fraction: number) => void; +}) => { + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + const handleOpen = (event: React.MouseEvent) => { + if (disabled) return; + setAnchorEl(event.currentTarget); + }; + const handleClose = () => setAnchorEl(null); + + const handlePick = (fraction: number) => { + onSelectPercent(fraction); + handleClose(); + }; + + return ( + <> + + + + v && handlePick(v)} + > + {[0.25, 0.5, 0.75].map((fraction) => ( + + + {Math.round(fraction * 100)}% + + + ))} + + + + + ); +}; diff --git a/src/components/transactions/Switch/ExpirySelector.tsx b/src/components/transactions/Swap/inputs/shared/ExpirySelector.tsx similarity index 65% rename from src/components/transactions/Switch/ExpirySelector.tsx rename to src/components/transactions/Swap/inputs/shared/ExpirySelector.tsx index 000cc46c15..feaef1dedc 100644 --- a/src/components/transactions/Switch/ExpirySelector.tsx +++ b/src/components/transactions/Swap/inputs/shared/ExpirySelector.tsx @@ -10,34 +10,28 @@ import { Typography, } from '@mui/material'; -const ONE_MINUTE_IN_SECONDS = 60; -const ONE_HOUR_IN_SECONDS = 3600; -const ONE_DAY_IN_SECONDS = 86400; -const ONE_MONTH_IN_SECONDS = 2592000; - -export const Expiry: { [key: string]: number } = { - 'Five minutes': ONE_MINUTE_IN_SECONDS * 5, - 'Half hour': ONE_HOUR_IN_SECONDS / 2, - 'One hour': ONE_HOUR_IN_SECONDS, - 'One day': ONE_DAY_IN_SECONDS, - 'One week': 7 * ONE_DAY_IN_SECONDS, - 'One month': ONE_MONTH_IN_SECONDS, - 'Three months': 3 * ONE_MONTH_IN_SECONDS, - 'One year': 12 * ONE_MONTH_IN_SECONDS, -}; +import { Expiry } from '../../types'; interface ExpirySelectorProps { - selectedExpiry: number; - setSelectedExpiry: (value: number) => void; + selectedExpiry: Expiry; + setSelectedExpiry: (value: Expiry) => void; } export const ExpirySelector = ({ selectedExpiry, setSelectedExpiry }: ExpirySelectorProps) => { const handleChange = (event: SelectChangeEvent) => { - setSelectedExpiry(Number(event.target.value)); + setSelectedExpiry(event.target.value as unknown as Expiry); }; return ( - Expires in + + + Expires in + +