Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show token prices #1382

Merged
merged 34 commits into from
Mar 7, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
0b03759
Implement fetching coin prices from CoinGecko
mudrila Feb 27, 2024
2942280
Leverage useFormatCoins for showing treasury total on DAO dashboard page
mudrila Feb 28, 2024
22b4d13
empty commit
adamgall Feb 28, 2024
324edbe
Merge branch 'develop' into fix/show-token-prices
adamgall Feb 28, 2024
e1f77e1
Add Netlify function tokenPrices, route request to CoinGecko, cache t…
mudrila Mar 4, 2024
2d6de29
Refactor and cleanup of storing/getting prices
mudrila Mar 4, 2024
ea7aa8f
Fix returningprice for ETH
mudrila Mar 4, 2024
c5b477a
Try fixing token fiat balance overflow
mudrila Mar 4, 2024
88f356a
Use ethers.constants.MaxUint256 instead of Number max value
mudrila Mar 4, 2024
3e5f170
Use smaller multiplicator
mudrila Mar 4, 2024
59dd067
Add math.round
mudrila Mar 4, 2024
e5afb36
Merge branch 'develop' into fix/show-token-prices
mudrila Mar 4, 2024
6781722
Rename netlify function, fix returning response error, better typing …
mudrila Mar 6, 2024
8633535
Handle better CoinGecko requesting error and return cached prices
mudrila Mar 6, 2024
a3783d4
Show cached prices on FE if request to CoinGecko failed
mudrila Mar 6, 2024
97930a9
Added some more explicit code and comments
adamgall Mar 6, 2024
9252d6e
Do not execute request to netlify function if tokensAddresses is empt…
mudrila Mar 6, 2024
50e148d
Merge pull request #1415 from decent-dao/fix/show-token-prices-2
mudrila Mar 6, 2024
88899cf
Merge branch 'develop' into fix/show-token-prices
mudrila Mar 6, 2024
2c92f7b
Trying changing possibleCachedTokenPrices filtering null values
mudrila Mar 6, 2024
873df06
Fix filtering logic of uncached token prices
mudrila Mar 6, 2024
15e1564
Use rawTokenAddresses for allUncachedTokenAddresses instead of splitt…
mudrila Mar 6, 2024
7c03888
Handle a lot of cases:
adamgall Mar 7, 2024
b207d66
Refactor to compartmentalize code
adamgall Mar 7, 2024
e70037d
Split stores per network
mudrila Mar 7, 2024
91c0326
Add network param to the tokenPrices function
mudrila Mar 7, 2024
2248280
Fix token address chain namespacing
mudrila Mar 7, 2024
621771c
Swap expiration with time of fetching
mudrila Mar 7, 2024
96420f8
Cleanup comments
mudrila Mar 7, 2024
dfaa285
Reverse adding cacheTime
mudrila Mar 7, 2024
b5a93f5
Fix cached token addresses filtering
mudrila Mar 7, 2024
2f85687
Convert minutes to seconds, specify in variable names that we're deal…
adamgall Mar 7, 2024
7720d94
Merge commit '11303a767df67e67e8007ca20e07894b268e3269' into fix/show…
adamgall Mar 7, 2024
daf34df
Prettier updates
adamgall Mar 7, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@ 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=""
mudrila marked this conversation as resolved.
Show resolved Hide resolved
# Shutter Public Key
NEXT_PUBLIC_SHUTTER_EON_PUBKEY=0x0e6493bbb4ee8b19aa9b70367685049ff01dc9382c46aed83f8bc07d2a5ba3e6030bd83b942c1fd3dff5b79bef3b40bf6b666e51e7f0be14ed62daaffad47435265f5c9403b1a801921981f7d8659a9bd91fe92fb1cf9afdb16178a532adfaf51a237103874bb03afafe9cab2118dae1be5f08a0a28bf488c1581e9db4bc23ca
76 changes: 76 additions & 0 deletions netlify/functions/tokensPrices.mts
mudrila marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { getStore } from '@netlify/blobs';

type TokenPriceMetadata = {
expiration: number;
};

export default async function getTokenprices(request: Request) {
mudrila marked this conversation as resolved.
Show resolved Hide resolved
const store = getStore('fractal-token-prices-store');
mudrila marked this conversation as resolved.
Show resolved Hide resolved
const tokensString = new URL(request.url).searchParams.get('tokens');

if (!tokensString) {
Response.json({ error: 'Tokens to request were not provided' });
}
mudrila marked this conversation as resolved.
Show resolved Hide resolved
const tokens = tokensString!.split(',');
try {
const now = new Date().getTime();
mudrila marked this conversation as resolved.
Show resolved Hide resolved
const cachedPrices = await Promise.all(
tokens.map(tokenAddress => store.getWithMetadata(tokenAddress, { type: 'json' }))
);
const cachedUnexpiredPrices = cachedPrices
.filter(
tokenPrice =>
tokenPrice && (tokenPrice?.metadata as any as TokenPriceMetadata).expiration <= now
)
.map(tokenPrice => ({
tokenAddress: tokenPrice?.data.tokenAddress,
price: tokenPrice?.data.price,
mudrila marked this conversation as resolved.
Show resolved Hide resolved
}));
mudrila marked this conversation as resolved.
Show resolved Hide resolved
const nonCachedTokensAddresses = tokens.filter(
address => !cachedUnexpiredPrices.find(tokenPrice => tokenPrice.tokenAddress === address)
);
const responseBody: Record<string, number> = {};
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');
mudrila marked this conversation as resolved.
Show resolved Hide resolved
return { error: 'Unknown error while fetching prices' };
mudrila marked this conversation as resolved.
Show resolved Hide resolved
}
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);
mudrila marked this conversation as resolved.
Show resolved Hide resolved
const tokenPricesResponseJson = await tokenPricesResponse.json();
const tokenPriceMetadata = { metadata: { expiration: now + 1000 * 60 * 30 } };
mudrila marked this conversation as resolved.
Show resolved Hide resolved
Object.keys(tokenPricesResponseJson).forEach(tokenAddress => {
const price = tokenPricesResponseJson[tokenAddress].usd;
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 price = (await ethPriceResponse.json()).ethereum.usd;
store.setJSON('ethereum', { tokenAddress: 'ethereum', price }, tokenPriceMetadata);
responseBody.ethereum = price;
}
return Response.json({ data: responseBody });
} catch (e) {
console.error('Error while fetching prices', e);
return Response.json({
error: 'Unknown error while fetching prices',
});
}
}
80 changes: 80 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
80 changes: 58 additions & 22 deletions src/components/pages/DAOTreasury/hooks/useFormatCoins.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -17,27 +20,60 @@ 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<TokenDisplayData[]>([]);
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 = asset.tokenAddress
? tokenPrices[asset.tokenAddress.toLowerCase()]
: tokenPrices.ethereum;

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,
Expand Down
14 changes: 0 additions & 14 deletions src/components/pages/DAOTreasury/hooks/useTreasuryTotalUSD.tsx

This file was deleted.

11 changes: 6 additions & 5 deletions src/components/pages/DaoDashboard/Info/InfoTreasury.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Flex
Expand Down Expand Up @@ -48,7 +49,7 @@ export function InfoTreasury({}: IDAOGovernance) {
textStyle="text-lg-mono-semibold"
color="grayscale.100"
>
{totalUSD}
{totalFiatValue}
</Text>
</Box>
);
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/en/treasury.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
46 changes: 46 additions & 0 deletions src/providers/App/hooks/usePriceAPI.ts
Original file line number Diff line number Diff line change
@@ -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'));
adamgall marked this conversation as resolved.
Show resolved Hide resolved
} else {
return pricesResponseBody.data;
}
} catch (e) {
logError('Error while getting tokens prices', e);
return;
}
},
[chainId, t]
);
return { getTokenPrices };
}
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"tests",
"test",
"app",
"netlify/functions",
".next/types/**/*.ts",
"next-env.d.ts",
"next.config.js",
Expand Down