From 154435b2ea33a6fe85ff96f2e374b395f2ead941 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 13:30:23 +0000 Subject: [PATCH 1/3] fix(balances): show "--" for the total when no token has a price The backend can return a key for each token with a null price. The total label checked only for keys, so it showed "$0.00". Now it checks for at least one real price. One price gives a partial total. No price gives "--". This matches freighter-mobile. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R8cp43wK2MV78D42ej16as --- .../__tests__/getTotalUsdLabel.test.ts | 23 +++++++++++++++++++ extension/src/popup/helpers/balance.ts | 9 ++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/extension/src/popup/helpers/__tests__/getTotalUsdLabel.test.ts b/extension/src/popup/helpers/__tests__/getTotalUsdLabel.test.ts index 3698a44461..982fca65a7 100644 --- a/extension/src/popup/helpers/__tests__/getTotalUsdLabel.test.ts +++ b/extension/src/popup/helpers/__tests__/getTotalUsdLabel.test.ts @@ -42,6 +42,29 @@ describe("getTotalUsdLabel", () => { expect(label({ tokenPrices: {} as ApiTokenPrices })).toBe("--"); }); + // The backend can return a key per token with no price behind any of them. + it("returns the placeholder when every price is null", () => { + expect( + label({ + tokenPrices: { "XLM:native": null, "USDC:GA5Z": null }, + totalUsd: new BigNumber(0), + }), + ).toBe("--"); + }); + + // One priced token is enough to show a total: the sum of what is priced. + it("returns the partial total when only some tokens are priced", () => { + expect( + label({ + tokenPrices: { + "XLM:native": { currentPrice: "0.5" }, + "USDC:GA5Z": null, + }, + totalUsd: new BigNumber("1665.155"), + }), + ).toBe("$1,665.16"); + }); + // A real zero on a priced network still reads as a total, not a gap. it("keeps a genuine zero total distinct from the placeholder", () => { expect(label({ totalUsd: new BigNumber(0) })).toBe("$0.00"); diff --git a/extension/src/popup/helpers/balance.ts b/extension/src/popup/helpers/balance.ts index 4b8cfef1b7..e5bf652789 100644 --- a/extension/src/popup/helpers/balance.ts +++ b/extension/src/popup/helpers/balance.ts @@ -326,8 +326,13 @@ 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. The backend can answer with a + // key per token and a null price for each, so check the values, not the + // keys. One priced token is enough: the total is then a partial sum. + const hasAnyPrice = + !!tokenPrices && + Object.values(tokenPrices).some((price) => !!price?.currentPrice); + if (!hasAnyPrice) { return NO_FIAT_VALUE; } From 040db4deee5b5696dd88d39a9c2d9c093c024ebc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 15:12:32 +0000 Subject: [PATCH 2/3] fix(balances): count only prices of held tokens for the total label The price cache can keep prices for tokens the account does not hold, such as a swap destination. The total does not add these prices. The label check counted them, so the label could show "$0.00" in place of "--". Add hasHeldPrice. It uses the same lookup as getTotalUsd. getTotalUsdLabel now takes a hasPrices boolean, as on mobile. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R8cp43wK2MV78D42ej16as --- .../AccountSheet/hooks/useGetWalletsData.tsx | 6 +- .../__tests__/getTotalUsdLabel.test.ts | 82 ++++++++++++------- extension/src/popup/helpers/balance.ts | 32 ++++++-- extension/src/popup/views/Account/index.tsx | 8 +- 4 files changed, 85 insertions(+), 43 deletions(-) diff --git a/extension/src/popup/components/account/AccountSheet/hooks/useGetWalletsData.tsx b/extension/src/popup/components/account/AccountSheet/hooks/useGetWalletsData.tsx index 15a511b068..57d21d98dc 100644 --- a/extension/src/popup/components/account/AccountSheet/hooks/useGetWalletsData.tsx +++ b/extension/src/popup/components/account/AccountSheet/hooks/useGetWalletsData.tsx @@ -12,6 +12,7 @@ import { isMainnet } from "helpers/stellar"; import { getTotalUsd, getTotalUsdLabel, + hasHeldPrice, omitHiddenBalances, } from "popup/helpers/balance"; import { APPLICATION_STATE } from "@shared/constants/applicationState"; @@ -140,7 +141,10 @@ function useGetWalletsData() { hasError: false, hasPriceFeed: isMainnetNetwork, isFunded: !!balances.isFunded, - tokenPrices: prices.tokenPrices, + hasPrices: hasHeldPrice( + prices.tokenPrices || {}, + visibleBalances, + ), totalUsd: totalPriceUsd, }), }; diff --git a/extension/src/popup/helpers/__tests__/getTotalUsdLabel.test.ts b/extension/src/popup/helpers/__tests__/getTotalUsdLabel.test.ts index 982fca65a7..a40aa9f338 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 { getTotalUsdLabel, hasHeldPrice } 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,37 +33,60 @@ 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. + it("keeps a genuine zero total distinct from the placeholder", () => { + 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[]; + +describe("hasHeldPrice", () => { + it("is true when a held balance has a price", () => { + const prices: ApiTokenPrices = { + native: { currentPrice: "0.5" }, + [USDC]: null, + }; + expect(hasHeldPrice(prices, HELD)).toBe(true); }); // The backend can return a key per token with no price behind any of them. - it("returns the placeholder when every price is null", () => { - expect( - label({ - tokenPrices: { "XLM:native": null, "USDC:GA5Z": null }, - totalUsd: new BigNumber(0), - }), - ).toBe("--"); + it("is false when every price is null", () => { + expect(hasHeldPrice({ native: null, [USDC]: null }, HELD)).toBe(false); }); - // One priced token is enough to show a total: the sum of what is priced. - it("returns the partial total when only some tokens are priced", () => { - expect( - label({ - tokenPrices: { - "XLM:native": { currentPrice: "0.5" }, - "USDC:GA5Z": null, - }, - totalUsd: new BigNumber("1665.155"), - }), - ).toBe("$1,665.16"); + it("is false for an empty price map", () => { + expect(hasHeldPrice({}, HELD)).toBe(false); }); - // A real zero on a priced network still reads as a total, not a gap. - it("keeps a genuine zero total distinct from the placeholder", () => { - expect(label({ totalUsd: new BigNumber(0) })).toBe("$0.00"); + // The price cache can keep tokens the account does not hold. The total + // does not add them, so they must not count as a price either. + it("ignores a price for a token the account does not hold", () => { + const prices: ApiTokenPrices = { + native: null, + [USDC]: null, + [EURC]: { currentPrice: "1.1" }, + }; + expect(hasHeldPrice(prices, HELD)).toBe(false); }); }); diff --git a/extension/src/popup/helpers/balance.ts b/extension/src/popup/helpers/balance.ts index e5bf652789..bbe8e15aaf 100644 --- a/extension/src/popup/helpers/balance.ts +++ b/extension/src/popup/helpers/balance.ts @@ -291,6 +291,20 @@ export const getTotalUsd = (prices: ApiTokenPrices, balances: AssetType[]) => { }, new BigNumber(0)); }; +/** + * True when at least one held balance has a price. Uses the same lookup as + * {@link getTotalUsd}, so it counts exactly the prices that the total adds. + * + * The price map can hold tokens the account does not hold, such as a swap + * destination in the price cache. Those do not count. + */ +export const hasHeldPrice = (prices: ApiTokenPrices, balances: AssetType[]) => + Object.keys(prices).some( + (canonical) => + !!prices[canonical]?.currentPrice && + !!getBalanceByAsset(getAssetFromCanonical(canonical), balances), + ); + /** * Picks what to show for an account's total USD value. * @@ -301,7 +315,7 @@ export const getTotalUsdLabel = ({ hasError, hasPriceFeed, isFunded, - tokenPrices, + hasPrices = false, totalUsd, }: { /** Account data failed to load. */ @@ -309,7 +323,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 hasHeldPrice}. Omit + * where no prices were fetched. + */ + hasPrices?: boolean; /** Omit where no total was computed; the branches that ignore it win. */ totalUsd?: BigNumber; }) => { @@ -326,13 +344,9 @@ export const getTotalUsdLabel = ({ } // A funded account on a network that prices tokens, yet nothing priced — - // the total exists but could not be read. The backend can answer with a - // key per token and a null price for each, so check the values, not the - // keys. One priced token is enough: the total is then a partial sum. - const hasAnyPrice = - !!tokenPrices && - Object.values(tokenPrices).some((price) => !!price?.currentPrice); - if (!hasAnyPrice) { + // 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..5a204891e3 100644 --- a/extension/src/popup/views/Account/index.tsx +++ b/extension/src/popup/views/Account/index.tsx @@ -30,7 +30,11 @@ import { NotFundedMessage } from "popup/components/account/NotFundedMessage"; import { isMainnet } from "helpers/stellar"; import { newTabHref } from "helpers/urls"; -import { getTotalUsd, getTotalUsdLabel } from "popup/helpers/balance"; +import { + getTotalUsd, + getTotalUsdLabel, + hasHeldPrice, +} from "popup/helpers/balance"; import { reRouteOnboarding } from "popup/helpers/route"; import { ROUTES } from "popup/constants/routes"; import { AppDataType } from "helpers/hooks/useGetAppData"; @@ -219,7 +223,7 @@ export const Account = () => { hasError, hasPriceFeed: isMainnet(networkDetails), isFunded, - tokenPrices, + hasPrices: hasHeldPrice(tokenPrices ?? {}, balances), totalUsd: totalBalanceUsd, }); From 4f8116548bac191eed8c1a11aa19d6e9f418d639 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 16:49:34 +0000 Subject: [PATCH 3/3] refactor(balances): compute the total and hasPrices in one lookup getTotalUsd now returns the total and whether any held balance has a price. This removes hasHeldPrice, which repeated the same lookup loop. The label and the total now always count the same prices. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R8cp43wK2MV78D42ej16as --- .../AccountSheet/hooks/useGetWalletsData.tsx | 10 +--- .../popup/helpers/__tests__/balance.test.js | 2 +- .../__tests__/getTotalUsdLabel.test.ts | 44 ++++++++------- extension/src/popup/helpers/balance.ts | 55 ++++++++++--------- extension/src/popup/views/Account/index.tsx | 13 ++--- 5 files changed, 64 insertions(+), 60 deletions(-) diff --git a/extension/src/popup/components/account/AccountSheet/hooks/useGetWalletsData.tsx b/extension/src/popup/components/account/AccountSheet/hooks/useGetWalletsData.tsx index 57d21d98dc..1b5616abc2 100644 --- a/extension/src/popup/components/account/AccountSheet/hooks/useGetWalletsData.tsx +++ b/extension/src/popup/components/account/AccountSheet/hooks/useGetWalletsData.tsx @@ -12,7 +12,6 @@ import { isMainnet } from "helpers/stellar"; import { getTotalUsd, getTotalUsdLabel, - hasHeldPrice, omitHiddenBalances, } from "popup/helpers/balance"; import { APPLICATION_STATE } from "@shared/constants/applicationState"; @@ -132,7 +131,7 @@ function useGetWalletsData() { networkDetails, useCache: true, }); - const totalPriceUsd = getTotalUsd( + const { totalUsd, hasPrices } = getTotalUsd( prices.tokenPrices || {}, visibleBalances, ); @@ -141,11 +140,8 @@ function useGetWalletsData() { hasError: false, hasPriceFeed: isMainnetNetwork, isFunded: !!balances.isFunded, - hasPrices: hasHeldPrice( - prices.tokenPrices || {}, - visibleBalances, - ), - 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 a40aa9f338..96959a3942 100644 --- a/extension/src/popup/helpers/__tests__/getTotalUsdLabel.test.ts +++ b/extension/src/popup/helpers/__tests__/getTotalUsdLabel.test.ts @@ -2,7 +2,7 @@ import BigNumber from "bignumber.js"; import { AssetType } from "@shared/api/types/account-balance"; import { ApiTokenPrices } from "@shared/api/types"; -import { getTotalUsdLabel, hasHeldPrice } from "popup/helpers/balance"; +import { getTotalUsd, getTotalUsdLabel } from "popup/helpers/balance"; const label = (overrides: Partial[0]>) => getTotalUsdLabel({ @@ -61,32 +61,36 @@ const HELD = [ }, ] as unknown as AssetType[]; -describe("hasHeldPrice", () => { - it("is true when a held balance has a price", () => { - const prices: ApiTokenPrices = { - native: { currentPrice: "0.5" }, - [USDC]: null, - }; - expect(hasHeldPrice(prices, HELD)).toBe(true); +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("is false when every price is null", () => { - expect(hasHeldPrice({ native: null, [USDC]: null }, HELD)).toBe(false); + it("reports no prices when every price is null", () => { + expect(usd({ native: null, [USDC]: null })).toEqual({ + totalUsd: "0", + hasPrices: false, + }); }); - it("is false for an empty price map", () => { - expect(hasHeldPrice({}, HELD)).toBe(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. The total - // does not add them, so they must not count as a price either. + // 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", () => { - const prices: ApiTokenPrices = { - native: null, - [USDC]: null, - [EURC]: { currentPrice: "1.1" }, - }; - expect(hasHeldPrice(prices, HELD)).toBe(false); + 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 bbe8e15aaf..ad8268504d 100644 --- a/extension/src/popup/helpers/balance.ts +++ b/extension/src/popup/helpers/balance.ts @@ -275,34 +275,39 @@ 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)); -}; - /** - * True when at least one held balance has a price. Uses the same lookup as - * {@link getTotalUsd}, so it counts exactly the prices that the total adds. + * 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. + * destination in the price cache. Those do not count. A token with a null or + * empty price adds nothing. */ -export const hasHeldPrice = (prices: ApiTokenPrices, balances: AssetType[]) => - Object.keys(prices).some( - (canonical) => - !!prices[canonical]?.currentPrice && - !!getBalanceByAsset(getAssetFromCanonical(canonical), balances), +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 }, ); /** @@ -324,7 +329,7 @@ export const getTotalUsdLabel = ({ hasPriceFeed: boolean; isFunded: boolean; /** - * At least one held balance has a price. See {@link hasHeldPrice}. Omit + * At least one held balance has a price. See {@link getTotalUsd}. Omit * where no prices were fetched. */ hasPrices?: boolean; diff --git a/extension/src/popup/views/Account/index.tsx b/extension/src/popup/views/Account/index.tsx index 5a204891e3..5d612b2bea 100644 --- a/extension/src/popup/views/Account/index.tsx +++ b/extension/src/popup/views/Account/index.tsx @@ -30,11 +30,7 @@ import { NotFundedMessage } from "popup/components/account/NotFundedMessage"; import { isMainnet } from "helpers/stellar"; import { newTabHref } from "helpers/urls"; -import { - getTotalUsd, - getTotalUsdLabel, - hasHeldPrice, -} from "popup/helpers/balance"; +import { getTotalUsd, getTotalUsdLabel } from "popup/helpers/balance"; import { reRouteOnboarding } from "popup/helpers/route"; import { ROUTES } from "popup/constants/routes"; import { AppDataType } from "helpers/hooks/useGetAppData"; @@ -215,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. @@ -223,7 +222,7 @@ export const Account = () => { hasError, hasPriceFeed: isMainnet(networkDetails), isFunded, - hasPrices: hasHeldPrice(tokenPrices ?? {}, balances), + hasPrices, totalUsd: totalBalanceUsd, });