diff --git a/e2e/fixtures/swap/helpers/checkTokenDropdown.ts b/e2e/fixtures/swap/helpers/checkTokenDropdown.ts index a02e60e2..c3e9174a 100644 --- a/e2e/fixtures/swap/helpers/checkTokenDropdown.ts +++ b/e2e/fixtures/swap/helpers/checkTokenDropdown.ts @@ -10,10 +10,10 @@ export const createCheckTokenDropdown: Wrapper = ({ page, element }) => ( await page.waitForLoadState('networkidle') - await element.checkVisibility({ testId: 'token-select-input' }) - await element.checkVisibility({ testId: 'token-select-option-USDT' }) + await element.checkVisibility({ testId: 'amount-input-input' }) + await element.checkVisibility({ testId: 'amount-input-option-USDT' }) - await page.getByTestId('token-select-option-USDT').click() + await page.getByTestId('amount-input-option-USDT').click() await element.checkText({ testId: 'amount-input-token', expectedText: 'USDT' }) @@ -21,11 +21,11 @@ export const createCheckTokenDropdown: Wrapper = ({ page, element }) => ( await page.waitForLoadState('networkidle') - await page.getByTestId('token-select-input').fill(stakeToken) + await page.getByTestId('amount-input-input').fill(stakeToken) - await element.checkVisibility({ testId: `token-select-option-${stakeToken}` }) - await element.checkVisibility({ testId: 'token-select-option-USDT', isVisible: false }) + await element.checkVisibility({ testId: `amount-input-option-${stakeToken}` }) + await element.checkVisibility({ testId: 'amount-input-option-USDT', isVisible: false }) - await page.getByTestId(`token-select-option-${stakeToken}`).click() + await page.getByTestId(`amount-input-option-${stakeToken}`).click() } ) diff --git a/e2e/fixtures/vault/setVaultData.ts b/e2e/fixtures/vault/setVaultData.ts index 27586978..873f348b 100644 --- a/e2e/fixtures/vault/setVaultData.ts +++ b/e2e/fixtures/vault/setVaultData.ts @@ -79,6 +79,7 @@ export const createSetVaultData: Wrapper = ({ page }) => ( }, isPostPectra: true, protocolFeePercent: String(feePercent / 100), + avgQueueDays: 1, } await page.addInitScript((payload) => { diff --git a/e2e/tests/stake.spec.ts b/e2e/tests/stake.spec.ts index 3c41217f..07cb671a 100644 --- a/e2e/tests/stake.spec.ts +++ b/e2e/tests/stake.spec.ts @@ -114,7 +114,7 @@ test('Stake info', async ({ swap, wallet }) => { expect(parseFloat(assetPrev)).toEqual(0) expect(parseFloat(assetNext)).toEqual(value) - expect(Number(gas.replace('$ ' , ''))).toBeGreaterThan(0) + expect(Number(gas.replace('$' , ''))).toBeGreaterThan(0) }) test('Stake', async ({ wallet, swap }) => { 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/pnpm-lock.yaml b/pnpm-lock.yaml index 5b8e5e4a..851f1a85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@cowprotocol/cow-sdk': specifier: 7.2.5 version: 7.2.5(ajv@8.17.1)(cross-fetch@3.2.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0) + '@cowprotocol/sdk-ethers-v6-adapter': + specifier: 0.3.0 + version: 0.3.0(ethers@6.14.3) '@floating-ui/react': specifier: 0.27.16 version: 0.27.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -370,6 +373,11 @@ packages: '@cowprotocol/sdk-contracts-ts@0.8.1': resolution: {integrity: sha512-8yQO11S4R3mAfgKqb5afCo9c+z/sHpKiOZuN+gry46fjBKK3kUd2hKLWNYnQds5xUVJ07DlT0k7jFGgkEnremw==} + '@cowprotocol/sdk-ethers-v6-adapter@0.3.0': + resolution: {integrity: sha512-1TXtTLB5Zi2MGbCvEB9az0fJL1RDmQOa9sohO3biQNP/crGDb6jwPv30RtR3Sr621Dy8HNrfnJFHC87klPXC2g==} + peerDependencies: + ethers: ^6.13.7 + '@cowprotocol/sdk-order-book@0.4.4': resolution: {integrity: sha512-iTP2Vfi9h4ACvTnRArA0s3BmJgnUnNcQfsdMva4udkLy1GwjQgju7FkcCmlUxPaPvFgGm9hz8E9yPuNMWp/xWg==} @@ -5656,6 +5664,11 @@ snapshots: '@cowprotocol/sdk-common': 0.4.0 '@cowprotocol/sdk-config': 0.6.2 + '@cowprotocol/sdk-ethers-v6-adapter@0.3.0(ethers@6.14.3)': + dependencies: + '@cowprotocol/sdk-common': 0.4.0 + ethers: 6.14.3 + '@cowprotocol/sdk-order-book@0.4.4': dependencies: '@cowprotocol/sdk-common': 0.4.0 diff --git a/scripts/generateColors/index.js b/scripts/generateColors/index.js index 1363c700..62d28e59 100644 --- a/scripts/generateColors/index.js +++ b/scripts/generateColors/index.js @@ -34,7 +34,7 @@ const getColors = () => { colors[theme][formattedTitle] = { hex: value, rgb: hexToRgb(value), - isGradientColor: /-(start|end)$/.test(formattedTitle), + isGradientColor: /-(start|end)$/.test(formattedTitle) } } }) @@ -98,7 +98,7 @@ const generateColors = () => { newBaseFile = newBaseFile .replace( new RegExp(`:root .body-${theme}-theme {[^}]*}\n`, 'g'), - `:root .body-${theme}-theme {\n${colorsBase[theme]} }\n` + `:root .body-${theme}-theme {\n${colorsBase[theme]} }\n`, ) }) 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/config/core/config/createConfig.tsx b/src/config/core/config/createConfig.tsx index 5f23a37e..944d2389 100644 --- a/src/config/core/config/createConfig.tsx +++ b/src/config/core/config/createConfig.tsx @@ -17,6 +17,7 @@ const createConfig = (middleware?: ConfigProvider.Middleware) = serverNetworkId, supportedNetworkIds, onFinishConnect, + onChangeAddress, onStartConnect, onConnectError, onChangeChain, @@ -28,6 +29,7 @@ const createConfig = (middleware?: ConfigProvider.Middleware) = supportedNetworkIds, serverNetworkId, onFinishConnect, + onChangeAddress, onStartConnect, onConnectError, onChangeChain, diff --git a/src/config/core/config/types.d.ts b/src/config/core/config/types.d.ts index b232d5d7..13a7e575 100644 --- a/src/config/core/config/types.d.ts +++ b/src/config/core/config/types.d.ts @@ -39,12 +39,6 @@ declare global { unsubscribeBeforeChange: Subscription } - type CancelOnChangeInput = { - logic: () => any - chainId: ChainIds - address: string | null - } - type Context = T & State & { wallet: Wallet chainId: ChainIds @@ -54,6 +48,7 @@ declare global { type Callbacks = { onStartConnect: (activationMessage: Intl.Message | string) => void + onChangeAddress?: () => void onFinishConnect: () => void onConnectError: () => void onChangeChain?: () => void diff --git a/src/config/core/config/util/getSpecialErrors.ts b/src/config/core/config/util/getSpecialErrors.ts index 67ecd270..a0e9b9dc 100644 --- a/src/config/core/config/util/getSpecialErrors.ts +++ b/src/config/core/config/util/getSpecialErrors.ts @@ -35,18 +35,18 @@ const getSpecialErrors = (error: any) => { return messages.connectErrors.ledger.settings } - if (error.name === 'TransportStatusError') { - const statusCode = error.statusCode - const isNotOpened = statusCode === 0x650f || statusCode === 0x6511 + if (error.name === 'LockedDeviceError' && error.statusCode === 0x5515) { + return messages.connectErrors.ledger.lock + } - return isNotOpened - ? messages.connectErrors.ledger.notOpened - : messages.connectErrors.ledger.lock + if (error.name === 'TransportStatusError' && [ 0x650f, 0x6511 ].includes(error.statusCode)) { + return messages.connectErrors.ledger.notOpened } const isNotConnected = ( - errorMessage.indexOf('0x6804') !== -1 - || errorMessage.indexOf('U2F DEVICE_INELIGIBLE') !== -1 + errorMessage?.indexOf('0x6804') !== -1 + || errorMessage?.indexOf('U2F DEVICE_INELIGIBLE') !== -1 + || error.name === 'TransportOpenUserCancelled' ) if (isNotConnected) { diff --git a/src/config/core/config/util/useConfigContext.ts b/src/config/core/config/util/useConfigContext.ts index 5b97bc55..4ddab4d6 100644 --- a/src/config/core/config/util/useConfigContext.ts +++ b/src/config/core/config/util/useConfigContext.ts @@ -24,6 +24,7 @@ const useConfigContext = (values: Input): ConfigProvider.Contex serverNetworkId, supportedNetworkIds, onFinishConnect, + onChangeAddress, onConnectError, onStartConnect, onChangeChain, @@ -43,6 +44,15 @@ const useConfigContext = (values: Input): ConfigProvider.Contex const setData = useCallback((data: Partial) => { setState((state) => { const isChainChanged = data.networkId && data.networkId !== state.networkId + const isAddressChanged = ( + state.autoConnectChecked + && data.address !== state.address + && typeof data.address !== 'undefined' + ) + + if (isAddressChanged && typeof onChangeAddress === 'function') { + onChangeAddress() + } if (isChainChanged && typeof onChangeChain === 'function') { onChangeChain() @@ -53,7 +63,7 @@ const useConfigContext = (values: Input): ConfigProvider.Contex ...data, } }) - }, [ onChangeChain, setState ]) + }, [ onChangeChain, onChangeAddress, setState ]) const configState = useMemo(() => ({ data: state, diff --git a/src/config/core/config/util/useStorageUpdate.ts b/src/config/core/config/util/useStorageUpdate.ts index af3a4c95..af782bc3 100644 --- a/src/config/core/config/util/useStorageUpdate.ts +++ b/src/config/core/config/util/useStorageUpdate.ts @@ -3,8 +3,8 @@ import { localStorage } from 'sdk' import cookie from 'helpers/cookie' import * as constants from 'helpers/constants' -import networks from './networks' import wallets from '../../wallets' +import networks from './networks' const useStorageUpdate = (configState: ConfigProvider.ConfigState) => { diff --git a/src/config/core/config/util/useWallet/useConnect.ts b/src/config/core/config/util/useWallet/useConnect.ts index a1928948..3236335e 100644 --- a/src/config/core/config/util/useWallet/useConnect.ts +++ b/src/config/core/config/util/useWallet/useConnect.ts @@ -31,7 +31,6 @@ const useConnect = (values: Input) => { const intlRef = intl.useIntlRef() const inProgressRef = useRef(false) const { dataRef, setData } = configState - const timerRef = useRef(null) const resetConnection = useCallback(() => { notifications.open({ @@ -98,6 +97,11 @@ const useConnect = (values: Input) => { resetConnectTimer = setTimeout(resetConnection, 10_000) } + // In the safe app we need to resolve provider before next calls to avoid timeout error + if (isGnosisSafe && typeof connector.handleGetProvider === 'function') { + await connector.handleGetProvider() + } + if (activationMessage) { onStartConnect(activationMessage) } @@ -172,10 +176,6 @@ const useConnect = (values: Input) => { onFinishConnect() - if (resetConnectTimer) { - clearTimeout(resetConnectTimer) - } - const networkId = networks.idByChain[chainId] setData({ @@ -188,10 +188,6 @@ const useConnect = (values: Input) => { }) inProgressRef.current = false - - if (timerRef.current) { - clearTimeout(timerRef.current) - } } catch (error: any) { console.log(error) @@ -206,10 +202,6 @@ const useConnect = (values: Input) => { connector.deactivate?.() - if (resetConnectTimer) { - clearTimeout(resetConnectTimer) - } - if (isWalletConnect) { localStorage.clearAll() } @@ -238,6 +230,11 @@ const useConnect = (values: Input) => { return Promise.reject(error) } + finally { + if (resetConnectTimer) { + clearTimeout(resetConnectTimer) + } + } }, [ intlRef, dataRef, diff --git a/src/config/core/config/util/useWallet/useDisconnect.ts b/src/config/core/config/util/useWallet/useDisconnect.ts index b77dff0b..385ec1bd 100644 --- a/src/config/core/config/util/useWallet/useDisconnect.ts +++ b/src/config/core/config/util/useWallet/useDisconnect.ts @@ -1,5 +1,7 @@ import { useCallback } from 'react' +import { localStorage } from 'sdk' import intl from 'modules/intl' +import * as constants from 'helpers/constants' import notifications from 'modules/notifications' import wallets from '../../../wallets' @@ -39,6 +41,8 @@ const useDisconnect = (values: Input) => { autoConnectChecked: true, }) + localStorage.removeItem(constants.localStorageNames.walletName) + if (activeWallet) { const { title } = wallets[activeWallet] const wallet = intlRef.current.formatMessage(title as Intl.MessageTranslation) diff --git a/src/config/core/config/util/useWallet/useUpdateWallet.ts b/src/config/core/config/util/useWallet/useUpdateWallet.ts index 70d4ce15..a74a96c8 100644 --- a/src/config/core/config/util/useWallet/useUpdateWallet.ts +++ b/src/config/core/config/util/useWallet/useUpdateWallet.ts @@ -1,7 +1,8 @@ import { useEffect, useCallback } from 'react' import { getAddress, BrowserProvider } from 'ethers' import type { Eip1193Provider } from 'ethers' -import { methods } from 'helpers' +import getSDK from 'helpers/methods/getSDK' +import ens from 'helpers/methods/ens' import networks from '../networks' @@ -30,10 +31,10 @@ const useUpdateWallet = (values: Input) => { const handleENS = useCallback(async () => { const hasAccountName = Boolean(dataRef.current.accountName) - const sdk = methods.getSDK({ chainId }) + const sdk = getSDK({ chainId }) if (address) { - methods.ens.fetchName({ + ens.fetchName({ address, chainId, provider: sdk.provider, diff --git a/src/config/core/connectors/BinanceConnector.ts b/src/config/core/connectors/BinanceConnector.ts index f228fb7a..bb3a970a 100644 --- a/src/config/core/connectors/BinanceConnector.ts +++ b/src/config/core/connectors/BinanceConnector.ts @@ -1,8 +1,8 @@ 'use client' import { getProvider } from '@binance/w3w-ethereum-provider' import EventAggregator from 'modules/event-aggregator' -import apiUrls from 'helpers/methods/apiUrls' import { AbstractProvider } from 'ethers' +import apiUrls from 'helpers/methods/apiUrls' import { Network } from 'sdk' import networks from '../config/util/networks' diff --git a/src/config/core/connectors/LedgerConnector/LedgerProvider.ts b/src/config/core/connectors/LedgerConnector/LedgerProvider.ts index 30f84753..6a3de65f 100644 --- a/src/config/core/connectors/LedgerConnector/LedgerProvider.ts +++ b/src/config/core/connectors/LedgerConnector/LedgerProvider.ts @@ -1,6 +1,6 @@ import { configs, Network } from 'sdk' import AppEth from '@ledgerhq/hw-app-eth' -import * as methods from 'helpers/methods' +import prefix0x from 'helpers/methods/prefix0x' import { EIP712Message } from '@ledgerhq/types-live' import { Signature, Transaction, isAddress, TypedDataEncoder } from 'ethers' import type { TransactionLike, Eip1193Provider } from 'ethers' @@ -183,14 +183,14 @@ class LedgerProvider extends LedgerTransport implements Eip1193Provider { const path = this.#getDerivationPath() const unsignedTx = Transaction.from(transaction).unsignedSerialized - const rawTxHex = methods.prefix0x.remove(unsignedTx) + const rawTxHex = prefix0x.remove(unsignedTx) const address = await app.getAddress(path) const { r, s, v } = await app.signTransaction(path, rawTxHex) const signature = { - r: methods.prefix0x.add(r), - s: methods.prefix0x.add(s), + r: prefix0x.add(r), + s: prefix0x.add(s), v: parseInt(v), // There is no "from" field in the signature parameters, but if you add it, @@ -215,7 +215,7 @@ class LedgerProvider extends LedgerTransport implements Eip1193Provider { async signPersonalMessage(data: string) { return this.connectLedger(async (app: AppEth) => { const path = this.#getDerivationPath() - const formattedData = methods.prefix0x.remove(data) + const formattedData = prefix0x.remove(data) const result = await app.signPersonalMessage(path, formattedData) diff --git a/src/config/core/connectors/SafeAppConnector.ts b/src/config/core/connectors/SafeAppConnector.ts index ca8d8946..8a6bfe76 100644 --- a/src/config/core/connectors/SafeAppConnector.ts +++ b/src/config/core/connectors/SafeAppConnector.ts @@ -30,8 +30,27 @@ class SafeAppConnector extends WagmiConnector { }) } + async handleGetProvider(count: number = 0): Promise { + try { + const provider = await this.connector.getProvider() + + return provider as SafeAppProvider + } + catch (error: any) { + const nextCount = count + 1 + + if (nextCount < 10) { + await new Promise((resolve) => setTimeout(resolve, nextCount * 100)) + + return this.handleGetProvider(nextCount) + } + + return Promise.reject(error) + } + } + async getProvider() { - const provider = await this.connector.getProvider() as SafeAppProvider + const provider = await this.handleGetProvider() as SafeAppProvider const method = provider.request @@ -63,7 +82,7 @@ class SafeAppConnector extends WagmiConnector { } } catch { - // getBySafeTxHash can catch error if hash if real and not a safeTxHash + // getBySafeTxHash can catch error if hash is real and not a safeTxHash return response } } @@ -81,7 +100,7 @@ class SafeAppConnector extends WagmiConnector { } try { - const provider = await this.connector.getProvider() + const provider = await this.handleGetProvider() return Boolean(provider) } diff --git a/src/config/core/global.d.ts b/src/config/core/global.d.ts index cfbabacd..301fe69c 100644 --- a/src/config/core/global.d.ts +++ b/src/config/core/global.d.ts @@ -6,6 +6,7 @@ const walletsIds = Object.values(wallets).map(({ id }) => id) declare global { type WalletIds = typeof walletsIds[number] + type ReadOnlyConnector = ReadOnlyConnectorType type NetworkIds = OneOfArray type Connectors = Unpromise> 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/cookie.ts b/src/helpers/cookie.ts index 76e41470..fad42f8b 100644 --- a/src/helpers/cookie.ts +++ b/src/helpers/cookie.ts @@ -1,14 +1,15 @@ -import cookie, { CookieAttributes } from 'js-cookie' +import cookie from 'js-cookie' +import type Cookies from 'js-cookie' import cookieNames from './constants/cookieNames' const get = cookie.get -const set = async (name: string, value: string, attributes: CookieAttributes = {}) => { +const set = async (name: string, value: string, attributes: Cookies.CookieAttributes = {}) => { const isValid = Object.values(cookieNames).includes(name) - const options: CookieAttributes = attributes + const options: Cookies.CookieAttributes = attributes if (!isValid) { throw new Error(`Add cookie name "${name}" to constants`) diff --git a/src/helpers/messages/index.ts b/src/helpers/messages/index.ts index 10775d1a..e050af84 100644 --- a/src/helpers/messages/index.ts +++ b/src/helpers/messages/index.ts @@ -19,6 +19,15 @@ export default { transaction, notification, accessibility, + exitDuration: { + en: 'Exit duration', + ru: 'Продолжительность выхода', + fr: 'Durée de sortie', + es: 'Duración de salida', + pt: 'Duração de saída', + de: 'Austrittsdauer', + zh: '退出持续时间', + }, upgradeLeverageStrategy: { en: 'Upgrade leverage strategy contract', ru: 'Обновить контракт стратегии с плечом', diff --git a/src/helpers/messages/tooltip.ts b/src/helpers/messages/tooltip.ts index 2373263c..f690550a 100644 --- a/src/helpers/messages/tooltip.ts +++ b/src/helpers/messages/tooltip.ts @@ -1,32 +1,12 @@ export default { queue: { - en: ` - The amount of {token} that enters the exit queue. - It may take up to {queueDays} day(s) for the exit request to process. - `, - ru: ` - Количество {token}, которое входит в очередь выхода. - Обработка запроса на выход может занять до {queueDays} дней. - `, - fr: ` - La quantité de {token} qui entre dans la file d'attente de sortie. - Le traitement de la demande de sortie peut prendre jusqu'à {queueDays} jour(s). - `, - es: ` - La cantidad de {token} que ingresa a la cola de salida. - Puede tomar hasta {queueDays} día(s) procesar la solicitud de salida. - `, - pt: ` - A quantidade de {token} que entra na fila de saída. - Pode levar até {queueDays} dia(s) para processar a solicitação de saída. - `, - de: ` - Die Menge an {token}, die in die Austrittswarteschlange gelangt. - Es kann bis zu {queueDays} Tag(e) dauern, bis die Austrittsanfrage bearbeitet wird. - `, - zh: ` - {token} 进入退出队列的数量。退出请求处理可能需要长达 {queueDays} 天。 - `, + en: 'The amount of {token} that enters the exit queue.', + ru: 'Количество {token}, которое попадает в очередь выхода.', + fr: 'Le montant de {token} qui entre dans la file de sortie.', + es: 'La cantidad de {token} que entra en la cola de salida.', + pt: 'A quantidade de {token} que entra na fila de saída.', + de: 'Der Betrag von {token}, der in die Ausstiegswarteschlange gelangt.', + zh: '{token} 进入退出队列的数量。', }, gas: { en: 'The fee (in {nativeToken}) you need to pay for the transaction to be confirmed by the network.', @@ -155,4 +135,22 @@ export default { 目前,验证器退出并让 {depositToken} 可申领需要最多 {queueDays} 天。 `, }, + duration: { + en: 'It may take up to {queueDays, plural, one {# day} other {# days}} for an exit request to be processed on the {network} network.', + ru: 'Обработка запроса на выход в сети {network} может занять до {queueDays, plural, one {# дня} other {# дней}}.', + fr: 'Il peut falloir jusqu\'à {queueDays, plural, one {# jour} other {# jours}} pour qu\'une demande de sortie soit traitée sur le réseau {network}.', + es: 'Puede tardar hasta {queueDays, plural, one {# día} other {# días}} en procesarse una solicitud de salida en la red {network}.', + pt: 'Pode levar até {queueDays, plural, one {# dia} other {# dias}} para que um pedido de saída seja processado na rede {network}.', + de: 'Es kann bis zu {queueDays, plural, one {# Tag} other {# Tagen}} dauern, bis eine Ausstiegsanfrage im Netzwerk {network} bearbeitet wird.', + zh: '{network}网络上的退出请求可能需要最多{queueDays, plural, one {# 天} other {# 天}}来处理。', + }, + avgDuration: { + en: 'On average, exits over the past month took {avgQueueDays, plural, one {# day} other {# days}} to complete', + ru: 'В среднем, выходы за прошлый месяц занимали {avgQueueDays, plural, one {# день} few {# дня} other {# дней}} для завершения', + fr: 'En moyenne, les sorties du mois dernier ont pris {avgQueueDays, plural, one {# jour} other {# jours}} pour se compléter', + es: 'En promedio, las salidas del último mes tomaron {avgQueueDays, plural, one {# día} other {# días}} en completarse', + pt: 'Em média, as saídas do último mês levaram {avgQueueDays, plural, one {# dia} other {# dias}} para serem concluídas', + de: 'Im Durchschnitt dauerten die Ausstiege im letzten Monat {avgQueueDays, plural, one {# Tag} other {# Tage}}', + zh: '平均而言,上个月完成退出耗时 {avgQueueDays, plural, one {# 天} other {# 天}}', + }, } diff --git a/src/helpers/methods/formatFiatValue.ts b/src/helpers/methods/formatFiatValue.ts index 0e99e27a..1cce2eca 100644 --- a/src/helpers/methods/formatFiatValue.ts +++ b/src/helpers/methods/formatFiatValue.ts @@ -14,7 +14,7 @@ const formatFiatValue = ({ value, currencySymbol, isMinimal }: Input) => { formattedResult = `< ${currencySymbol} 0.01` } else { - formattedResult = `${currencySymbol} ${formattedResult}` + formattedResult = `${currencySymbol}${formattedResult}` } return formattedResult diff --git a/src/helpers/modifiers/formatDateToNumerical.ts b/src/helpers/modifiers/formatDateToNumerical.ts index bac994b2..1aeeffb0 100644 --- a/src/helpers/modifiers/formatDateToNumerical.ts +++ b/src/helpers/modifiers/formatDateToNumerical.ts @@ -1,7 +1,7 @@ import date from 'modules/date' -const formatDateToNumerical = (value: string | number | Date | ReturnType, separator: string = '-') => { +const formatDateToNumerical = (value: string | number | Date | Date.Time, separator: string = '-') => { let dayjsDate if (typeof value === 'string') { diff --git a/src/helpers/requests/vault/fetchData.ts b/src/helpers/requests/vault/fetchData.ts index af909997..3de59453 100644 --- a/src/helpers/requests/vault/fetchData.ts +++ b/src/helpers/requests/vault/fetchData.ts @@ -1,6 +1,7 @@ import { Network } from 'sdk' import * as methods from '../../methods' +import fetchQueueDays from './fetchQueueDays' type Input = { @@ -22,10 +23,12 @@ const fetchData = async ({ sdk, withTime, vaultAddress }: Input) => { data, versions, feePercent, + avgQueueDays, ] = await Promise.all([ sdk.vault.getVault({ vaultAddress, withTime }), sdk.vault.getVaultVersion({ vaultAddress }), sdk.contracts.base.mintTokenController.feePercent(), + fetchQueueDays({ sdk, vaultAddress }), ]) const chainId = sdk.config.network.chainId @@ -41,6 +44,7 @@ const fetchData = async ({ sdk, withTime, vaultAddress }: Input) => { ...data, versions, isPostPectra, + avgQueueDays, protocolFeePercent: String(feePercent / 100n), } } diff --git a/src/helpers/requests/vault/fetchQueueDays.ts b/src/helpers/requests/vault/fetchQueueDays.ts new file mode 100644 index 00000000..0ca1a892 --- /dev/null +++ b/src/helpers/requests/vault/fetchQueueDays.ts @@ -0,0 +1,49 @@ +import { methods } from 'helpers' + + +type AvgExitQueueQueryPayload = { + vaults: Array<{ + avgExitQueueLength: number + }> +} +type Input = { + sdk: SDK + vaultAddress: string +} + +const fetchQueueDays = async (values: Input) => { + const { sdk, vaultAddress } = values + + try { + const result = await methods.fetch(sdk.config.api.backend, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: ` + query AvgExitQueue($vaultAddress: String!) { + vaults(id: $vaultAddress) { + avgExitQueueLength + } + } + `, + variables: { + vaultAddress, + }, + }), + }) + + const seconds = result.vaults?.[0]?.avgExitQueueLength || 0 + + return Math.round(seconds / 86400) + } + catch (error: any) { + console.error('fetchQueueDays', error) + + return 0 + } +} + + +export default fetchQueueDays 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/controls/useEventListener.ts b/src/hooks/controls/useEventListener.ts index 256d7091..6008a8f6 100644 --- a/src/hooks/controls/useEventListener.ts +++ b/src/hooks/controls/useEventListener.ts @@ -5,7 +5,7 @@ import { useEffect, useRef, useCallback, RefObject } from 'react' const useEventListener = ( eventName: string, handler: EventListener, - elementRef?: RefObject, + elementRef?: RefObject, passive?: boolean ) => { const handlerRef = useRef(null) diff --git a/src/hooks/controls/useFieldListener.ts b/src/hooks/controls/useFieldListener.ts index 301a625b..371edab0 100644 --- a/src/hooks/controls/useFieldListener.ts +++ b/src/hooks/controls/useFieldListener.ts @@ -1,6 +1,6 @@ 'use client' import { useEffect } from 'react' -import { methods } from 'helpers' +import debounce from 'helpers/methods/debounce' type Procedure = (...args: any[]) => void @@ -14,7 +14,7 @@ const useFieldListener = ( useEffect(() => { if (typeof handler === 'function') { const handleChangeField = wait - ? methods.debounce(handler, wait) + ? debounce(handler, wait) : handler field.subscribe('change', handleChangeField) diff --git a/src/hooks/controls/useImagesPrefetch.ts b/src/hooks/controls/useImagesPrefetch.ts index 48643aab..082fb2be 100644 --- a/src/hooks/controls/useImagesPrefetch.ts +++ b/src/hooks/controls/useImagesPrefetch.ts @@ -1,19 +1,14 @@ 'use client' import { useEffect } from 'react' -import cacheStorage from 'modules/cache-storage' - -const cache = cacheStorage.get('PREFETCHED_IMAGES') - -const useImagesPrefetch = (images: Record) => { +const useImagesPrefetch = (images: Record, rel: 'prefetch' | 'preload' = 'prefetch') => { useEffect(() => { if (!images || !Object.values(images).length) { return } - const cacheResult = cache.getData() || [] - const newImages = Object.values(images).filter((image) => !cacheResult.includes(image)) + const newImages = Object.values(images) if (!newImages.length) { return @@ -22,17 +17,13 @@ const useImagesPrefetch = (images: Record) => { newImages.forEach((url) => { const link = document.createElement('link') - link.rel = 'prefetch' + link.rel = rel link.as = 'image' link.href = url document.head.appendChild(link) }) - - const uniqueArrayOfImages = [ ...cacheResult, ...newImages ] - - cache.setData(uniqueArrayOfImages, 0) - }, [ images ]) + }, [ images, rel ]) } diff --git a/src/hooks/controls/useModalClose.ts b/src/hooks/controls/useModalClose.ts index 44b3a8e5..5e396bd8 100644 --- a/src/hooks/controls/useModalClose.ts +++ b/src/hooks/controls/useModalClose.ts @@ -1,5 +1,5 @@ -import { useEffect } from 'react' -import { useConfig } from 'config' +import useChainChanged from './useChainChanged' +import useAddressChanged from './useAddressChanged' type Input = { @@ -9,16 +9,8 @@ type Input = { const useModalClose = (values: Input) => { const { closeModal } = values - const { wallet } = useConfig() - - useEffect(() => { - wallet.subscribeBeforeChange('chain', closeModal) - wallet.subscribeBeforeChange('address', closeModal) - return () => { - wallet.unsubscribeBeforeChange('chain', closeModal) - wallet.unsubscribeBeforeChange('address', closeModal) - } - }, []) + useChainChanged(closeModal) + useAddressChanged(closeModal) } diff --git a/src/hooks/controls/useTabButton.ts b/src/hooks/controls/useTabButton.ts index e5ade1fb..e7d782a2 100644 --- a/src/hooks/controls/useTabButton.ts +++ b/src/hooks/controls/useTabButton.ts @@ -1,4 +1,4 @@ -import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import device from 'modules/device' import intl from 'modules/intl' @@ -6,10 +6,11 @@ import intl from 'modules/intl' type Input = { gap?: number index: number + firstRenderClassName?: string } const useTabButton = (props: Input, deps: any[] = []) => { - const { gap = 0, index = 0 } = props || {} + const { gap = 0, index = 0, firstRenderClassName } = props || {} const { locale } = intl.useIntl() const { isMobile } = device.useData() @@ -20,16 +21,19 @@ const useTabButton = (props: Input, deps: any[] = []) => { const getPosition = useCallback((index: number) => { if (containerRef.current) { const buttons = Array.from(containerRef.current.children) as HTMLButtonElement[] - const widths = buttons.map(({ offsetWidth }) => offsetWidth) - const height = buttons.map(({ offsetHeight }) => offsetHeight).reduce((acc, height) => Math.max(acc, height), 0) + const sizes = buttons.map((button) => button.getBoundingClientRect()) + const widths = sizes.map(({ width }) => width) + const height = sizes.reduce((acc, { height }) => Math.max(acc, height), 0) const offset = widths .filter((_, widthIndex) => widthIndex < index) - .reduce((acc, width) => acc + width + gap, 0) + .reduce((acc, width) => acc + width, 0) + + const gapOffset = gap * index if (widths[index] && height) { return { - left: `${offset}px`, + left: `calc(${offset}px + ${gapOffset}rem)`, width: `${widths[index]}px`, height: `${height}px`, } @@ -68,6 +72,20 @@ const useTabButton = (props: Input, deps: any[] = []) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ ...deps, locale, isMobile, setPosition ]) + useEffect(() => { + const children = containerRef.current?.children + + if (children && firstRenderClassName) { + Array.from(children).forEach((button) => { + if (button !== tabButtonRef.current) { + const classNames = firstRenderClassName.split(' ') + + button.classList.remove(...classNames) + } + }) + } + }, [ firstRenderClassName, ...deps ]) + return useMemo(() => ({ tabButtonRef, containerRef, diff --git a/src/hooks/data/useClaimsTotal.ts b/src/hooks/data/useClaimsTotal.ts index 62bd98c3..a4de4b41 100644 --- a/src/hooks/data/useClaimsTotal.ts +++ b/src/hooks/data/useClaimsTotal.ts @@ -1,7 +1,8 @@ import { useMemo } from 'react' -import { methods } from 'helpers' import { useConfig } from 'config' import { formatEther } from 'ethers' +import getFiatValue from 'helpers/methods/getFiatValue' +import formatFiatValue from 'helpers/methods/formatFiatValue' import useStore from './useStore' @@ -25,7 +26,7 @@ const useClaimsTotal = () => { const tokenKeys = Object.keys(sdk.config.addresses.tokens).reduce((acc, tokenKey) => { const tokenAddress = sdk.config.addresses.tokens[tokenKey as keyof typeof sdk.config.addresses.tokens] - acc[tokenAddress] = tokenKey + acc[tokenAddress.toLowerCase()] = tokenKey return acc }, {} as Record) @@ -33,11 +34,11 @@ const useClaimsTotal = () => { let amount = 0 tokens.forEach((tokenAddress, index) => { - const tokenKey = tokenKeys[tokenAddress] + const tokenKey = tokenKeys[tokenAddress.toLowerCase()] const tokenName = sdk.config.tokens[tokenKey as keyof typeof sdk.config.tokens] const value = formatEther(unclaimedAmounts[index]) - const fiatValue = methods.getFiatValue({ + const fiatValue = getFiatValue({ token: tokenName, value, currency, @@ -48,7 +49,7 @@ const useClaimsTotal = () => { }) if (amount) { - return methods.formatFiatValue({ + return formatFiatValue({ value: amount, currencySymbol, isMinimal: true, diff --git a/src/hooks/data/useFiatValues.ts b/src/hooks/data/useFiatValues.ts index 8aec3832..4bc8c3ea 100644 --- a/src/hooks/data/useFiatValues.ts +++ b/src/hooks/data/useFiatValues.ts @@ -1,12 +1,13 @@ -import { methods } from 'helpers' import { useMemo, useCallback } from 'react' -import { createSelector } from '@reduxjs/toolkit' +import swGetFiatValue from 'helpers/methods/getFiatValue' +import { useSelector, createSelector } from 'store' +import formatFiatValue from 'helpers/methods/formatFiatValue' -import useStore from '../data/useStore' +type FiatToken = Extract type Input = Record @@ -36,7 +37,7 @@ const mock = { } const useFiatValues = (values: Input): Output => { - const { fiatRates, swapTokenRates, currency, currencySymbol, isFetching } = useStore(storeSelector) + const { fiatRates, swapTokenRates, currency, currencySymbol, isFetching } = useSelector(storeSelector) const getFiatValue = useCallback((params: Input[T]) => { const { token, value, isMinimal } = params @@ -45,7 +46,7 @@ const useFiatValues = (values: Input): Output => { return mock } - const allRates = { ...fiatRates, ...swapTokenRates } + const allRates = { ...swapTokenRates, ...fiatRates } const isValidToken = Object.keys(allRates).includes(token) @@ -54,7 +55,7 @@ const useFiatValues = (values: Input): Output => { return mock } - const fiatValue = methods.getFiatValue({ + const fiatValue = swGetFiatValue({ value, token, currency, @@ -62,7 +63,7 @@ const useFiatValues = (values: Input): Output => { isMinimal, }) - const formattedValue = methods.formatFiatValue({ + const formattedValue = formatFiatValue({ value: fiatValue, currencySymbol, isMinimal, diff --git a/src/hooks/data/useMintToken.ts b/src/hooks/data/useMintToken.ts index 7ba31044..56b25fa4 100644 --- a/src/hooks/data/useMintToken.ts +++ b/src/hooks/data/useMintToken.ts @@ -1,16 +1,28 @@ 'use client' import { useCallback } from 'react' +import insertMockE2E from 'helpers/methods/insertMockE2E' type Input = { sdk: SDK } +type Output = { + apy: string + feePercent: number +} + const useMintToken = (values: Input) => { const { sdk } = values const fetchAPY = useCallback(async () => { try { + const mockE2E = insertMockE2E('fixtures/osToken/setOsTokenApy') + + if (mockE2E) { + return mockE2E + } + const response = await sdk.osToken.getAPY() return response diff --git a/src/hooks/data/useStore.ts b/src/hooks/data/useStore.ts index 4e0c8606..df9de05f 100644 --- a/src/hooks/data/useStore.ts +++ b/src/hooks/data/useStore.ts @@ -1,6 +1,6 @@ 'use client' import { useRef, useCallback } from 'react' -import { useSelector } from 'react-redux' +import { useSelector } from 'store' import equal from 'fast-deep-equal' diff --git a/src/hooks/fetch/useAllowance.ts b/src/hooks/fetch/useAllowance.ts index 8c57ec40..2275ad93 100644 --- a/src/hooks/fetch/useAllowance.ts +++ b/src/hooks/fetch/useAllowance.ts @@ -36,7 +36,7 @@ const useAllowance = (values: Input) => { const { sdk, address } = useConfig() - const skip = values.skip || !address || recipient === ZeroAddress + const skip = values.skip || !address || !tokenAddress || recipient === ZeroAddress const handleTransaction = useTransaction() diff --git a/src/hooks/fetch/useSubgraphUpdate.ts b/src/hooks/fetch/useSubgraphUpdate.ts index d6c4f974..2a05bb2b 100644 --- a/src/hooks/fetch/useSubgraphUpdate.ts +++ b/src/hooks/fetch/useSubgraphUpdate.ts @@ -22,32 +22,45 @@ const useSubgraphUpdate = () => { const configNetworkIdRef = useRef(sdk.config.network.id) configNetworkIdRef.current = sdk.config.network.id + const fetchTransaction = useCallback(async (hash: string, attempt: number = 0) => { + try { + const transactions = await sdk.utils.getTransactions({ hash }) + + return transactions.length + } + catch (error) { + if (attempt < 10) { + await new Promise((resolve) => setTimeout(resolve, attempt * 100)) + + return fetchTransaction(hash, attempt + 1) + } + + return Promise.reject(error) + } + }, [ sdk ]) + const resolveTransaction = useCallback(async (props: ResolveTransactionProps) => { const { hash, expectedCount } = props + const count = await fetchTransaction(hash) const isConfigChanged = configNetworkIdRef.current !== sdk.config.network.id - if (!isConfigChanged) { - const transactions = await sdk.utils.getTransactions({ hash }) - const count = transactions.length - - if (!count || count < expectedCount) { - return new Promise((resolve) => { - setTimeout(() => { - const promise = resolveTransaction(props) + if (!isConfigChanged && (!count || count < expectedCount)) { + return new Promise((resolve) => { + setTimeout(() => { + const promise = resolveTransaction(props) - resolve(promise) - }, 1000) - }) - } + resolve(promise) + }, 1000) + }) } - }, [ sdk ]) + }, [ sdk, fetchTransaction ]) return useCallback(async ({ hash, count = 1 }: Input) => { actions.ui.resetBottomLoader() if (!hash) { - return Promise.reject() + return Promise.reject('Empty hash on subgraphUpdate') } actions.ui.setBottomLoaderTransaction(`${sdk.config.network.blockExplorerUrl}/tx/${hash}`) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 53999376..c328cbd4 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,14 +1,13 @@ -// Actions export { default as useCopyToClipboard } from './actions/useCopyToClipboard' export { default as useAddTokenToWallet } from './actions/useAddTokenToWallet' -// Controls export { default as useDeepMemo } from './controls/useDeepMemo' export { default as useTabButton } from './controls/useTabButton' export { default as useAutoFetch } from './controls/useAutoFetch' export { default as useMountedRef } from './controls/useMountedRef' export { default as useModalClose } from './controls/useModalClose' export { default as useObjectState } from './controls/useObjectState' +export { default as useChangeEffect } from './controls/useChangeEffect' export { default as useChainChanged } from './controls/useChainChanged' export { default as useFieldListener } from './controls/useFieldListener' export { default as useEventListener } from './controls/useEventListener' @@ -18,7 +17,6 @@ export { default as useAddressChanged } from './controls/useAddressChanged' export { default as useActiveBrowserTab } from './controls/useActiveBrowserTab' export { default as useIsomorphicLayoutEffect } from './controls/useIsomorphicLayoutEffect' -// Data export { default as useStore } from './data/useStore' export { default as useActions } from './data/useActions' export { default as useBalances } from './data/useBalances' @@ -27,8 +25,9 @@ export { default as useFiatValues } from './data/useFiatValues' export { default as useClaimsTotal } from './data/useClaimsTotal' export { default as useSwapTokenBalances } from './data/useSwapTokenBalances' -// Fetch 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' + +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..0aba5076 --- /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..513ba40c --- /dev/null +++ b/src/hooks/swap/useQuote.ts @@ -0,0 +1,122 @@ +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 = ({ swapTokens }: Input) => { + const { address, chainId, isEthereum, isReadOnlyMode } = 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 ]) + + const getQuoteRequest = useCallback((values: FetchQuoteInput) => { + const { buyAmount, sellAmount } = values + + const swapTokens = swapTokensRef.current + + const buyToken = swapTokens.buyToken.address || nativeTokenAddress + const sellToken = swapTokens.sellToken.address || nativeTokenAddress + const buyTokenDecimals = swapTokens.buyToken.units + const sellTokenDecimals = swapTokens.sellToken.units + const buyAmountString = buyAmount?.toString() + const sellAmountString = sellAmount?.toString() + + return { + buyToken, + sellToken, + buyTokenDecimals, + sellTokenDecimals, + kind: buyAmount ? 'buy' : 'sell', + owner: address || ZeroAddress, + amount: buyAmountString || sellAmountString, + receiver: address || ZeroAddress, + partiallyFillable: false, + } as TradeParameters + }, [ address, nativeTokenAddress ]) + + return useCallback(async (values: FetchQuoteInput) => { + const { tradingSdk } = await getSwapSDK() + + try { + const quoteRequest = getQuoteRequest(values) + + const { quoteResults: { quoteResponse } } = await tradingSdk.getQuote(quoteRequest) + + const quote = { + ...quoteResponse.quote, + receiver: quoteRequest.receiver, + buyAmount: quoteRequest.kind === 'buy' ? quoteRequest.amount : quoteResponse.quote.buyAmount, + sellAmount: quoteRequest.kind === 'sell' ? quoteRequest.amount : quoteResponse.quote.sellAmount, + } + + const rate = getRate({ + buyAmount: quote.buyAmount, + sellAmount: quote.sellAmount, + buyTokenDecimals: quoteRequest.buyTokenDecimals, + sellTokenDecimals: quoteRequest.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, isEthereum, isReadOnlyMode, nativeTokenAddress, + getRate, getSwapSDK, getQuoteRequest, + ]) +} + + +export default useQuote diff --git a/src/hooks/swap/useSigner.ts b/src/hooks/swap/useSigner.ts new file mode 100644 index 00000000..87b81332 --- /dev/null +++ b/src/hooks/swap/useSigner.ts @@ -0,0 +1,17 @@ +import { useCallback } from 'react' +import { useConfig } from 'config' +import { VoidSigner, ZeroAddress } from 'ethers' + + +const useSigner = () => { + const { signSDK, address, isReadOnlyMode } = useConfig() + + return useCallback(() => ( + address && !isReadOnlyMode + ? signSDK.provider.getSigner() + : new VoidSigner(ZeroAddress, signSDK.provider) + ), [ signSDK, address, isReadOnlyMode ]) +} + + +export default useSigner diff --git a/src/hooks/swap/useSwapSDK.ts b/src/hooks/swap/useSwapSDK.ts new file mode 100644 index 00000000..e91d3d34 --- /dev/null +++ b/src/hooks/swap/useSwapSDK.ts @@ -0,0 +1,49 @@ +import { useCallback } from 'react' +import { useConfig } from 'config' +import type { SupportedChainId } from '@cowprotocol/cow-sdk' + +import useSigner from './useSigner' + + +const useSwapSDK = () => { + const { signSDK, chainId } = useConfig() + + const getSigner = useSigner() + + return useCallback(async () => { + const [ + signer, + { + 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 { + 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 ( +