Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ function useGetWalletsData() {
networkDetails,
useCache: true,
});
const totalPriceUsd = getTotalUsd(
const { totalUsd, hasPrices } = getTotalUsd(
prices.tokenPrices || {},
visibleBalances,
);
Expand All @@ -140,8 +140,8 @@ function useGetWalletsData() {
hasError: false,
hasPriceFeed: isMainnetNetwork,
isFunded: !!balances.isFunded,
tokenPrices: prices.tokenPrices,
totalUsd: totalPriceUsd,
hasPrices,
totalUsd,
}),
};
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion extension/src/popup/helpers/__tests__/balance.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Expand Down
67 changes: 57 additions & 10 deletions extension/src/popup/helpers/__tests__/getTotalUsdLabel.test.ts
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof getTotalUsdLabel>[0]>) =>
getTotalUsdLabel({
hasError: false,
hasPriceFeed: true,
isFunded: true,
tokenPrices: PRICES,
hasPrices: true,
totalUsd: new BigNumber("1149.239"),
...overrides,
});
Expand All @@ -36,14 +33,64 @@ 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[];

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 });
});
});
62 changes: 43 additions & 19 deletions extension/src/popup/helpers/balance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -301,15 +320,19 @@ export const getTotalUsdLabel = ({
hasError,
hasPriceFeed,
isFunded,
tokenPrices,
hasPrices = false,
totalUsd,
}: {
/** Account data failed to load. */
hasError: boolean;
/** 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;
}) => {
Expand All @@ -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;
}

Expand Down
7 changes: 5 additions & 2 deletions extension/src/popup/views/Account/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,15 +211,18 @@ 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.
const roundedTotalBalanceUsd = getTotalUsdLabel({
hasError,
hasPriceFeed: isMainnet(networkDetails),
isFunded,
tokenPrices,
hasPrices,
totalUsd: totalBalanceUsd,
});

Expand Down
Loading