1
0
mirror of https://github.com/oceanprotocol/market.git synced 2024-12-02 05:57:29 +01:00

refactor price context to fetch multiple tokens (#1573)

* refactor price context to fetch multiple tokens

* fixes

* move tokenIds to app config

* make conversion work

* conversion for all user tokens, hide if 0

* different user balance key tactic

* remove NFT gas estimation

* closes #1633

* small simplification in getCoingeckoTokenId logic

* basic Prices provider test

* mock some hooks

* mock MarketMetadata in all tests
This commit is contained in:
Matthias Kretschmann 2022-08-12 14:11:33 +01:00 committed by GitHub
parent 2f93d84e5f
commit 4d119467a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 243 additions and 132 deletions

View File

@ -0,0 +1,73 @@
import siteContent from '../../content/site.json'
import appConfig from '../../app.config'
export default {
getOpcFeeForToken: jest.fn(),
siteContent,
appConfig,
opcFees: [
{
chainId: 1,
approvedTokens: [
'0x0642026e7f0b6ccac5925b4e7fa61384250e1701',
'0x967da4048cd07ab37855c090aaf366e4ce1b9f48'
],
swapApprovedFee: '0.001',
swapNotApprovedFee: '0.002'
},
{
chainId: 137,
approvedTokens: [
'0x282d8efce846a88b159800bd4130ad77443fa1a1',
'0xc5248aa0629c0b2d6a02834a5f172937ac83cbd3'
],
swapApprovedFee: '0.001',
swapNotApprovedFee: '0.002'
},
{
chainId: 56,
approvedTokens: ['0xdce07662ca8ebc241316a15b611c89711414dd1a'],
swapApprovedFee: '0.001',
swapNotApprovedFee: '0.002'
},
{
chainId: 246,
approvedTokens: ['0x593122aae80a6fc3183b2ac0c4ab3336debee528'],
swapApprovedFee: '0.001',
swapNotApprovedFee: '0.002'
},
{
chainId: 1285,
approvedTokens: ['0x99c409e5f62e4bd2ac142f17cafb6810b8f0baae'],
swapApprovedFee: '0.001',
swapNotApprovedFee: '0.002'
},
{
chainId: 3,
approvedTokens: ['0x5e8dcb2afa23844bcc311b00ad1a0c30025aade9'],
swapApprovedFee: '0.001',
swapNotApprovedFee: '0.002'
},
{
chainId: 4,
approvedTokens: [
'0x8967bcf84170c91b0d24d4302c2376283b0b3a07',
'0xd92e713d051c37ebb2561803a3b5fbabc4962431'
],
swapApprovedFee: '0.001',
swapNotApprovedFee: '0.002'
},
{
chainId: 80001,
approvedTokens: ['0xd8992ed72c445c35cb4a2be468568ed1079357c8'],
swapApprovedFee: '0.001',
swapNotApprovedFee: '0.002'
},
{
chainId: 1287,
approvedTokens: ['0xf6410bf5d773c7a41ebff972f38e7463fa242477'],
swapApprovedFee: '0.001',
swapNotApprovedFee: '0.002'
}
]
}

View File

@ -1,2 +1,7 @@
import '@testing-library/jest-dom/extend-expect' import '@testing-library/jest-dom/extend-expect'
import './__mocks__/matchMedia' import './__mocks__/matchMedia'
import marketMetadataMock from './__mocks__/MarketMetadata'
jest.mock('../../src/@context/MarketMetadata', () => ({
useMarketMetadata: () => marketMetadataMock
}))

View File

@ -62,6 +62,10 @@ module.exports = {
'LINK' 'LINK'
], ],
// Tokens to fetch the spot prices from coingecko, against above currencies.
// Refers to Coingecko API tokenIds.
coingeckoTokenIds: ['ocean-protocol', 'h2o', 'ethereum', 'matic-network'],
// Config for https://github.com/donavon/use-dark-mode // Config for https://github.com/donavon/use-dark-mode
darkModeConfig: { darkModeConfig: {
classNameDark: 'dark', classNameDark: 'dark',

View File

@ -16,6 +16,7 @@ export interface AppConfig {
consumeMarketOrderFee: string consumeMarketOrderFee: string
consumeMarketFixedSwapFee: string consumeMarketFixedSwapFee: string
currencies: string[] currencies: string[]
coingeckoTokenIds: string[]
allowFixedPricing: string allowFixedPricing: string
allowFreePricing: string allowFreePricing: string
defaultPrivacyPolicySlug: string defaultPrivacyPolicySlug: string

View File

@ -14,6 +14,7 @@ import { MarketMetadataProviderValue, OpcFee } from './_types'
import siteContent from '../../../content/site.json' import siteContent from '../../../content/site.json'
import appConfig from '../../../app.config' import appConfig from '../../../app.config'
import { fetchData, getQueryContext } from '@utils/subgraph' import { fetchData, getQueryContext } from '@utils/subgraph'
import { LoggerInstance } from '@oceanprotocol/lib'
const MarketMetadataContext = createContext({} as MarketMetadataProviderValue) const MarketMetadataContext = createContext({} as MarketMetadataProviderValue)
@ -43,6 +44,11 @@ function MarketMetadataProvider({
swapNotApprovedFee: response.data?.opc.swapNonOceanFee swapNotApprovedFee: response.data?.opc.swapNonOceanFee
} as OpcFee) } as OpcFee)
} }
LoggerInstance.log('[MarketMetadata] Got new data.', {
opcFees: opcData,
siteContent,
appConfig
})
setOpcFees(opcData) setOpcFees(opcData)
} }
getOpcData() getOpcData()

View File

@ -0,0 +1,13 @@
import { Prices } from './_types'
import { coingeckoTokenIds } from '../../../app.config'
export const initialData: Prices = coingeckoTokenIds.map((tokenId) => ({
[tokenId]: {
eur: 0.0,
usd: 0.0,
eth: 0.0,
btc: 0.0
}
}))[0]
export const refreshInterval = 120000 // 120 sec.

View File

@ -0,0 +1,9 @@
export interface Prices {
[key: string]: {
[key: string]: number
}
}
export interface PricesValue {
prices: Prices
}

View File

@ -0,0 +1,23 @@
//
// Deal with differences between token symbol & Coingecko API IDs
//
export function getCoingeckoTokenId(symbol: string) {
// can be OCEAN or mOCEAN
const isOcean = symbol?.toLowerCase().includes('ocean')
// can be H2O or H20
const isH2o = symbol?.toLowerCase().includes('h2')
const isEth = symbol?.toLowerCase() === 'eth'
const isMatic = symbol?.toLowerCase() === 'matic'
const priceTokenId = isOcean
? 'ocean-protocol'
: isH2o
? 'h2o'
: isEth
? 'ethereum'
: isMatic
? 'matic-network'
: symbol?.toLowerCase()
return priceTokenId
}

View File

@ -0,0 +1,36 @@
import React, { ReactElement } from 'react'
import * as SWR from 'swr'
import { renderHook } from '@testing-library/react'
import { PricesProvider, usePrices, getCoingeckoTokenId } from '.'
jest.spyOn(SWR, 'default').mockImplementation(() => ({
useSWR: { data: { 'ocean-protocol': { eur: '2' } } },
isValidating: false,
mutate: jest.fn()
}))
const wrapper = ({ children }: { children: ReactElement }) => (
<PricesProvider>{children}</PricesProvider>
)
test('should correctly initialize data', async () => {
const { result } = renderHook(() => usePrices(), { wrapper })
expect(result.current.prices['ocean-protocol'].eur).toBeDefined()
})
test('useSWR is called', async () => {
const { result } = renderHook(() => usePrices(), { wrapper })
expect(SWR.default).toHaveBeenCalled()
// somehow the above spy seems to not fully work, but this assertion is the goal
// expect(result.current.prices['ocean-protocol'].eur).toBe('2')
})
test('should get correct Coingecko API ID for OCEAN', async () => {
const id1 = getCoingeckoTokenId('OCEAN')
expect(id1).toBe('ocean-protocol')
const id2 = getCoingeckoTokenId('mOCEAN')
expect(id2).toBe('ocean-protocol')
})

View File

@ -9,24 +9,10 @@ import React, {
import { fetchData } from '@utils/fetch' import { fetchData } from '@utils/fetch'
import useSWR from 'swr' import useSWR from 'swr'
import { LoggerInstance } from '@oceanprotocol/lib' import { LoggerInstance } from '@oceanprotocol/lib'
import { useMarketMetadata } from './MarketMetadata' import { useMarketMetadata } from '../MarketMetadata'
import { Prices, PricesValue } from './_types'
interface Prices { import { initialData, refreshInterval } from './_constants'
[key: string]: number import { getCoingeckoTokenId } from './_utils'
}
interface PricesValue {
prices: Prices
}
const initialData: Prices = {
eur: 0.0,
usd: 0.0,
eth: 0.0,
btc: 0.0
}
const refreshInterval = 120000 // 120 sec.
const PricesContext = createContext(null) const PricesContext = createContext(null)
@ -36,23 +22,23 @@ export default function PricesProvider({
children: ReactNode children: ReactNode
}): ReactElement { }): ReactElement {
const { appConfig } = useMarketMetadata() const { appConfig } = useMarketMetadata()
const tokenId = 'ocean-protocol'
const [prices, setPrices] = useState(initialData) const [prices, setPrices] = useState(initialData)
const [url, setUrl] = useState('') const [url, setUrl] = useState<string>()
useEffect(() => { useEffect(() => {
if (!appConfig) return if (!appConfig) return
// comma-separated list
const currencies = appConfig.currencies.join(',') const currencies = appConfig.currencies.join(',')
const url = `https://api.coingecko.com/api/v3/simple/price?ids=${tokenId}&vs_currencies=${currencies}` const tokenIds = appConfig.coingeckoTokenIds.join(',')
const url = `https://api.coingecko.com/api/v3/simple/price?ids=${tokenIds}&vs_currencies=${currencies}`
setUrl(url) setUrl(url)
}, [appConfig]) }, [appConfig])
const onSuccess = async (data: { [tokenId]: Prices }) => { const onSuccess = async (data: Prices) => {
if (!data) return if (!data) return
LoggerInstance.log('[prices] Got new OCEAN spot prices.', data[tokenId]) LoggerInstance.log('[prices] Got new spot prices.', data)
setPrices(data[tokenId]) setPrices(data)
} }
// Fetch new prices periodically with swr // Fetch new prices periodically with swr
@ -71,4 +57,4 @@ export default function PricesProvider({
// Helper hook to access the provider values // Helper hook to access the provider values
const usePrices = (): PricesValue => useContext(PricesContext) const usePrices = (): PricesValue => useContext(PricesContext)
export { PricesProvider, usePrices } export { PricesProvider, usePrices, getCoingeckoTokenId }

View File

@ -160,12 +160,15 @@ function Web3Provider({ children }: { children: ReactNode }): ReactElement {
// Helper: Get user balance // Helper: Get user balance
// ----------------------------------- // -----------------------------------
const getUserBalance = useCallback(async () => { const getUserBalance = useCallback(async () => {
if (!accountId || !networkId || !web3) return if (!accountId || !networkId || !web3 || !networkData) return
try { try {
const balance: UserBalance = { const userBalance = web3.utils.fromWei(
eth: web3.utils.fromWei(await web3.eth.getBalance(accountId, 'latest')) await web3.eth.getBalance(accountId, 'latest')
} )
const key = networkData.nativeCurrency.symbol.toLowerCase()
const balance: UserBalance = { [key]: userBalance }
if (approvedBaseTokens?.length > 0) { if (approvedBaseTokens?.length > 0) {
await Promise.all( await Promise.all(
approvedBaseTokens.map(async (token) => { approvedBaseTokens.map(async (token) => {
@ -186,7 +189,7 @@ function Web3Provider({ children }: { children: ReactNode }): ReactElement {
} catch (error) { } catch (error) {
LoggerInstance.error('[web3] Error: ', error.message) LoggerInstance.error('[web3] Error: ', error.message)
} }
}, [accountId, approvedBaseTokens, networkId, web3]) }, [accountId, approvedBaseTokens, networkId, web3, networkData])
// ----------------------------------- // -----------------------------------
// Helper: Get user ENS name // Helper: Get user ENS name

View File

@ -1,4 +1,3 @@
interface UserBalance { interface UserBalance {
eth: string
[key: string]: string [key: string]: string
} }

View File

@ -1,68 +0,0 @@
import React, { ReactElement, useEffect, useState } from 'react'
import { usePrices } from '@context/Prices'
import { useWeb3 } from '@context/Web3'
import Web3 from 'web3'
import useNftFactory from '@hooks/contracts/useNftFactory'
import { NftFactory } from '@oceanprotocol/lib'
import Conversion from '@shared/Price/Conversion'
import { generateNftCreateData, NftMetadata } from '@utils/nft'
const getEstGasFee = async (
address: string,
nftFactory: NftFactory,
nftMetadata: NftMetadata,
ethToOceanConversionRate: number
): Promise<string> => {
if (!address || !nftFactory || !nftMetadata || !ethToOceanConversionRate)
return
const { web3 } = nftFactory
const nft = generateNftCreateData(nftMetadata, address)
const gasPrice = await web3.eth.getGasPrice()
const gasLimit = await nftFactory?.estGasCreateNFT(address, nft)
const gasFeeEth = Web3.utils.fromWei(
(+gasPrice * +gasLimit).toString(),
'ether'
)
const gasFeeOcean = (+gasFeeEth / +ethToOceanConversionRate).toString()
return gasFeeOcean
}
export default function TxFee({
nftMetadata
}: {
nftMetadata: NftMetadata
}): ReactElement {
const { accountId } = useWeb3()
const { prices } = usePrices()
const nftFactory = useNftFactory()
const [gasFee, setGasFee] = useState('')
useEffect(() => {
const calculateGasFee = async () =>
setGasFee(
await getEstGasFee(
accountId,
nftFactory,
nftMetadata,
(prices as any)?.eth
)
)
calculateGasFee()
}, [accountId, nftFactory, nftMetadata, prices])
return gasFee ? (
<p>
Gas fee estimation for this artwork
<Conversion price={gasFee} />
</p>
) : accountId ? (
<p>
An error occurred while estimating the gas fee for this artwork, please
try again.
</p>
) : (
<p>Please connect your wallet to get a gas fee estimate for this artwork</p>
)
}

View File

@ -27,7 +27,7 @@
position: absolute; position: absolute;
left: 0; left: 0;
bottom: 0; bottom: 0;
padding: 0 calc(var(--spacer) / 4); padding: calc(var(--spacer) / 4);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;

View File

@ -5,8 +5,6 @@ import { useField } from 'formik'
import React, { ReactElement, useEffect } from 'react' import React, { ReactElement, useEffect } from 'react'
import Refresh from '@images/refresh.svg' import Refresh from '@images/refresh.svg'
import styles from './index.module.css' import styles from './index.module.css'
import Tooltip from '@shared/atoms/Tooltip'
import TxFee from './TxFee'
export default function Nft(props: InputProps): ReactElement { export default function Nft(props: InputProps): ReactElement {
const [field, meta, helpers] = useField(props.name) const [field, meta, helpers] = useField(props.name)
@ -28,7 +26,6 @@ export default function Nft(props: InputProps): ReactElement {
<figure className={styles.image}> <figure className={styles.image}>
<img src={field?.value?.image_data} width="128" height="128" /> <img src={field?.value?.image_data} width="128" height="128" />
<div className={styles.actions}> <div className={styles.actions}>
<Tooltip content={<TxFee nftMetadata={field.value} />} />
<Button <Button
style="text" style="text"
size="small" size="small"

View File

@ -1,18 +1,17 @@
import React, { useEffect, useState, ReactElement } from 'react' import React, { useEffect, useState, ReactElement } from 'react'
import styles from './Conversion.module.css' import styles from './Conversion.module.css'
import classNames from 'classnames/bind'
import { formatCurrency, isCrypto } from '@coingecko/cryptoformat' import { formatCurrency, isCrypto } from '@coingecko/cryptoformat'
import { useUserPreferences } from '@context/UserPreferences' import { useUserPreferences } from '@context/UserPreferences'
import { usePrices } from '@context/Prices' import { usePrices, getCoingeckoTokenId } from '@context/Prices'
const cx = classNames.bind(styles)
export default function Conversion({ export default function Conversion({
price, price,
symbol,
className, className,
hideApproximateSymbol hideApproximateSymbol
}: { }: {
price: string // expects price in OCEAN, not wei price: string // expects price in OCEAN, not wei
symbol: string
className?: string className?: string
hideApproximateSymbol?: boolean hideApproximateSymbol?: boolean
}): ReactElement { }): ReactElement {
@ -25,18 +24,21 @@ export default function Conversion({
// isCrypto() only checks for BTC & ETH & unknown but seems sufficient for now // isCrypto() only checks for BTC & ETH & unknown but seems sufficient for now
// const isFiat = /(EUR|USD|CAD|SGD|HKD|CNY|JPY|GBP|INR|RUB)/g.test(currency) // const isFiat = /(EUR|USD|CAD|SGD|HKD|CNY|JPY|GBP|INR|RUB)/g.test(currency)
const styleClasses = cx({ // referring to Coingecko tokenId in Prices context provider
conversion: true, const priceTokenId = getCoingeckoTokenId(symbol)
[className]: className
})
useEffect(() => { useEffect(() => {
if (!prices || !price || price === '0') { if (
setPriceConverted('0.00') !prices ||
!price ||
price === '0' ||
!priceTokenId ||
!prices[priceTokenId]
) {
return return
} }
const conversionValue = prices[currency.toLowerCase()] const conversionValue = prices[priceTokenId][currency.toLowerCase()]
const converted = conversionValue * Number(price) const converted = conversionValue * Number(price)
const convertedFormatted = formatCurrency( const convertedFormatted = formatCurrency(
converted, converted,
@ -54,16 +56,16 @@ export default function Conversion({
(match) => `<span>${match}</span>` (match) => `<span>${match}</span>`
) )
setPriceConverted(convertedFormattedHTMLstring) setPriceConverted(convertedFormattedHTMLstring)
}, [price, prices, currency, locale, isFiat]) }, [price, prices, currency, locale, isFiat, priceTokenId])
return ( return Number(price) > 0 ? (
<span <span
className={styleClasses} className={`${styles.conversion} ${className || ''}`}
title="Approximation based on the current selected base token spot price on Coingecko" title="Approximation based on the current spot price on Coingecko"
> >
{!hideApproximateSymbol && '≈ '} {!hideApproximateSymbol && '≈ '}
<strong dangerouslySetInnerHTML={{ __html: priceConverted }} />{' '} <strong dangerouslySetInnerHTML={{ __html: priceConverted }} />{' '}
{!isFiat && currency} {!isFiat && currency}
</span> </span>
) ) : null
} }

View File

@ -41,7 +41,7 @@ export default function PriceUnit({
{Number.isNaN(Number(price)) ? '-' : formatPrice(price, locale)}{' '} {Number.isNaN(Number(price)) ? '-' : formatPrice(price, locale)}{' '}
<span className={styles.symbol}>{symbol}</span> <span className={styles.symbol}>{symbol}</span>
</div> </div>
{conversion && <Conversion price={price} />} {conversion && <Conversion price={price} symbol={symbol} />}
</> </>
)} )}
</div> </div>

View File

@ -17,7 +17,7 @@
} }
.balance { .balance {
font-size: var(--font-size-base); font-size: var(--font-size-small);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
color: var(--color-secondary); color: var(--color-secondary);
white-space: nowrap; white-space: nowrap;
@ -32,6 +32,14 @@
margin-right: 0.4rem; margin-right: 0.4rem;
} }
.value {
color: var(--font-color-text);
}
.conversion strong {
font-weight: var(--font-weight-base);
}
.actions { .actions {
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
margin-top: calc(var(--spacer) / 2); margin-top: calc(var(--spacer) / 2);

View File

@ -29,8 +29,7 @@ export default function Details(): ReactElement {
useEffect(() => { useEffect(() => {
if (!networkId) return if (!networkId) return
const symbol = const symbol = networkData?.nativeCurrency.symbol
networkId === 2021000 ? 'GX' : networkData?.nativeCurrency.symbol
setMainCurrency(symbol) setMainCurrency(symbol)
const oceanConfig = getOceanConfig(networkId) const oceanConfig = getOceanConfig(networkId)
@ -49,11 +48,17 @@ export default function Details(): ReactElement {
<li className={styles.balance} key={key}> <li className={styles.balance} key={key}>
<span className={styles.symbol}> <span className={styles.symbol}>
{key === 'eth' ? mainCurrency : key.toUpperCase()} {key === 'eth' ? mainCurrency : key.toUpperCase()}
</span>{' '} </span>
<span className={styles.value}>
{formatCurrency(Number(value), '', locale, false, { {formatCurrency(Number(value), '', locale, false, {
significantFigures: 4 significantFigures: 4
})} })}
{key === 'ocean' && <Conversion price={value} />} </span>
<Conversion
className={styles.conversion}
price={value}
symbol={key}
/>
</li> </li>
))} ))}

View File

@ -42,7 +42,13 @@ export default function Stats({
<div className={styles.stats}> <div className={styles.stats}>
<NumberUnit <NumberUnit
label="Total Sales" label="Total Sales"
value={<Conversion price={totalSales} hideApproximateSymbol />} value={
<Conversion
price={totalSales}
symbol={'ocean'}
hideApproximateSymbol
/>
}
/> />
<NumberUnit label={`Sale${sales === 1 ? '' : 's'}`} value={sales} /> <NumberUnit label={`Sale${sales === 1 ? '' : 's'}`} value={sales} />
<NumberUnit label="Published" value={assetsTotal} /> <NumberUnit label="Published" value={assetsTotal} />

View File

@ -1,4 +1,3 @@
import Input from '@shared/FormInput'
import InputElement from '@shared/FormInput/InputElement' import InputElement from '@shared/FormInput/InputElement'
import { useFormikContext } from 'formik' import { useFormikContext } from 'formik'
import React, { ChangeEvent, ReactElement } from 'react' import React, { ChangeEvent, ReactElement } from 'react'

View File

@ -52,7 +52,11 @@ export default function Price({
<div className={styles.datatoken}> <div className={styles.datatoken}>
<h4> <h4>
= <strong>1</strong> {dataTokenOptions.symbol}{' '} = <strong>1</strong> {dataTokenOptions.symbol}{' '}
<Conversion price={field.value} className={styles.conversion} /> <Conversion
price={field.value}
symbol={values.pricing?.baseToken?.symbol}
className={styles.conversion}
/>
</h4> </h4>
</div> </div>
</div> </div>