diff --git a/apps/cowswap-frontend/src/legacy/state/swap/hooks.test.ts b/apps/cowswap-frontend/src/legacy/state/swap/hooks.test.ts index 92c91c0590..04ac6376f1 100644 --- a/apps/cowswap-frontend/src/legacy/state/swap/hooks.test.ts +++ b/apps/cowswap-frontend/src/legacy/state/swap/hooks.test.ts @@ -1,7 +1,7 @@ import { parse } from 'qs' import { Field } from './actions' -import { queryParametersToSwapState } from './hooks' +import { queryParametersToSwapState } from './utils' jest.mock('legacy/components/analytics/hooks/useAnalyticsReporter.ts') diff --git a/apps/cowswap-frontend/src/legacy/state/swap/hooks.tsx b/apps/cowswap-frontend/src/legacy/state/swap/hooks.tsx index 9ff7ec8da6..835cf5f5c7 100644 --- a/apps/cowswap-frontend/src/legacy/state/swap/hooks.tsx +++ b/apps/cowswap-frontend/src/legacy/state/swap/hooks.tsx @@ -4,7 +4,6 @@ import { SupportedChainId } from '@cowprotocol/cow-sdk' import { Currency, CurrencyAmount, Percent } from '@uniswap/sdk-core' import { t } from '@lingui/macro' -import { ParsedQs } from 'qs' import { changeSwapAmountAnalytics, switchTokensAnalytics } from 'legacy/components/analytics' import { FEE_SIZE_THRESHOLD } from 'legacy/constants' @@ -34,9 +33,6 @@ import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount' import { formatSymbol } from 'utils/format' import { Field, setRecipient, switchCurrencies, typeInput } from './actions' -import { SwapState } from './reducer' - -import { TOKEN_SHORTHANDS } from '../../constants/tokens' export const BAD_RECIPIENT_ADDRESSES: { [address: string]: true } = { '0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f': true, // v2 factory @@ -44,36 +40,6 @@ export const BAD_RECIPIENT_ADDRESSES: { [address: string]: true } = { '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D': true, // v2 router 02 } -export function parseCurrencyFromURLParameter(urlParam: ParsedQs[string]): string { - if (typeof urlParam === 'string') { - const valid = isAddress(urlParam) - if (valid) return valid - const upper = urlParam.toUpperCase() - if (upper === 'ETH') return 'ETH' - if (upper in TOKEN_SHORTHANDS) return upper - } - return '' -} - -export function parseTokenAmountURLParameter(urlParam: any): string { - return typeof urlParam === 'string' && !isNaN(parseFloat(urlParam)) ? urlParam : '' -} - -export function parseIndependentFieldURLParameter(urlParam: any): Field { - return typeof urlParam === 'string' && urlParam.toLowerCase() === 'output' ? Field.OUTPUT : Field.INPUT -} - -const ENS_NAME_REGEX = /^[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)?$/ -const ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/ -export function validatedRecipient(recipient: any): string | null { - if (typeof recipient !== 'string') return null - const address = isAddress(recipient) - if (address) return address - if (ENS_NAME_REGEX.test(recipient)) return recipient - if (ADDRESS_REGEX.test(recipient)) return recipient - return null -} - export function useSwapState(): AppState['swap'] { const isProviderNetworkUnsupported = useIsProviderNetworkUnsupported() @@ -402,42 +368,6 @@ export function useDerivedSwapInfo(): DerivedSwapInfo { ) } -export function queryParametersToSwapState( - parsedQs: ParsedQs, - defaultInputCurrency = '', - chainId: number | null -): SwapState { - let inputCurrency = parseCurrencyFromURLParameter(parsedQs.inputCurrency) - let outputCurrency = parseCurrencyFromURLParameter(parsedQs.outputCurrency) - const typedValue = parseTokenAmountURLParameter(parsedQs.exactAmount) - const independentField = parseIndependentFieldURLParameter(parsedQs.exactField) - - if (inputCurrency === '' && outputCurrency === '' && typedValue === '' && independentField === Field.INPUT) { - // Defaults to having the wrapped native currency selected - inputCurrency = defaultInputCurrency // 'ETH' // mod - } else if (inputCurrency === outputCurrency) { - // clear output if identical - outputCurrency = '' - } - - const recipient = validatedRecipient(parsedQs.recipient) - const recipientAddress = validatedRecipient(parsedQs.recipientAddress) - - return { - chainId: chainId || null, - [Field.INPUT]: { - currencyId: inputCurrency === '' ? null : inputCurrency ?? null, - }, - [Field.OUTPUT]: { - currencyId: outputCurrency === '' ? null : outputCurrency ?? null, - }, - typedValue, - independentField, - recipient, - recipientAddress, - } -} - export function useIsFeeGreaterThanInput({ address, chainId, diff --git a/apps/cowswap-frontend/src/legacy/state/swap/reducer.ts b/apps/cowswap-frontend/src/legacy/state/swap/reducer.ts index 720817c6d6..ae582b0803 100644 --- a/apps/cowswap-frontend/src/legacy/state/swap/reducer.ts +++ b/apps/cowswap-frontend/src/legacy/state/swap/reducer.ts @@ -15,11 +15,12 @@ import { switchCurrencies, typeInput, } from 'legacy/state/swap/actions' -import { queryParametersToSwapState } from 'legacy/state/swap/hooks' import { getIsNativeToken } from 'utils/getIsNativeToken' import { getIsWrapOrUnwrap } from 'utils/getIsWrapOrUnwrap' +import { queryParametersToSwapState } from './utils' + export interface SwapState { // Mod: added chainId chainId: number | null diff --git a/apps/cowswap-frontend/src/legacy/state/swap/utils.ts b/apps/cowswap-frontend/src/legacy/state/swap/utils.ts index 1ea674e7ee..503ecd6f26 100644 --- a/apps/cowswap-frontend/src/legacy/state/swap/utils.ts +++ b/apps/cowswap-frontend/src/legacy/state/swap/utils.ts @@ -1,9 +1,13 @@ import { SupportedChainId } from '@cowprotocol/cow-sdk' -import { Currency, Percent, TradeType } from '@uniswap/sdk-core' +import { Currency } from '@uniswap/sdk-core' -import { WRAPPED_NATIVE_CURRENCY } from 'legacy/constants/tokens' +import { ParsedQs } from 'qs' -import TradeGp from './TradeGp' +import { TOKEN_SHORTHANDS, WRAPPED_NATIVE_CURRENCY } from 'legacy/constants/tokens' +import { isAddress } from 'legacy/utils' + +import { Field } from './actions' +import { SwapState } from './reducer' export function isWrappingTrade( sellCurrency: Currency | null | undefined, @@ -20,44 +24,69 @@ export function isWrappingTrade( ) } -export function logTradeDetails(trade: TradeGp | undefined, allowedSlippage: Percent) { - // don't do anything outside of dev env - if (!trade || process.env.NODE_ENV !== 'development') return - - const exactIn = trade.tradeType === TradeType.EXACT_INPUT - - // Log Exact In Trade info - if (exactIn) { - console.debug( - `[SwapMod::[SELL] Trade Constructed]`, - ` - Type: SELL - ========== - Input Amount: ${trade.inputAmount.toExact()} - Output Amount: ${trade.outputAmount.toExact()} - ========== - Fee Amount [as SELL]: ${trade.fee?.feeAsCurrency?.toExact()} ${trade.inputAmount.currency.symbol} - Fee Amount [as BUY]: ${ - trade.outputAmountWithoutFee && trade.outputAmountWithoutFee.subtract(trade.outputAmount).toExact() - } ${trade.outputAmount.currency.symbol} - ========== - Minimum Received: ${trade.minimumAmountOut(allowedSlippage).toExact()} - ` - ) - } else { - // Log Exact Out Trade info - console.debug( - `[SwapMod::[BUY] Trade Constructed]`, - ` - Type: BUY - ========= - Input Amount [w/FEE]: ${trade.inputAmountWithFee.toExact()} - Output Amount: ${trade.outputAmount.toExact()} - ========= - Fee Amount [as SELL]: ${trade.fee?.feeAsCurrency?.toExact()} ${trade.inputAmount.currency.symbol} - ========= - Maximum Sold: ${trade.fee?.feeAsCurrency && trade.maximumAmountIn(allowedSlippage).toExact()} - ` - ) +function parseIndependentFieldURLParameter(urlParam: any): Field { + return typeof urlParam === 'string' && urlParam.toLowerCase() === 'output' ? Field.OUTPUT : Field.INPUT +} + +export function parseCurrencyFromURLParameter(urlParam: ParsedQs[string]): string { + if (typeof urlParam === 'string') { + const valid = isAddress(urlParam) + if (valid) return valid + const upper = urlParam.toUpperCase() + if (upper === 'ETH') return 'ETH' + if (upper in TOKEN_SHORTHANDS) return upper + } + return '' +} + +export function parseTokenAmountURLParameter(urlParam: any): string { + return typeof urlParam === 'string' && !isNaN(parseFloat(urlParam)) ? urlParam : '' +} + +const ENS_NAME_REGEX = /^[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)?$/ +const ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/ + +export function validatedRecipient(recipient: any): string | null { + if (typeof recipient !== 'string') return null + const address = isAddress(recipient) + if (address) return address + if (ENS_NAME_REGEX.test(recipient)) return recipient + if (ADDRESS_REGEX.test(recipient)) return recipient + return null +} + +export function queryParametersToSwapState( + parsedQs: ParsedQs, + defaultInputCurrency = '', + chainId: number | null +): SwapState { + let inputCurrency = parseCurrencyFromURLParameter(parsedQs.inputCurrency) + let outputCurrency = parseCurrencyFromURLParameter(parsedQs.outputCurrency) + const typedValue = parseTokenAmountURLParameter(parsedQs.exactAmount) + const independentField = parseIndependentFieldURLParameter(parsedQs.exactField) + + if (inputCurrency === '' && outputCurrency === '' && typedValue === '' && independentField === Field.INPUT) { + // Defaults to having the wrapped native currency selected + inputCurrency = defaultInputCurrency // 'ETH' // mod + } else if (inputCurrency === outputCurrency) { + // clear output if identical + outputCurrency = '' + } + + const recipient = validatedRecipient(parsedQs.recipient) + const recipientAddress = validatedRecipient(parsedQs.recipientAddress) + + return { + chainId: chainId || null, + [Field.INPUT]: { + currencyId: inputCurrency === '' ? null : inputCurrency ?? null, + }, + [Field.OUTPUT]: { + currencyId: outputCurrency === '' ? null : outputCurrency ?? null, + }, + typedValue, + independentField, + recipient, + recipientAddress, } } diff --git a/apps/cowswap-frontend/src/modules/usdAmount/hooks/useUsdAmount.test.tsx b/apps/cowswap-frontend/src/modules/usdAmount/hooks/useUsdAmount.test.tsx new file mode 100644 index 0000000000..db0703a3fc --- /dev/null +++ b/apps/cowswap-frontend/src/modules/usdAmount/hooks/useUsdAmount.test.tsx @@ -0,0 +1,52 @@ +import { createStore } from 'jotai/vanilla' +import { ReactNode } from 'react' + +import { CurrencyAmount } from '@uniswap/sdk-core' + +import { renderHook } from '@testing-library/react-hooks' +import { JotaiTestProvider } from 'test-utils' + +import { WETH_GNOSIS_CHAIN } from 'legacy/utils/gnosis_chain/constants' + +import { useUsdAmount } from './useUsdAmount' + +import { usdRawPricesAtom, UsdRawPriceState } from '../state/usdRawPricesAtom' + +const WETH_RAW_PRICE_STATE: UsdRawPriceState = { + updatedAt: Date.now(), + price: 1650, + currency: WETH_GNOSIS_CHAIN, + isLoading: false, +} + +function getWrapper() { + const store = createStore() + const initialValues = [[usdRawPricesAtom, { [WETH_GNOSIS_CHAIN.address.toLowerCase()]: WETH_RAW_PRICE_STATE }]] + + return { + store, + TestComponent: function ({ children }: { children: ReactNode }) { + return ( + + {children} + + ) + }, + } +} + +describe('useUsdAmount', () => { + const ONE_WETH = CurrencyAmount.fromRawAmount(WETH_GNOSIS_CHAIN, 1 * 10 ** WETH_GNOSIS_CHAIN.decimals) + + it('USD amount for 1 WETH should be 1650', async () => { + const { TestComponent } = getWrapper() + const { result } = renderHook( + () => { + return useUsdAmount(ONE_WETH) + }, + { wrapper: TestComponent } + ) + + expect(result.current.value?.toExact()).toBe(WETH_RAW_PRICE_STATE.price?.toString()) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/usdAmount/updaters/UsdPricesUpdater.test.tsx b/apps/cowswap-frontend/src/modules/usdAmount/updaters/UsdPricesUpdater.test.tsx new file mode 100644 index 0000000000..e299ec6e94 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/usdAmount/updaters/UsdPricesUpdater.test.tsx @@ -0,0 +1,162 @@ +import { createStore } from 'jotai/vanilla' +import { ReactNode } from 'react' + +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { Token } from '@uniswap/sdk-core' + +import { act, render, waitFor } from '@testing-library/react' +import { SWRConfig } from 'swr' +import { JotaiTestProvider } from 'test-utils' + +import { COW as COWS, USDC_MAINNET } from 'legacy/constants/tokens' + +import { UsdPricesUpdater } from './UsdPricesUpdater' + +import * as coingeckoApi from '../apis/getCoingeckoUsdPrice' +import * as cowProtocolApi from '../apis/getCowProtocolUsdPrice' +import * as services from '../services/fetchCurrencyUsdPrice' +import { currenciesUsdPriceQueueAtom, UsdRawPrices, usdRawPricesAtom } from '../state/usdRawPricesAtom' + +const mockGetCoingeckoUsdPrice = jest.spyOn(coingeckoApi, 'getCoingeckoUsdPrice') +const mockGetCowProtocolUsdPrice = jest.spyOn(cowProtocolApi, 'getCowProtocolUsdPrice') +const mockFetchCurrencyUsdPrice = jest.spyOn(services, 'fetchCurrencyUsdPrice') + +const USDC = USDC_MAINNET +const COW = COWS[SupportedChainId.MAINNET] + +const usdcAddress = USDC.address.toLowerCase() +const cowAddress = COW.address.toLowerCase() + +const defaultQueue = { + [usdcAddress]: USDC, + [cowAddress]: COW, +} + +function getWrapper() { + const store = createStore() + const initialValues = [[currenciesUsdPriceQueueAtom, { ...defaultQueue }]] + + return { + store, + TestComponent: function ({ children }: { children: ReactNode }) { + return ( + // https://swr.vercel.app/docs/advanced/cache#reset-cache-between-test-cases + new Map() }}> + + {children} + + + ) + }, + } +} + +async function performTest( + priceMock: ((currency: Token) => Promise) | null = null, + waitForResolvesCount = 0 +): Promise { + let resolvesCount = 0 + + const { TestComponent, store } = getWrapper() + + if (priceMock) { + mockFetchCurrencyUsdPrice.mockImplementation((currency: Token) => { + return priceMock(currency).finally(() => { + resolvesCount++ + }) + }) + } + + act(() => { + render(, { wrapper: TestComponent }) + }) + + await waitFor(() => resolvesCount === waitForResolvesCount) + + return store.get(usdRawPricesAtom) +} + +describe('UsdPricesUpdater', () => { + afterEach(() => { + jest.resetAllMocks() + }) + + it('Should mark prices with isLoading=true before fetching', async () => { + const state = await performTest(() => { + // Never resolve price + return new Promise(() => void 0) + }, 0) + + expect(state[usdcAddress].isLoading).toBe(true) + expect(state[cowAddress].isLoading).toBe(true) + }) + + it('Should reset isLoading and value fields on fetching error', async () => { + const state = await performTest(() => { + // Always throw error on fetching try + return new Promise((resolve, reject) => { + reject(new Error('Server error')) + }) + }, 2) + + expect(state[usdcAddress]).toEqual({ isLoading: false, price: null, currency: USDC }) + expect(state[cowAddress]).toEqual({ isLoading: false, price: null, currency: COW }) + }) + + it('Should set price value and isLoading=false on fetching success', async () => { + const usdcPrice = 1.0 + const cowPrice = 0.5 + + const state = await performTest((currency: Token) => { + // Always return prices + return new Promise((resolve) => { + if (currency === USDC) { + resolve(usdcPrice) + } else if (currency === COW) { + resolve(cowPrice) + } + }) + }, 2) + + expect(state[usdcAddress]).toEqual({ + isLoading: false, + price: usdcPrice, + currency: USDC, + updatedAt: expect.any(Number), + }) + expect(state[cowAddress]).toEqual({ + isLoading: false, + price: cowPrice, + currency: COW, + updatedAt: expect.any(Number), + }) + }) + + it('Should use Coingecko API by default', async () => { + const price = 3.5 + + mockGetCoingeckoUsdPrice.mockImplementation(() => Promise.resolve(price)) + + const state = await performTest() + + expect(state[usdcAddress].price).toBe(price) + expect(state[cowAddress].price).toBe(price) + + expect(mockGetCoingeckoUsdPrice).toHaveBeenCalledTimes(2) + expect(mockGetCowProtocolUsdPrice).toHaveBeenCalledTimes(0) + }) + + it('Should fallback to CowProtocol API when Coingecko is down', async () => { + const price = 7.22 + + mockGetCowProtocolUsdPrice.mockImplementation(() => Promise.resolve(price)) + mockGetCoingeckoUsdPrice.mockImplementation(() => Promise.reject(new Error('Server error'))) + + const state = await performTest() + + expect(state[usdcAddress].price).toBe(price) + expect(state[cowAddress].price).toBe(price) + + expect(mockGetCowProtocolUsdPrice).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/usdAmount/updaters/UsdPricesUpdater.tsx b/apps/cowswap-frontend/src/modules/usdAmount/updaters/UsdPricesUpdater.tsx new file mode 100644 index 0000000000..bdab631f7d --- /dev/null +++ b/apps/cowswap-frontend/src/modules/usdAmount/updaters/UsdPricesUpdater.tsx @@ -0,0 +1,109 @@ +import { useAtomValue, useSetAtom } from 'jotai' +import { useEffect, useMemo } from 'react' + +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { Token } from '@uniswap/sdk-core' + +import ms from 'ms.macro' +import useSWR, { SWRConfiguration } from 'swr' + +import { USDC } from 'legacy/constants/tokens' + +import { useWalletInfo } from 'modules/wallet' + +import { getCowProtocolNativePrice } from '../apis/getCowProtocolNativePrice' +import { fetchCurrencyUsdPrice } from '../services/fetchCurrencyUsdPrice' +import { + currenciesUsdPriceQueueAtom, + UsdRawPrices, + usdRawPricesAtom, + UsdRawPriceState, + resetUsdPricesAtom, + setUsdPricesLoadingAtom, +} from '../state/usdRawPricesAtom' + +const swrOptions: SWRConfiguration = { + refreshInterval: ms`30s`, + refreshWhenHidden: false, + refreshWhenOffline: false, + revalidateOnFocus: true, +} + +export function UsdPricesUpdater() { + const { chainId } = useWalletInfo() + const setUsdPrices = useSetAtom(usdRawPricesAtom) + const setUsdPricesLoading = useSetAtom(setUsdPricesLoadingAtom) + const resetUsdPrices = useSetAtom(resetUsdPricesAtom) + const currenciesUsdPriceQueue = useAtomValue(currenciesUsdPriceQueueAtom) + + const queue = useMemo(() => Object.values(currenciesUsdPriceQueue), [currenciesUsdPriceQueue]) + + const swrResponse = useSWR( + ['UsdPricesUpdater', queue, chainId], + () => { + const getUsdcPrice = usdcPriceLoader(chainId) + + setUsdPricesLoading(queue) + + return processQueue(queue, getUsdcPrice).catch((error) => { + resetUsdPrices(queue) + + return Promise.reject(error) + }) + }, + swrOptions + ) + + useEffect(() => { + const { data, isLoading, error } = swrResponse + + if (error) { + console.error('Error loading USD prices', error) + return + } + + if (isLoading || !data) { + return + } + + setUsdPrices(data) + }, [swrResponse, setUsdPrices]) + + return null +} + +function usdcPriceLoader(chainId: SupportedChainId): () => Promise { + let usdcPricePromise: Promise | null = null + + return () => { + // Cache the result to avoid fetching it multiple times + if (!usdcPricePromise) { + usdcPricePromise = getCowProtocolNativePrice(USDC[chainId]) + } + + return usdcPricePromise + } +} + +async function processQueue(queue: Token[], getUsdcPrice: () => Promise): Promise { + const results = await Promise.all( + queue.map((currency) => { + return fetchCurrencyUsdPrice(currency, getUsdcPrice).then((price) => { + if (typeof price !== 'number') { + return null + } + + const state: UsdRawPriceState = { + updatedAt: Date.now(), + price, + currency, + isLoading: false, + } + + return { [currency.address.toLowerCase()]: state } + }) + }) + ) + + return results.reduce((acc, result) => ({ ...acc, ...result }), {}) +} diff --git a/apps/cowswap-frontend/src/test-utils.tsx b/apps/cowswap-frontend/src/test-utils.tsx index 95266f73c2..38595a99bd 100644 --- a/apps/cowswap-frontend/src/test-utils.tsx +++ b/apps/cowswap-frontend/src/test-utils.tsx @@ -1,5 +1,6 @@ import { Provider as JotaiProvider } from 'jotai' import { useHydrateAtoms } from 'jotai/utils' +import { createStore } from 'jotai/vanilla' import { ReactElement, ReactNode, useMemo } from 'react' import { initializeConnector, Web3ReactHooks, Web3ReactProvider } from '@web3-react/core' @@ -20,6 +21,8 @@ import { theme } from 'legacy/theme' import { LanguageProvider } from './i18n' +type JotaiStore = ReturnType + const MockedI18nProvider = ({ children }: any) => {children} const MockThemeProvider = ({ children }: { children: React.ReactNode }) => { @@ -75,13 +78,31 @@ export function WithMockedWeb3({ children, location }: { children?: ReactNode; l ) } -const HydrateAtoms = ({ initialValues, children }: { initialValues: any[]; children?: ReactNode }) => { - useHydrateAtoms(initialValues) +const HydrateAtoms = ({ + initialValues, + children, + store, +}: { + store?: JotaiStore + initialValues: any[] + children?: ReactNode +}) => { + useHydrateAtoms(initialValues, { store }) return <>{children} } -export const JotaiTestProvider = ({ initialValues, children }: { initialValues: any[]; children?: ReactNode }) => ( - - {children} +export const JotaiTestProvider = ({ + initialValues, + children, + store, +}: { + initialValues: any[] + children?: ReactNode + store?: JotaiStore +}) => ( + + + {children} + )