diff --git a/.env b/.env index 53e8a00d95..c19204e742 100644 --- a/.env +++ b/.env @@ -17,6 +17,6 @@ NEXT_PUBLIC_SITE_URL="https://app.dev.fractalframework.xyz/" # WalletConnect Cloud Project ID NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID="" # CoinGecko API key -NEXT_PUBLIC_COINGECKO_API_KEY="" +COINGECKO_API_KEY="" # Shutter Public Key NEXT_PUBLIC_SHUTTER_EON_PUBKEY=0x0e6493bbb4ee8b19aa9b70367685049ff01dc9382c46aed83f8bc07d2a5ba3e6030bd83b942c1fd3dff5b79bef3b40bf6b666e51e7f0be14ed62daaffad47435265f5c9403b1a801921981f7d8659a9bd91fe92fb1cf9afdb16178a532adfaf51a237103874bb03afafe9cab2118dae1be5f08a0a28bf488c1581e9db4bc23ca \ No newline at end of file diff --git a/netlify/functions/tokensPrices.mts b/netlify/functions/tokensPrices.mts new file mode 100644 index 0000000000..d0980462f4 --- /dev/null +++ b/netlify/functions/tokensPrices.mts @@ -0,0 +1,84 @@ +import { getStore } from '@netlify/blobs'; + +type TokenPriceMetadata = { + expiration: number; +}; + +export default async function getTokenprices(request: Request) { + const store = getStore('fractal-token-prices-store'); + const tokensString = new URL(request.url).searchParams.get('tokens'); + + if (!tokensString) { + Response.json({ error: 'Tokens to request were not provided' }); + } + const tokens = tokensString!.split(','); + try { + const now = new Date().getTime(); + const cachedPrices = await Promise.all( + tokens.map(tokenAddress => store.getWithMetadata(tokenAddress, { type: 'json' })) + ); + const expiredPrices: string[] = []; + const cachedUnexpiredPrices = cachedPrices + .filter(tokenPrice => { + if (tokenPrice && (tokenPrice?.metadata as any as TokenPriceMetadata).expiration <= now) { + expiredPrices.push(tokenPrice.data.tokenAddress); + return false; + } + return tokenPrice; + }) + .map(tokenPrice => ({ + tokenAddress: tokenPrice?.data.tokenAddress, + price: tokenPrice?.data.price, + })); + const nonCachedTokensAddresses = tokens.filter( + address => !cachedUnexpiredPrices.find(tokenPrice => tokenPrice.tokenAddress === address) + ); + const responseBody: Record = {}; + cachedUnexpiredPrices.forEach(tokenPrice => { + responseBody[tokenPrice.tokenAddress] = tokenPrice.price; + }); + if (nonCachedTokensAddresses.length === 0) { + return Response.json({ data: responseBody }); + } + if (!process.env.COINGECKO_API_KEY) { + console.error('CoinGecko API key is missing'); + return { error: 'Unknown error while fetching prices' }; + } + 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}`; + const tokenPricesUrl = `${PUBLIC_DEMO_API_BASE_URL}simple/token_price/ethereum/${AUTH_QUERY_PARAM}&vs_currencies=usd&contract_addresses=${nonCachedTokensAddresses.join( + ',' + )}`; + + const tokenPricesResponse = await fetch(tokenPricesUrl); + const tokenPricesResponseJson = await tokenPricesResponse.json(); + const tokenPriceMetadata = { metadata: { expiration: now + 1000 * 60 * 30 } }; + Object.keys(tokenPricesResponseJson).forEach(tokenAddress => { + const price = tokenPricesResponseJson[tokenAddress]; + responseBody[tokenAddress] = price; + store.setJSON(tokenAddress, { tokenAddress, price }, tokenPriceMetadata); + }); + + const ethAsset = nonCachedTokensAddresses.find(token => token === 'ethereum'); + if (ethAsset) { + // Unfortunately, there's no way avoiding 2 requests. We either need to fetch asset IDs from CoinGecko for given token contract addresses + // And then use this endpoint to get all the prices. But that brings us way more bandwidth + // Or, we are doing this "hardcoded" call for ETH price. But our request for token prices simpler. + const ethPriceUrl = `${PUBLIC_DEMO_API_BASE_URL}simple/price${AUTH_QUERY_PARAM}&ids=ethereum&vs_currencies=usd`; + const ethPriceResponse = await fetch(ethPriceUrl); + const ethPriceResponseJson = await ethPriceResponse.json(); + store.setJSON( + 'ethereum', + { tokenAddress: 'ethereum', price: ethPriceResponseJson.ethereum }, + tokenPriceMetadata + ); + responseBody.ethereum = ethPriceResponseJson.ethereum; + } + return Response.json({ data: responseBody }); + } catch (e) { + console.error('Error while fetching prices', e); + return Response.json({ + error: 'Unknown error while fetching prices', + }); + } +} diff --git a/package-lock.json b/package-lock.json index 1a43bbbad3..0adc786730 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,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", @@ -8100,6 +8102,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", @@ -34261,6 +34307,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 636560cb9d..5683cd58a7 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,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", diff --git a/src/components/pages/DAOTreasury/hooks/useFormatCoins.tsx b/src/components/pages/DAOTreasury/hooks/useFormatCoins.tsx index 54eae30ce8..df02f84c12 100644 --- a/src/components/pages/DAOTreasury/hooks/useFormatCoins.tsx +++ b/src/components/pages/DAOTreasury/hooks/useFormatCoins.tsx @@ -1,7 +1,7 @@ import { SafeBalanceUsdResponse } from '@safe-global/safe-service-client'; import { BigNumber, ethers } from 'ethers'; import { useEffect, useState } from 'react'; -import useCoinGeckoAPI from '../../../../providers/App/hooks/useCoinGeckoAPI'; +import useCoinGeckoAPI from '../../../../providers/App/hooks/usePriceAPI'; import { useNetworkConfig } from '../../../../providers/NetworkConfig/NetworkConfigProvider'; import { formatCoin, formatUSD } from '../../../../utils/numberFormats'; diff --git a/src/i18n/locales/en/treasury.json b/src/i18n/locales/en/treasury.json index f32ae46a59..a31ad81fdb 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." } \ No newline at end of file diff --git a/src/providers/App/hooks/useCoinGeckoAPI.ts b/src/providers/App/hooks/useCoinGeckoAPI.ts deleted file mode 100644 index 8adf9ff096..0000000000 --- a/src/providers/App/hooks/useCoinGeckoAPI.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { SafeBalanceUsdResponse } from '@safe-global/safe-service-client'; -import { useCallback } from 'react'; -import { useNetworkConfig } from '../../NetworkConfig/NetworkConfigProvider'; - -const PUBLIC_DEMO_API_BASE_URL = 'https://api.coingecko.com/api/v3/'; -const AUTH_QUERY_PARAM = `?x_cg_demo_api_key=${process.env.NEXT_PUBLIC_COINGECKO_API_KEY}`; - -export default function useCoinGeckoAPI() { - const { chainId } = useNetworkConfig(); - - 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; - } - - const tokenPricesUrl = `${PUBLIC_DEMO_API_BASE_URL}simple/token_price/ethereum/${AUTH_QUERY_PARAM}&vs_currencies=usd&contract_addresses=${tokens - .filter(token => token.balance !== '0' && token.tokenAddress) - .map(token => token.tokenAddress) - .join(',')}`; - - const tokenPricesResponse = await fetch(tokenPricesUrl); - - const ethAsset = tokens.find(token => !token.tokenAddress); - if (ethAsset) { - // Unfortunately, there's no way avoiding 2 requests. We either need to fetch asset IDs from CoinGecko for given token contract addresses - // And then use this endpoint to get all the prices. But that brings us way more bandwidth - // Or, we are doing this "hardcoded" call for ETH price. But our request for token prices simpler. - const ethPriceUrl = `${PUBLIC_DEMO_API_BASE_URL}simple/price${AUTH_QUERY_PARAM}&ids=ethereum&vs_currencies=usd`; - const ethPriceResponse = await fetch(ethPriceUrl); - - return { ...(await tokenPricesResponse.json()), ...(await ethPriceResponse.json()) }; - } - - return tokenPricesResponse.json(); - }, - [chainId] - ); - return { getTokenPrices }; -} diff --git a/src/providers/App/hooks/usePriceAPI.ts b/src/providers/App/hooks/usePriceAPI.ts new file mode 100644 index 0000000000..788cb7af90 --- /dev/null +++ b/src/providers/App/hooks/usePriceAPI.ts @@ -0,0 +1,46 @@ +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'); + } + const pricesResponse = await fetch( + `/.netlify/functions/tokensPrices?tokens=${tokensAddresses.join(',')}` + ); + + const pricesResponseBody = await pricesResponse.json(); + if (pricesResponseBody.error) { + // We don't need to log error here as it is supposed to be logged through Netlify function anyway + toast.warning(t('tokenPriceFetchingError')); + } else { + return pricesResponseBody.data; + } + } catch (e) { + logError('Error while getting tokens prices', e); + return; + } + }, + [chainId, t] + ); + return { getTokenPrices }; +} diff --git a/tsconfig.json b/tsconfig.json index ca09d9c784..9ee82e7be9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -34,6 +34,7 @@ "tests", "test", "app", + "netlify/functions", ".next/types/**/*.ts", "next-env.d.ts", "next.config.js",