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 './__mocks__/matchMedia'
import marketMetadataMock from './__mocks__/MarketMetadata'
jest.mock('../../src/@context/MarketMetadata', () => ({
useMarketMetadata: () => marketMetadataMock
}))

View File

@ -62,6 +62,10 @@ module.exports = {
'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
darkModeConfig: {
classNameDark: 'dark',

View File

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

View File

@ -14,6 +14,7 @@ import { MarketMetadataProviderValue, OpcFee } from './_types'
import siteContent from '../../../content/site.json'
import appConfig from '../../../app.config'
import { fetchData, getQueryContext } from '@utils/subgraph'
import { LoggerInstance } from '@oceanprotocol/lib'
const MarketMetadataContext = createContext({} as MarketMetadataProviderValue)
@ -43,6 +44,11 @@ function MarketMetadataProvider({
swapNotApprovedFee: response.data?.opc.swapNonOceanFee
} as OpcFee)
}
LoggerInstance.log('[MarketMetadata] Got new data.', {
opcFees: opcData,
siteContent,
appConfig
})
setOpcFees(opcData)
}
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 useSWR from 'swr'
import { LoggerInstance } from '@oceanprotocol/lib'
import { useMarketMetadata } from './MarketMetadata'
interface Prices {
[key: string]: number
}
interface PricesValue {
prices: Prices
}
const initialData: Prices = {
eur: 0.0,
usd: 0.0,
eth: 0.0,
btc: 0.0
}
const refreshInterval = 120000 // 120 sec.
import { useMarketMetadata } from '../MarketMetadata'
import { Prices, PricesValue } from './_types'
import { initialData, refreshInterval } from './_constants'
import { getCoingeckoTokenId } from './_utils'
const PricesContext = createContext(null)
@ -36,23 +22,23 @@ export default function PricesProvider({
children: ReactNode
}): ReactElement {
const { appConfig } = useMarketMetadata()
const tokenId = 'ocean-protocol'
const [prices, setPrices] = useState(initialData)
const [url, setUrl] = useState('')
const [url, setUrl] = useState<string>()
useEffect(() => {
if (!appConfig) return
// comma-separated list
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)
}, [appConfig])
const onSuccess = async (data: { [tokenId]: Prices }) => {
const onSuccess = async (data: Prices) => {
if (!data) return
LoggerInstance.log('[prices] Got new OCEAN spot prices.', data[tokenId])
setPrices(data[tokenId])
LoggerInstance.log('[prices] Got new spot prices.', data)
setPrices(data)
}
// Fetch new prices periodically with swr
@ -71,4 +57,4 @@ export default function PricesProvider({
// Helper hook to access the provider values
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
// -----------------------------------
const getUserBalance = useCallback(async () => {
if (!accountId || !networkId || !web3) return
if (!accountId || !networkId || !web3 || !networkData) return
try {
const balance: UserBalance = {
eth: web3.utils.fromWei(await web3.eth.getBalance(accountId, 'latest'))
}
const userBalance = web3.utils.fromWei(
await web3.eth.getBalance(accountId, 'latest')
)
const key = networkData.nativeCurrency.symbol.toLowerCase()
const balance: UserBalance = { [key]: userBalance }
if (approvedBaseTokens?.length > 0) {
await Promise.all(
approvedBaseTokens.map(async (token) => {
@ -186,7 +189,7 @@ function Web3Provider({ children }: { children: ReactNode }): ReactElement {
} catch (error) {
LoggerInstance.error('[web3] Error: ', error.message)
}
}, [accountId, approvedBaseTokens, networkId, web3])
}, [accountId, approvedBaseTokens, networkId, web3, networkData])
// -----------------------------------
// Helper: Get user ENS name

View File

@ -1,4 +1,3 @@
interface UserBalance {
eth: 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;
left: 0;
bottom: 0;
padding: 0 calc(var(--spacer) / 4);
padding: calc(var(--spacer) / 4);
display: flex;
justify-content: space-between;
align-items: center;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -42,7 +42,13 @@ export default function Stats({
<div className={styles.stats}>
<NumberUnit
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="Published" value={assetsTotal} />

View File

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

View File

@ -52,7 +52,11 @@ export default function Price({
<div className={styles.datatoken}>
<h4>
= <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>
</div>
</div>