diff --git a/pages/_app.page.tsx b/pages/_app.page.tsx index 64d8be9018..9f10c69dd5 100644 --- a/pages/_app.page.tsx +++ b/pages/_app.page.tsx @@ -54,6 +54,11 @@ const BridgeModal = dynamic(() => const BorrowModal = dynamic(() => import('src/components/transactions/Borrow/BorrowModal').then((module) => module.BorrowModal) ); +const BorrowModalSDK = dynamic(() => + import('src/components/transactions/Borrow/BorrowModalSDK').then( + (module) => module.BorrowModalSDK + ) +); const ClaimRewardsModal = dynamic(() => import('src/components/transactions/ClaimRewards/ClaimRewardsModal').then( (module) => module.ClaimRewardsModal @@ -71,6 +76,11 @@ const RepayModal = dynamic(() => const SupplyModal = dynamic(() => import('src/components/transactions/Supply/SupplyModal').then((module) => module.SupplyModal) ); +const SupplyModalSDK = dynamic(() => + import('src/components/transactions/Supply/SupplyModalSDK').then( + (module) => module.SupplyModalSDK + ) +); const WithdrawModal = dynamic(() => import('src/components/transactions/Withdraw/WithdrawModal').then( (module) => module.WithdrawModal @@ -168,8 +178,10 @@ export default function MyApp(props: MyAppProps) { {getLayout()} + + diff --git a/pages/reserve-overview.page.tsx b/pages/reserve-overview.page.tsx index eb3d7467b1..207b733565 100644 --- a/pages/reserve-overview.page.tsx +++ b/pages/reserve-overview.page.tsx @@ -5,12 +5,7 @@ import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; import StyledToggleButton from 'src/components/StyledToggleButton'; import StyledToggleButtonGroup from 'src/components/StyledToggleButtonGroup'; -import { - ComputedReserveData, - ReserveWithId, - useAppDataContext, -} from 'src/hooks/app-data-provider/useAppDataProvider'; -import { AssetCapsProvider } from 'src/hooks/useAssetCaps'; +import { ReserveWithId, useAppDataContext } from 'src/hooks/app-data-provider/useAppDataProvider'; import { AssetCapsProviderSDK } from 'src/hooks/useAssetCapsSDK'; import { MainLayout } from 'src/layouts/MainLayout'; import { ReserveActions } from 'src/modules/reserve-overview/ReserveActions'; @@ -46,7 +41,7 @@ const UnStakeModal = dynamic(() => export default function ReserveOverview() { const router = useRouter(); - const { supplyReserves, reserves } = useAppDataContext(); + const { supplyReserves } = useAppDataContext(); const underlyingAsset = router.query.underlyingAsset as string; const [mode, setMode] = useState<'overview' | 'actions' | ''>('overview'); @@ -57,10 +52,6 @@ export default function ReserveOverview() { return reserve.underlyingToken.address.toLowerCase() === underlyingAsset?.toLowerCase(); }) as ReserveWithId; - //With Reserves - const reserveLegacy = reserves.find((reserve) => { - return reserve.underlyingAsset.toLowerCase() === underlyingAsset?.toLowerCase(); - }) as ComputedReserveData; const [pageEventCalled, setPageEventCalled] = useState(false); useEffect(() => { @@ -127,10 +118,7 @@ export default function ReserveOverview() { width: { xs: '100%', lg: '416px' }, }} > - {/* Wrapped in AssetCapsProvider to provide the data using legacy method to avoid braking actions */} - - - + diff --git a/src/components/transactions/Borrow/BorrowActionsSDK.tsx b/src/components/transactions/Borrow/BorrowActionsSDK.tsx new file mode 100644 index 0000000000..f783f3f8cc --- /dev/null +++ b/src/components/transactions/Borrow/BorrowActionsSDK.tsx @@ -0,0 +1,232 @@ +import { bigDecimal, ChainId, evmAddress } from '@aave/client'; +import { approveBorrowCreditDelegation, borrow } from '@aave/client/actions'; +import { sendWith } from '@aave/client/viem'; +import { + API_ETH_MOCK_ADDRESS, + gasLimitRecommendations, + ProtocolAction, +} from '@aave/contract-helpers'; +import { valueToBigNumber } from '@aave/math-utils'; +import { Trans } from '@lingui/macro'; +import { BoxProps } from '@mui/material'; +import { useQueryClient } from '@tanstack/react-query'; +import { client } from 'pages/_app.page'; +import React, { useEffect, useState } from 'react'; +import { ReserveWithId, useAppDataContext } from 'src/hooks/app-data-provider/useAppDataProvider'; +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 { queryKeysFactory } from 'src/ui-config/queries'; +import { wagmiConfig } from 'src/ui-config/wagmiConfig'; +import { getWalletClient } from 'wagmi/actions'; +import { useShallow } from 'zustand/shallow'; + +import { TxActionsWrapper } from '../TxActionsWrapper'; +import { APPROVE_DELEGATION_GAS_LIMIT } from '../utils'; + +export interface BorrowActionsPropsSDK extends BoxProps { + poolReserve: ReserveWithId; + amountToBorrow: string; + poolAddress: string; + isWrongNetwork: boolean; + symbol: string; + borrowNative: boolean; + blocked: boolean; +} + +export const BorrowActionsSDK = React.memo( + ({ + symbol, + poolReserve, + amountToBorrow, + poolAddress, + isWrongNetwork, + borrowNative, + blocked, + sx, + }: BorrowActionsPropsSDK) => { + const [currentMarketData, addTransaction] = useRootStore( + useShallow((state) => [state.currentMarketData, state.addTransaction]) + ); + const { + approvalTxState, + mainTxState, + loadingTxns, + setMainTxState, + setTxError, + setGasLimit, + setLoadingTxns, + setApprovalTxState, + } = useModalContext(); + + const queryClient = useQueryClient(); + const { borrowReserves } = useAppDataContext(); + const { currentAccount, chainId: userChainId } = useWeb3Context(); + const [requiresApproval, setRequiresApproval] = useState( + borrowNative && poolAddress === API_ETH_MOCK_ADDRESS + ); + useEffect(() => { + setRequiresApproval( + borrowNative && poolAddress === API_ETH_MOCK_ADDRESS && !approvalTxState.success + ); + }, [borrowNative, poolAddress, approvalTxState.success]); + + const handleApproval = async () => { + if (!currentAccount) return; + try { + setApprovalTxState({ ...approvalTxState, loading: true }); + const walletClient = await getWalletClient(wagmiConfig, { + chainId: currentMarketData.chainId ?? userChainId, + }); + if (!walletClient) { + throw new Error('Wallet client not available'); + } + + const approvalResult = await approveBorrowCreditDelegation(client, { + market: evmAddress(currentMarketData.addresses.LENDING_POOL), + underlyingToken: evmAddress(poolReserve.underlyingToken.address), + amount: bigDecimal(amountToBorrow), + user: evmAddress(currentAccount), + delegatee: evmAddress(currentMarketData.addresses.WETH_GATEWAY ?? currentAccount), + chainId: (currentMarketData.chainId ?? userChainId) as ChainId, + }).andThen(sendWith(walletClient)); + + if (approvalResult.isErr()) { + const parsedError = getErrorTextFromError( + approvalResult.error as Error, + TxAction.APPROVAL, + false + ); + setTxError(parsedError); + setApprovalTxState({ txHash: undefined, loading: false }); + return; + } + + const txHash = String(approvalResult.value); + setApprovalTxState({ txHash, loading: false, success: true }); + setRequiresApproval(false); + setTxError(undefined); + } catch (error) { + const parsedError = getErrorTextFromError(error as Error, TxAction.APPROVAL, false); + setTxError(parsedError); + setApprovalTxState({ txHash: undefined, loading: false }); + } + }; + + useEffect(() => { + let borrowGasLimit = Number(gasLimitRecommendations[ProtocolAction.borrow].recommended); + if (requiresApproval && !approvalTxState.success) { + borrowGasLimit += Number(APPROVE_DELEGATION_GAS_LIMIT); + } + setGasLimit(borrowGasLimit.toString()); + }, [requiresApproval, approvalTxState.success, setGasLimit]); + + const handleAction = async () => { + if (!amountToBorrow || Number(amountToBorrow) === 0) return; + if (requiresApproval && !approvalTxState.success) { + setTxError( + getErrorTextFromError( + new Error('Approval required before borrowing'), + TxAction.APPROVAL, + false + ) + ); + return; + } + try { + setLoadingTxns(true); + setMainTxState({ ...mainTxState, loading: true }); + setTxError(undefined); + + const walletClient = await getWalletClient(wagmiConfig, { + chainId: currentMarketData.chainId ?? userChainId, + }); + + if (!walletClient) { + throw new Error('Wallet client not available'); + } + const amountInput = borrowNative + ? { native: bigDecimal(amountToBorrow) } + : { + erc20: { + currency: evmAddress(poolAddress), + value: bigDecimal(amountToBorrow), + }, + }; + + const result = await borrow(client, { + market: evmAddress(currentMarketData.addresses.LENDING_POOL), + amount: amountInput, + sender: evmAddress(currentAccount), + chainId: (currentMarketData.chainId ?? userChainId) as ChainId, + }) + .andThen(sendWith(walletClient)) + .andThen(client.waitForTransaction); + if (result.isErr()) { + const parsedError = getErrorTextFromError( + result.error as Error, + TxAction.MAIN_ACTION, + false + ); + setTxError(parsedError); + setMainTxState({ txHash: undefined, loading: false }); + return; + } + + const txHash = String(result.value); + setMainTxState({ + txHash, + loading: false, + success: true, + }); + + addTransaction(txHash, { + action: ProtocolAction.borrow, + txState: 'success', + asset: poolAddress, + amount: amountToBorrow, + assetName: symbol, + amountUsd: (() => { + const reserve = borrowReserves.find( + (r) => r.underlyingToken.address.toLowerCase() === poolAddress.toLowerCase() + ); + return reserve + ? valueToBigNumber(amountToBorrow).multipliedBy(reserve.usdExchangeRate).toString() + : undefined; + })(), + }); + + queryClient.invalidateQueries({ queryKey: queryKeysFactory.pool }); + queryClient.invalidateQueries({ queryKey: queryKeysFactory.gho }); + } catch (error) { + const parsedError = getErrorTextFromError(error as Error, TxAction.MAIN_ACTION, false); + setTxError(parsedError); + setMainTxState({ + txHash: undefined, + loading: false, + }); + } finally { + setLoadingTxns(false); + } + }; + + return ( + Borrow {symbol}} + actionInProgressText={Borrowing {symbol}} + handleApproval={requiresApproval ? handleApproval : undefined} + requiresApproval={requiresApproval} + preparingTransactions={loadingTxns} + sx={sx} + /> + ); + } +); diff --git a/src/components/transactions/Borrow/BorrowModalContentSDK.tsx b/src/components/transactions/Borrow/BorrowModalContentSDK.tsx new file mode 100644 index 0000000000..523f6f7002 --- /dev/null +++ b/src/components/transactions/Borrow/BorrowModalContentSDK.tsx @@ -0,0 +1,281 @@ +import { healthFactorPreview } from '@aave/client/actions'; +import { API_ETH_MOCK_ADDRESS } from '@aave/contract-helpers'; +import { valueToBigNumber } from '@aave/math-utils'; +import { bigDecimal, ChainId, evmAddress } from '@aave/types'; +import { Trans } from '@lingui/macro'; +import { Typography } from '@mui/material'; +import { client } from 'pages/_app.page'; +import { useEffect, useState } from 'react'; +import { mapAaveProtocolIncentives } from 'src/components/incentives/incentives.helper'; +import { ExtendedFormattedUser } from 'src/hooks/app-data-provider/useAppDataProvider'; +import { useAssetCapsSDK } from 'src/hooks/useAssetCapsSDK'; +import { useModalContext } from 'src/hooks/useModal'; +import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; +import { ERC20TokenType } from 'src/libs/web3-data-provider/Web3Provider'; +import { useRootStore } from 'src/store/root'; +import { GENERAL } from 'src/utils/events'; +import { roundToTokenDecimals } from 'src/utils/utils'; + +import { CapType } from '../../caps/helper'; +import { AssetInput } from '../AssetInput'; +import { GasEstimationError } from '../FlowCommons/GasEstimationError'; +import { ModalWrapperSDKProps } from '../FlowCommons/ModalWrapperSDK'; +import { TxSuccessView } from '../FlowCommons/Success'; +import { + DetailsHFLine, + DetailsIncentivesLine, + DetailsUnwrapSwitch, + TxModalDetails, +} from '../FlowCommons/TxModalDetails'; +import { BorrowActionsSDK } from './BorrowActionsSDK'; +import { BorrowAmountWarning } from './BorrowAmountWarning'; +import { ParameterChangewarning } from './ParameterChangewarning'; + +export enum ErrorType { + STABLE_RATE_NOT_ENABLED, + NOT_ENOUGH_LIQUIDITY, + BORROWING_NOT_AVAILABLE, + NOT_ENOUGH_BORROWED, +} + +export const BorrowModalContentSDK = ({ + underlyingAsset, + isWrongNetwork, + poolReserve, + unwrap: borrowUnWrapped, + setUnwrap: setBorrowUnWrapped, + symbol, + user, +}: ModalWrapperSDKProps & { + unwrap: boolean; + setUnwrap: (unwrap: boolean) => void; + user: ExtendedFormattedUser; +}) => { + const { mainTxState: borrowTxState, gasLimit, txError } = useModalContext(); + const currentMarketData = useRootStore((state) => state.currentMarketData); + const { currentAccount } = useWeb3Context(); + const currentNetworkConfig = useRootStore((store) => store.currentNetworkConfig); + const { borrowCap } = useAssetCapsSDK(); + const borrowNative = borrowUnWrapped && !!poolReserve.acceptsNative; + const borrowProtocolIncentives = mapAaveProtocolIncentives(poolReserve.incentives, 'borrow'); + + const [amount, setAmount] = useState(''); + const [hfPreviewAfter, setHfPreviewAfter] = useState(); + const [riskCheckboxAccepted, setRiskCheckboxAccepted] = useState(false); + + // amount calculations + const maxAmountToBorrow = poolReserve.userState?.borrowable.amount.value || '0'; + + // We set this in a useEffect, so it doesn't constantly change when + // max amount selected + const handleChange = (_value: string) => { + if (_value === '-1') { + setAmount(maxAmountToBorrow); + } else { + const decimalTruncatedValue = roundToTokenDecimals( + _value, + poolReserve.underlyingToken.decimals + ); + setAmount(decimalTruncatedValue); + } + }; + + const isMaxSelected = amount === maxAmountToBorrow; + + // health factor calculations + useEffect(() => { + const timer = setTimeout(async () => { + if (!amount || amount === '0') { + setHfPreviewAfter(undefined); + return; + } + + try { + const requestAmount = + borrowUnWrapped && poolReserve.acceptsNative + ? { native: bigDecimal(amount) } + : { + erc20: { + currency: evmAddress(poolReserve.underlyingToken.address), + value: bigDecimal(amount), + }, + }; + + const result = await healthFactorPreview(client, { + action: { + borrow: { + market: evmAddress(currentMarketData.addresses.LENDING_POOL), + amount: requestAmount, + sender: evmAddress(currentAccount), + onBehalfOf: evmAddress(currentAccount), + chainId: currentMarketData.chainId as ChainId, + }, + }, + }); + + if (result.isOk()) { + setHfPreviewAfter(result.value.after?.toString()); + } else { + setHfPreviewAfter(undefined); + } + } catch (error) { + setHfPreviewAfter(undefined); + } + }, 300); + + return () => clearTimeout(timer); + }, [ + amount, + currentAccount, + currentMarketData.addresses.LENDING_POOL, + currentMarketData.chainId, + poolReserve.acceptsNative, + poolReserve.underlyingToken.address, + poolReserve.underlyingToken.decimals, + borrowUnWrapped, + ]); + + const hf = hfPreviewAfter ?? '-1'; + const hfBN = valueToBigNumber(hf); + const displayRiskCheckbox = !hfBN.isEqualTo(-1) && hfBN.lt(1.5); + + // calculating input usd value + const usdValue = valueToBigNumber(amount).multipliedBy(poolReserve.usdExchangeRate ?? '0'); + + // error types handling + let blockingError: ErrorType | undefined = undefined; + if (valueToBigNumber(amount).gt(poolReserve.borrowInfo?.availableLiquidity.amount.value || '0')) { + blockingError = ErrorType.NOT_ENOUGH_LIQUIDITY; + } else if (poolReserve.borrowInfo?.borrowingState !== 'ENABLED') { + blockingError = ErrorType.BORROWING_NOT_AVAILABLE; + } + + // error render handling + const handleBlocked = () => { + switch (blockingError) { + case ErrorType.BORROWING_NOT_AVAILABLE: + return ( + + Borrowing is currently unavailable for {poolReserve.underlyingToken.symbol}. + + ); + case ErrorType.NOT_ENOUGH_LIQUIDITY: + return ( + <> + + There are not enough funds in the + {poolReserve.underlyingToken.symbol} + reserve to borrow + + + ); + default: + return null; + } + }; + + // token info to add to wallet + const addToken: ERC20TokenType = { + address: underlyingAsset, + symbol: poolReserve.underlyingToken.symbol, + decimals: poolReserve.underlyingToken.decimals, + }; + + const iconSymbol = + borrowUnWrapped && !!poolReserve.acceptsNative + ? currentNetworkConfig.baseAssetSymbol + : poolReserve.underlyingToken.symbol; + + if (borrowTxState.success) + return ( + Borrowed} + amount={amount} + symbol={iconSymbol} + addToken={borrowUnWrapped && !!poolReserve.acceptsNative ? undefined : addToken} + /> + ); + + return ( + <> + {borrowCap.determineWarningDisplay({ borrowCap })} + + Available} + event={{ + eventName: GENERAL.MAX_INPUT_SELECTION, + eventParams: { + asset: poolReserve.underlyingToken.address, + assetName: poolReserve.underlyingToken.name, + }, + }} + /> + + {blockingError !== undefined && ( + + {handleBlocked()} + + )} + + {!!poolReserve.acceptsNative && ( + {`Unwrap ${poolReserve.underlyingToken.symbol} (to borrow ${currentNetworkConfig.baseAssetSymbol})`} + } + /> + )} + + + + + + + {txError && } + + {displayRiskCheckbox && ( + { + setRiskCheckboxAccepted(!riskCheckboxAccepted); + }} + /> + )} + + + + + + ); +}; diff --git a/src/components/transactions/Borrow/BorrowModalSDK.tsx b/src/components/transactions/Borrow/BorrowModalSDK.tsx new file mode 100644 index 0000000000..4067d3a63c --- /dev/null +++ b/src/components/transactions/Borrow/BorrowModalSDK.tsx @@ -0,0 +1,52 @@ +import { Trans } from '@lingui/macro'; +import React, { useState } from 'react'; +import { UserAuthenticated } from 'src/components/UserAuthenticated'; +import { ModalContextType, ModalType, useModalContext } from 'src/hooks/useModal'; +import { useRootStore } from 'src/store/root'; +import { GENERAL } from 'src/utils/events'; + +import { BasicModal } from '../../primitives/BasicModal'; +import { ModalWrapperSDK } from '../FlowCommons/ModalWrapperSDK'; +import { BorrowModalContentSDK } from './BorrowModalContentSDK'; + +export const BorrowModalSDK = () => { + const { type, close, args } = useModalContext() as ModalContextType<{ + underlyingAsset: string; + }>; + + const [borrowUnWrapped, setBorrowUnWrapped] = useState(true); + const trackEvent = useRootStore((store) => store.trackEvent); + + const handleBorrowUnwrapped = (borrowUnWrapped: boolean) => { + trackEvent(GENERAL.OPEN_MODAL, { + modal: 'Unwrap Asset', + asset: args.underlyingAsset, + assetWrapped: borrowUnWrapped, + }); + setBorrowUnWrapped(borrowUnWrapped); + }; + + return ( + + Borrow} + underlyingAsset={args.underlyingAsset} + keepWrappedSymbol={!borrowUnWrapped} + > + {(params) => ( + + {(user) => ( + + )} + + )} + + + ); +}; diff --git a/src/components/transactions/FlowCommons/ModalWrapper.tsx b/src/components/transactions/FlowCommons/ModalWrapper.tsx index 00d3cdca06..8ee6de2e3c 100644 --- a/src/components/transactions/FlowCommons/ModalWrapper.tsx +++ b/src/components/transactions/FlowCommons/ModalWrapper.tsx @@ -65,7 +65,6 @@ export const ModalWrapper: React.FC<{ return reserve.isWrappedBaseAsset; return underlyingAsset === reserve.underlyingAsset; }) as ComputedReserveData; - const userReserve = user?.userReservesData.find((userReserve) => { if (underlyingAsset.toLowerCase() === API_ETH_MOCK_ADDRESS.toLowerCase()) return userReserve.reserve.isWrappedBaseAsset; diff --git a/src/components/transactions/FlowCommons/ModalWrapperSDK.tsx b/src/components/transactions/FlowCommons/ModalWrapperSDK.tsx new file mode 100644 index 0000000000..76584887fa --- /dev/null +++ b/src/components/transactions/FlowCommons/ModalWrapperSDK.tsx @@ -0,0 +1,118 @@ +import { API_ETH_MOCK_ADDRESS } from '@aave/contract-helpers'; +import { MarketUserState } from '@aave/graphql/import'; +import React from 'react'; +import { ReactElement } from 'react-markdown/lib/react-markdown'; +import { ReserveWithId, useAppDataContext } from 'src/hooks/app-data-provider/useAppDataProvider'; +import { useWalletBalances } from 'src/hooks/app-data-provider/useWalletBalances'; +import { AssetCapsProviderSDK } from 'src/hooks/useAssetCapsSDK'; +import { useIsWrongNetwork } from 'src/hooks/useIsWrongNetwork'; +import { useModalContext } from 'src/hooks/useModal'; +import { useWeb3Context } from 'src/libs/hooks/useWeb3Context'; +import { useRootStore } from 'src/store/root'; +import { GENERAL } from 'src/utils/events'; +import { getNetworkConfig } from 'src/utils/marketsAndNetworksConfig'; + +import { ChangeNetworkWarning } from '../Warnings/ChangeNetworkWarning'; +import { TxErrorView } from './Error'; +import { TxModalTitle } from './TxModalTitle'; + +export interface ModalWrapperSDKProps { + underlyingAsset: string; + poolReserve: ReserveWithId; + reserveUserState?: ReserveWithId['userState']; + marketUserState?: MarketUserState | null; + symbol: string; + tokenBalance: string; + nativeBalance: string; + isWrongNetwork: boolean; + action?: string; +} + +export const ModalWrapperSDK: React.FC<{ + underlyingAsset: string; + title: ReactElement; + requiredChainId?: number; + // if true wETH will stay wETH otherwise wETH will be returned as ETH + keepWrappedSymbol?: boolean; + hideTitleSymbol?: boolean; + children: (props: ModalWrapperSDKProps) => React.ReactNode; + action?: string; +}> = ({ + hideTitleSymbol, + underlyingAsset, + children, + requiredChainId: _requiredChainId, + title, + keepWrappedSymbol, +}) => { + const { readOnlyModeAddress } = useWeb3Context(); + const currentMarketData = useRootStore((store) => store.currentMarketData); + const currentNetworkConfig = useRootStore((store) => store.currentNetworkConfig); + const { walletBalances } = useWalletBalances(currentMarketData); + const { supplyReserves, borrowReserves, market: sdkMarket } = useAppDataContext(); + const { txError, mainTxState } = useModalContext(); + + const { isWrongNetwork, requiredChainId } = useIsWrongNetwork(_requiredChainId); + + if (txError && txError.blocking) { + return ; + } + const addr = underlyingAsset.toLowerCase(); + const poolReserveSDK = + supplyReserves.find((reserve) => + addr === API_ETH_MOCK_ADDRESS.toLowerCase() + ? !!reserve.acceptsNative + : addr === reserve.underlyingToken.address.toLowerCase() + ) || + borrowReserves.find((reserve) => + addr === API_ETH_MOCK_ADDRESS.toLowerCase() + ? !!reserve.acceptsNative + : addr === reserve.underlyingToken.address.toLowerCase() + ); + + if (!poolReserveSDK) throw new Error(`Reserve not found for ${underlyingAsset}`); + const reserveUserState = poolReserveSDK.userState; + const marketUserState = sdkMarket?.userState; + + const symbol = + poolReserveSDK.acceptsNative && !keepWrappedSymbol + ? currentNetworkConfig.baseAssetSymbol + : poolReserveSDK.underlyingToken.symbol; + const nativeKey = API_ETH_MOCK_ADDRESS.toLowerCase(); + const tokenKey = poolReserveSDK.underlyingToken.address.toLowerCase(); + const nativeBalance = walletBalances[nativeKey]?.amount || '0'; + const tokenBalance = + addr === nativeKey && poolReserveSDK.acceptsNative + ? nativeBalance + : walletBalances[tokenKey]?.amount || '0'; + return ( + + {!mainTxState.success && ( + + )} + {isWrongNetwork && !readOnlyModeAddress && ( + + )} + {children({ + isWrongNetwork, + nativeBalance, + tokenBalance, + poolReserve: poolReserveSDK, + symbol, + underlyingAsset, + reserveUserState: reserveUserState, + marketUserState: marketUserState, + })} + + ); +}; diff --git a/src/components/transactions/FlowCommons/TxModalDetails.tsx b/src/components/transactions/FlowCommons/TxModalDetails.tsx index 41600c9360..cc99e00137 100644 --- a/src/components/transactions/FlowCommons/TxModalDetails.tsx +++ b/src/components/transactions/FlowCommons/TxModalDetails.tsx @@ -292,7 +292,7 @@ export const DetailsIncentivesLine = ({ export interface DetailsHFLineProps { healthFactor: string; - futureHealthFactor: string; + futureHealthFactor: string | undefined; visibleHfChange: boolean; loading?: boolean; } @@ -324,7 +324,7 @@ export const DetailsHFLine = ({ {ArrowRightIcon} diff --git a/src/components/transactions/Supply/SupplyActionsSDK.tsx b/src/components/transactions/Supply/SupplyActionsSDK.tsx new file mode 100644 index 0000000000..c5787c5272 --- /dev/null +++ b/src/components/transactions/Supply/SupplyActionsSDK.tsx @@ -0,0 +1,248 @@ +import { bigDecimal, chainId as sdkChainId, evmAddress, signatureFrom } from '@aave/client'; +import { supply } from '@aave/client/actions'; +import { sendWith } from '@aave/client/viem'; +import { + API_ETH_MOCK_ADDRESS, + gasLimitRecommendations, + ProtocolAction, +} from '@aave/contract-helpers'; +import { valueToBigNumber } from '@aave/math-utils'; +import { Trans } from '@lingui/macro'; +import { BoxProps } from '@mui/material'; +import { useQueryClient } from '@tanstack/react-query'; +import { client } from 'pages/_app.page'; +import React, { useEffect, useState } from 'react'; +import { useAppDataContext } from 'src/hooks/app-data-provider/useAppDataProvider'; +import { SignedParams, useApprovalTx } from 'src/hooks/useApprovalTx'; +import { usePoolApprovedAmount } from 'src/hooks/useApprovedAmount'; +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 { wagmiConfig } from 'src/ui-config/wagmiConfig'; +import { getWalletClient } from 'wagmi/actions'; +import { useShallow } from 'zustand/shallow'; + +import { TxActionsWrapper } from '../TxActionsWrapper'; +import { APPROVAL_GAS_LIMIT, checkRequiresApproval } from '../utils'; + +export interface SupplyActionPropsSDK extends BoxProps { + amountToSupply: string; + isWrongNetwork: boolean; + customGasPrice?: string; + poolAddress: string; + symbol: string; + blocked: boolean; + decimals: number; + isWrappedBaseAsset: boolean; + setShowUSDTResetWarning?: (showUSDTResetWarning: boolean) => void; + chainId?: number; +} + +export const SupplyActionsSDK = React.memo( + ({ + amountToSupply, + poolAddress, + isWrongNetwork, + sx, + symbol, + blocked, + decimals, + isWrappedBaseAsset, + setShowUSDTResetWarning, + chainId, + ...props + }: SupplyActionPropsSDK) => { + const [tryPermit, walletApprovalMethodPreference, addTransaction, currentMarketData] = + useRootStore( + useShallow((state) => [ + state.tryPermit, + state.walletApprovalMethodPreference, + state.addTransaction, + state.currentMarketData, + ]) + ); + const { supplyReserves } = useAppDataContext(); + const { + approvalTxState, + mainTxState, + loadingTxns, + setLoadingTxns, + setMainTxState, + setGasLimit, + setTxError, + setApprovalTxState, + } = useModalContext(); + const { currentAccount, chainId: userChainId } = useWeb3Context(); + const queryClient = useQueryClient(); + const [signatureParams, setSignatureParams] = useState(); + + const isNativeSupply = poolAddress.toLowerCase() === API_ETH_MOCK_ADDRESS.toLowerCase(); + const permitAvailable = tryPermit({ reserveAddress: poolAddress, isWrappedBaseAsset }); + + const { + data: approvedAmount, + refetch: fetchApprovedAmount, + isRefetching: fetchingApprovedAmount, + isFetchedAfterMount, + } = usePoolApprovedAmount(currentMarketData, poolAddress); + + useEffect(() => { + setLoadingTxns(fetchingApprovedAmount); + }, [fetchingApprovedAmount, setLoadingTxns]); + + useEffect(() => { + if (!isFetchedAfterMount) { + fetchApprovedAmount(); + } + }, [fetchApprovedAmount, isFetchedAfterMount]); + + const requiresApproval = + Number(amountToSupply) !== 0 && + checkRequiresApproval({ + approvedAmount: approvedAmount?.amount || '0', + amount: amountToSupply, + signedAmount: signatureParams ? signatureParams.amount : '0', + }); + + if (requiresApproval && approvalTxState?.success) { + setApprovalTxState({}); + } + + const usePermit = permitAvailable && walletApprovalMethodPreference === ApprovalMethod.PERMIT; + + const { approval, requiresApprovalReset } = useApprovalTx({ + usePermit, + approvedAmount, + requiresApproval, + assetAddress: poolAddress, + symbol, + decimals, + signatureAmount: amountToSupply, + onApprovalTxConfirmed: fetchApprovedAmount, + onSignTxCompleted: (signedParams) => setSignatureParams(signedParams), + chainId, + setShowUSDTResetWarning, + }); + + useEffect(() => { + let supplyGasLimit = Number(gasLimitRecommendations[ProtocolAction.supply].recommended); + if (requiresApproval && !approvalTxState.success) { + supplyGasLimit += Number(APPROVAL_GAS_LIMIT); + } + setGasLimit(String(supplyGasLimit)); + }, [approvalTxState.success, requiresApproval, setGasLimit]); + + const handleAction = async () => { + if (!currentAccount || !amountToSupply || Number(amountToSupply) === 0) return; + + try { + setLoadingTxns(true); + setMainTxState({ ...mainTxState, loading: true }); + setTxError(undefined); + + const walletClient = await getWalletClient(wagmiConfig, { + chainId: chainId ?? currentMarketData.chainId ?? userChainId, + }); + + if (!walletClient) { + throw new Error('Wallet client not available'); + } + + const amountInput = isNativeSupply + ? { native: bigDecimal(amountToSupply) } + : { + erc20: { + currency: evmAddress(poolAddress), + value: bigDecimal(amountToSupply), + permitSig: + usePermit && signatureParams + ? { + value: signatureFrom(signatureParams.signature as string), + deadline: Number(signatureParams.deadline), + } + : null, + }, + }; + + const result = await supply(client, { + market: evmAddress(currentMarketData.addresses.LENDING_POOL), + amount: amountInput, + sender: evmAddress(currentAccount), + onBehalfOf: evmAddress(currentAccount), + chainId: sdkChainId(chainId ?? currentMarketData.chainId), + }) + .andThen(sendWith(walletClient)) + .andThen(client.waitForTransaction); + if (result.isErr()) { + const parsedError = getErrorTextFromError( + result.error as Error, + TxAction.MAIN_ACTION, + false + ); + setTxError(parsedError); + setMainTxState({ txHash: undefined, loading: false }); + return; + } + + const txHash = String(result.value); + setMainTxState({ + txHash, + loading: false, + success: true, + }); + + addTransaction(txHash, { + action: ProtocolAction.supply, + txState: 'success', + asset: poolAddress, + amount: amountToSupply, + assetName: symbol, + amountUsd: (() => { + const reserve = supplyReserves.find( + (r) => r.underlyingToken.address.toLowerCase() === poolAddress.toLowerCase() + ); + return reserve + ? valueToBigNumber(amountToSupply).multipliedBy(reserve.usdExchangeRate).toString() + : undefined; + })(), + }); + + queryClient.invalidateQueries({ queryKey: queryKeysFactory.pool }); + } catch (error) { + const parsedError = getErrorTextFromError(error as Error, TxAction.MAIN_ACTION, false); + setTxError(parsedError); + setMainTxState({ + txHash: undefined, + loading: false, + }); + } finally { + setLoadingTxns(false); + } + }; + + return ( + Supply {symbol}} + actionInProgressText={Supplying {symbol}} + handleApproval={requiresApproval ? approval : undefined} + handleAction={handleAction} + requiresApproval={requiresApproval} + tryPermit={permitAvailable} + requiresApprovalReset={requiresApprovalReset} + sx={sx} + {...props} + /> + ); + } +); diff --git a/src/components/transactions/Supply/SupplyModal.tsx b/src/components/transactions/Supply/SupplyModal.tsx index 9f6e8b6a06..25c0abe895 100644 --- a/src/components/transactions/Supply/SupplyModal.tsx +++ b/src/components/transactions/Supply/SupplyModal.tsx @@ -11,7 +11,6 @@ export const SupplyModal = () => { const { type, close, args } = useModalContext() as ModalContextType<{ underlyingAsset: string; }>; - return ( { + const currentMarketData = useRootStore((state) => state.currentMarketData); + const wrappedTokenReserves = useWrappedTokens(); + const { walletBalances } = useWalletBalances(currentMarketData); + const { supplyCap: supplyCapUsage, debtCeiling: debtCeilingUsage } = useAssetCapsSDK(); + const { supplyReserves } = useAppDataContext(); + const { poolReserve, reserveUserState, marketUserState } = params; + + const wrappedToken = wrappedTokenReserves.find( + (r) => r.tokenOut.underlyingAsset === params.underlyingAsset + ); + const canSupplyAsWrappedToken = + wrappedToken && + walletBalances[wrappedToken.tokenIn.underlyingAsset.toLowerCase()].amount !== '0'; + + const hasDifferentCollateral = supplyReserves.some( + (r) => + r.id !== poolReserve.id && + r.userState?.balance.amount.value !== '0' && + r.userState?.canBeCollateral === true + ); + + const showIsolationWarning = + !marketUserState?.isInIsolationMode && + !!poolReserve.isolationModeConfig?.canBeCollateral && + !hasDifferentCollateral && + (reserveUserState?.balance.amount.value !== '0' + ? reserveUserState?.canBeCollateral === true + : true); + + const props: SupplyModalContentPropsSDK = { + ...params, + isolationModeWarning: showIsolationWarning ? ( + + ) : null, + addTokenProps: { + address: poolReserve.aToken.address, + symbol: poolReserve.underlyingToken.symbol, + decimals: poolReserve.underlyingToken.decimals, + aToken: true, + }, + collateralType: getAssetCollateralTypeSdk({ + reserve: poolReserve, + reserveUserState, + marketUserState, + debtCeilingIsMaxed: debtCeilingUsage.isMaxed, + }), + supplyCapWarning: supplyCapUsage.determineWarningDisplay({ supplyCap: supplyCapUsage }), + debtCeilingWarning: debtCeilingUsage.determineWarningDisplay({ debtCeiling: debtCeilingUsage }), + wrappedTokenConfig: wrappedTokenReserves.find( + (r) => r.tokenOut.underlyingAsset === params.underlyingAsset + ), + }; + + return canSupplyAsWrappedToken ? ( + + ) : ( + + ); +}; + +interface SupplyModalContentPropsSDK extends ModalWrapperSDKProps { + addTokenProps: ERC20TokenType; + collateralType: CollateralType; + isolationModeWarning: React.ReactNode; + supplyCapWarning: React.ReactNode; + debtCeilingWarning: React.ReactNode; + wrappedTokenConfig?: WrappedTokenConfig; + user: ExtendedFormattedUser; +} + +export const SupplyModalContentSDK = React.memo( + ({ + underlyingAsset, + poolReserve, + isWrongNetwork, + nativeBalance, + tokenBalance, + isolationModeWarning, + addTokenProps, + collateralType, + supplyCapWarning, + debtCeilingWarning, + user, + }: SupplyModalContentPropsSDK) => { + const { mainTxState: supplyTxState, gasLimit, txError } = useModalContext(); + const { chainId, currentAccount } = useWeb3Context(); + const [currentMarketData, currentNetworkConfig] = useRootStore( + useShallow((state) => [state.currentMarketData, state.currentNetworkConfig]) + ); + + // states + const [amount, setAmount] = useState(''); + const [showUSDTResetWarning, setShowUSDTResetWarning] = useState(false); + const [hfPreviewAfter, setHfPreviewAfter] = useState(); + const supplyUnWrapped = underlyingAsset.toLowerCase() === API_ETH_MOCK_ADDRESS.toLowerCase(); + const supplyProtocolIncentives = mapAaveProtocolIncentives(poolReserve.incentives, 'supply'); + const walletBalance = supplyUnWrapped ? nativeBalance : tokenBalance; + + const supplyApy = poolReserve.supplyInfo.apy.value; + + // Calculate max amount to supply + const maxAmountToSupply = getMaxAmountAvailableToSupplySDK( + walletBalance, + poolReserve, + underlyingAsset.toLowerCase() + ); + + const handleChange = (value: string) => { + if (value === '-1') { + setAmount(maxAmountToSupply); + } else { + const decimalTruncatedValue = roundToTokenDecimals( + value, + poolReserve.underlyingToken.decimals + ); + setAmount(decimalTruncatedValue); + } + }; + + const amountInUsd = valueToBigNumber(amount).multipliedBy(poolReserve.usdExchangeRate ?? '0'); + + const isMaxSelected = amount === maxAmountToSupply; + + useEffect(() => { + const timer = setTimeout(async () => { + if (!amount || amount === '0' || !currentAccount) { + setHfPreviewAfter(undefined); + return; + } + + try { + const requestAmount = + supplyUnWrapped && poolReserve.acceptsNative + ? { native: bigDecimal(amount) } + : { + erc20: { + currency: evmAddress(poolReserve.underlyingToken.address), + value: bigDecimal(amount), + permitSig: null, + }, + }; + + const result = await healthFactorPreview(client, { + action: { + supply: { + market: evmAddress(currentMarketData.addresses.LENDING_POOL), + amount: requestAmount, + sender: evmAddress(currentAccount), + onBehalfOf: evmAddress(currentAccount), + chainId: currentMarketData.chainId as ChainId, + }, + }, + }); + + if (result.isOk()) { + setHfPreviewAfter(result.value.after?.toString()); + } else { + setHfPreviewAfter(undefined); + } + } catch (error) { + setHfPreviewAfter(undefined); + } + }, 300); + + return () => clearTimeout(timer); + }, [ + amount, + currentAccount, + currentMarketData.addresses.LENDING_POOL, + currentMarketData.chainId, + poolReserve.acceptsNative, + poolReserve.underlyingToken.address, + poolReserve.underlyingToken.decimals, + supplyUnWrapped, + ]); + + const supplyActionsProps = { + amountToSupply: amount, + isWrongNetwork, + poolAddress: supplyUnWrapped ? API_ETH_MOCK_ADDRESS : poolReserve.underlyingToken.address, + symbol: supplyUnWrapped + ? currentNetworkConfig.baseAssetSymbol + : poolReserve.underlyingToken.symbol, + blocked: false, + decimals: poolReserve.underlyingToken.decimals, + isWrappedBaseAsset: !!poolReserve.acceptsNative, + setShowUSDTResetWarning, + chainId, + }; + + if (supplyTxState.success) + return ( + Supplied} + amount={amount} + symbol={ + supplyUnWrapped + ? currentNetworkConfig.baseAssetSymbol + : poolReserve.underlyingToken.symbol + } + addToken={addTokenProps} + /> + ); + + return ( + <> + {isolationModeWarning} + {supplyCapWarning} + {debtCeilingWarning} + {poolReserve.underlyingToken.symbol === 'AMPL' && ( + + + + )} + {process.env.NEXT_PUBLIC_ENABLE_STAKING === 'true' && + poolReserve.underlyingToken.symbol === 'AAVE' && + isFeatureEnabled.staking(currentMarketData) && } + {poolReserve.underlyingToken.symbol === 'SNX' && maxAmountToSupply !== '0' && ( + + )} + + Wallet balance} + event={{ + eventName: GENERAL.MAX_INPUT_SELECTION, + eventParams: { + asset: poolReserve.underlyingToken.address, + assetName: poolReserve.underlyingToken.name, + }, + }} + /> + + + Supply APY} value={supplyApy} percent /> + + + + + + {txError && } + + {showUSDTResetWarning && } + + + + ); + } +); + +export const SupplyWrappedTokenModalContentSDK = ({ + poolReserve, + wrappedTokenConfig, + isolationModeWarning, + supplyCapWarning, + debtCeilingWarning, + addTokenProps, + collateralType, + isWrongNetwork, + user, +}: SupplyModalContentPropsSDK) => { + const currentMarketData = useRootStore((state) => state.currentMarketData); + const { mainTxState: supplyTxState, gasLimit, txError } = useModalContext(); + const { currentAccount } = useWeb3Context(); + const { walletBalances } = useWalletBalances(currentMarketData); + const [hfPreviewAfter, setHfPreviewAfter] = useState(); + const supplyProtocolIncentives = mapAaveProtocolIncentives(poolReserve.incentives, 'supply'); + + if (!wrappedTokenConfig) { + throw new Error('Wrapped token config is not defined'); + } + + const tokenInBalance = + walletBalances[wrappedTokenConfig.tokenIn.underlyingAsset.toLowerCase()]?.amount || '0'; + + const tokenOutBalance = + walletBalances[wrappedTokenConfig.tokenOut.underlyingAsset.toLowerCase()]?.amount || '0'; + + const assets = [ + { + balance: tokenInBalance, + symbol: wrappedTokenConfig.tokenIn.symbol, + iconSymbol: wrappedTokenConfig.tokenIn.symbol, + address: wrappedTokenConfig.tokenIn.underlyingAsset.toLowerCase(), + }, + ]; + + if (tokenOutBalance !== '0') { + assets.unshift({ + balance: tokenOutBalance, + symbol: wrappedTokenConfig.tokenOut.symbol, + iconSymbol: wrappedTokenConfig.tokenOut.symbol, + address: wrappedTokenConfig.tokenOut.underlyingAsset.toLowerCase(), + }); + } + + const [tokenToSupply, setTokenToSupply] = useState(assets[0]); + const [amount, setAmount] = useState(''); + const [convertedTokenInAmount, setConvertedTokenInAmount] = useState('0'); + const { data: tokenOutPerTokenIn } = useTokenOutForTokenIn( + '1', + poolReserve.underlyingToken.decimals, + wrappedTokenConfig.tokenWrapperAddress + ); + + useEffect(() => { + if (!tokenOutPerTokenIn) return; + const convertedAmount = valueToBigNumber(tokenInBalance) + .multipliedBy(tokenOutPerTokenIn) + .toString(); + setConvertedTokenInAmount(convertedAmount); + }, [tokenInBalance, tokenOutPerTokenIn]); + + const supplyCap = poolReserve.supplyInfo.supplyCap.amount.value; + const totalLiquidity = poolReserve.supplyInfo.total.value; + + const maxAmountToSupplyTokenOut = getMaxAmountAvailableToSupplySDK( + tokenOutBalance, + poolReserve, + poolReserve.underlyingToken.address + ); + + const tokenOutRemainingSupplyCap = remainingCap(supplyCap, totalLiquidity); + + let maxAmountOfTokenInToSupply = tokenInBalance; + const rateBN = valueToBigNumber(tokenOutPerTokenIn || '0'); + if (valueToBigNumber(convertedTokenInAmount).isGreaterThan(tokenOutRemainingSupplyCap)) { + maxAmountOfTokenInToSupply = valueToBigNumber(tokenOutRemainingSupplyCap) + .dividedBy(rateBN.eq(0) ? '1' : rateBN) + .toString(); + + maxAmountOfTokenInToSupply = roundToTokenDecimals( + maxAmountOfTokenInToSupply, + poolReserve.underlyingToken.decimals + ); + } + + let supplyingWrappedToken = false; + if (wrappedTokenConfig) { + supplyingWrappedToken = + tokenToSupply.address === wrappedTokenConfig.tokenIn.underlyingAsset.toLowerCase(); + } + + const handleChange = (value: string) => { + if (value === '-1') { + if (supplyingWrappedToken) { + setAmount(maxAmountOfTokenInToSupply); + } else { + setAmount(maxAmountToSupplyTokenOut); + } + } else { + const decimalTruncatedValue = roundToTokenDecimals( + value, + poolReserve.underlyingToken.decimals + ); + setAmount(decimalTruncatedValue); + } + }; + + const amountOutForPool = supplyingWrappedToken + ? valueToBigNumber(amount).multipliedBy(rateBN) + : valueToBigNumber(amount); + const amountOutForPoolStr = amountOutForPool.toString(10); + const amountInUsd = supplyingWrappedToken + ? valueToBigNumber(amount).multipliedBy(wrappedTokenConfig.tokenIn.priceInUSD ?? '0') + : amountOutForPool.multipliedBy(poolReserve.usdExchangeRate ?? '0'); + + const isMaxSelected = + amount === (supplyingWrappedToken ? maxAmountOfTokenInToSupply : maxAmountToSupplyTokenOut); + + useEffect(() => { + const timer = setTimeout(async () => { + if (!amount || amount === '0' || !currentAccount) { + setHfPreviewAfter(undefined); + return; + } + + try { + const requestAmount = { + erc20: { + currency: evmAddress(poolReserve.underlyingToken.address), + value: bigDecimal(amountOutForPoolStr), + permitSig: null, + }, + }; + + const result = await healthFactorPreview(client, { + action: { + supply: { + market: evmAddress(currentMarketData.addresses.LENDING_POOL), + amount: requestAmount, + sender: evmAddress(currentAccount), + onBehalfOf: evmAddress(currentAccount), + chainId: currentMarketData.chainId as ClientChainId, + }, + }, + }); + + if (result.isOk()) { + setHfPreviewAfter(result.value.after?.toString()); + } else { + setHfPreviewAfter(undefined); + } + } catch (error) { + setHfPreviewAfter(undefined); + } + }, 300); + + return () => clearTimeout(timer); + }, [ + amount, + amountOutForPoolStr, + currentAccount, + currentMarketData.addresses.LENDING_POOL, + currentMarketData.chainId, + poolReserve.underlyingToken.address, + poolReserve.underlyingToken.decimals, + ]); + + if (supplyTxState.success) { + const successModalAmount = supplyingWrappedToken ? amountOutForPool.toString(10) : amount; + + return ( + Supplied} + amount={successModalAmount} + symbol={poolReserve.underlyingToken.symbol} + addToken={addTokenProps} + /> + ); + } + + return ( + <> + {isolationModeWarning} + {supplyCapWarning} + {debtCeilingWarning} + Wallet balance} + event={{ + eventName: GENERAL.MAX_INPUT_SELECTION, + eventParams: { + asset: poolReserve.underlyingToken.address, + assetName: poolReserve.underlyingToken.name, + }, + }} + exchangeRateComponent={ + supplyingWrappedToken && ( + + ) + } + /> + + + Supply APY} + value={poolReserve.supplyInfo.apy.value} + percent + /> + + + + + + {txError && } + + {supplyingWrappedToken ? ( + + ) : ( + + )} + + ); +}; + +const ExchangeRate = ({ + supplyAmount, + decimals, + tokenInSymbol, + tokenOutSymbol, + tokenWrapperAddress, +}: { + supplyAmount: string; + decimals: number; + tokenInSymbol: string; + tokenOutSymbol: string; + tokenWrapperAddress: string; +}) => { + const { isFetching: loading, data: tokenOutAmount } = useTokenOutForTokenIn( + supplyAmount, + decimals, + tokenWrapperAddress + ); + + return ( + + Supply amount + + {loading ? ( + + ) : ( + <> + + + sDAI + + + )} + + + + + ); +}; diff --git a/src/components/transactions/Supply/SupplyModalSDK.tsx b/src/components/transactions/Supply/SupplyModalSDK.tsx new file mode 100644 index 0000000000..bf61755e85 --- /dev/null +++ b/src/components/transactions/Supply/SupplyModalSDK.tsx @@ -0,0 +1,29 @@ +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 { ModalWrapperSDK } from '../FlowCommons/ModalWrapperSDK'; +import { SupplyModalContentWrapperSDK } from './SupplyModalContentSDK'; + +export const SupplyModalSDK = () => { + const { type, close, args } = useModalContext() as ModalContextType<{ + underlyingAsset: string; + }>; + return ( + + Supply} + underlyingAsset={args.underlyingAsset} + > + {(params) => ( + + {(user) => } + + )} + + + ); +}; diff --git a/src/components/transactions/Supply/SupplyWrappedTokenActionsSDK.tsx b/src/components/transactions/Supply/SupplyWrappedTokenActionsSDK.tsx new file mode 100644 index 0000000000..86666b6c04 --- /dev/null +++ b/src/components/transactions/Supply/SupplyWrappedTokenActionsSDK.tsx @@ -0,0 +1,225 @@ +import { gasLimitRecommendations, ProtocolAction } from '@aave/contract-helpers'; +import { valueToBigNumber } from '@aave/math-utils'; +import { SignatureLike } from '@ethersproject/bytes'; +import { TransactionResponse } from '@ethersproject/providers'; +import { Trans } from '@lingui/macro'; +import { BoxProps } from '@mui/material'; +import { useQueryClient } from '@tanstack/react-query'; +import { parseUnits } from 'ethers/lib/utils'; +import { useState } from 'react'; +import { ReserveWithId } from 'src/hooks/app-data-provider/useAppDataProvider'; +import { useApprovalTx } from 'src/hooks/useApprovalTx'; +import { useApprovedAmount } from 'src/hooks/useApprovedAmount'; +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 { queryKeysFactory } from 'src/ui-config/queries'; +import { useSharedDependencies } from 'src/ui-config/SharedDependenciesProvider'; +import { useShallow } from 'zustand/shallow'; + +import { TxActionsWrapper } from '../TxActionsWrapper'; +import { APPROVAL_GAS_LIMIT, checkRequiresApproval } from '../utils'; + +interface SignedParams { + signature: SignatureLike; + deadline: string; + amount: string; +} + +interface SupplyWrappedTokenActionPropsSDK extends BoxProps { + tokenIn: string; + amountToSupply: string; + decimals: number; + symbol: string; + tokenWrapperAddress: string; + isWrongNetwork: boolean; + reserve: ReserveWithId; +} +export const SupplyWrappedTokenActionsSDK = ({ + tokenIn, + amountToSupply, + decimals, + symbol, + tokenWrapperAddress, + isWrongNetwork, + sx, + reserve, + ...propsx +}: SupplyWrappedTokenActionPropsSDK) => { + const [user, estimateGasLimit, addTransaction, marketData] = useRootStore( + useShallow((state) => [ + state.account, + state.estimateGasLimit, + state.addTransaction, + state.currentMarketData, + ]) + ); + + const { tokenWrapperService } = useSharedDependencies(); + const [signatureParams, setSignatureParams] = useState(); + + const { + approvalTxState, + mainTxState, + loadingTxns, + setApprovalTxState, + setMainTxState, + setTxError, + setGasLimit, + } = useModalContext(); + + const { sendTx } = useWeb3Context(); + const queryClient = useQueryClient(); + + const { + data: approvedAmount, + isFetching, + refetch: fetchApprovedAmount, + } = useApprovedAmount({ + chainId: marketData.chainId, + token: tokenIn, + spender: tokenWrapperAddress, + }); + + let requiresApproval = false; + if (approvedAmount !== undefined) { + requiresApproval = checkRequiresApproval({ + approvedAmount: approvedAmount.toString(), + amount: amountToSupply, + signedAmount: signatureParams ? signatureParams.amount : '0', + }); + } + + // Since the only wrapped token right now is sDAI/DAI, disable permit since it is not supported + const usePermit = false; + + // Update gas estimation + let supplyGasLimit = 0; + if (usePermit) { + supplyGasLimit = Number(gasLimitRecommendations[ProtocolAction.supplyWithPermit].recommended); + } else { + supplyGasLimit = Number(gasLimitRecommendations[ProtocolAction.supply].recommended); + if (requiresApproval && !approvalTxState.success) { + supplyGasLimit += Number(APPROVAL_GAS_LIMIT); + } + } + + setGasLimit(supplyGasLimit.toString()); + + if (requiresApproval && approvalTxState?.success) { + // There was a successful approval tx, but the approval amount is not enough. + // Clear the state to prompt for another approval. + setApprovalTxState({}); + } + + const { approval: approvalAction } = useApprovalTx({ + usePermit, + approvedAmount: { + amount: approvedAmount?.toString() || '0', + spender: tokenWrapperAddress, + token: tokenIn, + user, + }, + requiresApproval, + assetAddress: tokenIn, + symbol, + decimals: decimals, + signatureAmount: amountToSupply, + onApprovalTxConfirmed: fetchApprovedAmount, + onSignTxCompleted: (signedParams) => setSignatureParams(signedParams), + }); + + const action = async () => { + try { + setMainTxState({ ...mainTxState, loading: true }); + + let response: TransactionResponse; + let action = ProtocolAction.default; + + // determine if approval is signature or transaction + // checking user preference is not sufficient because permit may be available but the user has an existing approval + if (usePermit && signatureParams) { + action = ProtocolAction.supplyWithPermit; + let signedSupplyWithPermitTxData = await tokenWrapperService.supplyWrappedTokenWithPermit( + parseUnits(amountToSupply, decimals).toString(), + tokenWrapperAddress, + user, + signatureParams.deadline, + signatureParams.signature + ); + + signedSupplyWithPermitTxData = await estimateGasLimit(signedSupplyWithPermitTxData); + response = await sendTx(signedSupplyWithPermitTxData); + + await response.wait(1); + } else { + action = ProtocolAction.supply; + let supplyTxData = await tokenWrapperService.supplyWrappedToken( + parseUnits(amountToSupply, decimals).toString(), + tokenWrapperAddress, + user + ); + supplyTxData = await estimateGasLimit(supplyTxData); + response = await sendTx(supplyTxData); + + await response.wait(1); + } + + setMainTxState({ + txHash: response.hash, + loading: false, + success: true, + }); + + addTransaction(response.hash, { + action, + txState: 'success', + asset: tokenIn, + amount: amountToSupply, + assetName: symbol, + amountUsd: valueToBigNumber(amountToSupply) + .multipliedBy(reserve.usdExchangeRate) + .toString(), + }); + + queryClient.invalidateQueries({ queryKey: queryKeysFactory.pool }); + queryClient.invalidateQueries({ + queryKey: queryKeysFactory.approvedAmount( + user, + tokenIn, + tokenWrapperAddress, + marketData.chainId + ), + }); + } catch (error) { + const parsedError = getErrorTextFromError(error, TxAction.GAS_ESTIMATION, false); + setTxError(parsedError); + setMainTxState({ + txHash: undefined, + loading: false, + }); + } + }; + + return ( + Supply {symbol}} + actionInProgressText={Supplying {symbol}} + handleApproval={() => approvalAction()} + handleAction={action} + requiresApproval={requiresApproval} + tryPermit={usePermit} + sx={sx} + {...propsx} + /> + ); +}; diff --git a/src/components/transactions/utils.ts b/src/components/transactions/utils.ts index 0d25e94625..755bc31732 100644 --- a/src/components/transactions/utils.ts +++ b/src/components/transactions/utils.ts @@ -1,6 +1,10 @@ +import { MarketUserState, ReserveUserState } from '@aave/graphql/import'; import { BigNumber } from 'bignumber.js'; import { CollateralType } from 'src/helpers/types'; -import { ComputedUserReserveData } from 'src/hooks/app-data-provider/useAppDataProvider'; +import { + ComputedUserReserveData, + ReserveWithId, +} from 'src/hooks/app-data-provider/useAppDataProvider'; export enum ErrorType { SUPPLY_CAP_REACHED, @@ -94,3 +98,50 @@ export const getAssetCollateralType = ( return collateralType; }; + +export const getAssetCollateralTypeSdk = ({ + reserve, + reserveUserState, + marketUserState, + debtCeilingIsMaxed, +}: { + reserve: ReserveWithId; + reserveUserState?: ReserveUserState | null; + marketUserState?: MarketUserState | null; + debtCeilingIsMaxed: boolean; +}) => { + if (!reserve.supplyInfo.canBeCollateral) return CollateralType.UNAVAILABLE; + + const isIsolationAsset = reserve.isolationModeConfig?.canBeCollateral === true; + const userIsInIsolationMode = marketUserState?.isInIsolationMode === true; + const userHasSuppliedReserve = (reserveUserState?.balance.amount.value ?? '0') !== '0'; + const userHasCollateral = (marketUserState?.totalCollateralBase ?? '0') !== '0'; + const userCollateralEnabled = reserveUserState?.canBeCollateral === true; + + let collateralType: CollateralType = CollateralType.ENABLED; + + if (isIsolationAsset) { + if (debtCeilingIsMaxed) return CollateralType.UNAVAILABLE; + if (userIsInIsolationMode) { + if (userHasSuppliedReserve) { + collateralType = userCollateralEnabled + ? CollateralType.ISOLATED_ENABLED + : CollateralType.DISABLED; + } else if (userHasCollateral) { + collateralType = CollateralType.UNAVAILABLE_DUE_TO_ISOLATION; + } + } else { + collateralType = userHasCollateral + ? CollateralType.ISOLATED_DISABLED + : CollateralType.ISOLATED_ENABLED; + } + } else { + if (userIsInIsolationMode) { + collateralType = CollateralType.UNAVAILABLE_DUE_TO_ISOLATION; + } else if (userHasSuppliedReserve) { + collateralType = userCollateralEnabled ? CollateralType.ENABLED : CollateralType.DISABLED; + } + } + console.log('collateralType', collateralType); + return collateralType; +}; diff --git a/src/hooks/useModal.tsx b/src/hooks/useModal.tsx index f307d864d7..ce32536a0d 100644 --- a/src/hooks/useModal.tsx +++ b/src/hooks/useModal.tsx @@ -11,8 +11,10 @@ import { Proposal } from './governance/useProposals'; export enum ModalType { Supply, + SupplySDK, Withdraw, Borrow, + BorrowSDK, Repay, CollateralChange, Stake, @@ -83,6 +85,13 @@ export interface ModalContextType { funnel: string, isReserve?: boolean ) => void; + openSupplySDK: ( + underlyingAsset: string, + currentMarket: string, + name: string, + funnel: string, + isReserve?: boolean + ) => void; openWithdraw: ( underlyingAsset: string, currentMarket: string, @@ -96,6 +105,13 @@ export interface ModalContextType { funnel: string, isReserve?: boolean ) => void; + openBorrowSDK: ( + underlyingAsset: string, + currentMarket: string, + name: string, + funnel: string, + isReserve?: boolean + ) => void; openRepay: ( underlyingAsset: string, isFrozen: boolean, @@ -213,6 +229,28 @@ export const ModalContextProvider: React.FC = ({ children }) }); } }, + openSupplySDK: (underlyingAsset, currentMarket, name, funnel, isReserve) => { + setType(ModalType.SupplySDK); + setArgs({ underlyingAsset }); + + if (isReserve) { + trackEvent(GENERAL.OPEN_MODAL, { + modal: 'SupplySDK', + market: currentMarket, + assetName: name, + asset: underlyingAsset, + funnel, + }); + } else { + trackEvent(GENERAL.OPEN_MODAL, { + modal: 'SupplySDK', + market: currentMarket, + assetName: name, + asset: underlyingAsset, + funnel, + }); + } + }, openWithdraw: (underlyingAsset, currentMarket, name, funnel) => { setType(ModalType.Withdraw); setArgs({ underlyingAsset }); @@ -246,6 +284,27 @@ export const ModalContextProvider: React.FC = ({ children }) }); } }, + openBorrowSDK: (underlyingAsset, currentMarket, name, funnel, isReserve) => { + setType(ModalType.BorrowSDK); + setArgs({ underlyingAsset }); + if (isReserve) { + trackEvent(GENERAL.OPEN_MODAL, { + modal: 'BorrowSDK', + market: currentMarket, + assetName: name, + asset: underlyingAsset, + funnel, + }); + } else { + trackEvent(GENERAL.OPEN_MODAL, { + modal: 'BorrowSDK', + market: currentMarket, + assetName: name, + asset: underlyingAsset, + funnel, + }); + } + }, openRepay: (underlyingAsset, isFrozen, currentMarket, name, funnel) => { setType(ModalType.Repay); setArgs({ underlyingAsset, isFrozen }); diff --git a/src/hooks/useReserveActionState.tsx b/src/hooks/useReserveActionState.tsx index 7391aefa22..82178ee2d9 100644 --- a/src/hooks/useReserveActionState.tsx +++ b/src/hooks/useReserveActionState.tsx @@ -3,25 +3,20 @@ import { Trans } from '@lingui/macro'; import { Button, Stack, SvgIcon, Typography } from '@mui/material'; import { Link, ROUTES } from 'src/components/primitives/Link'; import { Warning } from 'src/components/primitives/Warning'; -import { getEmodeMessage } from 'src/components/transactions/Emode/EmodeNaming'; -import { - ComputedReserveData, - useAppDataContext, -} from 'src/hooks/app-data-provider/useAppDataProvider'; -import { useAssetCaps } from 'src/hooks/useAssetCaps'; +import { ReserveWithId, useAppDataContext } from 'src/hooks/app-data-provider/useAppDataProvider'; import { WalletEmptyInfo } from 'src/modules/dashboard/lists/SupplyAssetsList/WalletEmptyInfo'; import { useRootStore } from 'src/store/root'; -import { assetCanBeBorrowedByUser } from 'src/utils/getMaxAmountAvailableToBorrow'; import { displayGhoForMintableMarket } from 'src/utils/ghoUtilities'; import { useShallow } from 'zustand/shallow'; +import { useAssetCapsSDK } from './useAssetCapsSDK'; import { useModalContext } from './useModal'; interface ReserveActionStateProps { balance: string; maxAmountToSupply: string; maxAmountToBorrow: string; - reserve: ComputedReserveData; + reserve: ReserveWithId; } export const useReserveActionState = ({ @@ -30,8 +25,8 @@ export const useReserveActionState = ({ maxAmountToBorrow, reserve, }: ReserveActionStateProps) => { - const { user, eModes } = useAppDataContext(); - const { supplyCap, borrowCap, debtCeiling } = useAssetCaps(); + const { userState } = useAppDataContext(); + const { supplyCap, borrowCap, debtCeiling } = useAssetCapsSDK(); const [currentMarket, currentNetworkConfig, currentChainId, currentMarketData] = useRootStore( useShallow((store) => [ store.currentMarket, @@ -44,14 +39,23 @@ export const useReserveActionState = ({ const { bridge, name: networkName } = currentNetworkConfig; - const assetCanBeBorrowedFromPool = user ? assetCanBeBorrowedByUser(reserve, user) : false; - const userHasNoCollateralSupplied = user?.totalCollateralMarketReferenceCurrency === '0'; - const isolationModeBorrowDisabled = user?.isInIsolationMode && !reserve.borrowableInIsolation; + const assetCanBeBorrowedFromPool = + !!reserve.userState?.canBeBorrowed && + reserve.borrowInfo?.borrowingState !== 'DISABLED' && + !reserve.isPaused && + !reserve.isFrozen; + const userHasNoCollateralSupplied = !userState || userState.totalCollateralBase === '0'; + const isolationModeBorrowDisabled = + !!userState?.isInIsolationMode && reserve.isolationModeConfig?.canBeBorrowed === false; const eModeBorrowDisabled = - user?.isInEmode && !reserve.eModes.find((e) => e.id === user.userEmodeCategoryId); - - const isGho = displayGhoForMintableMarket({ symbol: reserve.symbol, currentMarket }); + !!userState?.eModeEnabled && + (reserve.userState?.emode?.canBeBorrowed === false || reserve.userState?.emode == null); + const isGho = displayGhoForMintableMarket({ + symbol: reserve.underlyingToken.symbol, + currentMarket, + }); + const eModeLabel = reserve.userState?.emode?.label ?? 'Disabled'; return { disableSupplyButton: balance === '0' || maxAmountToSupply === '0' || isGho, disableBorrowButton: @@ -67,7 +71,8 @@ export const useReserveActionState = ({ {currentNetworkConfig.isTestnet ? ( - Your {networkName} wallet is empty. Get free test {reserve.name} at + Your {networkName} wallet is empty. Get free test {reserve.underlyingToken.name}{' '} + at {' '} {!currentMarketData.addresses.FAUCET ? (