diff --git a/apps/agent/app/src/components/BalanceToken.js b/apps/agent/app/src/components/BalanceToken.js index bf6430f836..6c9293033a 100644 --- a/apps/agent/app/src/components/BalanceToken.js +++ b/apps/agent/app/src/components/BalanceToken.js @@ -4,6 +4,7 @@ import BN from 'bn.js' import { GU, Help, formatTokenAmount, textStyle, useTheme } from '@aragon/ui' import { useNetwork } from '@aragon/api-react' import { tokenIconUrl } from '../lib/icon-utils' + function BalanceToken({ address, amount, @@ -15,6 +16,7 @@ function BalanceToken({ }) { const theme = useTheme() const network = useNetwork() + const amountFormatted = formatTokenAmount(amount, decimals, { digits: decimals, }) @@ -22,6 +24,7 @@ function BalanceToken({ digits: 3, }) const amountWasRounded = amountFormatted !== amountFormattedRounded + return (
{ - let cancelled = false - let retryTimer = null - - const update = async () => { - if (!symbolsQuery) { - setRates({}) - return - } - - try { - const response = await fetch(convertRatesUrl(symbolsQuery)) - const rates = await response.json() - if (!cancelled) { - setRates(rates) - } - } catch (err) { - // The !cancelled check is needed in case: - // 1. The fetch() request is ongoing. - // 2. The component gets unmounted. - // 3. An error gets thrown. - // - // Assuming the fetch() request keeps throwing, it would create new - // requests even though the useEffect() got cancelled. - if (!cancelled) { - retryTimer = setTimeout(update, CONVERT_API_RETRY_DELAY) - } - } - } - update() - - return () => { - cancelled = true - clearTimeout(retryTimer) - } - }, [symbolsQuery]) - - return rates -} +import { getConvertedAmount } from '../lib/conversion-utils' +import { useConvertRates } from './useConvertRates' // Prepare the balances for the BalanceToken component function useBalanceItems(balances) { @@ -63,18 +14,19 @@ function useBalanceItems(balances) { const convertRates = useConvertRates(verifiedSymbols) const balanceItems = useMemo(() => { - return balances.map(({ address, amount, decimals, symbol, verified }) => ({ - address, - amount, - convertedAmount: convertRates[symbol] - ? amount.divn(convertRates[symbol]) - : new BN(-1), - decimals, - symbol, - verified, - })) + return balances.map(({ address, amount, decimals, symbol, verified }) => { + return { + address, + amount, + convertedAmount: convertRates[symbol] + ? getConvertedAmount(amount, convertRates[symbol], decimals) + : new BN('-1'), + decimals, + symbol, + verified, + } + }) }, [balances, convertRates]) - return balanceItems } @@ -89,7 +41,7 @@ function Balances({ balances }) {
{ + let cancelled = false + let retryTimer = null + + const update = async () => { + if (!symbolsQuery) { + setRates({}) + return + } + + try { + const response = await fetch(convertRatesUrl(symbolsQuery)) + const rates = await response.json() + if (!cancelled) { + setRates(rates) + retryDelay.current = CONVERT_API_RETRY_DELAY + } + } catch (err) { + // The !cancelled check is needed in case: + // 1. The fetch() request is ongoing. + // 2. The component gets unmounted. + // 3. An error gets thrown. + // + // Assuming the fetch() request keeps throwing, it would create new + // requests even though the useEffect() got cancelled. + if (!cancelled) { + // Add more delay after every failed attempt + retryDelay.current = Math.min( + CONVERT_API_RETRY_DELAY_MAX, + retryDelay.current * 1.2 + ) + retryTimer = setTimeout(update, retryDelay.current) + } + } + } + update() + + return () => { + cancelled = true + clearTimeout(retryTimer) + retryDelay.current = CONVERT_API_RETRY_DELAY + } + }, [symbolsQuery]) + + return rates +} diff --git a/apps/agent/app/src/lib/conversion-utils.js b/apps/agent/app/src/lib/conversion-utils.js new file mode 100644 index 0000000000..0478fc9aec --- /dev/null +++ b/apps/agent/app/src/lib/conversion-utils.js @@ -0,0 +1,16 @@ +import BN from 'bn.js' + +export function getConvertedAmount(amount, convertRate) { + const [whole = '', dec = ''] = convertRate.toString().split('.') + // Remove any trailing zeros from the decimal part + const parsedDec = dec.replace(/0*$/, '') + // Construct the final rate, and remove any leading zeros + const rate = `${whole}${parsedDec}`.replace(/^0*/, '') + + // Number of decimals to shift the amount of the token passed in, + // resulting from converting the rate to a number without any decimal + // places + const carryAmount = new BN(parsedDec.length.toString()) + + return amount.mul(new BN('10').pow(carryAmount)).div(new BN(rate)) +} diff --git a/apps/agent/app/src/lib/conversion-utils.test.js b/apps/agent/app/src/lib/conversion-utils.test.js new file mode 100644 index 0000000000..fdd6690285 --- /dev/null +++ b/apps/agent/app/src/lib/conversion-utils.test.js @@ -0,0 +1,29 @@ +import BN from 'bn.js' +import { getConvertedAmount } from './conversion-utils' + +const ONE_ETH = new BN('10').pow(new BN('18')) + +describe('getConvertedAmount tests', () => { + test('Converts amounts correctly', () => { + expect(getConvertedAmount(new BN('1'), 1).toString()).toEqual('1') + expect(getConvertedAmount(new BN(ONE_ETH), 1).toString()).toEqual( + ONE_ETH.toString() + ) + expect(getConvertedAmount(new BN('1'), 0.5).toString()).toEqual('2') + expect(getConvertedAmount(new BN('1'), 0.25).toString()).toEqual('4') + expect(getConvertedAmount(new BN('1'), 0.125).toString()).toEqual('8') + + expect(getConvertedAmount(new BN('100'), 50).toString()).toEqual('2') + // This is the exact case that broke the previous implementation, + // which is AAVE's amount of WBTC + the exchange rate at a certain + // hour on 2020-06-24 + expect( + getConvertedAmount(new BN('1145054'), 0.00009248).toString() + ).toEqual('12381639273') + }) + + test('Throws on invalid inputs', () => { + expect(() => getConvertedAmount(new BN('1'), 0)).toThrow() + expect(() => getConvertedAmount('1000', 0)).toThrow() + }) +})