diff --git a/.env b/.env index df3aabc62a..fe1f56c9fe 100644 --- a/.env +++ b/.env @@ -14,5 +14,9 @@ NEXT_PUBLIC_INFURA_IPFS_API_SECRET="" NEXT_PUBLIC_SITE_URL="https://app.dev.fractalframework.xyz/" # WalletConnect Cloud Project ID NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID="" +# CoinGecko API key +COINGECKO_API_KEY="" +# Minutes to cache prices for token addresses +TOKEN_PRICE_CACHE_INTERVAL_MINUTES="" # Shutter Public Key NEXT_PUBLIC_SHUTTER_EON_PUBKEY=0x0e6493bbb4ee8b19aa9b70367685049ff01dc9382c46aed83f8bc07d2a5ba3e6030bd83b942c1fd3dff5b79bef3b40bf6b666e51e7f0be14ed62daaffad47435265f5c9403b1a801921981f7d8659a9bd91fe92fb1cf9afdb16178a532adfaf51a237103874bb03afafe9cab2118dae1be5f08a0a28bf488c1581e9db4bc23ca \ No newline at end of file diff --git a/.gitignore b/.gitignore index ea11b4cd41..888bc818a6 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ yarn-error.log* # GraphQL generated files .graphclient/ + +# Local Netlify folder +.netlify diff --git a/.nvmrc b/.nvmrc index 72c7744b30..741b4916ea 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18.12.1 \ No newline at end of file +18.14.0 \ No newline at end of file diff --git a/netlify/functions/tokenPrices.mts b/netlify/functions/tokenPrices.mts new file mode 100644 index 0000000000..530399cf55 --- /dev/null +++ b/netlify/functions/tokenPrices.mts @@ -0,0 +1,268 @@ +// eslint-disable-next-line import/named +import { Store, getStore } from '@netlify/blobs'; +import { ethers } from 'ethers'; + +const PUBLIC_DEMO_API_BASE_URL = 'https://api.coingecko.com/api/v3/'; +const AUTH_QUERY_PARAM = `?x_cg_demo_api_key=${process.env.COINGECKO_API_KEY}`; + +type TokenPriceWithMetadata = { + data: { + tokenAddress: string; + price: number; + }; + metadata: { + fetched: number; + }; +}; + +type Config = { + store: Store; + nowSeconds: number; + cacheTimeSeconds: number; +}; + +const SUPPORTED_NETWORKS = ['ethereum'] as const; +type SupportedNetworks = (typeof SUPPORTED_NETWORKS)[number]; + +function sanitizeUserInput(tokensString: string, network: SupportedNetworks) { + const rawTokenAddresses = tokensString.split(','); + const needNativeAsset = rawTokenAddresses.map(address => address.toLowerCase()).includes(network); + const validTokenAddresses = rawTokenAddresses.filter(address => ethers.utils.isAddress(address)); + const lowerCaseTokenAddresses = validTokenAddresses.map(address => address.toLowerCase()); + const tokens = [...new Set(lowerCaseTokenAddresses)]; + if (needNativeAsset) tokens.push(network); + return { tokens, needNativeAsset }; +} + +async function splitData( + config: Config, + tokens: string[], + responseBodyCallback: (address: string, price: number) => void, + network: SupportedNetworks, +) { + // Try to get all of the tokens from our store. + // Any token address that we don't have a record for will + // come back as null. + const possibleCachedTokenPrices = await Promise.all( + tokens.map( + tokenAddress => + config.store.getWithMetadata(`${network}/${tokenAddress}`, { + type: 'json', + }) as Promise | null, + ), + ); + + // Filter out the null values, leaving us with an array of + // TokenPricesWithMetadata. All of these TokenPrices will be either + // expired or unexpired. + const cachedTokenPrices = possibleCachedTokenPrices.filter( + (possible): possible is TokenPriceWithMetadata => possible !== null, + ); + + // Let's pull out all of the expired addresses from our cache. + const expiredCachedTokenAddresses = cachedTokenPrices + .filter(tokenPrice => tokenPrice.metadata.fetched + config.cacheTimeSeconds < config.nowSeconds) + .map(tokenPrice => tokenPrice.data.tokenAddress); + + // Finally let's get a list of all of the token addresses that + // we don't have any record of in our cache. + const uncachedTokenAddresses = tokens.filter( + address => + !cachedTokenPrices.find(cachedTokenPrice => cachedTokenPrice.data.tokenAddress === address), + ); + + // We'll update our response with those cached expired and + // unexpired prices. + cachedTokenPrices.forEach(tokenPrice => { + responseBodyCallback(tokenPrice.data.tokenAddress, tokenPrice.data.price); + }); + + return { expiredCachedTokenAddresses, uncachedTokenAddresses }; +} + +function getTokenPricesUrl(tokens: string[], network: SupportedNetworks) { + const tokenPricesUrl = `${PUBLIC_DEMO_API_BASE_URL}simple/token_price/${network}/${AUTH_QUERY_PARAM}&vs_currencies=usd&contract_addresses=${tokens.join( + ',', + )}`; + return tokenPricesUrl; +} + +function getNativeAssetPriceUrl(network: SupportedNetworks) { + const nativeAssetPriceUrl = `${PUBLIC_DEMO_API_BASE_URL}simple/price${AUTH_QUERY_PARAM}&ids=${network}&vs_currencies=usd`; + return nativeAssetPriceUrl; +} + +async function storeTokenPrice( + config: Config, + tokenAddress: string, + price: number, + network: SupportedNetworks, +) { + await config.store.setJSON( + `${network}/${tokenAddress}`, + { tokenAddress, price }, + { metadata: { fetched: config.nowSeconds } }, + ); +} + +async function processTokenPricesResponse( + config: Config, + tokenPricesResponseJson: Record, + responseBodyCallback: (address: string, price: number) => void, + network: SupportedNetworks, +) { + const coinGeckoResponseAddresses = Object.keys(tokenPricesResponseJson); + for await (const tokenAddress of coinGeckoResponseAddresses) { + // Sometimes no USD price is returned. If this happens, + // we should consider it as though CoinGecko doesn't support + // this address and fallback to 0. + const price = tokenPricesResponseJson[tokenAddress].usd || 0; + const sanitizedAddress = tokenAddress.toLowerCase(); + + responseBodyCallback(sanitizedAddress, price); + await storeTokenPrice(config, sanitizedAddress, price, network); + } + + return coinGeckoResponseAddresses; +} + +async function processUnknownAddresses( + config: Config, + needPricesTokenAddresses: string[], + responseAddresses: string[], + network: SupportedNetworks, +) { + const unknownAddresses = needPricesTokenAddresses + .filter(x => !responseAddresses.includes(x)) + .map(address => address.toLowerCase()); + for await (const tokenAddress of unknownAddresses) { + await storeTokenPrice(config, tokenAddress, 0, network); + } +} + +async function coinGeckoRequestAndResponse( + config: Config, + url: string, + responseBodyCallback: (address: string, price: number) => void, + network: SupportedNetworks, +) { + // Make the request to CoinGecko. + // Response is of shape: + // { + // [tokenAddress]: { usd: 1234 }, + // } + let ethPriceResponseJson: Record; + try { + const ethPriceResponse = await fetch(url); + ethPriceResponseJson = await ethPriceResponse.json(); + } catch (e) { + throw e; + } + + // Update the cache with the new price and update + // the response object. + const responseAddresses = processTokenPricesResponse( + config, + ethPriceResponseJson, + responseBodyCallback, + network, + ); + + return responseAddresses; +} + +export default async function getTokenPrices(request: Request) { + if (!process.env.COINGECKO_API_KEY) { + console.error('CoinGecko API key is missing'); + return Response.json({ error: 'Error while fetching prices' }, { status: 503 }); + } + + if (!process.env.TOKEN_PRICE_CACHE_INTERVAL_MINUTES) { + console.error('TOKEN_PRICE_CACHE_INTERVAL_MINUTES is not set'); + return Response.json({ error: 'Error while fetching prices' }, { status: 503 }); + } + + const requestSearchParams = new URL(request.url).searchParams; + const tokensStringParam = requestSearchParams.get('tokens'); + if (!tokensStringParam) { + return Response.json({ error: 'Tokens missing from request' }, { status: 400 }); + } + + const networkParam = requestSearchParams.get('network') as SupportedNetworks; + if (!networkParam || !SUPPORTED_NETWORKS.includes(networkParam)) { + return Response.json({ error: 'Requested network is not supported' }, { status: 400 }); + } + + const store = getStore('token-prices'); + const nowSeconds = Math.floor(Date.now() / 1000); + const cacheTimeSeconds = parseInt(process.env.TOKEN_PRICE_CACHE_INTERVAL_MINUTES) * 60; + const config = { store, nowSeconds, cacheTimeSeconds }; + + const { tokens, needNativeAsset } = sanitizeUserInput(tokensStringParam, networkParam); + + // Let's immediately build up our repsonse object, containing each + // token address and an value of 0. We'll modify this along the way + // populating it with more relevant prices. + const responseBody: Record = tokens.reduce((p, c) => ({ ...p, [c]: 0 }), {}); + + const { expiredCachedTokenAddresses, uncachedTokenAddresses } = await splitData( + config, + tokens, + (address, price) => { + responseBody[address] = price; + }, + networkParam, + ); + + // If there are no expired token prices, and no token addresses that we + // don't have a cached value for at all, we can early return! + if (expiredCachedTokenAddresses.length === 0 && uncachedTokenAddresses.length === 0) { + return Response.json({ data: responseBody }); + } + + // If we got here, then we have either some expired prices for given tokens, + // or no prices at all for given tokens. + + // First, let's build up our list of token addresses to query CoinGecko with, + // which is all uncached tokens and tokens that have expired. + // Remove native asset name if it's in this list. + const needPricesTokenAddresses = [ + ...uncachedTokenAddresses, + ...expiredCachedTokenAddresses, + ].filter(address => address !== networkParam); + + let responseAddresses: string[]; + try { + responseAddresses = await coinGeckoRequestAndResponse( + config, + getTokenPricesUrl(needPricesTokenAddresses, networkParam), + (address, price) => { + responseBody[address] = price; + }, + networkParam, + ); + } catch (e) { + console.error('Error while querying CoinGecko', e); + return Response.json({ error: 'Error while fetching prices', data: responseBody }); + } + await processUnknownAddresses(config, needPricesTokenAddresses, responseAddresses, networkParam); + + // Do we need to get the price of our chain's gas token? + if (needNativeAsset) { + try { + await coinGeckoRequestAndResponse( + config, + getNativeAssetPriceUrl(networkParam), + (address, price) => { + responseBody[address] = price; + }, + networkParam, + ); + } catch (e) { + console.error('Error while querying CoinGecko', e); + return Response.json({ error: 'Error while fetching prices', data: responseBody }); + } + } + + return Response.json({ data: responseBody }); +} diff --git a/package-lock.json b/package-lock.json index c69ef1ce99..8e3deb9328 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,8 @@ "@fractal-framework/fractal-contracts": "^0.4.0", "@graphprotocol/client-apollo": "^1.0.16", "@lido-sdk/contracts": "^3.0.2", + "@netlify/blobs": "^6.5.0", + "@netlify/functions": "^2.6.0", "@rainbow-me/rainbowkit": "^1.3.3", "@safe-global/safe-deployments": "^1.23.0", "@safe-global/safe-ethers-lib": "^1.9.2", @@ -78,8 +80,8 @@ "vitest": "^1.2.2" }, "engines": { - "node": "18.12.1", - "npm": "8.19.2" + "node": "18.14.0", + "npm": "9.3.1" } }, "node_modules/@adobe/css-tools": { @@ -8099,6 +8101,50 @@ "tslib": "^2.3.1" } }, + "node_modules/@netlify/blobs": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-6.5.0.tgz", + "integrity": "sha512-wRFlNnL/Qv3WNLZd3OT/YYqF1zb6iPSo8T31sl9ccL1ahBxW1fBqKgF4b1XL7Z+6mRIkatvcsVPkWBcO+oJMNA==", + "engines": { + "node": "^14.16.0 || >=16.0.0" + } + }, + "node_modules/@netlify/functions": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-2.6.0.tgz", + "integrity": "sha512-vU20tij0fb4nRGACqb+5SQvKd50JYyTyEhQetCMHdakcJFzjLDivvRR16u1G2Oy4A7xNAtGJF1uz8reeOtTVcQ==", + "dependencies": { + "@netlify/serverless-functions-api": "1.14.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@netlify/node-cookies": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@netlify/node-cookies/-/node-cookies-0.1.0.tgz", + "integrity": "sha512-OAs1xG+FfLX0LoRASpqzVntVV/RpYkgpI0VrUnw2u0Q1qiZUzcPffxRK8HF3gc4GjuhG5ahOEMJ9bswBiZPq0g==", + "engines": { + "node": "^14.16.0 || >=16.0.0" + } + }, + "node_modules/@netlify/serverless-functions-api": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@netlify/serverless-functions-api/-/serverless-functions-api-1.14.0.tgz", + "integrity": "sha512-HUNETLNvNiC2J+SB/YuRwJA9+agPrc0azSoWVk8H85GC+YE114hcS5JW+dstpKwVerp2xILE3vNWN7IMXP5Q5Q==", + "dependencies": { + "@netlify/node-cookies": "^0.1.0", + "urlpattern-polyfill": "8.0.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + } + }, + "node_modules/@netlify/serverless-functions-api/node_modules/urlpattern-polyfill": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz", + "integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==" + }, "node_modules/@next/env": { "version": "13.3.0", "resolved": "https://registry.npmjs.org/@next/env/-/env-13.3.0.tgz", @@ -34233,6 +34279,40 @@ } } }, + "@netlify/blobs": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-6.5.0.tgz", + "integrity": "sha512-wRFlNnL/Qv3WNLZd3OT/YYqF1zb6iPSo8T31sl9ccL1ahBxW1fBqKgF4b1XL7Z+6mRIkatvcsVPkWBcO+oJMNA==" + }, + "@netlify/functions": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-2.6.0.tgz", + "integrity": "sha512-vU20tij0fb4nRGACqb+5SQvKd50JYyTyEhQetCMHdakcJFzjLDivvRR16u1G2Oy4A7xNAtGJF1uz8reeOtTVcQ==", + "requires": { + "@netlify/serverless-functions-api": "1.14.0" + } + }, + "@netlify/node-cookies": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@netlify/node-cookies/-/node-cookies-0.1.0.tgz", + "integrity": "sha512-OAs1xG+FfLX0LoRASpqzVntVV/RpYkgpI0VrUnw2u0Q1qiZUzcPffxRK8HF3gc4GjuhG5ahOEMJ9bswBiZPq0g==" + }, + "@netlify/serverless-functions-api": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@netlify/serverless-functions-api/-/serverless-functions-api-1.14.0.tgz", + "integrity": "sha512-HUNETLNvNiC2J+SB/YuRwJA9+agPrc0azSoWVk8H85GC+YE114hcS5JW+dstpKwVerp2xILE3vNWN7IMXP5Q5Q==", + "requires": { + "@netlify/node-cookies": "^0.1.0", + "urlpattern-polyfill": "8.0.2" + }, + "dependencies": { + "urlpattern-polyfill": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz", + "integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==" + } + } + }, "@next/env": { "version": "13.3.0", "resolved": "https://registry.npmjs.org/@next/env/-/env-13.3.0.tgz", diff --git a/package.json b/package.json index 2088ddcca9..a3920d9836 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "@fractal-framework/fractal-contracts": "^0.4.0", "@graphprotocol/client-apollo": "^1.0.16", "@lido-sdk/contracts": "^3.0.2", + "@netlify/blobs": "^6.5.0", + "@netlify/functions": "^2.6.0", "@rainbow-me/rainbowkit": "^1.3.3", "@safe-global/safe-deployments": "^1.23.0", "@safe-global/safe-ethers-lib": "^1.9.2", @@ -62,8 +64,8 @@ "tests": "playwright test" }, "engines": { - "node": "18.12.1", - "npm": "8.19.2" + "node": "18.14.0", + "npm": "9.3.1" }, "browserslist": { "production": [ diff --git a/src/components/pages/DAOTreasury/hooks/useFormatCoins.tsx b/src/components/pages/DAOTreasury/hooks/useFormatCoins.tsx index 3ea1a5abd2..3944108ff9 100644 --- a/src/components/pages/DAOTreasury/hooks/useFormatCoins.tsx +++ b/src/components/pages/DAOTreasury/hooks/useFormatCoins.tsx @@ -1,5 +1,8 @@ import { SafeBalanceUsdResponse } from '@safe-global/safe-service-client'; -import { ethers } from 'ethers'; +import { BigNumber, constants } from 'ethers'; +import { useEffect, useState } from 'react'; +import { logError } from '../../../../helpers/errorLogging'; +import usePriceAPI from '../../../../providers/App/hooks/usePriceAPI'; import { useNetworkConfig } from '../../../../providers/NetworkConfig/NetworkConfigProvider'; import { formatCoin, formatUSD } from '../../../../utils/numberFormats'; @@ -17,27 +20,62 @@ export interface TokenDisplayData { export function useFormatCoins(assets: SafeBalanceUsdResponse[]) { const { nativeTokenSymbol, nativeTokenIcon } = useNetworkConfig(); - let totalFiatValue = 0; - let displayData: TokenDisplayData[] = []; - for (let i = 0; i < assets.length; i++) { - let asset = assets[i]; - if (asset.balance === '0') continue; - totalFiatValue += Number(asset.fiatBalance); - let symbol = asset.token === null ? nativeTokenSymbol : asset.token.symbol; - const formatted: TokenDisplayData = { - iconUri: asset.token === null ? nativeTokenIcon : asset.token.logoUri, - address: asset.tokenAddress === null ? ethers.constants.AddressZero : asset.tokenAddress, - truncatedCoinTotal: formatCoin(asset.balance, true, asset?.token?.decimals, symbol), - fiatValue: Number(asset.fiatBalance), - symbol: symbol, - fiatConversion: `1 ${symbol} = ${formatUSD(Number(asset.fiatConversion))}`, - fullCoinTotal: formatCoin(asset.balance, false, asset?.token?.decimals, symbol), - fiatDisplayValue: formatUSD(Number(asset.fiatBalance)), - rawValue: asset.balance, - }; - displayData.push(formatted); - } - displayData.sort((a, b) => b.fiatValue - a.fiatValue); // sort by USD value + const [totalFiatValue, setTotalFiatValue] = useState(0); + const [displayData, setDisplayData] = useState([]); + const { getTokenPrices } = usePriceAPI(); + + useEffect(() => { + async function loadDisplayData() { + let newTotalFiatValue = 0; + let newDisplayData = []; + const tokenPrices = await getTokenPrices(assets); + for (let i = 0; i < assets.length; i++) { + let asset = assets[i]; + if (asset.balance === '0') continue; + const tokenPrice = tokenPrices + ? asset.tokenAddress + ? tokenPrices[asset.tokenAddress.toLowerCase()] + : tokenPrices.ethereum + : 0; + + let tokenFiatBalance = 0; + if (tokenPrice && asset.balance) { + try { + const multiplicator = 10000; + const tokenFiatBalanceBn = BigNumber.from(asset.balance) + .mul(Math.round(parseFloat(tokenPrice.toFixed(5)) * multiplicator)) // We'll be loosing precision with super small prices like for meme coins. But that shouldn't be awfully off + .div(BigNumber.from(10).pow(asset.token?.decimals || 18)); + tokenFiatBalance = tokenFiatBalanceBn.gte(constants.MaxUint256) + ? tokenFiatBalanceBn.div(multiplicator).toNumber() + : tokenFiatBalanceBn.toNumber() / multiplicator; + newTotalFiatValue += tokenFiatBalance; + } catch (e) { + logError('Error while calculating token fiat balance', e); + } + } + + let symbol = asset.token === null ? nativeTokenSymbol : asset.token.symbol; + const formatted: TokenDisplayData = { + iconUri: asset.token === null ? nativeTokenIcon : asset.token.logoUri, + address: asset.tokenAddress === null ? constants.AddressZero : asset.tokenAddress, + truncatedCoinTotal: formatCoin(asset.balance, true, asset?.token?.decimals, symbol), + fiatValue: tokenFiatBalance, + symbol: symbol, + fiatConversion: tokenPrice ? `1 ${symbol} = ${formatUSD(tokenPrice)}` : 'N/A', + fullCoinTotal: formatCoin(asset.balance, false, asset?.token?.decimals, symbol), + fiatDisplayValue: formatUSD(tokenFiatBalance), + rawValue: asset.balance, + }; + newDisplayData.push(formatted); + } + newDisplayData.sort((a, b) => b.fiatValue - a.fiatValue); // sort by USD value + setTotalFiatValue(newTotalFiatValue); + setDisplayData(newDisplayData); + } + + loadDisplayData(); + }, [assets, nativeTokenIcon, nativeTokenSymbol, getTokenPrices]); + return { totalFiatValue, displayData, diff --git a/src/components/pages/DAOTreasury/hooks/useTreasuryTotalUSD.tsx b/src/components/pages/DAOTreasury/hooks/useTreasuryTotalUSD.tsx deleted file mode 100644 index c00c09d0c3..0000000000 --- a/src/components/pages/DAOTreasury/hooks/useTreasuryTotalUSD.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { useMemo } from 'react'; -import { useFractal } from '../../../../providers/App/AppProvider'; -import { formatUSD } from '../../../../utils/numberFormats'; - -export function useTreasuryTotalUSD(): string { - const { - treasury: { assetsFungible }, - } = useFractal(); - return useMemo(() => { - return formatUSD( - assetsFungible.reduce((prev, asset) => (prev += Number(asset.fiatBalance)), 0), - ); - }, [assetsFungible]); -} diff --git a/src/components/pages/DaoDashboard/Info/InfoTreasury.tsx b/src/components/pages/DaoDashboard/Info/InfoTreasury.tsx index 4a5c4a61f3..9ff2cebb6b 100644 --- a/src/components/pages/DaoDashboard/Info/InfoTreasury.tsx +++ b/src/components/pages/DaoDashboard/Info/InfoTreasury.tsx @@ -3,18 +3,19 @@ import { Treasury } from '@decent-org/fractal-ui'; import { useTranslation } from 'react-i18next'; import { useFractal } from '../../../../providers/App/AppProvider'; import { BarLoader } from '../../../ui/loaders/BarLoader'; -import { useTreasuryTotalUSD } from '../../DAOTreasury/hooks/useTreasuryTotalUSD'; +import { useFormatCoins } from '../../DAOTreasury/hooks/useFormatCoins'; interface IDAOGovernance {} export function InfoTreasury({}: IDAOGovernance) { - const { t } = useTranslation('dashboard'); - const totalUSD = useTreasuryTotalUSD(); - const { node: { daoAddress }, + treasury: { assetsFungible }, } = useFractal(); + const { t } = useTranslation('dashboard'); + const { totalFiatValue } = useFormatCoins(assetsFungible); + if (!daoAddress) { return ( - {totalUSD} + {totalFiatValue} ); diff --git a/src/i18n/locales/en/treasury.json b/src/i18n/locales/en/treasury.json index d59e42e02d..531a8c353f 100644 --- a/src/i18n/locales/en/treasury.json +++ b/src/i18n/locales/en/treasury.json @@ -21,5 +21,6 @@ "stake": "Stake", "unstake": "Unstake", "claimUnstakedETH": "Claim ETH", - "nonClaimableYet": "Your ETH cannot yet be claimed from Lido" + "nonClaimableYet": "Your ETH cannot yet be claimed from Lido", + "tokenPriceFetchingError": "There was an error obtaining token prices. Please, try visiting Treasury page later." } diff --git a/src/providers/App/hooks/usePriceAPI.ts b/src/providers/App/hooks/usePriceAPI.ts new file mode 100644 index 0000000000..28fc0f8494 --- /dev/null +++ b/src/providers/App/hooks/usePriceAPI.ts @@ -0,0 +1,52 @@ +import { SafeBalanceUsdResponse } from '@safe-global/safe-service-client'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { logError } from '../../../helpers/errorLogging'; +import { useNetworkConfig } from '../../NetworkConfig/NetworkConfigProvider'; + +export default function usePriceAPI() { + const { chainId } = useNetworkConfig(); + const { t } = useTranslation('treasury'); + + const getTokenPrices = useCallback( + async (tokens: SafeBalanceUsdResponse[]) => { + if (chainId !== 1) { + // Support only mainnet for now. CoinGecko does not support Sepolia (obviously, I guess :D) and we don't want to burn API credits to "simulate" prices display + return; + } + + try { + const tokensAddresses = tokens + .filter(token => token.balance !== '0' && !!token.tokenAddress) + .map(token => token.tokenAddress); + const ethAsset = tokens.find(token => !token.tokenAddress); + if (ethAsset) { + tokensAddresses.push('ethereum'); + } + if (tokensAddresses.length > 0) { + const pricesResponse = await fetch( + `/.netlify/functions/tokenPrices?tokens=${tokensAddresses.join(',')}&network=ethereum`, + ); + + const pricesResponseBody = await pricesResponse.json(); + if (pricesResponseBody.data) { + return pricesResponseBody.data; + } else { + // In theory - we shouldn't get into such situation, when request data is missing, but also we haven't fallen into catch case + // Yet, better safe than sorry + logError('Error fetching prices, response data is missing!', pricesResponseBody); + toast.warning(t('tokenPriceFetchingError')); + return; + } + } + } catch (e) { + logError('Error while getting tokens prices', e); + toast.warning(t('tokenPriceFetchingError')); + return; + } + }, + [chainId, t], + ); + return { getTokenPrices }; +} diff --git a/tsconfig.json b/tsconfig.json index 088de09f39..084038183b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,6 +28,7 @@ "tests", "test", "app", + "netlify/functions", ".next/types/**/*.ts", "next-env.d.ts", "next.config.js"