diff --git a/extension/src/popup/components/account/AccountSheet/hooks/useGetWalletsData.tsx b/extension/src/popup/components/account/AccountSheet/hooks/useGetWalletsData.tsx index 15a511b068..1b5616abc2 100644 --- a/extension/src/popup/components/account/AccountSheet/hooks/useGetWalletsData.tsx +++ b/extension/src/popup/components/account/AccountSheet/hooks/useGetWalletsData.tsx @@ -131,7 +131,7 @@ function useGetWalletsData() { networkDetails, useCache: true, }); - const totalPriceUsd = getTotalUsd( + const { totalUsd, hasPrices } = getTotalUsd( prices.tokenPrices || {}, visibleBalances, ); @@ -140,8 +140,8 @@ function useGetWalletsData() { hasError: false, hasPriceFeed: isMainnetNetwork, isFunded: !!balances.isFunded, - tokenPrices: prices.tokenPrices, - totalUsd: totalPriceUsd, + hasPrices, + totalUsd, }), }; } catch (error) { diff --git a/extension/src/popup/helpers/__tests__/balance.test.js b/extension/src/popup/helpers/__tests__/balance.test.js index 196b330fc4..a4ae8b0584 100644 --- a/extension/src/popup/helpers/__tests__/balance.test.js +++ b/extension/src/popup/helpers/__tests__/balance.test.js @@ -303,7 +303,7 @@ describe("SEP-41 balances are valued in whole units", () => { }); it("sums the scaled value into the total", () => { - const total = getTotalUsd(prices, [SOLVBTC, USDC]); + const { totalUsd: total } = getTotalUsd(prices, [SOLVBTC, USDC]); expect(total.toNumber()).toBeCloseTo(100.18522526, 6); }); }); diff --git a/extension/src/popup/helpers/__tests__/getTotalUsdLabel.test.ts b/extension/src/popup/helpers/__tests__/getTotalUsdLabel.test.ts index 3698a44461..96959a3942 100644 --- a/extension/src/popup/helpers/__tests__/getTotalUsdLabel.test.ts +++ b/extension/src/popup/helpers/__tests__/getTotalUsdLabel.test.ts @@ -1,18 +1,15 @@ import BigNumber from "bignumber.js"; -import { getTotalUsdLabel } from "popup/helpers/balance"; +import { AssetType } from "@shared/api/types/account-balance"; import { ApiTokenPrices } from "@shared/api/types"; - -const PRICES = { - "XLM:native": { currentPrice: "0.5", percentagePriceChange24h: null }, -} as unknown as ApiTokenPrices; +import { getTotalUsd, getTotalUsdLabel } from "popup/helpers/balance"; const label = (overrides: Partial[0]>) => getTotalUsdLabel({ hasError: false, hasPriceFeed: true, isFunded: true, - tokenPrices: PRICES, + hasPrices: true, totalUsd: new BigNumber("1149.239"), ...overrides, }); @@ -36,10 +33,9 @@ describe("getTotalUsdLabel", () => { expect(label({ hasError: true })).toBe("--"); }); - it("returns the placeholder when a funded account prices nothing", () => { - expect(label({ tokenPrices: null })).toBe("--"); - expect(label({ tokenPrices: undefined })).toBe("--"); - expect(label({ tokenPrices: {} as ApiTokenPrices })).toBe("--"); + it("returns the placeholder when no held balance has a price", () => { + expect(label({ hasPrices: false })).toBe("--"); + expect(label({ hasPrices: undefined })).toBe("--"); }); // A real zero on a priced network still reads as a total, not a gap. @@ -47,3 +43,54 @@ describe("getTotalUsdLabel", () => { expect(label({ totalUsd: new BigNumber(0) })).toBe("$0.00"); }); }); + +const USDC_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const USDC = `USDC:${USDC_ISSUER}`; +// A token the account does not hold, such as a swap destination. +const EURC = `EURC:${USDC_ISSUER}`; + +const HELD = [ + { token: { type: "native", code: "XLM" }, total: new BigNumber("100") }, + { + token: { + type: "credit_alphanum4", + code: "USDC", + issuer: { key: USDC_ISSUER }, + }, + total: new BigNumber("40"), + }, +] as unknown as AssetType[]; + +const usd = (prices: ApiTokenPrices) => { + const { totalUsd, hasPrices } = getTotalUsd(prices, HELD); + return { totalUsd: totalUsd.toString(), hasPrices }; +}; + +describe("getTotalUsd", () => { + it("sums only the priced held balances", () => { + expect(usd({ native: { currentPrice: "0.5" }, [USDC]: null })).toEqual({ + totalUsd: "50", + hasPrices: true, + }); + }); + + // The backend can return a key per token with no price behind any of them. + it("reports no prices when every price is null", () => { + expect(usd({ native: null, [USDC]: null })).toEqual({ + totalUsd: "0", + hasPrices: false, + }); + }); + + it("reports no prices for an empty price map", () => { + expect(usd({})).toEqual({ totalUsd: "0", hasPrices: false }); + }); + + // The price cache can keep tokens the account does not hold. They add + // nothing to the total and do not count as a price. + it("ignores a price for a token the account does not hold", () => { + expect( + usd({ native: null, [USDC]: null, [EURC]: { currentPrice: "1.1" } }), + ).toEqual({ totalUsd: "0", hasPrices: false }); + }); +}); diff --git a/extension/src/popup/helpers/balance.ts b/extension/src/popup/helpers/balance.ts index 4b8cfef1b7..ad8268504d 100644 --- a/extension/src/popup/helpers/balance.ts +++ b/extension/src/popup/helpers/balance.ts @@ -275,21 +275,40 @@ export const sortBalancesByValue = ( .map((x) => x.b); }; -export const getTotalUsd = (prices: ApiTokenPrices, balances: AssetType[]) => { - return Object.keys(prices).reduce((prev, curr) => { - const asset = getAssetFromCanonical(curr); - const priceBalance = getBalanceByAsset(asset, balances); - if (!priceBalance) { - return prev; - } - const currentAssetBalance = getTotalInWholeUnits(priceBalance); - const currentPrice = prices[curr] ? prices[curr].currentPrice : "0"; - const currentUsdBalance = new BigNumber(currentPrice).multipliedBy( - currentAssetBalance, - ); - return currentUsdBalance.plus(prev); - }, new BigNumber(0)); -}; +/** + * Sums the USD value of the held balances, and reports whether any held + * balance has a price. One lookup gives both, so the label and the total + * always count the same prices. + * + * The price map can hold tokens the account does not hold, such as a swap + * destination in the price cache. Those do not count. A token with a null or + * empty price adds nothing. + */ +export const getTotalUsd = (prices: ApiTokenPrices, balances: AssetType[]) => + Object.keys(prices).reduce( + (acc, canonical) => { + const currentPrice = prices[canonical]?.currentPrice; + if (!currentPrice) { + return acc; + } + const balance = getBalanceByAsset( + getAssetFromCanonical(canonical), + balances, + ); + if (!balance) { + return acc; + } + return { + totalUsd: acc.totalUsd.plus( + new BigNumber(currentPrice).multipliedBy( + getTotalInWholeUnits(balance), + ), + ), + hasPrices: true, + }; + }, + { totalUsd: new BigNumber(0), hasPrices: false }, + ); /** * Picks what to show for an account's total USD value. @@ -301,7 +320,7 @@ export const getTotalUsdLabel = ({ hasError, hasPriceFeed, isFunded, - tokenPrices, + hasPrices = false, totalUsd, }: { /** Account data failed to load. */ @@ -309,7 +328,11 @@ export const getTotalUsdLabel = ({ /** Whether the network prices tokens at all. */ hasPriceFeed: boolean; isFunded: boolean; - tokenPrices?: ApiTokenPrices | null; + /** + * At least one held balance has a price. See {@link getTotalUsd}. Omit + * where no prices were fetched. + */ + hasPrices?: boolean; /** Omit where no total was computed; the branches that ignore it win. */ totalUsd?: BigNumber; }) => { @@ -326,8 +349,9 @@ export const getTotalUsdLabel = ({ } // A funded account on a network that prices tokens, yet nothing priced — - // the total exists but could not be read. - if (!tokenPrices || Object.keys(tokenPrices).length === 0) { + // the total exists but could not be read. One priced token is enough: the + // total is then a partial sum. + if (!hasPrices) { return NO_FIAT_VALUE; } diff --git a/extension/src/popup/views/Account/index.tsx b/extension/src/popup/views/Account/index.tsx index 47e3dab025..5d612b2bea 100644 --- a/extension/src/popup/views/Account/index.tsx +++ b/extension/src/popup/views/Account/index.tsx @@ -211,7 +211,10 @@ export const Account = () => { // was dismissed. const reloadBalances = () => refreshBalances(); - const totalBalanceUsd = getTotalUsd(tokenPrices ?? {}, balances); + const { totalUsd: totalBalanceUsd, hasPrices } = getTotalUsd( + tokenPrices ?? {}, + balances, + ); // The hero is never hidden; see getTotalUsdLabel for which of a total, a // zero or the placeholder it shows. The network comes from Redux because // `resolvedData` is null once the fetch has failed. @@ -219,7 +222,7 @@ export const Account = () => { hasError, hasPriceFeed: isMainnet(networkDetails), isFunded, - tokenPrices, + hasPrices, totalUsd: totalBalanceUsd, });