From cb637c5b072a12a8fab6495db8a6c301c2c93346 Mon Sep 17 00:00:00 2001 From: MikeDiam Date: Fri, 9 Jan 2026 09:55:29 +0300 Subject: [PATCH 01/24] [swap updates] update swap logic Signed-off-by: MikeDiam --- package.json | 1 + .../Dropdown/DropdownView/DropdownView.tsx | 27 +- src/components/Image/util/images.ts | 1 + src/components/ImagesProvider/images/index.ts | 2 + .../images/token/swap/wxDAI.svg | 1 + .../Select/SelectView/SelectView.tsx | 4 +- .../TokenDropdown/TokenDropdown.tsx | 29 +- .../TokenDropdown/util/useTokenDropdown.ts | 60 +--- src/components/Transactions/types.d.ts | 4 +- src/components/Transactions/util/useLogic.ts | 39 +-- src/components/index.ts | 2 +- src/helpers/contracts/addresses.ts | 3 + src/helpers/swapTokens.ts | 22 +- src/hooks/data/useFiatValues.ts | 6 +- src/hooks/index.ts | 3 + src/hooks/swap/index.ts | 18 ++ src/hooks/swap/useActions/index.ts | 111 +++++++ src/hooks/swap/useActions/useErc20Flow.ts | 87 ++++++ src/hooks/swap/useActions/useTrade.ts | 85 ++++++ .../swap/useData/_SSR}/fetchSwapTokenRates.ts | 23 +- src/hooks/swap/useData/_SSR/index.ts | 1 + src/hooks/swap/useData/index.ts | 71 +++++ .../swap/useData}/useSwapTokenRates.ts | 25 +- src/hooks/swap/useFee.ts | 109 +++++++ src/hooks/swap/useMaxAmount.ts | 41 +++ src/hooks/swap/useOrderId.ts | 100 ++++++ src/hooks/swap/useQuote.ts | 115 +++++++ src/hooks/swap/useSwapSDK.ts | 57 ++++ src/hooks/swap/useToken.ts | 69 +++++ src/hooks/swap/useTokens.ts | 83 +++++ src/hooks/swap/useTokensList.ts | 130 ++++++++ .../Connect/NetworkSelect/NetworkSelect.tsx | 29 +- .../AppLayout/Header/Settings/Settings.tsx | 4 +- .../TransactionsFlowModal.tsx | 9 +- .../util/useTransactionsFlow.ts | 3 - src/store/store/swapTokenRates.ts | 16 +- .../Stake/StakeInfo/util/useOptions.ts | 2 +- .../content/Stake/StakeInput/StakeInput.tsx | 14 +- src/views/SwapView/util/swapCtx.ts | 2 +- .../actions/useBoost/useBoostSubmit/index.ts | 11 +- .../useBoostSubmit/useBoostActions.ts | 18 +- .../util/vault/actions/useStake/index.ts | 47 +-- .../util/vault/actions/useStake/swap/index.ts | 3 - .../actions/useStake/swap/useSwapActions.ts | 284 ------------------ .../actions/useStake/swap/useSwapQuote.ts | 151 ---------- .../vault/actions/useStake/swap/useSwapSDK.ts | 25 -- .../actions/useStake/swap/useSwapTokens.ts | 136 --------- .../vault/actions/useStake/useStakeField.ts | 12 +- .../actions/useStake/useStakeMaxAmount.ts | 43 --- .../actions/useStake/useStakeSubmit/index.ts | 31 +- .../useStakeSubmit/useStakeApprove.ts | 13 +- .../useStake/useStakeSubmit/useStakeSteps.ts | 11 +- .../actions/useUnboost/useUnboostSubmit.ts | 15 +- .../util/vault/helpers/useStakeReceive.ts | 13 +- 54 files changed, 1327 insertions(+), 894 deletions(-) create mode 100644 src/components/ImagesProvider/images/token/swap/wxDAI.svg create mode 100644 src/hooks/swap/index.ts create mode 100644 src/hooks/swap/useActions/index.ts create mode 100644 src/hooks/swap/useActions/useErc20Flow.ts create mode 100644 src/hooks/swap/useActions/useTrade.ts rename src/{components/TokenDropdown/util => hooks/swap/useData/_SSR}/fetchSwapTokenRates.ts (77%) create mode 100644 src/hooks/swap/useData/_SSR/index.ts create mode 100644 src/hooks/swap/useData/index.ts rename src/{components/TokenDropdown/util => hooks/swap/useData}/useSwapTokenRates.ts (69%) create mode 100644 src/hooks/swap/useFee.ts create mode 100644 src/hooks/swap/useMaxAmount.ts create mode 100644 src/hooks/swap/useOrderId.ts create mode 100644 src/hooks/swap/useQuote.ts create mode 100644 src/hooks/swap/useSwapSDK.ts create mode 100644 src/hooks/swap/useToken.ts create mode 100644 src/hooks/swap/useTokens.ts create mode 100644 src/hooks/swap/useTokensList.ts delete mode 100644 src/views/SwapView/util/vault/actions/useStake/swap/index.ts delete mode 100644 src/views/SwapView/util/vault/actions/useStake/swap/useSwapActions.ts delete mode 100644 src/views/SwapView/util/vault/actions/useStake/swap/useSwapQuote.ts delete mode 100644 src/views/SwapView/util/vault/actions/useStake/swap/useSwapSDK.ts delete mode 100644 src/views/SwapView/util/vault/actions/useStake/swap/useSwapTokens.ts delete mode 100644 src/views/SwapView/util/vault/actions/useStake/useStakeMaxAmount.ts diff --git a/package.json b/package.json index 4d1c9bc1..84a57296 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "dependencies": { "@binance/w3w-ethereum-provider": "1.1.8-alpha.0", "@cowprotocol/cow-sdk": "7.2.5", + "@cowprotocol/sdk-ethers-v6-adapter": "0.3.0", "@floating-ui/react": "0.27.16", "@floating-ui/react-dom": "2.1.6", "@headlessui/react": "2.2.4", diff --git a/src/components/Dropdown/DropdownView/DropdownView.tsx b/src/components/Dropdown/DropdownView/DropdownView.tsx index ef49a64e..6e7ea808 100644 --- a/src/components/Dropdown/DropdownView/DropdownView.tsx +++ b/src/components/Dropdown/DropdownView/DropdownView.tsx @@ -1,15 +1,14 @@ -import React, { Fragment, KeyboardEventHandler, ReactElement, ReactNode, useRef } from 'react' +import React, { KeyboardEventHandler, ReactElement, ReactNode, useRef } from 'react' import cx from 'classnames' -import { offset, shift, VirtualElement, OffsetOptions } from '@floating-ui/react' import type { Placement } from '@floating-ui/react' import { autoUpdate, flip, useFloating } from '@floating-ui/react-dom' +import { offset, shift, OffsetOptions } from '@floating-ui/react' import { Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react' import s from './Dropdown.module.scss' type ButtonInput = { - ref: (node: Element | VirtualElement | null) => void isOpen: boolean } @@ -18,8 +17,6 @@ export type DropdownViewProps = { contentClassName?: string children: ReactNode disabled?: boolean - // The child component must inherit the props, so be sure to make - button: ReactElement | ((props: ButtonInput) => ReactElement) value?: string placement?: Placement withArrow?: boolean @@ -30,6 +27,7 @@ export type DropdownViewProps = { autoUpdate?: boolean offsetOptions?: Omit } + button: (props: ButtonInput) => ReactElement onOpen?: () => void onClose?: () => void onChange?: (value: any) => void @@ -43,7 +41,7 @@ type DropdownViewComponent = React.FC & { const DropdownView: DropdownViewComponent = (props: DropdownViewProps) => { const { - className, contentClassName, children, button, value, disabled, withArrow, middleware, + className, contentClassName, children, button, value, disabled, middleware, placement = 'bottom-end', dataTestId, onOpen, onClose, onChange, onOptionsClick, onOptionsKeyDown, } = props @@ -72,11 +70,9 @@ const DropdownView: DropdownViewComponent = (props: DropdownViewProps) => { value={value} onChange={onChange} > - + { ({ open }) => { - const arrow = open ? 'up' : 'down' - if (open && !isOpenRef.current && typeof onOpen === 'function') { onOpen() } @@ -87,17 +83,8 @@ const DropdownView: DropdownViewComponent = (props: DropdownViewProps) => { isOpenRef.current = open - if (typeof button === 'function') { - return button({ - ref: refs.setReference, - isOpen: open, - }) - } - - return React.cloneElement(button as any, { - // @ts-ignore - ref: refs.setReference, - arrow: withArrow ? arrow : undefined, + return button({ + isOpen: open, }) } } diff --git a/src/components/Image/util/images.ts b/src/components/Image/util/images.ts index b8257dcc..99d2a9b1 100644 --- a/src/components/Image/util/images.ts +++ b/src/components/Image/util/images.ts @@ -31,6 +31,7 @@ export const logos = [ 'token/lsETH', 'token/stETH', 'token/wbETH', + 'token/wxDAI', 'token/wstETH', 'token/frxETH', diff --git a/src/components/ImagesProvider/images/index.ts b/src/components/ImagesProvider/images/index.ts index 9f1daf07..a50c00fe 100644 --- a/src/components/ImagesProvider/images/index.ts +++ b/src/components/ImagesProvider/images/index.ts @@ -27,6 +27,7 @@ import ezETH from './token/swap/ezETH.svg' import lsETH from './token/swap/lsETH.svg' import stETH from './token/swap/stETH.svg' import wbETH from './token/swap/wbETH.svg' +import wxDAI from './token/swap/wxDAI.svg' import wstETH from './token/swap/wstETH.svg' import frxETH from './token/swap/frxETH.svg' @@ -120,6 +121,7 @@ export default { 'token/lsETH': lsETH.src, 'token/stETH': stETH.src, 'token/wbETH': wbETH.src, + 'token/wxDAI': wxDAI.src, 'token/wstETH': wstETH.src, 'token/frxETH': frxETH.src, diff --git a/src/components/ImagesProvider/images/token/swap/wxDAI.svg b/src/components/ImagesProvider/images/token/swap/wxDAI.svg new file mode 100644 index 00000000..fb6a579e --- /dev/null +++ b/src/components/ImagesProvider/images/token/swap/wxDAI.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Select/SelectView/SelectView.tsx b/src/components/Select/SelectView/SelectView.tsx index 4425795e..408e8ddb 100644 --- a/src/components/Select/SelectView/SelectView.tsx +++ b/src/components/Select/SelectView/SelectView.tsx @@ -40,7 +40,7 @@ const SelectView: React.FC = (props) => { value={value} withArrow dataTestId={dataTestId} - button={label ? ( + button={({ isOpen }) => label ? ( = (props) => { isError={isError} title={selectedOption?.title} dataTestId={`${dataTestId}-button`} + icon={isOpen ? 'arrow/up' : 'arrow/down'} {...htmlAttrs} /> ) : ( @@ -58,6 +59,7 @@ const SelectView: React.FC = (props) => { logo={selectedOption?.logo} title={selectedOption?.title} dataTestId={`${dataTestId}-button`} + icon={isOpen ? 'arrow/up' : 'arrow/down'} {...htmlAttrs} /> )} diff --git a/src/components/TokenDropdown/TokenDropdown.tsx b/src/components/TokenDropdown/TokenDropdown.tsx index fdfef161..0d5b9de8 100644 --- a/src/components/TokenDropdown/TokenDropdown.tsx +++ b/src/components/TokenDropdown/TokenDropdown.tsx @@ -1,8 +1,9 @@ import React, { useCallback } from 'react' -import device from 'modules/device' +import cx from 'classnames' +import { Network } from 'sdk' import { useConfig } from 'config' import forms from 'modules/forms' -import { Network } from 'sdk' +import device from 'modules/device' import Icon from '../Icon/Icon' import ButtonBase from '../ButtonBase/ButtonBase' @@ -19,15 +20,21 @@ export type TokenDropdownProps = Omit void } const TokenDropdown: React.FC = (props) => { - const { className, contentClassName, value, tokens, dataTestId, isDisabled, onChange, ...rest } = props + const { + className, contentClassName, value, tokens, dataTestId = '', isDisabled, isFetchingDisabled, + onChange, ...rest + } = props + const { chainId } = useConfig() const { isMobile } = device.useData() - const { isReadOnlyMode, chainId } = useConfig() - const { isFetching, open } = useTokenDropdown() + const { isFetching, open } = useTokenDropdown({ + isFetchingDisabled, + }) const field = forms.useField({ valueType: 'string', @@ -48,7 +55,7 @@ const TokenDropdown: React.FC = (props) => { ) @@ -58,7 +65,9 @@ const TokenDropdown: React.FC = (props) => { return ( = (props) => { } : undefined )} - button={({ ref, isOpen }) => ( + button={({ isOpen }) => ( {tokenBaseNode} { - const { address, chainId } = useConfig() - const [ { fetchedKey, isOpen, isFetching }, setState ] = useObjectState({ - fetchedKey: '', - isOpen: false, - isFetching: true, - }) - - const fetchRates = useSwapTokenRates() - const { refetchSwapTokenBalances } = useBalances() - - const dataKey = `${address}-${chainId}` - - const handleFetch = useCallback(async () => { - if (isOpen) { - setState({ isFetching: true }) +const useTokenDropdown = ({ isFetchingDisabled }: Input) => { + const [ isOpen, setOpen ] = useState(false) - await Promise.all([ - refetchSwapTokenBalances(), - fetchRates(), - ]) - - setState({ fetchedKey: dataKey, isFetching: false }) - } - }, [ isOpen, dataKey, setState, fetchRates, refetchSwapTokenBalances ]) - - useChainChanged(handleFetch) - - useAutoFetch({ - action: handleFetch, - interval: 15 * 60 * 1000, - skip: !isOpen, + const { isFetching } = swapHooks.useData({ + skip: !isOpen || isFetchingDisabled, }) - const open = useCallback((isOpen: boolean) => { - setState({ isOpen }) - }, []) - return useMemo(() => ({ - isFetching: isFetching && fetchedKey !== dataKey, - open, + isFetching, + open: setOpen, }), [ - dataKey, - fetchedKey, isFetching, - open, + setOpen, ]) } diff --git a/src/components/Transactions/types.d.ts b/src/components/Transactions/types.d.ts index 5a981964..1b4d9cd2 100644 --- a/src/components/Transactions/types.d.ts +++ b/src/components/Transactions/types.d.ts @@ -1,10 +1,8 @@ import { Transaction, TransactionStatus } from './util' -export type SetTransaction = (id: string | number, status: TransactionStatus) => void +export type SetTransaction = (id: string | number, status: TransactionStatus, next?: boolean) => void export type StepData = Partial> -export type SetNextTransactionsFailed = (id: string | number) => void - export type StepsData = StepData[] diff --git a/src/components/Transactions/util/useLogic.ts b/src/components/Transactions/util/useLogic.ts index 1094a36c..ee2d0600 100644 --- a/src/components/Transactions/util/useLogic.ts +++ b/src/components/Transactions/util/useLogic.ts @@ -1,6 +1,4 @@ import { useCallback, useMemo, useState } from 'react' -import type { SetNextTransactionsFailed } from 'components' - import type { SetTransaction } from '../types' @@ -29,38 +27,21 @@ export type ModifiedTransaction = Omit & { const useLogic = (initialTransactions: Transaction[] = []) => { const [ transactions, setTransactions ] = useState(initialTransactions) - const setTransaction: SetTransaction = useCallback((id, status) => { + const setTransaction: SetTransaction = useCallback((id, status, updateNextTransactions) => { setTransactions((steps) => { - return steps.map((step) => { - if (step.id === id) { - return { - ...step, - status, - } - } + const currentIndex = steps.findIndex((step) => step.id === id) - return step - }) - }) - }, []) - - const resetTransactions = useCallback(() => { - setTransactions(initialTransactions) - }, [ initialTransactions ]) - - const setNextTransactionsFailed: SetNextTransactionsFailed = useCallback((id) => { - setTransactions((steps) => { - const failedIndex = steps.findIndex((step) => step.id === id) - - if (failedIndex === -1) { + if (currentIndex === -1) { return steps } return steps.map((step, index) => { - if (index >= failedIndex) { + const isStepToUpdate = updateNextTransactions ? index >= currentIndex : index === currentIndex + + if (isStepToUpdate) { return { ...step, - status: TransactionStatus.Fail, + status, } } @@ -69,6 +50,10 @@ const useLogic = (initialTransactions: Transaction[] = []) => { }) }, []) + const resetTransactions = useCallback(() => { + setTransactions(initialTransactions) + }, [ initialTransactions ]) + return useMemo(() => ({ transactions: transactions.map(({ onCancel, ...transaction }) => ({ ...transaction, @@ -79,13 +64,11 @@ const useLogic = (initialTransactions: Transaction[] = []) => { setTransaction, setTransactions, resetTransactions, - setNextTransactionsFailed, }), [ transactions, setTransaction, setTransactions, resetTransactions, - setNextTransactionsFailed, ]) } diff --git a/src/components/index.ts b/src/components/index.ts index 3ce3c458..aac09c9d 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -137,7 +137,7 @@ export { default as Tooltip } from './Tooltip/Tooltip' export type { TooltipProps } from './Tooltip/Tooltip' export { default as Transactions } from './Transactions/Transactions' -export type { SetTransaction, SetNextTransactionsFailed, StepsData } from './Transactions/types' +export type { SetTransaction, StepsData } from './Transactions/types' export { TransactionStatus } from './Transactions/util' export type { Transaction } from './Transactions/util' export type { TransactionsProps } from './Transactions/Transactions' diff --git a/src/helpers/contracts/addresses.ts b/src/helpers/contracts/addresses.ts index f642d2f6..56123ff2 100644 --- a/src/helpers/contracts/addresses.ts +++ b/src/helpers/contracts/addresses.ts @@ -9,6 +9,7 @@ const addresses = { }, cow: { vaultRelayer: '0xc92e8bdf79f0507f65a392b0ab4667716bfe0110', + nativeToken: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', }, }, [Network.Hoodi]: { @@ -17,6 +18,7 @@ const addresses = { }, cow: { vaultRelayer: ZeroAddress, + nativeToken: ZeroAddress, }, }, [Network.Gnosis]: { @@ -25,6 +27,7 @@ const addresses = { }, cow: { vaultRelayer: '0xc92e8bdf79f0507f65a392b0ab4667716bfe0110', + nativeToken: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', }, }, } diff --git a/src/helpers/swapTokens.ts b/src/helpers/swapTokens.ts index 1f935db4..2c396c41 100644 --- a/src/helpers/swapTokens.ts +++ b/src/helpers/swapTokens.ts @@ -1,8 +1,10 @@ -import { Network } from 'sdk' +import { Network, configs } from 'sdk' const swapTokens = { [Network.Mainnet]: { + osETH: configs[Network.Mainnet].addresses.tokens.mintToken, + SWISE: configs[Network.Mainnet].addresses.tokens.swise, stETH: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', wbETH: '0xa2E3356610840701BDf5611a53974510Ae27E2e1', rETH: '0xae78736Cd615f374D3085123A210448E74Fc6393', @@ -24,17 +26,26 @@ const swapTokens = { cmETH: '0xE6829d9a7eE3040e1276Fa75293Bde931859e8fA', }, [Network.Gnosis]: { + osGNO: configs[Network.Gnosis].addresses.tokens.mintToken, + SWISE: configs[Network.Gnosis].addresses.tokens.swise, wBTC: '0x8e5bBbb09Ed1ebdE8674Cda39A0c169401db4252', USDT: '0x4ECaBa5870353805a9F068101A40E0f32ed605C6', 'USDC.e': '0x2a22f9c3b484c3629090FeED35F17Ff8F88f76F0', EURe: '0xcB444e90D8198415266c6a2724b7900fb12FC56E', sDAI: '0xaf204776c7245bF4147c2612BF6e5972Ee483701', wstETH: '0x6C76971f98945AE98dD7d4DFcA8711ebea946eA6', + wxDAI: '0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d', WETH: '0x6A023CCd1ff6F2045C3309768eAd9E68F978f6e1', }, + [Network.Hoodi]: { + osETH: configs[Network.Hoodi].addresses.tokens.mintToken, + }, } const swapTokenTitles = { + osETH: 'StakeWise Staked ETH', + osGNO: 'StakeWise Staked GNO', + SWISE: 'StakeWise', USDT: 'Tether USD', USDC: 'USD Coin', 'USDC.e': 'USD Coin', @@ -57,10 +68,19 @@ const swapTokenTitles = { cmETH: 'Mantle Restaked ETH', sDAI: 'Savings xDAI', EURe: 'Monerium EUR emoney', + wxDAI: 'Wrapped xDAI', +} + +const swapTokenCustomUnits = { + USDT: 6, + USDC: 6, + 'USDC.e': 6, + wBTC: 8, } export { swapTokens, swapTokenTitles, + swapTokenCustomUnits, } diff --git a/src/hooks/data/useFiatValues.ts b/src/hooks/data/useFiatValues.ts index 8aec3832..ae1c7524 100644 --- a/src/hooks/data/useFiatValues.ts +++ b/src/hooks/data/useFiatValues.ts @@ -5,8 +5,10 @@ import { createSelector } from '@reduxjs/toolkit' import useStore from '../data/useStore' +type FiatToken = Extract + type Input = Record @@ -45,7 +47,7 @@ const useFiatValues = (values: Input): Output => { return mock } - const allRates = { ...fiatRates, ...swapTokenRates } + const allRates = { ...swapTokenRates, ...fiatRates } const isValidToken = Object.keys(allRates).includes(token) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 53999376..4301cf6a 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -32,3 +32,6 @@ export { default as useApprove } from './fetch/useApprove' export { default as useAllowance } from './fetch/useAllowance' export { default as useTransaction } from './fetch/useTransaction' export { default as useSubgraphUpdate } from './fetch/useSubgraphUpdate' + +// Swap +export { default as swapHooks } from './swap' diff --git a/src/hooks/swap/index.ts b/src/hooks/swap/index.ts new file mode 100644 index 00000000..3d7d8256 --- /dev/null +++ b/src/hooks/swap/index.ts @@ -0,0 +1,18 @@ +import useFee from './useFee' +import useData from './useData' +import useQuote from './useQuote' +import useToken from './useToken' +import useTokens from './useTokens' +import useActions from './useActions' +import useMaxAmount from './useMaxAmount' + + +export default { + useFee, + useData, + useQuote, + useToken, + useTokens, + useActions, + useMaxAmount, +} diff --git a/src/hooks/swap/useActions/index.ts b/src/hooks/swap/useActions/index.ts new file mode 100644 index 00000000..69bfc65d --- /dev/null +++ b/src/hooks/swap/useActions/index.ts @@ -0,0 +1,111 @@ +import { useRef, useMemo, useState, useCallback } from 'react' +import { useConfig } from 'config' +import { StakeStep } from 'helpers/enums' + +import { SetTransaction, Transactions } from 'components' + +import useActions from '../../data/useActions' +import useBalances from '../../data/useBalances' + +import useQuote from '../useQuote' +import useErc20Flow from './useErc20Flow' + + +type Input = { + fetchQuote: ReturnType +} + +type SendOrderInput = { + buyAmount?: bigint + sellAmount?: bigint +} + +type CancelSwapInput = { + setTransaction: SetTransaction +} + +type SwapInput = SendOrderInput & CancelSwapInput + +const step = StakeStep.Swap + +const useSwapActions = (values: Input) => { + const { fetchQuote } = values + + const { address } = useConfig() + const actions = useActions() + const { refetchDepositTokenBalance, refetchSwapTokenBalances } = useBalances() + + const [ isCancelling, setCancelling ] = useState(false) + const [ isCancelAvailable, setCancelAvailable ] = useState(false) + + const stateRef = useRef({ isCancelAvailable }) + stateRef.current = { isCancelAvailable } + + const erc20Flow = useErc20Flow({ step, setCancelAvailable }) + + const swap = useCallback(async (values: SwapInput) => { + const { setTransaction, ...rest } = values + + try { + if (!address || (!rest.sellAmount && !rest.buyAmount)) { + return Promise.reject('No swap amount defined') + } + + setTransaction(step, Transactions.Status.Confirm) + + const { quoteRequest } = await fetchQuote(values) + + const result = await erc20Flow.sendOrder({ quoteRequest, setTransaction }) + + refetchSwapTokenBalances() + refetchDepositTokenBalance() + + return result + } + catch (error) { + const status = error === 'Order was cancelled' + ? Transactions.Status.Cancel + : Transactions.Status.Fail + + setTransaction(step, status, true) + + return Promise.reject(error as string) + } + finally { + setCancelAvailable(false) + actions.ui.resetBottomLoader() + } + }, [ + address, + actions, + erc20Flow, + fetchQuote, + refetchSwapTokenBalances, + refetchDepositTokenBalance, + ]) + + const cancelSwap = useCallback(({ setTransaction }: CancelSwapInput) => { + const { isCancelAvailable } = stateRef.current + + if (isCancelAvailable) { + const data = { setCancelling, setTransaction } + + return erc20Flow.cancelOrder(data) + } + }, [ erc20Flow ]) + + return useMemo(() => ({ + isCancelling, + isCancelAvailable, + swap, + cancelSwap, + }), [ + isCancelling, + isCancelAvailable, + swap, + cancelSwap, + ]) +} + + +export default useSwapActions diff --git a/src/hooks/swap/useActions/useErc20Flow.ts b/src/hooks/swap/useActions/useErc20Flow.ts new file mode 100644 index 00000000..ed6a7d71 --- /dev/null +++ b/src/hooks/swap/useActions/useErc20Flow.ts @@ -0,0 +1,87 @@ +import { useMemo, useCallback } from 'react' +import { StakeStep } from 'helpers/enums' +import type { TradeParameters } from '@cowprotocol/cow-sdk' + +import { SetTransaction, Transactions } from 'components' + +import useTrade from './useTrade' +import useSwapSDK from '../useSwapSDK' + + +type Input = { + step: StakeStep.Swap + setCancelAvailable: (isCancelAvailable: boolean) => void +} + +type CancelOrderInput = { + setCancelling: (isCancelling: boolean) => void + setTransaction: SetTransaction +} + +type SendOrderInput = { + quoteRequest: TradeParameters + setTransaction: SetTransaction +} + +const useErc20Flow = (values: Input) => { + const { step, setCancelAvailable } = values + + const getSwapSDK = useSwapSDK() + + const { orderIdRef, handleTrade, setOrderId } = useTrade({ + step, + setCancelAvailable, + }) + + const sendOrder = useCallback(async (values: SendOrderInput) => { + const { quoteRequest, setTransaction } = values + + const { tradingSdk } = await getSwapSDK() + + const { orderId } = await tradingSdk.postSwapOrder(quoteRequest) + + return handleTrade({ orderId, setTransaction }) + }, [ getSwapSDK, handleTrade ]) + + const cancelOrder = useCallback(async (values: CancelOrderInput) => { + const { setCancelling, setTransaction } = values + + // ATTN we need orderIdRef since orderId is null at the moment of TxFlowModal opening + if (!orderIdRef.current) { + return + } + + setCancelling(true) + setTransaction(step, Transactions.Status.Canceling) + + try { + const { tradingSdk } = await getSwapSDK() + + const success = await tradingSdk.offChainCancelOrder({ + orderUid: orderIdRef.current, + }) + + if (success) { + setOrderId(null) + setTransaction(step, Transactions.Status.Cancel, true) + } + } + catch (error) { + console.error(error) + } + finally { + setCancelling(false) + } + }, [ step, orderIdRef, getSwapSDK, setOrderId ]) + + return useMemo(() => ({ + sendOrder, + cancelOrder, + }), [ + sendOrder, + cancelOrder, + ]) +} + + +export default useErc20Flow diff --git a/src/hooks/swap/useActions/useTrade.ts b/src/hooks/swap/useActions/useTrade.ts new file mode 100644 index 00000000..8631d0a5 --- /dev/null +++ b/src/hooks/swap/useActions/useTrade.ts @@ -0,0 +1,85 @@ +import { useMemo, useCallback, useState, useRef } from 'react' +import { useConfig } from 'config' +import { StakeStep } from 'helpers/enums' + +import { SetTransaction, Transactions } from 'components' + +import useActions from '../../data/useActions' + +import useOrderId from '../useOrderId' + + +type Input = { + step: StakeStep.Swap + setCancelAvailable: (isCancelAvailable: boolean) => void +} + +type HandleTrade = { + orderId?: string + setTransaction: SetTransaction +} + +const useTrade = (values: Input) => { + const { step, setCancelAvailable } = values + + const { isEthereum } = useConfig() + const { waitForTrade } = useOrderId() + const [ orderId, setOrderId ] = useState(null) + const actions = useActions() + + const orderIdRef = useRef(orderId) + orderIdRef.current = orderId + + const handleSetOrderId = useCallback((orderId: string | null) => { + setOrderId(orderId) + setCancelAvailable(Boolean(orderId)) + }, [ setCancelAvailable ]) + + const handleTrade = useCallback(async (values: HandleTrade) => { + const { orderId, setTransaction } = values + + if (!orderId) { + return Promise.reject('Order ID is not defined') + } + + handleSetOrderId(orderId) + + const blockExplorerUrl = isEthereum + ? 'https://explorer.cow.fi/orders' + : 'https://explorer.cow.fi/gc/orders' + + const hashUrl = `${blockExplorerUrl}/${orderId}` + + actions.ui.setBottomLoaderTransaction(hashUrl) + + setTransaction(step, Transactions.Status.Processing) + + const { hash: tradeHash, status, buyAmount } = await waitForTrade(orderId) + + const isCancelled = status === 'cancelled' + + if (!tradeHash || isCancelled) { + return Promise.reject(isCancelled ? 'Order was cancelled' : 'TxHash is not defined') + } + + setTransaction(step, Transactions.Status.Success) + + return { + buyAmount: BigInt(buyAmount), + hash: hashUrl, + } + }, [ step, actions, isEthereum, waitForTrade, handleSetOrderId ]) + + return useMemo(() => ({ + orderIdRef, + handleTrade, + setOrderId: handleSetOrderId, + }), [ + orderIdRef, + handleTrade, + handleSetOrderId, + ]) +} + + +export default useTrade diff --git a/src/components/TokenDropdown/util/fetchSwapTokenRates.ts b/src/hooks/swap/useData/_SSR/fetchSwapTokenRates.ts similarity index 77% rename from src/components/TokenDropdown/util/fetchSwapTokenRates.ts rename to src/hooks/swap/useData/_SSR/fetchSwapTokenRates.ts index de9ee2f7..5525d06f 100644 --- a/src/components/TokenDropdown/util/fetchSwapTokenRates.ts +++ b/src/hooks/swap/useData/_SSR/fetchSwapTokenRates.ts @@ -1,5 +1,6 @@ +'use server' import { Network } from 'sdk' -import { swapTokens, methods } from 'helpers' +import { swapTokens, constants, methods } from 'helpers' import cacheStorage from 'modules/cache-storage' @@ -46,13 +47,23 @@ const fetchSwapTokenRate = async ({ chainId, address }: { chainId: Network, addr } } +const getChainTokens = (chainId: Network) => { + const chainTokens = swapTokens[chainId as keyof typeof swapTokens] + + const appTokens: string[] = [ + constants.tokens.osETH, + constants.tokens.osGNO, + constants.tokens.swise, + ] + + return Object.fromEntries( + Object.entries(chainTokens).filter(([ token ]) => !appTokens.includes(token)) + ) +} + const fetchSwapTokenRates = async (chainId: Network) => { const cacheData = cache[chainId as keyof typeof cache]?.getData() - const chainTokens: Record | undefined = swapTokens[chainId as keyof typeof swapTokens] - - if (!chainTokens) { - return {} - } + const chainTokens = getChainTokens(chainId) if (cacheData) { return cacheData diff --git a/src/hooks/swap/useData/_SSR/index.ts b/src/hooks/swap/useData/_SSR/index.ts new file mode 100644 index 00000000..e2561030 --- /dev/null +++ b/src/hooks/swap/useData/_SSR/index.ts @@ -0,0 +1 @@ +export { default as fetchSwapTokenRates } from './fetchSwapTokenRates' diff --git a/src/hooks/swap/useData/index.ts b/src/hooks/swap/useData/index.ts new file mode 100644 index 00000000..a90baf81 --- /dev/null +++ b/src/hooks/swap/useData/index.ts @@ -0,0 +1,71 @@ +import { useConfig } from 'config' +import { useCallback, useMemo } from 'react' + +import useStore from '../../data/useStore' +import useBalances from '../../data/useBalances' +import useAutoFetch from '../../controls/useAutoFetch' +import useChainChanged from '../../controls/useChainChanged' + +import useSwapTokenRates from './useSwapTokenRates' + + +const storeSelector = (store: Store) => ({ + swapTokenRates: store.swapTokenRates.data, + swapTokenBalances: store.account.swapTokenBalances.data, + isRatesFetching: store.swapTokenRates.isFetching, + isBalancesFetching: store.account.swapTokenBalances.isFetching, +}) + +type Input = { + skip?: boolean +} + +const useData = (values?: Input) => { + const { skip } = values || {} + + const { address } = useConfig() + const { swapTokenRates, swapTokenBalances, isRatesFetching, isBalancesFetching } = useStore(storeSelector) + + const isFetched = useMemo(() => { + const isBalancesFetched = Object.keys(swapTokenBalances).length > 0 + const isRatesFetched = Object.keys(swapTokenRates).some((token) => swapTokenRates[token].USD) + + if (!address) { + return isRatesFetched + } + + return isRatesFetched && isBalancesFetched + }, [ address, swapTokenRates, swapTokenBalances ]) + + const fetchRates = useSwapTokenRates() + const { refetchSwapTokenBalances } = useBalances() + + const handleFetch = useCallback(async () => { + if (!skip) { + await Promise.all([ + refetchSwapTokenBalances(), + fetchRates(), + ]) + } + }, [ skip, fetchRates, refetchSwapTokenBalances ]) + + useChainChanged(handleFetch) + + useAutoFetch({ + action: handleFetch, + interval: 15 * 60 * 1000, + skip, + }) + + const isFetching = isRatesFetching || isBalancesFetching + + return useMemo(() => ({ + isFetching: isFetching && !isFetched, + }), [ + isFetching, + isFetched, + ]) +} + + +export default useData diff --git a/src/components/TokenDropdown/util/useSwapTokenRates.ts b/src/hooks/swap/useData/useSwapTokenRates.ts similarity index 69% rename from src/components/TokenDropdown/util/useSwapTokenRates.ts rename to src/hooks/swap/useData/useSwapTokenRates.ts index a7df26d5..e8849276 100644 --- a/src/components/TokenDropdown/util/useSwapTokenRates.ts +++ b/src/hooks/swap/useData/useSwapTokenRates.ts @@ -1,17 +1,28 @@ -import { useCallback } from 'react' +import { useCallback, useEffect } from 'react' import { useConfig } from 'config' -import { useActions } from 'hooks' -import { swapTokens, methods } from 'helpers' +import { methods, swapTokens } from 'helpers' -import fetchSwapTokenRates from './fetchSwapTokenRates' +import useActions from '../../data/useActions' +import { fetchSwapTokenRates } from './_SSR' -const useFiatRates = () => { + +const useSwapTokenRates = () => { const actions = useActions() - const { sdk, chainId } = useConfig() + const { sdk, chainId, wallet } = useConfig() const chainTokens = swapTokens[chainId as keyof typeof swapTokens] + useEffect(() => { + const onChangeChain = actions.swapTokenRates.resetData + + wallet.subscribeBeforeChange('chain', onChangeChain) + + return () => { + wallet.unsubscribeBeforeChange('chain', onChangeChain) + } + }, [ actions, wallet ]) + return useCallback(async () => { if (!chainTokens) { actions.swapTokenRates.setFetching(false) @@ -55,4 +66,4 @@ const useFiatRates = () => { } -export default useFiatRates +export default useSwapTokenRates diff --git a/src/hooks/swap/useFee.ts b/src/hooks/swap/useFee.ts new file mode 100644 index 00000000..079345b2 --- /dev/null +++ b/src/hooks/swap/useFee.ts @@ -0,0 +1,109 @@ +import { useCallback, useMemo, useEffect, useRef } from 'react' +import { BigDecimal } from 'sdk' +import { useConfig } from 'config' +import { parseUnits } from 'ethers' + +import useQuote from './useQuote' +import useTokens from './useTokens' +import useObjectState from '../controls/useObjectState' + + +type Input = { + swapTokens: ReturnType + skip?: boolean +} + +const initialState = { + swapFee: 0n, + swappedDepositAmount: 0n, + isSwapFeeFetching: false, +} + +const useFee = (values: Input) => { + const { swapTokens, skip: isSwapDisabled } = values + + const { address } = useConfig() + + const fetchQuote = useQuote({ swapTokens }) + + const balance = address + ? swapTokens.sellToken.balance + : parseUnits('1', swapTokens.sellToken.units) + + const swapTokensRef = useRef(swapTokens) + swapTokensRef.current = swapTokens + + const skip = !balance || isSwapDisabled + + const [ state, setState ] = useObjectState({ + ...initialState, + isSwapFeeFetching: !skip, + }) + + const fetchBalanceQuote = useCallback(async () => { + setState({ ...initialState, isSwapFeeFetching: true }) + + let fee = '0' + let buyAmount = '0' + + try { + const { quote } = await fetchQuote({ sellAmount: balance }) + + fee = quote.feeAmount + buyAmount = quote.buyAmount + } + catch (error: any) { + if (error?.feeAmount) { + fee = error.feeAmount + } + } + + setState({ + swapFee: BigInt(fee), + swappedDepositAmount: BigInt(buyAmount), + isSwapFeeFetching: false, + }) + }, [ balance, fetchQuote, setState ]) + + const getSwappedDepositAmount = useCallback((value: bigint) => { + // Swap fee is not fetched for depositTokens + if (isSwapDisabled) { + return value + } + + if (balance && state.swappedDepositAmount) { + const balancePercent = new BigDecimal(balance).divide(100) + const percent = new BigDecimal(value).divide(balancePercent) + + const result = new BigDecimal(state.swappedDepositAmount) + .divide(100) + .multiply(percent) + .decimals(0) + .toNumber() + + return BigInt(result) + } + + return 0n + }, [ balance, state.swappedDepositAmount, isSwapDisabled ]) + + useEffect(() => { + if (skip) { + setState(initialState) + } + else { + fetchBalanceQuote() + } + }, [ skip, fetchBalanceQuote, setState ]) + + return useMemo(() => ({ + ...state, + getSwappedDepositAmount, + }), [ + state, + getSwappedDepositAmount, + ]) +} + + +export default useFee diff --git a/src/hooks/swap/useMaxAmount.ts b/src/hooks/swap/useMaxAmount.ts new file mode 100644 index 00000000..1fd28979 --- /dev/null +++ b/src/hooks/swap/useMaxAmount.ts @@ -0,0 +1,41 @@ +import { useMemo } from 'react' +import { useConfig, wallets} from 'config' + +import useStore from '../data/useStore' +import useToken from './useToken' + + +type Input = { + token: ReturnType['data'] + transactionPrice: bigint +} + +const storeSelector = (store: Store) => ({ + nativeTokenBalance : store.account.balances.nativeToken, +}) + +const useMaxAmount = (values: Input) => { + const { token, transactionPrice } = values + + const { activeWallet, isGnosis } = useConfig() + const { nativeTokenBalance } = useStore(storeSelector) + + return useMemo(() => { + if (token?.address) { + return token.balance + } + + const isGnosisSafeWallet = activeWallet === wallets.gnosisSafe.id + + if (isGnosis || isGnosisSafeWallet) { + return nativeTokenBalance + } + + const maxAmount = nativeTokenBalance - (transactionPrice * 2n) + + return maxAmount > 0n ? maxAmount : 0n + }, [ token, activeWallet, transactionPrice, nativeTokenBalance, isGnosis ]) +} + + +export default useMaxAmount diff --git a/src/hooks/swap/useOrderId.ts b/src/hooks/swap/useOrderId.ts new file mode 100644 index 00000000..b9d8bd54 --- /dev/null +++ b/src/hooks/swap/useOrderId.ts @@ -0,0 +1,100 @@ +import { useCallback, useMemo } from 'react' +import type { OrderStatus } from '@cowprotocol/cow-sdk' + +import useSwapSDK from './useSwapSDK' + + +type WaitForTradeOutput = { + hash?: string + status?: OrderStatus + buyToken: string + sellToken: string + buyAmount: string + sellAmount: string +} + +const useOrderId = () => { + const getSwapSDK = useSwapSDK() + + const fetchOrder = useCallback(async (orderId: string, count: number = 0) => { + try { + const { orderBookApi } = await getSwapSDK() + + const order = await orderBookApi.getOrder(orderId) + + if (order) { + return order + } + + throw new Error('Order not found') + } + catch (error) { + if (count < 10) { + await new Promise((resolve) => setTimeout(resolve, count * 300)) + + return fetchOrder(orderId, count + 1) + } + + throw new Error(error as string) + } + }, [ getSwapSDK ]) + + const waitForTrade = useCallback(async (orderId: string): Promise => { + try { + const { orderBookApi } = await getSwapSDK() + + const { status, buyAmount, sellAmount, sellToken, buyToken } = await orderBookApi.getOrder(orderId) + const isExpired = status === 'expired' + const isCancelled = status === 'cancelled' + + if (isExpired || isCancelled) { + if (isExpired) { + console.error('Swap failed', undefined, { + orderId, + status, + sellToken, + sellAmount, + }) + } + + return { + status, + buyToken, + sellToken, + buyAmount, + sellAmount, + } + } + + const trades = await orderBookApi.getTrades({ orderUid: orderId }) + const hash = trades[0]?.txHash as string + + if (hash) { + return { + hash, + buyToken, + sellToken, + buyAmount, + sellAmount, + } + } + + await new Promise((resolve) => setTimeout(resolve, 500)) + return waitForTrade(orderId) + } + catch (error) { + throw new Error(error as string) + } + }, [ getSwapSDK ]) + + return useMemo(() => ({ + fetchOrder, + waitForTrade, + }), [ + fetchOrder, + waitForTrade, + ]) +} + + +export default useOrderId diff --git a/src/hooks/swap/useQuote.ts b/src/hooks/swap/useQuote.ts new file mode 100644 index 00000000..6138e0be --- /dev/null +++ b/src/hooks/swap/useQuote.ts @@ -0,0 +1,115 @@ +import { useCallback, useRef } from 'react' +import { useConfig } from 'config' +import { constants } from 'helpers' +import { ZeroAddress } from 'ethers' +import addresses from 'helpers/contracts/addresses' +import type { TradeParameters } from '@cowprotocol/cow-sdk' + +import useTokens from './useTokens' +import useSwapSDK from './useSwapSDK' + + +type Input = { + swapTokens: ReturnType +} + +type GetRateInput = { + buyAmount: string + sellAmount: string + buyTokenDecimals: number + sellTokenDecimals: number +} + +type FetchQuoteInput = { + buyAmount?: bigint + sellAmount?: bigint +} + +const useQuote = (values: Input) => { + const { swapTokens } = values + const { address, chainId } = useConfig() + + const nativeTokenAddress = addresses[chainId].cow.nativeToken + + const getSwapSDK = useSwapSDK() + + const swapTokensRef = useRef(swapTokens) + swapTokensRef.current = swapTokens + + const formatAmount = useCallback((amount: string, units: number) => { + const amountMultiplier = 10n ** BigInt(18 - units) + + return BigInt(amount) * amountMultiplier + }, []) + + const getRate = useCallback((values: GetRateInput) => { + const { buyAmount, sellAmount, buyTokenDecimals, sellTokenDecimals } = values + + const formattedBuyAmount = formatAmount(buyAmount, buyTokenDecimals) + const formattedSellAmount = formatAmount(sellAmount, sellTokenDecimals) + + return formattedBuyAmount * constants.blockchain.amount1 / formattedSellAmount + }, [ formatAmount ]) + + return useCallback(async (values: FetchQuoteInput) => { + const { buyAmount, sellAmount } = values + + const { tradingSdk, kind } = await getSwapSDK() + + const buyToken = swapTokensRef.current.buyToken.address || nativeTokenAddress + const sellToken = swapTokensRef.current.sellToken.address || nativeTokenAddress + const buyTokenDecimals = swapTokensRef.current.buyToken.units + const sellTokenDecimals = swapTokensRef.current.sellToken.units + + const buyAmountString = buyAmount?.toString() + const sellAmountString = sellAmount?.toString() + + const quoteRequest = { + buyToken, + sellToken, + buyTokenDecimals, + sellTokenDecimals, + kind: buyAmount ? kind.buy : kind.sell, + owner: address || ZeroAddress, + amount: buyAmountString || sellAmountString, + receiver: address || ZeroAddress, + partiallyFillable: false, + } as TradeParameters + + try { + const { quoteResults: { quoteResponse } } = await tradingSdk.getQuote(quoteRequest) + + const quote = { + ...quoteResponse.quote, + receiver: quoteRequest.receiver, + buyAmount: buyAmountString || quoteResponse.quote.buyAmount, + sellAmount: sellAmountString || quoteResponse.quote.sellAmount, + } + + const rate = getRate({ + buyAmount: quote.buyAmount, + sellAmount: quote.sellAmount, + buyTokenDecimals, + sellTokenDecimals, + }) + + return { + rate, + quote, + quoteRequest, + } + } + catch (error: any) { + const feeAmount = error?.body?.data?.fee_amount + + if (feeAmount) { + return Promise.reject({ feeAmount }) + } + + return Promise.reject(error) + } + }, [ address, nativeTokenAddress, getRate, getSwapSDK ]) +} + + +export default useQuote diff --git a/src/hooks/swap/useSwapSDK.ts b/src/hooks/swap/useSwapSDK.ts new file mode 100644 index 00000000..d32c1761 --- /dev/null +++ b/src/hooks/swap/useSwapSDK.ts @@ -0,0 +1,57 @@ +import { useCallback } from 'react' +import { useConfig } from 'config' +import { ZeroAddress, VoidSigner } from 'ethers' +import type { SupportedChainId } from '@cowprotocol/cow-sdk' + + +const useSwapSDK = () => { + const { signSDK, chainId, address } = useConfig() + + const getSigner = useCallback(() => ( + address + ? signSDK.provider.getSigner() + : new VoidSigner(ZeroAddress, signSDK.provider) + ), [ signSDK, address ]) + + return useCallback(async () => { + const [ + signer, + { + OrderKind, + TradingSdk, + OrderBookApi, + setGlobalAdapter, + }, + { EthersV6Adapter }, + ] = await Promise.all([ + getSigner(), + import('@cowprotocol/cow-sdk'), + import('@cowprotocol/sdk-ethers-v6-adapter'), + ]) + + const cowSdkAdapter = new EthersV6Adapter({ + provider: signSDK.provider, + signer, + }) + + setGlobalAdapter(cowSdkAdapter) + + return { + kind: { + sell: OrderKind.SELL, + buy: OrderKind.BUY, + }, + tradingSdk: new TradingSdk({ + signer, + appCode: 'StakeWise', + chainId: chainId as SupportedChainId, + }), + orderBookApi: new OrderBookApi({ + chainId: chainId as SupportedChainId, + }), + } + }, [ signSDK, chainId, getSigner ]) +} + + +export default useSwapSDK diff --git a/src/hooks/swap/useToken.ts b/src/hooks/swap/useToken.ts new file mode 100644 index 00000000..2d1d0635 --- /dev/null +++ b/src/hooks/swap/useToken.ts @@ -0,0 +1,69 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import { useConfig } from 'config' +import { constants } from 'helpers' +import { ZeroAddress } from 'ethers' + +import { LogoName } from 'components' + +import useChangeEffect from '../controls/useChangeEffect' +import useTokensList from './useTokensList' + + +type Input = { + list: ReturnType['list'], + initialToken: string | null +} + +const useToken = (values: Input) => { + const { list, initialToken } = values + + const { chainId } = useConfig() + const [ token, setToken ] = useState(initialToken || null) + + const chainIdRef = useRef(chainId) + chainIdRef.current = chainId + + const initialTokenChainIdRef = useRef(chainId) + + useChangeEffect<[ string | null ]>(() => { + setToken(initialToken as string) + initialTokenChainIdRef.current = chainIdRef.current + }, [ initialToken ]) + + const tokenData = useMemo(() => { + const selectedToken = initialTokenChainIdRef.current === chainIdRef.current ? token : initialToken + + return list.find(({ address }) => selectedToken === address) as SwapToken + }, [ list, token, initialToken ]) + + const resetToken = useCallback(() => { + setToken(initialToken || null) + initialTokenChainIdRef.current = chainIdRef.current + }, [ initialToken ]) + + return useMemo(() => ({ + data: tokenData, + set: setToken, + reset: resetToken, + }), [ + tokenData, + resetToken, + ]) +} + +useToken.mock = { + data: { + units: 18, + balance: 0n, + name: 'Ether', + address: ZeroAddress, + title: constants.tokens.eth, + emptyBalance: constants.blockchain.emptyBalance, + logo: `token/${constants.tokens.eth}` as LogoName, + }, + set: () => {}, + reset: () => {}, +} as ReturnType + + +export default useToken diff --git a/src/hooks/swap/useTokens.ts b/src/hooks/swap/useTokens.ts new file mode 100644 index 00000000..2575af00 --- /dev/null +++ b/src/hooks/swap/useTokens.ts @@ -0,0 +1,83 @@ +import { useCallback, useMemo } from 'react' +import { useConfig } from 'config' + +import useToken from './useToken' +import useTokensList from './useTokensList' + + +type InitialToken = string | null + +type SetTokensInput = { + buyToken?: InitialToken + sellToken?: InitialToken +} + +type Input = { + initialBuyToken?: InitialToken + initialSellToken?: InitialToken +} + +const useTokens = (values?: Input) => { + const { initialBuyToken, initialSellToken } = values || {} + + const { sdk } = useConfig() + const { list, isTokensListFetching } = useTokensList() + + const buyToken = useToken({ + list, + initialToken: typeof initialBuyToken !== 'undefined' ? initialBuyToken : sdk.config.addresses.tokens.depositToken, + }) + + const sellToken = useToken({ + list, + initialToken: initialSellToken || null, + }) + + const filteredList = useMemo(() => ( + list.filter(({ address }) => ( + ![ buyToken.data.address, sellToken.data.address ].includes(address) + )) + ), [ buyToken, sellToken, list ]) + + const setTokens = useCallback((values: SetTokensInput) => { + if (values.buyToken || values.buyToken === null) { + buyToken.set(values.buyToken) + } + if (values.sellToken || values.sellToken === null) { + sellToken.set(values.sellToken) + } + }, [ buyToken, sellToken ]) + + const resetTokens = useCallback(() => { + buyToken.reset() + sellToken.reset() + }, [ buyToken, sellToken ]) + + return useMemo(() => ({ + list: filteredList, + buyToken: buyToken.data, + sellToken: sellToken.data, + isFetching: isTokensListFetching, + set: setTokens, + reset: resetTokens, + }), [ + buyToken, + sellToken, + filteredList, + isTokensListFetching, + setTokens, + resetTokens, + ]) +} + +useTokens.mock = { + list: [], + buyToken: useToken.mock.data, + sellToken: useToken.mock.data, + isFetching: false, + set: () => {}, + reset: () => {}, +} as ReturnType + + +export default useTokens diff --git a/src/hooks/swap/useTokensList.ts b/src/hooks/swap/useTokensList.ts new file mode 100644 index 00000000..39a557f7 --- /dev/null +++ b/src/hooks/swap/useTokensList.ts @@ -0,0 +1,130 @@ +import { useMemo } from 'react' +import { useConfig } from 'config' +import { swapTokens, swapTokenTitles, swapTokenCustomUnits, constants } from 'helpers' + +import { LogoName } from 'components' + +import useStore from '../data/useStore' + + +const emptyBalances = { + 6: constants.blockchain.emptyBalance6, + 8: constants.blockchain.emptyBalance8, + 18: constants.blockchain.emptyBalance, +} + +const storeSelector = (store: Store) => ({ + fiatRates: store.fiatRates.data, + swapTokenRates: store.swapTokenRates.data, + swapTokenBalances: store.account.swapTokenBalances.data, + nativeTokenBalance: store.account.balances.nativeToken, + depositTokenBalance: store.account.balances.depositToken, + isFiatRatesFetching: store.fiatRates.isFetching, + isSwapTokenRatesFetching: store.swapTokenRates.isFetching, + isSwapTokenBalancesFetching: store.account.swapTokenBalances.isFetching, +}) + +const useTokensList = () => { + const { sdk, chainId, isEthereum, isGnosis } = useConfig() + + const { + fiatRates, + swapTokenRates, + swapTokenBalances, + nativeTokenBalance, + depositTokenBalance, + isFiatRatesFetching, + isSwapTokenRatesFetching, + isSwapTokenBalancesFetching, + } = useStore(storeSelector) + + const rates = useMemo(() => { + return { ...swapTokenRates, ...fiatRates } + }, [ swapTokenRates, fiatRates ]) + + const isRatesFetching = isSwapTokenRatesFetching || isFiatRatesFetching + + const chainTokens = swapTokens[chainId as keyof typeof swapTokens] + + const initialTokensList = useMemo(() => { + const result: SwapToken[] = [ + { + logo: `token/${sdk.config.tokens.depositToken}`, + name: sdk.config.tokens.depositToken, + emptyBalance: constants.blockchain.emptyBalance, + title: isEthereum ? 'Ether' : 'Gnosis', + balance: depositTokenBalance, + address: isEthereum ? null : sdk.config.addresses.tokens.depositToken, + units: 18, + }, + ] + + if (isGnosis) { + result.push({ + logo: `token/${sdk.config.tokens.nativeToken}`, + name: sdk.config.tokens.nativeToken, + emptyBalance: constants.blockchain.emptyBalance, + title: sdk.config.tokens.nativeToken, + balance: nativeTokenBalance, + address: null, + units: 18, + }) + } + + return result + }, [ sdk, isEthereum, isGnosis, nativeTokenBalance, depositTokenBalance ]) + + const swapTokensList = useMemo(() => { + return Object.keys(chainTokens || {}) + .map((name) => { + const address = chainTokens[name as keyof typeof chainTokens] + const balance = swapTokenBalances[name] || 0n + + const logo = `token/${name}` as LogoName + const title = swapTokenTitles[name as keyof typeof swapTokenTitles] + const units = swapTokenCustomUnits[name as keyof typeof swapTokenCustomUnits] || 18 + const emptyBalance = emptyBalances[units as keyof typeof emptyBalances] + + return { + title, + name, + logo, + units, + address, + balance, + emptyBalance, + } + }) + .filter(({ name }) => isRatesFetching || rates[name as keyof typeof rates]) + .sort((a, b) => { + if (a.balance > 0n && b.balance === 0n) { + return -1 + } + + if (a.balance === 0n && b.balance > 0n) { + return 1 + } + + return 0 + }) + }, [ chainTokens, rates, swapTokenBalances, isRatesFetching ]) + + const isTokensListFetching = isRatesFetching || isSwapTokenBalancesFetching + + return useMemo(() => ({ + list: [ ...initialTokensList, ...swapTokensList ], + isTokensListFetching, + }), [ + swapTokensList, + initialTokensList, + isTokensListFetching, + ]) +} + +useTokensList.mock = { + list: [], + isTokensListFetching: false, +} as ReturnType + + +export default useTokensList diff --git a/src/layouts/AppLayout/Header/Connect/NetworkSelect/NetworkSelect.tsx b/src/layouts/AppLayout/Header/Connect/NetworkSelect/NetworkSelect.tsx index a9c7c914..1fb1373e 100644 --- a/src/layouts/AppLayout/Header/Connect/NetworkSelect/NetworkSelect.tsx +++ b/src/layouts/AppLayout/Header/Connect/NetworkSelect/NetworkSelect.tsx @@ -40,7 +40,7 @@ const NetworkSelect: React.FC = (props) => { const { isMobile } = device.useData() const isChangeChainDisabled = useChangeChainDisabled() - const { sdk, networkId, wallet, isGnosis } = useConfig() + const { sdk, networkId, wallet, address, isGnosis } = useConfig() const handleChangeChain = useCallback(async (selectedNetworkId: string) => { if (selectedNetworkId !== networkId) { @@ -60,18 +60,23 @@ const NetworkSelect: React.FC = (props) => { - )} + button={({ isOpen }) => { + const arrow = isOpen ? 'up' : 'down' + + return ( +