Skip to content

Commit

Permalink
Add error state to icpSwapUsdPricesStore (#5953)
Browse files Browse the repository at this point in the history
# Motivation

We loaded token prices from ICP Swap.
If this results in an error, we want to show `-/-` as USD values and
show an error state with tooltip.
There can be 2 reasons why there is an error:
1. Fetching the ICP Swap data fails.
2. Parsing the ICP Swap data fails.

In this PR we add an extra possible `"error"` value to the derived store
`icpSwapUsdPricesStore` and use it if parsing the ICP Swap data fails.

# Changes

1. Set `icpSwapUsdPricesStore` to `"error"` if there is an error when
parsing ICP Swap tickers or if there is an unreasonable value.

# Tests

Unit tests added.

# Todos

- [ ] Add entry to changelog (if necessary).
not yet
  • Loading branch information
dskloetd authored Dec 9, 2024
1 parent fc3cee2 commit e81d19a
Show file tree
Hide file tree
Showing 5 changed files with 139 additions and 51 deletions.
7 changes: 5 additions & 2 deletions frontend/src/lib/components/ui/UsdValueBanner.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { icpSwapUsdPricesStore } from "$lib/derived/icp-swap.derived";
import { i18n } from "$lib/stores/i18n";
import { formatNumber } from "$lib/utils/format.utils";
import { nonNullish } from "@dfinity/utils";
import { isNullish, nonNullish } from "@dfinity/utils";
export let usdAmount: number | undefined;
Expand All @@ -17,7 +17,10 @@
: absentValue;
let icpPrice: number | undefined;
$: icpPrice = $icpSwapUsdPricesStore?.[LEDGER_CANISTER_ID.toText()];
$: icpPrice =
isNullish($icpSwapUsdPricesStore) || $icpSwapUsdPricesStore === "error"
? undefined
: $icpSwapUsdPricesStore[LEDGER_CANISTER_ID.toText()];
let icpPriceFormatted: string;
$: icpPriceFormatted = nonNullish(icpPrice)
Expand Down
90 changes: 59 additions & 31 deletions frontend/src/lib/derived/icp-swap.derived.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,69 @@ import { icpSwapTickersStore } from "$lib/stores/icp-swap.store";
import type { IcpSwapTicker } from "$lib/types/icp-swap";
import { mapEntries } from "$lib/utils/utils";
import { isNullish } from "@dfinity/utils";
import { derived } from "svelte/store";
import { derived, type Readable } from "svelte/store";

export type IcpSwapUsdPricesStoreData =
| Record<string, number>
| undefined
| "error";

export type IcpSwapUsdPricesStore = Readable<IcpSwapUsdPricesStoreData>;

/// Holds a record mapping ledger canister IDs to the ckUSDC price of their
/// tokens.
export const icpSwapUsdPricesStore = derived(icpSwapTickersStore, (tickers) => {
if (isNullish(tickers)) {
return undefined;
}
const icpLedgerCanisterId = LEDGER_CANISTER_ID.toText();
const ledgerCanisterIdToTicker: Record<string, IcpSwapTicker> =
Object.fromEntries(
tickers
// Only keep ICP based tickers
.filter((ticker) => ticker.target_id === icpLedgerCanisterId)
.map((ticker) => [ticker.base_id, ticker])
);

const ckusdcTicker =
ledgerCanisterIdToTicker[CKUSDC_LEDGER_CANISTER_ID.toText()];
if (isNullish(ckusdcTicker)) {
return {};
}
export const icpSwapUsdPricesStore: IcpSwapUsdPricesStore = derived(
icpSwapTickersStore,
(tickers) => {
if (isNullish(tickers)) {
return undefined;
}
// The contents of icpSwapTickersStore come from ICP Swap, so there's no
// guarantee that it's format is as expected.
try {
const icpLedgerCanisterId = LEDGER_CANISTER_ID.toText();
const ledgerCanisterIdToTicker: Record<string, IcpSwapTicker> =
Object.fromEntries(
tickers
// Only keep ICP based tickers
.filter((ticker) => ticker.target_id === icpLedgerCanisterId)
.map((ticker) => [ticker.base_id, ticker])
);

const icpPriceInCkusdc = Number(ckusdcTicker?.last_price);
const ckusdcTicker =
ledgerCanisterIdToTicker[CKUSDC_LEDGER_CANISTER_ID.toText()];
if (isNullish(ckusdcTicker)) {
return "error";
}

const ledgerCanisterIdToUsdPrice: Record<string, number> = mapEntries({
obj: ledgerCanisterIdToTicker,
mapFn: ([ledgerCanisterId, ticker]) => [
ledgerCanisterId,
icpPriceInCkusdc / Number(ticker.last_price),
],
});
const icpPriceInCkusdc = Number(ckusdcTicker?.last_price);

// There is no ticker for ICP to ICP but we do want the ICP price in ckUSDC.
ledgerCanisterIdToUsdPrice[LEDGER_CANISTER_ID.toText()] = icpPriceInCkusdc;
if (icpPriceInCkusdc === 0 || !Number.isFinite(icpPriceInCkusdc)) {
return "error";
}

return ledgerCanisterIdToUsdPrice;
});
const ledgerCanisterIdToUsdPrice: Record<string, number> = mapEntries({
obj: ledgerCanisterIdToTicker,
mapFn: ([ledgerCanisterId, ticker]) => {
const lastPrice = Number(ticker.last_price);
if (lastPrice === 0 || !Number.isFinite(lastPrice)) {
return undefined;
}
return [
ledgerCanisterId,
icpPriceInCkusdc / Number(ticker.last_price),
];
},
});

// There is no ticker for ICP to ICP but we do want the ICP price in ckUSDC.
ledgerCanisterIdToUsdPrice[LEDGER_CANISTER_ID.toText()] =
icpPriceInCkusdc;

return ledgerCanisterIdToUsdPrice;
} catch (error) {
console.error(error);
return "error";
}
}
);
17 changes: 9 additions & 8 deletions frontend/src/lib/derived/icp-tokens-list-user.derived.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import {
icpAccountsStore,
type IcpAccountsStore,
} from "$lib/derived/icp-accounts.derived";
import { icpSwapUsdPricesStore } from "$lib/derived/icp-swap.derived";
import {
icpSwapUsdPricesStore,
type IcpSwapUsdPricesStore,
} from "$lib/derived/icp-swap.derived";
import { i18n } from "$lib/stores/i18n";
import type { Account, AccountType } from "$lib/types/account";
import { UserTokenAction, type UserToken } from "$lib/types/tokens-page";
Expand Down Expand Up @@ -99,17 +102,15 @@ const convertAccountToUserTokenData = ({
};

export const icpTokensListUser = derived<
[
Readable<Universe>,
IcpAccountsStore,
Readable<I18n>,
Readable<Record<string, number> | undefined>,
],
[Readable<Universe>, IcpAccountsStore, Readable<I18n>, IcpSwapUsdPricesStore],
UserToken[]
>(
[nnsUniverseStore, icpAccountsStore, i18n, icpSwapUsdPricesStore],
([nnsUniverse, icpAccounts, i18nObj, icpSwapUsdPrices]) => {
const icpPrice = icpSwapUsdPrices?.[LEDGER_CANISTER_ID.toText()];
const icpPrice =
isNullish(icpSwapUsdPrices) || icpSwapUsdPrices === "error"
? undefined
: icpSwapUsdPrices[LEDGER_CANISTER_ID.toText()];
return [
convertAccountToUserTokenData({
nnsUniverse,
Expand Down
16 changes: 10 additions & 6 deletions frontend/src/lib/derived/tokens-list-user.derived.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { OWN_CANISTER_ID_TEXT } from "$lib/constants/canister-ids.constants";
import { icpSwapUsdPricesStore } from "$lib/derived/icp-swap.derived";
import {
icpSwapUsdPricesStore,
type IcpSwapUsdPricesStore,
type IcpSwapUsdPricesStoreData,
} from "$lib/derived/icp-swap.derived";
import { failedExistentImportedTokenLedgerIdsStore } from "$lib/derived/imported-tokens.derived";
import type { IcrcTokenMetadata } from "$lib/types/icrc";
import {
Expand All @@ -11,7 +15,7 @@ import { sumAccounts } from "$lib/utils/accounts.utils";
import { buildAccountsUrl, buildWalletUrl } from "$lib/utils/navigation.utils";
import { isUniverseNns } from "$lib/utils/universe.utils";
import { toUserTokenFailed } from "$lib/utils/user-token.utils";
import { TokenAmountV2, isNullish } from "@dfinity/utils";
import { isNullish, TokenAmountV2 } from "@dfinity/utils";
import { derived, type Readable } from "svelte/store";
import type { UniversesAccounts } from "./accounts-list.derived";
import { tokensListBaseStore } from "./tokens-list-base.derived";
Expand All @@ -25,13 +29,13 @@ const getUsdValue = ({
}: {
balance: TokenAmountV2;
ledgerCanisterId: string;
icpSwapUsdPrices: Record<string, number> | undefined;
icpSwapUsdPrices: IcpSwapUsdPricesStoreData;
}): number | undefined => {
const balanceE8s = Number(balance.toE8s());
if (balanceE8s === 0) {
return 0;
}
if (isNullish(icpSwapUsdPrices)) {
if (isNullish(icpSwapUsdPrices) || icpSwapUsdPrices === "error") {
return undefined;
}
const tokenUsdPrice = icpSwapUsdPrices[ledgerCanisterId];
Expand All @@ -50,7 +54,7 @@ const convertToUserTokenData = ({
accounts: UniversesAccounts;
tokensByUniverse: Record<string, IcrcTokenMetadata>;
baseTokenData: UserTokenBase;
icpSwapUsdPrices: Record<string, number> | undefined;
icpSwapUsdPrices: IcpSwapUsdPricesStoreData;
}): UserToken => {
const token = tokensByUniverse[baseTokenData.universeId.toText()];
const rowHref = isUniverseNns(baseTokenData.universeId)
Expand Down Expand Up @@ -109,7 +113,7 @@ export const tokensListUserStore = derived<
Readable<UniversesAccounts>,
Readable<Record<string, IcrcTokenMetadata>>,
Readable<Array<string>>,
Readable<Record<string, number> | undefined>,
IcpSwapUsdPricesStore,
],
UserToken[]
>(
Expand Down
60 changes: 56 additions & 4 deletions frontend/src/tests/lib/derived/icp-swap.derived.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CKETH_LEDGER_CANISTER_ID } from "$lib/constants/cketh-canister-ids.cons
import { CKUSDC_LEDGER_CANISTER_ID } from "$lib/constants/ckusdc-canister-ids.constants";
import { icpSwapUsdPricesStore } from "$lib/derived/icp-swap.derived";
import { icpSwapTickersStore } from "$lib/stores/icp-swap.store";
import type { IcpSwapTicker } from "$lib/types/icp-swap";
import { mockIcpSwapTicker } from "$tests/mocks/icp-swap.mock";
import { get } from "svelte/store";

Expand All @@ -12,14 +13,42 @@ describe("icp-swap.derived", () => {
expect(get(icpSwapUsdPricesStore)).toBeUndefined();
});

it("should be empty if there are no tickers", () => {
it("should be 'error' if tickers are not an array", () => {
// This is theoretically possible because the tickers store content is
// read from ICP Swap, which we have no control over.
icpSwapTickersStore.set({} as unknown as IcpSwapTicker[]);

vi.spyOn(console, "error").mockReturnValue();

expect(get(icpSwapUsdPricesStore)).toEqual("error");

expect(console.error).toBeCalledWith(
new TypeError("tickers.filter is not a function")
);
expect(console.error).toBeCalledTimes(1);
});

it("should be 'error' if there are no tickers", () => {
icpSwapTickersStore.set([]);
expect(get(icpSwapUsdPricesStore)).toEqual({});
expect(get(icpSwapUsdPricesStore)).toEqual("error");
});

it("should be empty if there is no ckUSDC ticker", () => {
it("should be 'error' if there is no ckUSDC ticker", () => {
icpSwapTickersStore.set([mockIcpSwapTicker]);
expect(get(icpSwapUsdPricesStore)).toEqual({});
expect(get(icpSwapUsdPricesStore)).toEqual("error");
});

it("should be 'error' if ICP price in ckUSDC is zero", () => {
const icpPriceInUsd = 0.0;

const ckusdcTicker = {
...mockIcpSwapTicker,
base_id: CKUSDC_LEDGER_CANISTER_ID.toText(),
last_price: `${icpPriceInUsd}`,
};

icpSwapTickersStore.set([ckusdcTicker]);
expect(get(icpSwapUsdPricesStore)).toEqual("error");
});

it("should have an ICP price if there is a ckUSDC ticker", () => {
Expand Down Expand Up @@ -85,5 +114,28 @@ describe("icp-swap.derived", () => {
[CKUSDC_LEDGER_CANISTER_ID.toText()]: 1,
});
});

it("should not divide by zero for zero price", () => {
const icpPriceInUsd = 12.4;
const icpPriceInCketh = 0.0;

const ckusdcTicker = {
...mockIcpSwapTicker,
base_id: CKUSDC_LEDGER_CANISTER_ID.toText(),
last_price: `${icpPriceInUsd}`,
};
const ckethTicker = {
...mockIcpSwapTicker,
base_id: CKETH_LEDGER_CANISTER_ID.toText(),
last_price: `${icpPriceInCketh}`,
};

icpSwapTickersStore.set([ckusdcTicker, ckethTicker]);
expect(get(icpSwapUsdPricesStore)).toEqual({
[LEDGER_CANISTER_ID.toText()]: icpPriceInUsd,
[CKUSDC_LEDGER_CANISTER_ID.toText()]: 1,
// No entry for CKETH_LEDGER_CANISTER_ID
});
});
});
});

0 comments on commit e81d19a

Please sign in to comment.