diff --git a/.env.example b/.env.example index 904c2110a..de3cfaa3e 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,9 @@ SUBGRAPH_REQUEST_TIMEOUT=5000 # allow some state overrides from browser console for QA ENABLE_QA_HELPERS=false +# 1inch API token to power /api/oneinch-rate +ONE_INCH_API_KEY= + REWARDS_BACKEND=http://127.0.0.1:4000 # rate limit diff --git a/config/matomoClickEvents.ts b/config/matomoClickEvents.ts index 18f1903e7..4d213a932 100644 --- a/config/matomoClickEvents.ts +++ b/config/matomoClickEvents.ts @@ -8,6 +8,7 @@ export const enum MATOMO_CLICK_EVENTS_TYPES { clickExploreDeFi = 'clickExploreDeFi', // / page openOceanDiscount = 'openOceanDiscount', + oneInchDiscount = 'oneInchDiscount', viewEtherscanOnStakePage = 'viewEtherscanOnStakePage', l2BannerStake = 'l2BannerStake', l2LowFeeStake = 'l2LowFeeStake', @@ -93,6 +94,11 @@ export const MATOMO_CLICK_EVENTS: Record< 'Push "Get discount" on OpenOcean banner on widget', 'eth_widget_openocean_discount', ], + [MATOMO_CLICK_EVENTS_TYPES.oneInchDiscount]: [ + 'Ethereum_Staking_Widget', + 'Push "Get discount" on 1inch banner on widget', + 'eth_widget_oneinch_discount', + ], [MATOMO_CLICK_EVENTS_TYPES.viewEtherscanOnStakePage]: [ 'Ethereum_Staking_Widget', 'Push «View on Etherscan» on the right side of Lido Statistics', diff --git a/config/stake.ts b/config/stake.ts index 58d1c8fb4..0822d4d66 100644 --- a/config/stake.ts +++ b/config/stake.ts @@ -3,6 +3,7 @@ import { BigNumber } from 'ethers'; import dynamics from './dynamics'; import { IPFS_REFERRAL_ADDRESS } from './ipfs'; import { AddressZero } from '@ethersproject/constants'; +import { StakeSwapDiscountIntegrationKey } from 'features/stake/swap-discount-banner'; export const PRECISION = 10 ** 6; @@ -22,3 +23,6 @@ export const STAKE_GASLIMIT_FALLBACK = BigNumber.from( export const STAKE_FALLBACK_REFERRAL_ADDRESS = dynamics.ipfsMode ? IPFS_REFERRAL_ADDRESS : AddressZero; + +export const STAKE_SWAP_INTEGRATION: StakeSwapDiscountIntegrationKey = + 'one-inch'; diff --git a/features/stake/hooks.tsx b/features/stake/hooks.tsx index cce87febc..6478bb4bb 100644 --- a/features/stake/hooks.tsx +++ b/features/stake/hooks.tsx @@ -2,14 +2,13 @@ import { useMemo } from 'react'; import { isDesktop } from 'react-device-detect'; import { useConnectorInfo } from 'reef-knot/web3-react'; -const ONE_INCH_URL = 'https://app.1inch.io/#/1/swap/ETH/steth'; const LEDGER_LIVE_ONE_INCH_DESKTOP_DEEPLINK = 'ledgerlive://discover/1inch-lld'; const LEDGER_LIVE_ONE_INCH_MOBILE_DEEPLINK = 'ledgerlive://discover/1inch-llm'; -export const use1inchLinkProps = () => { +export const use1inchDeepLinkProps = () => { const { isLedgerLive } = useConnectorInfo(); - const linkProps = useMemo(() => { + return useMemo(() => { if (isLedgerLive) { const href = isDesktop ? LEDGER_LIVE_ONE_INCH_DESKTOP_DEEPLINK @@ -20,13 +19,7 @@ export const use1inchLinkProps = () => { target: '_self', }; } else { - return { - href: ONE_INCH_URL, - target: '_blank', - rel: 'noopener noreferrer', - }; + return {}; } }, [isLedgerLive]); - - return linkProps; }; diff --git a/features/stake/swap-discount-banner/index.ts b/features/stake/swap-discount-banner/index.ts index a87f815e7..293498bef 100644 --- a/features/stake/swap-discount-banner/index.ts +++ b/features/stake/swap-discount-banner/index.ts @@ -1 +1,2 @@ export { SwapDiscountBanner } from './swap-discount-banner'; +export type { StakeSwapDiscountIntegrationKey } from './types'; diff --git a/features/stake/swap-discount-banner/integrations.tsx b/features/stake/swap-discount-banner/integrations.tsx new file mode 100644 index 000000000..4f534729e --- /dev/null +++ b/features/stake/swap-discount-banner/integrations.tsx @@ -0,0 +1,68 @@ +import { TOKENS } from '@lido-sdk/constants'; +import { getOpenOceanRate } from 'utils/get-open-ocean-rate'; +import { + StakeSwapDiscountIntegrationKey, + StakeSwapDiscountIntegrationMap, +} from './types'; +import { OpenOceanIcon, OneInchIcon, OverlayLink } from './styles'; +import { parseEther } from '@ethersproject/units'; +import { OPEN_OCEAN_REFERRAL_ADDRESS } from 'config/external-links'; +import { MATOMO_CLICK_EVENTS } from 'config/matomoClickEvents'; +import { getOneInchRate } from 'utils/get-one-inch-rate'; +import { use1inchDeepLinkProps } from 'features/stake/hooks'; + +const DEFAULT_AMOUNT = parseEther('1'); + +const STAKE_SWAP_INTEGRATION_CONFIG: StakeSwapDiscountIntegrationMap = { + 'open-ocean': { + title: 'OpenOcean', + async getRate() { + const { rate } = await getOpenOceanRate( + DEFAULT_AMOUNT, + 'ETH', + TOKENS.STETH, + ); + return rate; + }, + BannerText({ discountPercent }) { + return ( + <> + Get a {discountPercent.toFixed(2)}% discount by swapping to + stETH on the OpenOcean platform + > + ); + }, + Icon: OpenOceanIcon, + linkHref: `https://app.openocean.finance/classic?referrer=${OPEN_OCEAN_REFERRAL_ADDRESS}#/ETH/ETH/STETH`, + matomoEvent: MATOMO_CLICK_EVENTS.openOceanDiscount, + }, + 'one-inch': { + title: '1inch', + async getRate() { + const { rate } = await getOneInchRate({ token: 'ETH' }); + return rate; + }, + BannerText({ discountPercent }) { + return ( + <> + Get a {discountPercent.toFixed(2)}% discount by swapping to + stETH on the 1inch platform + > + ); + }, + Icon: OneInchIcon, + linkHref: `https://app.1inch.io/#/1/simple/swap/ETH/stETH`, + matomoEvent: MATOMO_CLICK_EVENTS.oneInchDiscount, + CustomLink({ children, ...props }) { + const customProps = use1inchDeepLinkProps(); + return ( + + {children} + + ); + }, + }, +}; + +export const getSwapIntegration = (key: StakeSwapDiscountIntegrationKey) => + STAKE_SWAP_INTEGRATION_CONFIG[key]; diff --git a/features/stake/swap-discount-banner/styles.ts b/features/stake/swap-discount-banner/styles.ts index af9aaeb76..8caea19c0 100644 --- a/features/stake/swap-discount-banner/styles.ts +++ b/features/stake/swap-discount-banner/styles.ts @@ -1,6 +1,7 @@ import styled from 'styled-components'; import BgSrc from 'assets/icons/swap-banner-bg.svg'; import OpenOcean from 'assets/icons/open-ocean.svg'; +import OneInch from 'assets/icons/oneinch-circle.svg'; export const Wrap = styled.div` position: relative; @@ -42,13 +43,22 @@ export const OverlayLink = styled.a` export const OpenOceanIcon = styled.img.attrs({ src: OpenOcean, - alt: 'openOcean', + alt: 'OpenOcean', })` width: 40px; height: 40px; display: block; `; +export const OneInchIcon = styled.img.attrs({ + src: OneInch, + alt: '1inch', +})` + display: block; + width: 40px; + height: 40px; +`; + export const TextWrap = styled.p` flex: 1 1 auto; color: #fff; diff --git a/features/stake/swap-discount-banner/swap-discount-banner.tsx b/features/stake/swap-discount-banner/swap-discount-banner.tsx index d697abd50..a763fa58d 100644 --- a/features/stake/swap-discount-banner/swap-discount-banner.tsx +++ b/features/stake/swap-discount-banner/swap-discount-banner.tsx @@ -1,90 +1,43 @@ import { Button } from '@lidofinance/lido-ui'; import { trackEvent } from '@lidofinance/analytics-matomo'; -import { MATOMO_CLICK_EVENTS } from 'config'; -import { OPEN_OCEAN_REFERRAL_ADDRESS } from 'config/external-links'; -import { STRATEGY_LAZY } from 'utils/swrStrategies'; -import { getOpenOceanRate } from 'utils/get-open-ocean-rate'; -import { parseEther } from '@ethersproject/units'; -import { TOKENS } from '@lido-sdk/constants'; -import { useLidoSWR } from '@lido-sdk/react'; -import { enableQaHelpers } from 'utils'; - -import { Wrap, TextWrap, OpenOceanIcon, OverlayLink } from './styles'; - -const SWAP_URL = `https://app.openocean.finance/classic?referrer=${OPEN_OCEAN_REFERRAL_ADDRESS}#/ETH/ETH/STETH`; -const DISCOUNT_THRESHOLD = 1.004; -const DEFAULT_AMOUNT = parseEther('1'); -const MOCK_LS_KEY = 'mock-qa-helpers-discount-rate'; - -type FetchRateResult = { - rate: number; - shouldShowDiscount: boolean; - discountPercent: number; -}; - -const calculateDiscountState = (rate: number): FetchRateResult => ({ - rate, - shouldShowDiscount: rate > DISCOUNT_THRESHOLD, - discountPercent: (1 - 1 / rate) * 100, -}); - -// we show banner if STETH is considerably cheaper to get on dex than staking -// ETH -> stETH rate > THRESHOLD -const fetchRate = async (): Promise => { - const { rate } = await getOpenOceanRate(DEFAULT_AMOUNT, 'ETH', TOKENS.STETH); - return calculateDiscountState(rate); -}; - -const linkClickHandler = () => - trackEvent(...MATOMO_CLICK_EVENTS.openOceanDiscount); - -if (enableQaHelpers && typeof window !== 'undefined') { - (window as any).setMockDiscountRate = (rate?: number) => - rate === undefined - ? localStorage.removeItem(MOCK_LS_KEY) - : localStorage.setItem(MOCK_LS_KEY, rate.toString()); -} - -const getData = (data: FetchRateResult | undefined) => { - if (!enableQaHelpers || typeof window == 'undefined') return data; - const mock = localStorage.getItem(MOCK_LS_KEY); - if (mock) { - return calculateDiscountState(parseFloat(mock)); - } - return data; -}; +import { useSwapDiscount } from './use-swap-discount'; +import { Wrap, TextWrap, OverlayLink } from './styles'; export const SwapDiscountBanner = ({ children }: React.PropsWithChildren) => { - const swr = useLidoSWR( - ['swr:open-ocean-rate'], - fetchRate, - STRATEGY_LAZY, - ); - - const data = getData(swr.data); + const { data, initialLoading } = useSwapDiscount(); - if (swr.initialLoading) return null; + if (initialLoading) return null; - if (!data?.shouldShowDiscount) return <>{children}>; + if (!data || !data.shouldShowDiscount) return <>{children}>; + const { + BannerText, + Icon, + discountPercent, + matomoEvent, + linkHref, + CustomLink, + } = data; + const Link = CustomLink ?? OverlayLink; return ( - + - Get a {data?.discountPercent.toFixed(2)}% discount by swapping to - stETH on the OpenOcean platform + - { + trackEvent(...matomoEvent); + }} > Get discount - + ); }; diff --git a/features/stake/swap-discount-banner/types.ts b/features/stake/swap-discount-banner/types.ts new file mode 100644 index 000000000..b53268621 --- /dev/null +++ b/features/stake/swap-discount-banner/types.ts @@ -0,0 +1,24 @@ +import { MatomoEventType } from '@lidofinance/analytics-matomo'; + +export type StakeSwapDiscountIntegrationKey = 'open-ocean' | 'one-inch'; + +export type StakeSwapDiscountIntegrationValue = { + title: string; + getRate: () => Promise; + linkHref: string; + CustomLink?: React.FC>>; + matomoEvent: MatomoEventType; + BannerText: React.FC<{ discountPercent: number }>; + Icon: React.FC; +}; + +export type StakeSwapDiscountIntegrationMap = Record< + StakeSwapDiscountIntegrationKey, + StakeSwapDiscountIntegrationValue +>; + +export type FetchRateResult = { + rate: number; + shouldShowDiscount: boolean; + discountPercent: number; +}; diff --git a/features/stake/swap-discount-banner/use-swap-discount.ts b/features/stake/swap-discount-banner/use-swap-discount.ts new file mode 100644 index 000000000..f61a36a2b --- /dev/null +++ b/features/stake/swap-discount-banner/use-swap-discount.ts @@ -0,0 +1,60 @@ +import { STRATEGY_LAZY } from 'utils/swrStrategies'; +import { useLidoSWR } from '@lido-sdk/react'; +import { enableQaHelpers } from 'utils'; +import type { + FetchRateResult, + StakeSwapDiscountIntegrationKey, + StakeSwapDiscountIntegrationValue, +} from './types'; +import { STAKE_SWAP_INTEGRATION } from 'config'; +import { getSwapIntegration } from './integrations'; + +const DISCOUNT_THRESHOLD = 1.004; +const MOCK_LS_KEY = 'mock-qa-helpers-discount-rate'; + +if (enableQaHelpers && typeof window !== 'undefined') { + (window as any).setMockDiscountRate = (rate?: number) => + rate === undefined + ? localStorage.removeItem(MOCK_LS_KEY) + : localStorage.setItem(MOCK_LS_KEY, rate.toString()); +} + +// we show banner if STETH is considerably cheaper to get on dex than staking +// ETH -> stETH rate > THRESHOLD +const fetchRate = async ( + _: string, + integrationKey: StakeSwapDiscountIntegrationKey, +): Promise => { + const integration = getSwapIntegration(integrationKey); + let rate: number; + const mock = localStorage.getItem(MOCK_LS_KEY); + if (enableQaHelpers && mock) { + rate = parseFloat(mock); + } else { + rate = await integration.getRate(); + } + return { + ...integration, + rate, + shouldShowDiscount: rate > DISCOUNT_THRESHOLD, + discountPercent: (1 - 1 / rate) * 100, + }; +}; + +export const useSwapDiscount = () => { + return useLidoSWR( + ['swr:swap-discount-rate', STAKE_SWAP_INTEGRATION], + // @ts-expect-error useLidoSWR has broken fetcher-key type signature + fetchRate, + { + ...STRATEGY_LAZY, + onError(error, key) { + console.warn( + `[useSwapDiscount] Error fetching ETH->Steth:`, + key, + error, + ); + }, + }, + ); +}; diff --git a/features/withdrawals/hooks/useTvlMessage.tsx b/features/withdrawals/hooks/useTvlMessage.ts similarity index 100% rename from features/withdrawals/hooks/useTvlMessage.tsx rename to features/withdrawals/hooks/useTvlMessage.ts diff --git a/features/withdrawals/hooks/useWithdrawalRates.ts b/features/withdrawals/hooks/useWithdrawalRates.ts deleted file mode 100644 index 000659c03..000000000 --- a/features/withdrawals/hooks/useWithdrawalRates.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { useMemo } from 'react'; -import { useWatch } from 'react-hook-form'; -import { BigNumber } from 'ethers'; - -import { Zero } from '@ethersproject/constants'; -import { CHAINS, getTokenAddress, TOKENS } from '@lido-sdk/constants'; -import { useLidoSWR } from '@lido-sdk/react'; - -import { useDebouncedValue } from 'shared/hooks/useDebouncedValue'; -import { STRATEGY_LAZY } from 'utils/swrStrategies'; - -import { RequestFormInputType } from '../request/request-form-context'; -import { getOpenOceanRate } from 'utils/get-open-ocean-rate'; -import { standardFetcher } from 'utils/standardFetcher'; -import { FetcherError } from 'utils/fetcherError'; - -type GetWithdrawalRateParams = { - amount: BigNumber; - token: TOKENS.STETH | TOKENS.WSTETH; -}; - -type RateCalculationResult = { - rate: number; - toReceive: BigNumber; -}; - -type SingleWithdrawalRateResult = { - name: string; - rate: number | null; - toReceive: BigNumber | null; - displayEmpty?: boolean; -}; - -type GetRateType = ( - params: GetWithdrawalRateParams, -) => Promise; - -type GetWithdrawalRateResult = SingleWithdrawalRateResult[]; - -const RATE_PRECISION = 100000; -const RATE_PRECISION_BN = BigNumber.from(RATE_PRECISION); - -const calculateRateReceive = ( - amount: BigNumber, - src: BigNumber, - dest: BigNumber, -): RateCalculationResult => { - const _rate = dest.mul(RATE_PRECISION_BN).div(src); - const toReceive = amount.mul(dest).div(src); - const rate = _rate.toNumber() / RATE_PRECISION; - return { rate, toReceive }; -}; - -const getOpenOceanWithdrawalRate: GetRateType = async ({ amount, token }) => { - let displayEmpty = false; - - if (amount && amount.gt(Zero)) { - try { - const rate = await getOpenOceanRate(amount, token, 'ETH'); - return { - name: 'openOcean', - ...rate, - }; - } catch (e) { - displayEmpty = e instanceof FetcherError && e.status < 500; - console.warn('[getOpenOceanRate] Failed to receive withdraw rate', e); - } - } - - return { - name: 'openOcean', - rate: null, - toReceive: null, - displayEmpty, - }; -}; - -type ParaSwapPriceResponsePartial = { - priceRoute: { - srcAmount: string; - destAmount: string; - }; -}; - -const getParaSwapRate: GetRateType = async ({ amount, token }) => { - let rateInfo: RateCalculationResult | null; - let displayEmpty = false; - - try { - if (amount.isZero() || amount.isNegative()) { - return { - name: 'paraswap', - rate: 0, - toReceive: BigNumber.from(0), - }; - } - const capped_amount = amount; - const api = `https://apiv5.paraswap.io/prices`; - const query = new URLSearchParams({ - srcToken: getTokenAddress(CHAINS.Mainnet, token), - srcDecimals: '18', - destToken: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', - destDecimals: '18', - side: 'SELL', - excludeDirectContractMethods: 'true', - userAddress: '0x0000000000000000000000000000000000000000', - amount: capped_amount.toString(), - network: '1', - partner: 'lido', - }); - - const url = `${api}?${query.toString()}`; - const data: ParaSwapPriceResponsePartial = - await standardFetcher(url); - - rateInfo = calculateRateReceive( - amount, - BigNumber.from(data.priceRoute.srcAmount), - BigNumber.from(data.priceRoute.destAmount), - ); - } catch (e) { - rateInfo = null; - displayEmpty = e instanceof FetcherError && e.status < 500; - } - - return { - name: 'paraswap', - rate: rateInfo?.rate ?? null, - toReceive: rateInfo?.toReceive ?? null, - displayEmpty, - }; -}; - -const getWithdrawalRates = async ( - params: GetWithdrawalRateParams, -): Promise => { - const rates = await Promise.all([ - getOpenOceanWithdrawalRate(params), - getParaSwapRate(params), - ]); - - if (rates.length > 1) { - // sort by rate, then alphabetic - rates.sort((r1, r2) => { - const rate1 = r1.rate ?? 0; - const rate2 = r2.rate ?? 0; - if (rate1 == rate2) { - if (r1.name < r2.name) { - return -1; - } - if (r1.name > r2.name) { - return 1; - } - return 0; - } - return rate2 - rate1; - }); - } - - return rates; -}; - -type useWithdrawalRatesOptions = { - fallbackValue?: BigNumber; -}; - -export const useWithdrawalRates = ({ - fallbackValue = Zero, -}: useWithdrawalRatesOptions = {}) => { - const [token, amount] = useWatch({ - name: ['token', 'amount'], - }); - const fallbackedAmount = amount ?? fallbackValue; - const debouncedAmount = useDebouncedValue(fallbackedAmount, 1000); - const swr = useLidoSWR( - ['swr:withdrawal-rates', debouncedAmount, token], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (_, amount, token) => - getWithdrawalRates({ - amount: amount as BigNumber, - token: token as TOKENS.STETH | TOKENS.WSTETH, - }), - { - ...STRATEGY_LAZY, - isPaused: () => !debouncedAmount || !debouncedAmount._isBigNumber, - }, - ); - - const bestRate = useMemo(() => { - return swr.data?.[0]?.rate ?? null; - }, [swr.data]); - - return { - amount: fallbackedAmount, - bestRate, - selectedToken: token, - data: swr.data, - get initialLoading() { - return swr.initialLoading || !debouncedAmount.eq(fallbackedAmount); - }, - get loading() { - return swr.loading || !debouncedAmount.eq(fallbackedAmount); - }, - get error() { - return swr.error; - }, - update: swr.update, - }; -}; diff --git a/features/withdrawals/request/form/options/dex-options.tsx b/features/withdrawals/request/form/options/dex-options.tsx index d69f9960f..c0a5080b6 100644 --- a/features/withdrawals/request/form/options/dex-options.tsx +++ b/features/withdrawals/request/form/options/dex-options.tsx @@ -1,14 +1,10 @@ import { BigNumber } from 'ethers'; -import { CHAINS, getTokenAddress, TOKENS } from '@lido-sdk/constants'; import { useMemo } from 'react'; -import { useWithdrawalRates } from 'features/withdrawals/hooks/useWithdrawalRates'; +import { useWithdrawalRates } from 'features/withdrawals/request/withdrawal-rates/use-withdrawal-rates'; import { FormatToken } from 'shared/formatters/format-token'; -import { - trackMatomoEvent, - MATOMO_CLICK_EVENTS_TYPES, -} from 'config/trackMatomoEvent'; +import { trackMatomoEvent } from 'config/trackMatomoEvent'; import { DexOptionBlockLink, DexOptionBlockTitle, @@ -17,55 +13,15 @@ import { DexOptionAmount, InlineLoaderSmall, DexOptionLoader, - OpenOceanIcon, - ParaSwapIcon, DexWarning, } from './styles'; -import { formatEther } from '@ethersproject/units'; -import { OPEN_OCEAN_REFERRAL_ADDRESS } from 'config/external-links'; // @ts-expect-error https://www.npmjs.com/package/@svgr/webpack import { ReactComponent as AttentionTriangle } from 'assets/icons/attention-triangle.svg'; - -const placeholder = Array.from({ length: 1 }).fill(null); - -const dexInfo: { - [key: string]: { - title: string; - icon: JSX.Element; - onClickGoTo: React.MouseEventHandler; - link: (amount: BigNumber, token: TOKENS.STETH | TOKENS.WSTETH) => string; - }; -} = { - openOcean: { - title: 'OpenOcean', - icon: , - onClickGoTo: () => { - trackMatomoEvent(MATOMO_CLICK_EVENTS_TYPES.withdrawalGoToOpenOcean); - }, - link: (amount, token) => - `https://app.openocean.finance/classic?referrer=${OPEN_OCEAN_REFERRAL_ADDRESS}&amount=${formatEther( - amount, - )}#/ETH/${token}/ETH`, - }, - paraswap: { - title: 'ParaSwap', - icon: , - onClickGoTo: () => { - trackMatomoEvent(MATOMO_CLICK_EVENTS_TYPES.withdrawalGoToParaswap); - }, - link: (amount, token) => - `https://app.paraswap.io/?referrer=Lido&takeSurplus=true#/${getTokenAddress( - CHAINS.Mainnet, - token, - )}-0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE/${formatEther( - amount, - )}/SELL?network=ethereum`, - }, -}; +import { Zero } from '@ethersproject/constants'; type DexOptionProps = { title: string; - icon: JSX.Element; + icon: React.FC; url: string; loading?: boolean; toReceive: BigNumber | null; @@ -74,7 +30,7 @@ type DexOptionProps = { const DexOption: React.FC = ({ title, - icon, + icon: Icon, url, toReceive, loading, @@ -82,7 +38,7 @@ const DexOption: React.FC = ({ }) => { return ( - {icon} + {title} = ({ export const DexOptions: React.FC< React.ComponentProps > = (props) => { - const { data, initialLoading, loading, amount, selectedToken } = + const { data, initialLoading, loading, amount, selectedToken, enabledDexes } = useWithdrawalRates(); - const dexesFiltered = useMemo(() => { - return data?.filter(({ rate, name, displayEmpty }) => { - const dex = dexInfo[name]; - return dex && (amount.eq('0') || rate !== null || displayEmpty); - }); - }, [amount, data]); + const dexesFiltered = useMemo( + () => + data?.filter( + ({ rate, isServiceAvailable }) => + isServiceAvailable || amount.eq(Zero) || rate != null, + ) ?? [], + [amount, data], + ); return ( - {initialLoading && placeholder.map((_, i) => )} - {!initialLoading && (!dexesFiltered || dexesFiltered.length === 0) && ( + {initialLoading && + enabledDexes.map((_, i) => )} + {!initialLoading && dexesFiltered.length === 0 && ( Aggregator's prices are not available now )} {!initialLoading && - dexesFiltered?.map(({ name, toReceive, rate }) => { - const dex = dexInfo[name]; - return ( - - ); - })} + dexesFiltered.map( + ({ title, toReceive, link, rate, matomoEvent, icon }) => { + return ( + trackMatomoEvent(matomoEvent)} + url={link(amount, selectedToken)} + key={title} + loading={loading} + toReceive={rate ? toReceive : null} + /> + ); + }, + )} ); }; diff --git a/features/withdrawals/request/form/options/options-picker.tsx b/features/withdrawals/request/form/options/options-picker.tsx index 7719f6f7a..c916579bd 100644 --- a/features/withdrawals/request/form/options/options-picker.tsx +++ b/features/withdrawals/request/form/options/options-picker.tsx @@ -1,7 +1,10 @@ import { formatEther, parseEther } from '@ethersproject/units'; import { useWaitingTime } from 'features/withdrawals/hooks/useWaitingTime'; -import { useWithdrawalRates } from 'features/withdrawals/hooks/useWithdrawalRates'; +import { + getDexConfig, + useWithdrawalRates, +} from 'features/withdrawals/request/withdrawal-rates'; import { useWstethToStethRatio } from 'shared/components/data-table-row-steth-by-wsteth'; import { formatBalance } from 'utils/formatBalance'; @@ -15,8 +18,6 @@ import { OptionsPickerLabel, OptionsPickerRow, OptionsPickerSubLabel, - OpenOceanIcon, - ParaSwapIcon, } from './styles'; import { trackMatomoEvent, @@ -25,6 +26,7 @@ import { import { useWatch } from 'react-hook-form'; import { RequestFormInputType } from 'features/withdrawals/request/request-form-context'; import { TOKENS } from '@lido-sdk/constants'; +import { ENABLED_WITHDRAWAL_DEXES } from 'features/withdrawals/withdrawals-constants'; type OptionButtonProps = { onClick: React.ComponentProps<'button'>['onClick']; @@ -91,8 +93,10 @@ const DexButton: React.FC = ({ isActive, onClick }) => { Use aggregators - - + {ENABLED_WITHDRAWAL_DEXES.map((dexKey) => { + const Icon = getDexConfig(dexKey).icon; + return ; + })} diff --git a/features/withdrawals/request/form/options/styles.ts b/features/withdrawals/request/form/options/styles.ts index 90f3cdb11..795d1b941 100644 --- a/features/withdrawals/request/form/options/styles.ts +++ b/features/withdrawals/request/form/options/styles.ts @@ -3,9 +3,7 @@ import { InlineLoader, ThemeName } from '@lidofinance/lido-ui'; import { FormatToken } from 'shared/formatters'; import Lido from 'assets/icons/lido.svg'; -import OpenOcean from 'assets/icons/open-ocean.svg'; import ExternalLink from 'assets/icons/external-link-icon.svg'; -import Paraswap from 'assets/icons/paraswap-circle.svg'; // ICONS @@ -16,20 +14,6 @@ export const LidoIcon = styled.img.attrs({ display: block; `; -export const OpenOceanIcon = styled.img.attrs({ - src: OpenOcean, - alt: 'openOcean', -})` - display: block; -`; - -export const ParaSwapIcon = styled.img.attrs({ - src: Paraswap, - alt: 'paraswap', -})` - display: block; -`; - export const OptionAmountRow = styled.div` display: flex; align-items: center; diff --git a/features/withdrawals/request/withdrawal-rates/icons.tsx b/features/withdrawals/request/withdrawal-rates/icons.tsx new file mode 100644 index 000000000..e6343474f --- /dev/null +++ b/features/withdrawals/request/withdrawal-rates/icons.tsx @@ -0,0 +1,25 @@ +import styled from 'styled-components'; +import OpenOcean from 'assets/icons/open-ocean.svg'; +import Paraswap from 'assets/icons/paraswap-circle.svg'; +import Oneinch from 'assets/icons/oneinch-circle.svg'; + +export const OpenOceanIcon = styled.img.attrs({ + src: OpenOcean, + alt: 'openOcean', +})` + display: block; +`; + +export const ParaSwapIcon = styled.img.attrs({ + src: Paraswap, + alt: 'paraswap', +})` + display: block; +`; + +export const OneInchIcon = styled.img.attrs({ + src: Oneinch, + alt: '1inch', +})` + display: block; +`; diff --git a/features/withdrawals/request/withdrawal-rates/index.ts b/features/withdrawals/request/withdrawal-rates/index.ts new file mode 100644 index 000000000..16a99745b --- /dev/null +++ b/features/withdrawals/request/withdrawal-rates/index.ts @@ -0,0 +1,5 @@ +export type { useWithdrawalRatesOptions } from './use-withdrawal-rates'; +export type { GetWithdrawalRateResult, DexWithdrawalApi } from './types'; + +export { useWithdrawalRates } from './use-withdrawal-rates'; +export { getDexConfig } from './integrations'; diff --git a/features/withdrawals/request/withdrawal-rates/integrations.ts b/features/withdrawals/request/withdrawal-rates/integrations.ts new file mode 100644 index 000000000..3daafd33e --- /dev/null +++ b/features/withdrawals/request/withdrawal-rates/integrations.ts @@ -0,0 +1,178 @@ +import { Zero } from '@ethersproject/constants'; +import { getTokenAddress, CHAINS, TOKENS } from '@lido-sdk/constants'; +import { BigNumber } from 'ethers'; +import { formatEther } from '@ethersproject/units'; + +import { getOneInchRate } from 'utils/get-one-inch-rate'; +import { getOpenOceanRate } from 'utils/get-open-ocean-rate'; +import { standardFetcher } from 'utils/standardFetcher'; +import { OPEN_OCEAN_REFERRAL_ADDRESS } from 'config/external-links'; +import { MATOMO_CLICK_EVENTS_TYPES } from 'config/matomoClickEvents'; + +import { OneInchIcon, OpenOceanIcon, ParaSwapIcon } from './icons'; + +import type { + DexWithdrawalApi, + DexWithdrawalIntegrationMap, + GetRateType, + RateCalculationResult, +} from './types'; +import { FetcherError } from 'utils/fetcherError'; + +const RATE_PRECISION = 100000; +const RATE_PRECISION_BN = BigNumber.from(RATE_PRECISION); + +// Helper function to calculate rate for SRC->DEST swap +// accepts amount, so toReceive can be calculated when src!=amount +const calculateRateReceive = ( + amount: BigNumber, + src: BigNumber, + dest: BigNumber, +): RateCalculationResult => { + const _rate = dest.mul(RATE_PRECISION_BN).div(src); + const toReceive = amount.mul(dest).div(src); + const rate = _rate.toNumber() / RATE_PRECISION; + return { rate, toReceive }; +}; + +const checkError = (e: unknown) => e instanceof FetcherError && e.status <= 500; + +const getOpenOceanWithdrawalRate: GetRateType = async ({ amount, token }) => { + let isServiceAvailable = true; + if (amount && amount.gt(Zero)) { + try { + return { + ...(await getOpenOceanRate(amount, token, 'ETH')), + isServiceAvailable, + }; + } catch (e) { + console.warn( + '[getOpenOceanWithdrawalRate] Failed to receive withdraw rate', + e, + ); + isServiceAvailable = checkError(e); + } + } + + return { + rate: null, + toReceive: null, + isServiceAvailable, + }; +}; + +type ParaSwapPriceResponsePartial = { + priceRoute: { + srcAmount: string; + destAmount: string; + }; +}; + +const getParaSwapWithdrawalRate: GetRateType = async ({ amount, token }) => { + let isServiceAvailable = true; + try { + if (amount.gt(Zero)) { + const api = `https://apiv5.paraswap.io/prices`; + const query = new URLSearchParams({ + srcToken: getTokenAddress(CHAINS.Mainnet, token), + srcDecimals: '18', + destToken: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + destDecimals: '18', + side: 'SELL', + excludeDirectContractMethods: 'true', + userAddress: '0x0000000000000000000000000000000000000000', + amount: amount.toString(), + network: '1', + partner: 'lido', + }); + + const url = `${api}?${query.toString()}`; + const data: ParaSwapPriceResponsePartial = + await standardFetcher(url); + const toReceive = BigNumber.from(data.priceRoute.destAmount); + + const rate = calculateRateReceive( + amount, + BigNumber.from(data.priceRoute.srcAmount), + toReceive, + ).rate; + + return { + rate, + toReceive: BigNumber.from(data.priceRoute.destAmount), + isServiceAvailable, + }; + } + } catch (e) { + console.warn( + '[getParaSwapWithdrawalRate] Failed to receive withdraw rate', + e, + ); + isServiceAvailable = checkError(e); + } + + return { + rate: null, + toReceive: null, + isServiceAvailable, + }; +}; + +const getOneInchWithdrawalRate: GetRateType = async (params) => { + let isServiceAvailable = true; + try { + if (params.amount.gt(Zero)) { + return { ...(await getOneInchRate(params)), isServiceAvailable }; + } + } catch (e) { + console.warn( + '[getOneInchWithdrawalRate] Failed to receive withdraw rate', + e, + ); + isServiceAvailable = checkError(e); + } + return { + rate: null, + toReceive: null, + isServiceAvailable, + }; +}; + +const dexWithdrawalMap: DexWithdrawalIntegrationMap = { + 'open-ocean': { + title: 'OpenOcean', + fetcher: getOpenOceanWithdrawalRate, + icon: OpenOceanIcon, + matomoEvent: MATOMO_CLICK_EVENTS_TYPES.withdrawalGoToOpenOcean, + link: (amount, token) => + `https://app.openocean.finance/classic?referrer=${OPEN_OCEAN_REFERRAL_ADDRESS}&amount=${formatEther( + amount, + )}#/ETH/${token}/ETH`, + }, + paraswap: { + title: 'ParaSwap', + icon: ParaSwapIcon, + fetcher: getParaSwapWithdrawalRate, + matomoEvent: MATOMO_CLICK_EVENTS_TYPES.withdrawalGoToParaswap, + link: (amount, token) => + `https://app.paraswap.io/?referrer=Lido&takeSurplus=true#/${getTokenAddress( + CHAINS.Mainnet, + token, + )}-0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE/${formatEther( + amount, + )}/SELL?network=ethereum`, + }, + 'one-inch': { + title: '1inch', + fetcher: getOneInchWithdrawalRate, + icon: OneInchIcon, + matomoEvent: MATOMO_CLICK_EVENTS_TYPES.withdrawalGoTo1inch, + link: (amount, token) => + `https://app.1inch.io/#/1/simple/swap/${ + token == TOKENS.STETH ? 'stETH' : 'wstETH' + }/ETH?sourceTokenAmount=${formatEther(amount)}`, + }, +} as const; + +export const getDexConfig = (dexKey: DexWithdrawalApi) => + dexWithdrawalMap[dexKey]; diff --git a/features/withdrawals/request/withdrawal-rates/types.ts b/features/withdrawals/request/withdrawal-rates/types.ts new file mode 100644 index 000000000..7ef82f877 --- /dev/null +++ b/features/withdrawals/request/withdrawal-rates/types.ts @@ -0,0 +1,43 @@ +import type { TOKENS } from '@lido-sdk/constants'; +import { MATOMO_CLICK_EVENTS_TYPES } from 'config'; +import type { BigNumber } from 'ethers'; +import { TokensWithdrawable } from 'features/withdrawals/types/tokens-withdrawable'; + +export type GetWithdrawalRateParams = { + amount: BigNumber; + token: TOKENS.STETH | TOKENS.WSTETH; + dexes: DexWithdrawalApi[]; +}; + +export type RateCalculationResult = { + rate: number; + toReceive: BigNumber; +}; + +export type SingleWithdrawalRateResult = { + rate: number | null; + toReceive: BigNumber | null; + isServiceAvailable: boolean; +}; + +export type DexWithdrawalApi = 'paraswap' | 'open-ocean' | 'one-inch'; + +export type DexWithdrawalIntegration = { + title: string; + fetcher: GetRateType; + icon: React.FC; + matomoEvent: MATOMO_CLICK_EVENTS_TYPES; + link: (amount: BigNumber, token: TokensWithdrawable) => string; +}; + +export type DexWithdrawalIntegrationMap = Record< + DexWithdrawalApi, + DexWithdrawalIntegration +>; + +export type GetRateType = ( + params: GetWithdrawalRateParams, +) => Promise; + +export type GetWithdrawalRateResult = (SingleWithdrawalRateResult & + DexWithdrawalIntegration)[]; diff --git a/features/withdrawals/request/withdrawal-rates/use-withdrawal-rates.ts b/features/withdrawals/request/withdrawal-rates/use-withdrawal-rates.ts new file mode 100644 index 000000000..25589446b --- /dev/null +++ b/features/withdrawals/request/withdrawal-rates/use-withdrawal-rates.ts @@ -0,0 +1,94 @@ +import { useMemo } from 'react'; +import { useWatch } from 'react-hook-form'; +import { BigNumber } from 'ethers'; +import { Zero } from '@ethersproject/constants'; +import { TOKENS } from '@lido-sdk/constants'; +import { useLidoSWR } from '@lido-sdk/react'; + +import { useDebouncedValue } from 'shared/hooks/useDebouncedValue'; +import { STRATEGY_LAZY } from 'utils/swrStrategies'; + +import type { RequestFormInputType } from '../request-form-context'; +import { getDexConfig } from './integrations'; + +import { ENABLED_WITHDRAWAL_DEXES } from 'features/withdrawals/withdrawals-constants'; + +import type { GetWithdrawalRateParams, GetWithdrawalRateResult } from './types'; + +export type useWithdrawalRatesOptions = { + fallbackValue?: BigNumber; +}; + +export const getWithdrawalRates = async ( + params: GetWithdrawalRateParams, +): Promise => { + const rates = await Promise.all( + params.dexes.map((dexKey) => { + const dex = getDexConfig(dexKey); + return dex.fetcher(params).then((result) => ({ + ...dex, + ...result, + })); + }), + ); + + if (rates.length > 1) { + // sort by rate, then alphabetic + rates.sort((r1, r2) => { + const rate1 = r1.rate ?? 0; + const rate2 = r2.rate ?? 0; + if (rate1 == rate2) { + return r1.title.toLowerCase() > r2.title.toLowerCase() ? 1 : -1; + } + return rate2 - rate1; + }); + } + + return rates; +}; + +export const useWithdrawalRates = ({ + fallbackValue = Zero, +}: useWithdrawalRatesOptions = {}) => { + const [token, amount] = useWatch({ + name: ['token', 'amount'], + }); + const fallbackedAmount = amount ?? fallbackValue; + const debouncedAmount = useDebouncedValue(fallbackedAmount, 1000); + const swr = useLidoSWR( + ['swr:withdrawal-rates', debouncedAmount.toString(), token], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_, amount, token) => + getWithdrawalRates({ + amount: BigNumber.from(amount), + token: token as TOKENS.STETH | TOKENS.WSTETH, + dexes: ENABLED_WITHDRAWAL_DEXES, + }), + { + ...STRATEGY_LAZY, + isPaused: () => !debouncedAmount || !debouncedAmount._isBigNumber, + }, + ); + + const bestRate = useMemo(() => { + return swr.data?.[0]?.rate ?? null; + }, [swr.data]); + + return { + amount: fallbackedAmount, + bestRate, + enabledDexes: ENABLED_WITHDRAWAL_DEXES, + selectedToken: token, + data: swr.data, + get initialLoading() { + return swr.initialLoading || !debouncedAmount.eq(fallbackedAmount); + }, + get loading() { + return swr.loading || !debouncedAmount.eq(fallbackedAmount); + }, + get error() { + return swr.error; + }, + update: swr.update, + }; +}; diff --git a/features/withdrawals/withdrawals-constants/index.ts b/features/withdrawals/withdrawals-constants/index.ts index 5c7abdf4c..037375acc 100644 --- a/features/withdrawals/withdrawals-constants/index.ts +++ b/features/withdrawals/withdrawals-constants/index.ts @@ -1,3 +1,5 @@ +import type { DexWithdrawalApi } from '../request/withdrawal-rates'; + // max requests count for one tx export const MAX_REQUESTS_COUNT = 256; export const MAX_REQUESTS_COUNT_LEDGER_LIMIT = 2; @@ -8,3 +10,9 @@ export const MAX_SHOWN_REQUEST_PER_TYPE = 1024; // time that validation function waits for context data to resolve // should be enough to load token balances/tvl/max&min amounts and other contract data export const VALIDATION_CONTEXT_TIMEOUT = 4000; + +export const ENABLED_WITHDRAWAL_DEXES: DexWithdrawalApi[] = [ + 'one-inch', + 'open-ocean', + 'paraswap', +]; diff --git a/next.config.mjs b/next.config.mjs index 324af3b91..5ea7889eb 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -4,7 +4,7 @@ import generateBuildId from './scripts/generate-build-id.mjs'; buildDynamics(); -const ipfsMode = process.env.IPFS_MODE; +const ipfsMode = process.env.IPFS_MODE == 'true'; // https://nextjs.org/docs/pages/api-reference/next-config-js/basePath const basePath = process.env.BASE_PATH; @@ -16,6 +16,7 @@ const rpcUrls_17000 = process.env.EL_RPC_URLS_17000?.split(',') ?? []; const ethAPIBasePath = process.env.ETH_API_BASE_PATH; const ethplorerApiKey = process.env.ETHPLORER_API_KEY; +const oneInchApiKey = process.env.ONE_INCH_API_KEY; const cspTrustedHosts = process.env.CSP_TRUSTED_HOSTS; const cspReportOnly = process.env.CSP_REPORT_ONLY; @@ -108,12 +109,12 @@ export default withBundleAnalyzer({ loader: 'webpack-preprocessor-loader', options: { params: { - IPFS_MODE: String(ipfsMode === 'true'), + IPFS_MODE: ipfsMode, }, }, }, ], - } + }, ); return config; @@ -168,6 +169,7 @@ export default withBundleAnalyzer({ rpcUrls_5, rpcUrls_17000, ethplorerApiKey, + oneInchApiKey, cspTrustedHosts, cspReportOnly, cspReportUri, diff --git a/pages/_app.tsx b/pages/_app.tsx index 3fd9068f7..93f714ebf 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,6 +1,7 @@ import { memo } from 'react'; import { AppProps } from 'next/app'; import 'nprogress/nprogress.css'; +import Head from 'next/head'; import { ToastContainer, @@ -38,6 +39,13 @@ const AppWrapper = (props: AppProps): JSX.Element => { return ( + {/* see https://nextjs.org/docs/messages/no-document-viewport-meta */} + + + - {dynamics.ipfsMode && ( (); +type OneInchRateResponse = { + rate: number; + toReceive: string; +}; + +const cache = new Cache(); -const DEFAULT_AMOUNT = BigNumber.from(10).pow(18); +const DEFAULT_AMOUNT = parseEther('1'); const TOKEN_ETH = 'ETH'; +// Amounts larger make 1inch API return 500 +const MAX_BIGINT = BigNumber.from( + '10000000000000000000000000000000000000000000000000000000000000000000', +); const TOKEN_ALLOWED_LIST = [TOKEN_ETH, TOKENS.STETH, TOKENS.WSTETH]; +const ETH_DUMMY_ADDRESS = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'; +const PASSTHROUGH_CODES = [400, 422, 429]; -const validateAndGetQueryToken = async ( - req: NextApiRequest, - res: NextApiResponse, -): Promise => { +const validateAndParseParams = (req: NextApiRequest, res: NextApiResponse) => { let token = req.query?.token || TOKEN_ETH; + let amount = DEFAULT_AMOUNT; + try { + // Token can be array - /api/oneinch-rate?token=eth&token=eth&token=eth + if (Array.isArray(token)) { + throw new Error(`Token must be a string`); + } - // Token can be array - /api/oneinch-rate?token=eth&token=eth&token=eth - if (Array.isArray(token)) { - token = token[0]; - } - - token = token.toLocaleUpperCase(); + token = token.toLocaleUpperCase(); + if (!TOKEN_ALLOWED_LIST.includes(token)) { + throw new Error(`You can only use ${TOKEN_ALLOWED_LIST.toString()}`); + } - if (!TOKEN_ALLOWED_LIST.includes(token)) { - res.status(400); - throw new Error(`You can use only: ${TOKEN_ALLOWED_LIST.toString()}`); + if (req.query.amount) { + if (token === 'ETH') { + throw new Error(`Amount is not allowed to token ETH`); + } + if (Array.isArray(req.query.amount)) { + throw new Error(`Amount must be a string`); + } + try { + amount = BigNumber.from(req.query.amount); + } catch { + throw new Error(`Amount must be a valid BigNumber string`); + } + if (amount.lte(Zero)) throw new Error(`Amount must be positive`); + if (amount.gt(MAX_BIGINT)) throw new Error('Amount too large'); + } + } catch (e) { + const message = e instanceof Error ? e.message : 'invalid request'; + res.status(422).json({ error: message }); + throw e; } - return token; + return { token, amount }; }; // Proxy for third-party API. // Query params: // * (optional) token: see TOKEN_ALLOWED_LIST above. Default see TOKEN_ETH above. +// * (optional) amount: BigNumber string. Default see DEFAULT_AMOUNT above. // Returns 1inch rate const oneInchRate: API = async (req, res) => { - res.status(403); - return; - // TODO: enable test in test/consts.ts - const token = await validateAndGetQueryToken(req, res); - const cacheKey = `${CACHE_ONE_INCH_RATE_KEY}-${token}`; + const { token, amount } = validateAndParseParams(req, res); + const cacheKey = `${CACHE_ONE_INCH_RATE_KEY}-${token}-${amount}`; const cachedOneInchRate = cache.get(cacheKey); if (cachedOneInchRate) { @@ -66,25 +96,34 @@ const oneInchRate: API = async (req, res) => { return; } - // Execute below if not found a cache - let oneInchRate; + // for ETH, ETH -> STETH + // else, TOKEN -> ETH + const fromToken = + token === TOKEN_ETH + ? ETH_DUMMY_ADDRESS + : getTokenAddress(CHAINS.Mainnet, token as TOKENS); + const toToken = + token === TOKEN_ETH + ? getTokenAddress(CHAINS.Mainnet, TOKENS.STETH) + : ETH_DUMMY_ADDRESS; - if (token === TOKEN_ETH) { - oneInchRate = await getOneInchRate( - '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', - getTokenAddress(CHAINS.Mainnet, TOKENS.STETH), - DEFAULT_AMOUNT, - ); - } else { - oneInchRate = await getOneInchRate( - getTokenAddress(CHAINS.Mainnet, token as TOKENS), - '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', - DEFAULT_AMOUNT, - ); + try { + const oneInchRate = await getOneInchRate(fromToken, toToken, amount); + invariant(oneInchRate); + const result = { + rate: oneInchRate.rate, + toReceive: oneInchRate.toAmount.toString(), + }; + cache.put(cacheKey, result, CACHE_ONE_INCH_RATE_TTL); + res.status(200).json(result); + } catch (e) { + if (e instanceof FetcherError && PASSTHROUGH_CODES.includes(e.status)) { + res.status(e.status).json({ message: e.message }); + return; + } + console.error('[oneInchRate] Request to 1inch failed', e); + throw e; } - cache.put(cacheKey, { rate: oneInchRate }, CACHE_ONE_INCH_RATE_TTL); - - res.status(200).json({ rate: oneInchRate }); }; export default wrapNextRequest([ diff --git a/shared/components/tx-stage-modal/tx-stage-modal.tsx b/shared/components/tx-stage-modal/tx-stage-modal.tsx index 8091cd87c..0c8e1a945 100644 --- a/shared/components/tx-stage-modal/tx-stage-modal.tsx +++ b/shared/components/tx-stage-modal/tx-stage-modal.tsx @@ -1,6 +1,6 @@ import { memo, useRef, useState } from 'react'; import { useConnectorInfo } from 'reef-knot/web3-react'; -import { use1inchLinkProps } from 'features/stake/hooks'; +import { use1inchDeepLinkProps } from 'features/stake/hooks'; import { TxLinkEtherscan } from 'shared/components/tx-link-etherscan'; import { L2LowFee } from 'shared/banners/l2-low-fee'; @@ -60,7 +60,7 @@ export const TxStageModal = memo((props: TxStageModalProps) => { } = props; const { isLedger } = useConnectorInfo(); - const oneInchLinkProps = use1inchLinkProps(); + const oneInchLinkProps = use1inchDeepLinkProps(); const isCloseButtonHidden = isLedger && diff --git a/test/consts.ts b/test/consts.ts index 8db7fef22..eee68f600 100644 --- a/test/consts.ts +++ b/test/consts.ts @@ -160,18 +160,18 @@ const LIDO_STATS_SCHEMA = { }; export const GET_REQUESTS: GetRequest[] = [ - // TODO: enabled when bringing back 1inch endpoint - // { - // uri: '/api/oneinch-rate', - // schema: { - // type: 'object', - // properties: { - // rate: { type: 'number', min: 0 }, - // }, - // required: ['rate'], - // additionalProperties: false, - // }, - // }, + { + uri: '/api/oneinch-rate', + schema: { + type: 'object', + properties: { + rate: { type: 'number', min: 0 }, + toReceive: { type: 'string' }, + }, + required: ['rate', 'toReceive'], + additionalProperties: false, + }, + }, { uri: `/api/short-lido-stats?chainId=${CONFIG.STAND_CONFIG.chainId}`, schema: { diff --git a/test/utils/collect-next-pages.ts b/test/utils/collect-next-pages.ts index d7d43ced5..766b8f3aa 100644 --- a/test/utils/collect-next-pages.ts +++ b/test/utils/collect-next-pages.ts @@ -18,7 +18,6 @@ export const getAllPagesRoutes = () => { !item.endsWith('404.html') && !item.endsWith('500.html') ) { - //console.log(item, path.basename(item)); const routePath = path.join('/', item.replace(/(index)?\.html/, '')); routes.push(routePath); } else if (stats.isDirectory()) { diff --git a/utils/get-one-inch-rate.ts b/utils/get-one-inch-rate.ts new file mode 100644 index 000000000..b68582e47 --- /dev/null +++ b/utils/get-one-inch-rate.ts @@ -0,0 +1,31 @@ +import { TOKENS } from '@lido-sdk/constants'; +import { BigNumber } from 'ethers'; +import { standardFetcher } from './standardFetcher'; +import { dynamics } from 'config'; +import { prependBasePath } from './prependBasePath'; + +type GetOneInchRateParams = { + token: TOKENS.STETH | TOKENS.WSTETH | 'ETH'; + amount?: BigNumber; +}; + +export const getOneInchRate = async ({ + token, + amount, +}: GetOneInchRateParams) => { + const params = new URLSearchParams({ token }); + if (amount) params.append('amount', amount.toString()); + const apiOneInchRatePath = `api/oneinch-rate?${params.toString()}`; + const data = await standardFetcher<{ + rate: number; + toReceive: string; + }>( + dynamics.ipfsMode + ? `${dynamics.widgetApiBasePathForIpfs}/${apiOneInchRatePath}` + : prependBasePath(apiOneInchRatePath), + ); + return { + rate: data.rate, + toReceive: BigNumber.from(data.toReceive), + }; +}; diff --git a/utilsApi/get-one-inch-rate.ts b/utilsApi/get-one-inch-rate.ts index f07fa6fa6..3cc61dd1d 100644 --- a/utilsApi/get-one-inch-rate.ts +++ b/utilsApi/get-one-inch-rate.ts @@ -1,19 +1,25 @@ import { BigNumber } from 'ethers'; +import getConfig from 'next/config'; import { standardFetcher } from 'utils/standardFetcher'; import { responseTimeExternalMetricWrapper } from 'utilsApi'; -export const API_LIDO_1INCH = `https://api-lido.1inch.io/v5.2/1/quote`; +const { serverRuntimeConfig } = getConfig(); +const ONE_INCH_API_KEY = serverRuntimeConfig.oneInchApiKey as string; +const ONE_INCH_API_ENDPOINT = 'https://api.1inch.dev/swap/v5.2/1/quote'; +const RATE_PRECISION = 1000000; export type OneInchFetchResponse = { toAmount: string; }; -type GetOneInchRateStats = ( +export type GetOneInchRateResult = { rate: number; toAmount: BigNumber }; + +export type GetOneInchRateStats = ( fromTokenAddress: string, toTokenAddress: string, amount: BigNumber, -) => Promise; +) => Promise; export const getOneInchRate: GetOneInchRateStats = async ( fromTokenAddress, @@ -21,28 +27,29 @@ export const getOneInchRate: GetOneInchRateStats = async ( amount, ) => { console.debug('[getOneInchRate] Started fetching...'); + if (!ONE_INCH_API_KEY) console.warn('[getOneInchRate] missing 1inch Api Key'); + const query = new URLSearchParams({ src: fromTokenAddress, dst: toTokenAddress, amount: amount.toString(), }); - const url = `${API_LIDO_1INCH}?${query.toString()}`; + const url = `${ONE_INCH_API_ENDPOINT}?${query.toString()}`; const respData = await responseTimeExternalMetricWrapper({ - payload: API_LIDO_1INCH, - request: () => standardFetcher(url), + payload: ONE_INCH_API_ENDPOINT, + request: () => + standardFetcher(url, { + headers: { Authorization: `Bearer ${ONE_INCH_API_KEY}` }, + }), }); - if (!respData || !respData.toAmount) { - console.error('[getOneInchRate] Request to 1inch failed'); - return null; - } + const toAmount = BigNumber.from(respData.toAmount); const rate = - BigNumber.from(respData.toAmount) - .mul(BigNumber.from(100000)) - .div(amount) - .toNumber() / 100000; + toAmount.mul(BigNumber.from(RATE_PRECISION)).div(amount).toNumber() / + RATE_PRECISION; + console.debug('[getOneInchRate] Rate on 1inch:', rate); - return rate; + return { rate, toAmount }; };