diff --git a/.config/spellcheck.dic b/.config/spellcheck.dic index ae45af82cb..aaf3662389 100644 --- a/.config/spellcheck.dic +++ b/.config/spellcheck.dic @@ -1,4 +1,5 @@ -86 +87 +ICPSwap Monterey IC ICP diff --git a/CHANGELOG-Nns-Dapp-unreleased.md b/CHANGELOG-Nns-Dapp-unreleased.md index 2f5f7a2595..9c78719f57 100644 --- a/CHANGELOG-Nns-Dapp-unreleased.md +++ b/CHANGELOG-Nns-Dapp-unreleased.md @@ -38,6 +38,9 @@ proposal is successful, the changes it released will be moved from this file to #### Security +- Use the most liquid ICPSwap pool that has a price for a token, so a new pool + with one tiny trade cannot change the USD values shown. + #### Not Published ### Operations diff --git a/frontend/src/lib/services/icp-swap.provider.ts b/frontend/src/lib/services/icp-swap.provider.ts index c27acac918..e1301a5cbd 100644 --- a/frontend/src/lib/services/icp-swap.provider.ts +++ b/frontend/src/lib/services/icp-swap.provider.ts @@ -6,11 +6,55 @@ import { ProviderErrors, type TickersData } from "$lib/types/tickers"; import { mapEntries } from "$lib/utils/utils"; import { isNullish } from "@dfinity/utils"; +// The tickers come from ICP Swap, so a field can be missing or hold something +// that is not a number. Such a field counts as 0. +const toAmount = (value: string): number => { + const amount = Number(value); + return Number.isFinite(amount) ? amount : 0; +}; + +// A price is usable only when it is a finite number above 0. ICP Swap reports +// a last_price of 0 for a pool that nobody has traded. +const hasUsablePrice = ({ last_price }: IcpSwapTicker): boolean => { + const price = Number(last_price); + return price > 0 && Number.isFinite(price); +}; + +// Keep the pool with the most liquidity, because an attacker must lock more +// value than the real pool to be selected. A tie goes to the higher 24h +// volume, then to the first pool in the feed. +// The caller must pass at least one ticker. reduce with no initial value +// throws on an empty array. +const selectMostLiquidTicker = (tickers: IcpSwapTicker[]): IcpSwapTicker => + tickers.reduce((selected, ticker) => { + const liquidityDifference = + toAmount(ticker.liquidity_in_usd) - toAmount(selected.liquidity_in_usd); + if (liquidityDifference !== 0) { + return liquidityDifference > 0 ? ticker : selected; + } + const volumeDifference = + toAmount(ticker.volume_usd_24H) - toAmount(selected.volume_usd_24H); + return volumeDifference > 0 ? ticker : selected; + }); + +// Anybody can create an ICP Swap pool, so a token can have several pools and +// each pool reports its own price. Select among the pools that carry a usable +// price, because a pool with no usable price gives no rate. Otherwise an +// untraded pool with more liquidity wins and the pair loses its price. +// When no pool of the pair carries a usable price, keep the most liquid pool. +// The price checks below then drop the pair, as they did before. +const selectTickerForPair = ( + tickersForPair: IcpSwapTicker[] +): IcpSwapTicker => { + const tickersWithPrice = tickersForPair.filter(hasUsablePrice); + return selectMostLiquidTicker( + tickersWithPrice.length > 0 ? tickersWithPrice : tickersForPair + ); +}; + const adapter = (tickers: IcpSwapTicker[]): TickersData => { if (isNullish(tickers)) throw new Error(ProviderErrors.NO_DATA); - // The contents of icpSwapTickersStore come from ICP Swap, so there's no - // guarantee that it's format is as expected. const icpLedgerCanisterId = LEDGER_CANISTER_ID.toText(); // First, get all ICP-based tickers @@ -30,26 +74,14 @@ const adapter = (tickers: IcpSwapTicker[]): TickersData => { {} as Record ); - // Apply volume filter only when there are multiple tickers for the same pair - const filteredTickers = Object.values(tickersByBaseId).flatMap( - (tickersForPair) => { - if (tickersForPair.length === 1) { - // Single ticker for this pair - keep it regardless of volume - return tickersForPair; - } else { - // Multiple tickers for this pair - filter by volume - return ( - tickersForPair.find((ticker) => Number(ticker.volume_usd_24H) > 0) ?? - [] - ); - } - } - ); - - const ledgerCanisterIdToTicker: Record = - Object.fromEntries( - filteredTickers.map((ticker) => [ticker.base_id, ticker]) - ); + // Keep one ticker per pair. + const ledgerCanisterIdToTicker: Record = mapEntries({ + obj: tickersByBaseId, + mapFn: ([baseId, tickersForPair]) => [ + baseId, + selectTickerForPair(tickersForPair), + ], + }); const ckusdcTicker = ledgerCanisterIdToTicker[CKUSDC_LEDGER_CANISTER_ID.toText()]; @@ -57,17 +89,16 @@ const adapter = (tickers: IcpSwapTicker[]): TickersData => { throw new Error(ProviderErrors.INVALID_CKUSDC_PRICE); } - const icpPriceInCkusdc = Number(ckusdcTicker?.last_price); - - if (icpPriceInCkusdc === 0 || !Number.isFinite(icpPriceInCkusdc)) { + if (!hasUsablePrice(ckusdcTicker)) { throw new Error(ProviderErrors.INVALID_ICP_PRICE); } + const icpPriceInCkusdc = Number(ckusdcTicker.last_price); + const ledgerCanisterIdToUsdPrice: Record = mapEntries({ obj: ledgerCanisterIdToTicker, mapFn: ([ledgerCanisterId, ticker]) => { - const lastPrice = Number(ticker.last_price); - if (lastPrice === 0 || !Number.isFinite(lastPrice)) { + if (!hasUsablePrice(ticker)) { return undefined; } return [ledgerCanisterId, icpPriceInCkusdc / Number(ticker.last_price)]; diff --git a/frontend/src/tests/e2e/icp-swap-pool-selection.spec.ts b/frontend/src/tests/e2e/icp-swap-pool-selection.spec.ts new file mode 100644 index 0000000000..5e794e1b3d --- /dev/null +++ b/frontend/src/tests/e2e/icp-swap-pool-selection.spec.ts @@ -0,0 +1,110 @@ +import type { IcpSwapTicker } from "$lib/types/icp-swap"; +import { AppPo } from "$tests/page-objects/App.page-object"; +import { PlaywrightPageObjectElement } from "$tests/page-objects/playwright.page-object"; +import { + dfxCanisterId, + disableCssAnimations, + signInWithNewUser, + step, +} from "$tests/utils/e2e.test-utils"; +import { expect, test } from "@playwright/test"; + +// The ICP price in USD that the banner shows is the `last_price` of the +// ckUSDC ticker. The app formats it with `formatNumber`, which replaces the +// decimal comma from `Intl.NumberFormat("fr-FR")` with a dot, so 4 becomes +// "4.00" and 400 becomes "400.00". +const REAL_ICP_PRICE = "4"; +const ATTACKER_ICP_PRICE = "400"; +const REAL_ICP_PRICE_SHOWN = "4.00"; + +const ticker = (overrides: Partial): IcpSwapTicker => ({ + ticker_id: "ne2vj-6yaaa-aaaag-qb3ia-cai", + ticker_name: "CKUSDC_ICP", + base_id: "2ouva-viaaa-aaaaq-aaamq-cai", + base_currency: "ckUSDC", + target_id: "ryjl3-tyaaa-aaaaa-aaaba-cai", + target_currency: "ICP", + last_price: "1", + base_volume: "0", + target_volume: "0", + base_volume_24H: "0", + target_volume_24H: "0", + total_volume_usd: "0", + volume_usd_24H: "0", + fee_usd: "0", + liquidity_in_usd: "0", + ...overrides, +}); + +test("The ICP price comes from the most liquid ICPSwap pool", async ({ + page, + context, +}) => { + const icpLedgerCanisterId = await dfxCanisterId("nns-ledger"); + const ckusdcLedgerCanisterId = await dfxCanisterId("ckusdc_ledger"); + + // The real ckUSDC pool holds value but nobody traded it in the last 24 + // hours. This is true of 296 of the 407 ICP pairs in the live feed. + const realCkusdcTicker = ticker({ + base_id: ckusdcLedgerCanisterId, + target_id: icpLedgerCanisterId, + last_price: REAL_ICP_PRICE, + liquidity_in_usd: "617000", + volume_usd_24H: "0", + }); + + // Anybody can create a second ICPSwap pool for the same pair. This one holds + // almost nothing, carries one wash trade and reports an outlier price. The + // old code selected it, because it was the first ticker with a volume. + const attackerCkusdcTicker = ticker({ + base_id: ckusdcLedgerCanisterId, + target_id: icpLedgerCanisterId, + last_price: ATTACKER_ICP_PRICE, + liquidity_in_usd: "5", + volume_usd_24H: "1", + }); + + let tickersRequested = false; + let markTickersRequested: () => void; + const tickersRequestedPromise = new Promise((resolve) => { + markTickersRequested = resolve; + }); + await page.route("**/tickers", async (route) => { + tickersRequested = true; + markTickersRequested(); + await route.fulfill({ + // The attacker pool comes first, the order an attacker would want. + json: [attackerCkusdcTicker, realCkusdcTicker], + }); + }); + + await page.goto("/accounts"); + await disableCssAnimations(page); + await expect(page).toHaveTitle("Account | Network Nervous System"); + + await signInWithNewUser({ page, context }); + + const pageElement = PlaywrightPageObjectElement.fromPage(page); + const appPo = new AppPo(pageElement); + const usdValueBannerPo = appPo + .getAccountsPo() + .getNnsAccountsPo() + .getUsdValueBannerPo(); + await usdValueBannerPo.waitFor(); + + // The app asks ICPSwap for the tickers only when the network configures an + // ICPSwap URL. `dfx.json` gives that URL to mainnet, app and beta, not to + // the local network, so the local app shows no price at all. Wait for the + // request, with a 5s cap for a network that never sends it. + await Promise.race([tickersRequestedPromise, page.waitForTimeout(5_000)]); + test.skip( + !tickersRequested, + "This network configures no ICPSwap URL, so the app requests no tickers." + ); + + step("The banner shows the price of the most liquid pool"); + + const icpPrice = await usdValueBannerPo.getIcpPrice(); + expect(icpPrice).toBe(REAL_ICP_PRICE_SHOWN); + expect(await usdValueBannerPo.hasError()).toBe(false); +}); diff --git a/frontend/src/tests/lib/services/icp-swap.provider.spec.ts b/frontend/src/tests/lib/services/icp-swap.provider.spec.ts index 3bd8787690..02af983c86 100644 --- a/frontend/src/tests/lib/services/icp-swap.provider.spec.ts +++ b/frontend/src/tests/lib/services/icp-swap.provider.spec.ts @@ -78,7 +78,7 @@ describe("icp-swap.provider", () => { expect(result).toHaveProperty(ckusdcLedgerCanisterId); }); - it("should handle multiple tickers for the same pair by selecting one with volume", async () => { + it("should handle multiple tickers for the same pair by selecting the most liquid one", async () => { const icpLedgerCanisterId = LEDGER_CANISTER_ID.toText(); const ckusdcLedgerCanisterId = CKUSDC_LEDGER_CANISTER_ID.toText(); @@ -87,38 +87,310 @@ describe("icp-swap.provider", () => { base_id: ckusdcLedgerCanisterId, target_id: icpLedgerCanisterId, last_price: "0.04", + liquidity_in_usd: "617000", volume_usd_24H: "1000", }; // Multiple tickers for the same token pair - const tokenTickerWithVolume: IcpSwapTicker = { + const mostLiquidTokenTicker: IcpSwapTicker = { ...mockIcpSwapTicker, base_id: "token-canister-id", target_id: icpLedgerCanisterId, last_price: "2", - volume_usd_24H: "1000", // Has volume + liquidity_in_usd: "10000", + volume_usd_24H: "1", // Less volume }; - const tokenTickerWithoutVolume: IcpSwapTicker = { + const lessLiquidTokenTicker: IcpSwapTicker = { ...mockIcpSwapTicker, base_id: "token-canister-id", target_id: icpLedgerCanisterId, last_price: "2.5", - volume_usd_24H: "0", // No volume + liquidity_in_usd: "9999", + volume_usd_24H: "1000", // More volume }; vi.spyOn(icpSwapApi, "queryIcpSwapTickers").mockResolvedValue([ ckusdcTicker, - tokenTickerWithVolume, - tokenTickerWithoutVolume, + lessLiquidTokenTicker, + mostLiquidTokenTicker, ]); const result = await icpSwapTickerProvider(); - // Should use the ticker with volume (last_price: "2") + // Should use the most liquid ticker (last_price: "2") expect(result["token-canister-id"]).toBe(0.02); // 0.04 / 2 = 0.02 }); + it("should select the most liquid ticker whatever the order of the tickers", async () => { + const icpLedgerCanisterId = LEDGER_CANISTER_ID.toText(); + const ckusdcLedgerCanisterId = CKUSDC_LEDGER_CANISTER_ID.toText(); + + const ckusdcTicker: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: ckusdcLedgerCanisterId, + target_id: icpLedgerCanisterId, + last_price: "0.04", + liquidity_in_usd: "617000", + volume_usd_24H: "1000", + }; + + // The pool of the token holds real liquidity but nobody traded it today. + const realTokenTicker: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: "token-canister-id", + target_id: icpLedgerCanisterId, + last_price: "2", + liquidity_in_usd: "10000", + volume_usd_24H: "0", + }; + + // The pool of the attacker holds almost nothing and has one wash trade. + const attackerTokenTicker: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: "token-canister-id", + target_id: icpLedgerCanisterId, + last_price: "0.0001", + liquidity_in_usd: "5", + volume_usd_24H: "1", + }; + + for (const tickers of [ + [ckusdcTicker, attackerTokenTicker, realTokenTicker], + [ckusdcTicker, realTokenTicker, attackerTokenTicker], + ]) { + vi.spyOn(icpSwapApi, "queryIcpSwapTickers").mockResolvedValue(tickers); + + const result = await icpSwapTickerProvider(); + + // The price comes from the real pool: 0.04 / 2 = 0.02 + expect(result["token-canister-id"]).toBe(0.02); + } + }); + + it("should ignore a ckUSDC ticker with less liquidity", async () => { + const icpLedgerCanisterId = LEDGER_CANISTER_ID.toText(); + const ckusdcLedgerCanisterId = CKUSDC_LEDGER_CANISTER_ID.toText(); + + const realCkusdcTicker: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: ckusdcLedgerCanisterId, + target_id: icpLedgerCanisterId, + last_price: "0.04", + liquidity_in_usd: "617000", + volume_usd_24H: "0", + }; + + const attackerCkusdcTicker: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: ckusdcLedgerCanisterId, + target_id: icpLedgerCanisterId, + last_price: "400", + liquidity_in_usd: "5", + volume_usd_24H: "1", + }; + + const tokenTicker: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: "token-canister-id", + target_id: icpLedgerCanisterId, + last_price: "2", + liquidity_in_usd: "10000", + volume_usd_24H: "0", + }; + + vi.spyOn(icpSwapApi, "queryIcpSwapTickers").mockResolvedValue([ + attackerCkusdcTicker, + realCkusdcTicker, + tokenTicker, + ]); + + const result = await icpSwapTickerProvider(); + + expect(result[icpLedgerCanisterId]).toBe(0.04); + expect(result[ckusdcLedgerCanisterId]).toBe(1); + expect(result["token-canister-id"]).toBe(0.02); + }); + + it("should ignore an untraded pool with more liquidity", async () => { + const icpLedgerCanisterId = LEDGER_CANISTER_ID.toText(); + const ckusdcLedgerCanisterId = CKUSDC_LEDGER_CANISTER_ID.toText(); + + const ckusdcTicker: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: ckusdcLedgerCanisterId, + target_id: icpLedgerCanisterId, + last_price: "0.04", + liquidity_in_usd: "617000", + volume_usd_24H: "1000", + }; + + // Nobody traded this pool, so ICP Swap reports a last_price of 0. + const untradedTokenTicker: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: "token-canister-id", + target_id: icpLedgerCanisterId, + last_price: "0.000000", + liquidity_in_usd: "1000", + volume_usd_24H: "0", + }; + + // This pool holds less liquidity but it carries a real price. + const tradedTokenTicker: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: "token-canister-id", + target_id: icpLedgerCanisterId, + last_price: "2", + liquidity_in_usd: "10", + volume_usd_24H: "1000", + }; + + for (const tickers of [ + [ckusdcTicker, untradedTokenTicker, tradedTokenTicker], + [ckusdcTicker, tradedTokenTicker, untradedTokenTicker], + ]) { + vi.spyOn(icpSwapApi, "queryIcpSwapTickers").mockResolvedValue(tickers); + + const result = await icpSwapTickerProvider(); + + // The price comes from the traded pool: 0.04 / 2 = 0.02 + expect(result["token-canister-id"]).toBe(0.02); + } + }); + + it("should ignore an untraded ckUSDC pool with more liquidity", async () => { + const icpLedgerCanisterId = LEDGER_CANISTER_ID.toText(); + const ckusdcLedgerCanisterId = CKUSDC_LEDGER_CANISTER_ID.toText(); + + // Nobody traded this ckUSDC pool, so ICP Swap reports a last_price of 0. + const untradedCkusdcTicker: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: ckusdcLedgerCanisterId, + target_id: icpLedgerCanisterId, + last_price: "0.000000", + liquidity_in_usd: "700000", + volume_usd_24H: "0", + }; + + const tradedCkusdcTicker: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: ckusdcLedgerCanisterId, + target_id: icpLedgerCanisterId, + last_price: "0.04", + liquidity_in_usd: "617000", + volume_usd_24H: "1000", + }; + + for (const tickers of [ + [untradedCkusdcTicker, tradedCkusdcTicker], + [tradedCkusdcTicker, untradedCkusdcTicker], + ]) { + vi.spyOn(icpSwapApi, "queryIcpSwapTickers").mockResolvedValue(tickers); + + const result = await icpSwapTickerProvider(); + + // The ICP price comes from the traded pool, and no error is thrown. + expect(result[icpLedgerCanisterId]).toBe(0.04); + expect(result[ckusdcLedgerCanisterId]).toBe(1); + } + }); + + it("should select the ticker with more volume when the liquidity is equal", async () => { + const icpLedgerCanisterId = LEDGER_CANISTER_ID.toText(); + const ckusdcLedgerCanisterId = CKUSDC_LEDGER_CANISTER_ID.toText(); + + const ckusdcTicker: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: ckusdcLedgerCanisterId, + target_id: icpLedgerCanisterId, + last_price: "0.04", + liquidity_in_usd: "617000", + volume_usd_24H: "1000", + }; + + const lessTradedTokenTicker: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: "token-canister-id", + target_id: icpLedgerCanisterId, + last_price: "4", + liquidity_in_usd: "1000", + volume_usd_24H: "1", + }; + + const moreTradedTokenTicker: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: "token-canister-id", + target_id: icpLedgerCanisterId, + last_price: "2", + liquidity_in_usd: "1000", + volume_usd_24H: "5", + }; + + vi.spyOn(icpSwapApi, "queryIcpSwapTickers").mockResolvedValue([ + ckusdcTicker, + lessTradedTokenTicker, + moreTradedTokenTicker, + ]); + + const result = await icpSwapTickerProvider(); + + // The price comes from the more traded pool: 0.04 / 2 = 0.02 + expect(result["token-canister-id"]).toBe(0.02); + }); + + it("should count a missing or non-numeric liquidity as zero", async () => { + const icpLedgerCanisterId = LEDGER_CANISTER_ID.toText(); + const ckusdcLedgerCanisterId = CKUSDC_LEDGER_CANISTER_ID.toText(); + + const ckusdcTicker: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: ckusdcLedgerCanisterId, + target_id: icpLedgerCanisterId, + last_price: "0.04", + liquidity_in_usd: "617000", + volume_usd_24H: "1000", + }; + + const tickerWithoutLiquidity: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: "token-canister-id", + target_id: icpLedgerCanisterId, + last_price: "4", + liquidity_in_usd: undefined as unknown as string, + volume_usd_24H: "1000", + }; + + const tickerWithBrokenLiquidity: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: "token-canister-id", + target_id: icpLedgerCanisterId, + last_price: "8", + liquidity_in_usd: "a lot", + volume_usd_24H: "1000", + }; + + const tickerWithLiquidity: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: "token-canister-id", + target_id: icpLedgerCanisterId, + last_price: "2", + liquidity_in_usd: "1", + volume_usd_24H: "0", + }; + + vi.spyOn(icpSwapApi, "queryIcpSwapTickers").mockResolvedValue([ + ckusdcTicker, + tickerWithoutLiquidity, + tickerWithBrokenLiquidity, + tickerWithLiquidity, + ]); + + const result = await icpSwapTickerProvider(); + + // The only ticker with a liquidity wins: 0.04 / 2 = 0.02 + expect(result["token-canister-id"]).toBe(0.02); + }); + it("should keep single ticker for a pair even if it has no volume", async () => { const icpLedgerCanisterId = LEDGER_CANISTER_ID.toText(); const ckusdcLedgerCanisterId = CKUSDC_LEDGER_CANISTER_ID.toText(); @@ -150,7 +422,7 @@ describe("icp-swap.provider", () => { expect(result["token-canister-id"]).toBe(0.02); }); - it("should handle multiple tickers with no volume by selecting the first one", async () => { + it("should handle multiple tickers with no volume by selecting the most liquid one", async () => { const icpLedgerCanisterId = LEDGER_CANISTER_ID.toText(); const ckusdcLedgerCanisterId = CKUSDC_LEDGER_CANISTER_ID.toText(); @@ -159,41 +431,44 @@ describe("icp-swap.provider", () => { base_id: ckusdcLedgerCanisterId, target_id: icpLedgerCanisterId, last_price: "0.04", + liquidity_in_usd: "617000", volume_usd_24H: "1000", }; - // Multiple tickers, all without volume - should return empty array (no ticker selected) - const tokenTicker1: IcpSwapTicker = { + // Multiple tickers, all without volume + const lessLiquidTokenTicker: IcpSwapTicker = { ...mockIcpSwapTicker, base_id: "token-canister-id", target_id: icpLedgerCanisterId, - last_price: "2", + last_price: "2.5", + liquidity_in_usd: "9999", volume_usd_24H: "0", }; - const tokenTicker2: IcpSwapTicker = { + const mostLiquidTokenTicker: IcpSwapTicker = { ...mockIcpSwapTicker, base_id: "token-canister-id", target_id: icpLedgerCanisterId, - last_price: "2.5", + last_price: "2", + liquidity_in_usd: "10000", volume_usd_24H: "0", }; vi.spyOn(icpSwapApi, "queryIcpSwapTickers").mockResolvedValue([ ckusdcTicker, - tokenTicker1, - tokenTicker2, + lessLiquidTokenTicker, + mostLiquidTokenTicker, ]); const result = await icpSwapTickerProvider(); - // Should not include the token since no ticker with volume was found - expect(result).not.toHaveProperty("token-canister-id"); + // The most liquid ticker gives the price: 0.04 / 2 = 0.02 + expect(result["token-canister-id"]).toBe(0.02); expect(result).toHaveProperty(icpLedgerCanisterId); expect(result).toHaveProperty(ckusdcLedgerCanisterId); }); - it("should filter out tickers with invalid prices (zero or non-finite)", async () => { + it("should filter out tickers with invalid prices (zero, negative or non-finite)", async () => { const icpLedgerCanisterId = LEDGER_CANISTER_ID.toText(); const ckusdcLedgerCanisterId = CKUSDC_LEDGER_CANISTER_ID.toText(); @@ -221,6 +496,14 @@ describe("icp-swap.provider", () => { volume_usd_24H: "1000", }; + const tokenTickerNegativePrice: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: "token-negative-price", + target_id: icpLedgerCanisterId, + last_price: "-1", + volume_usd_24H: "1000", + }; + const tokenTickerValidPrice: IcpSwapTicker = { ...mockIcpSwapTicker, base_id: "token-valid-price", @@ -233,6 +516,7 @@ describe("icp-swap.provider", () => { ckusdcTicker, tokenTickerZeroPrice, tokenTickerInvalidPrice, + tokenTickerNegativePrice, tokenTickerValidPrice, ]); @@ -240,6 +524,7 @@ describe("icp-swap.provider", () => { expect(result).not.toHaveProperty("token-zero-price"); expect(result).not.toHaveProperty("token-invalid-price"); + expect(result).not.toHaveProperty("token-negative-price"); expect(result).toHaveProperty("token-valid-price"); expect(result["token-valid-price"]).toBe(0.02); }); @@ -316,6 +601,27 @@ describe("icp-swap.provider", () => { ); }); + it("should throw error when ckUSDC ticker has invalid price (negative)", async () => { + const icpLedgerCanisterId = LEDGER_CANISTER_ID.toText(); + const ckusdcLedgerCanisterId = CKUSDC_LEDGER_CANISTER_ID.toText(); + + const ckusdcTicker: IcpSwapTicker = { + ...mockIcpSwapTicker, + base_id: ckusdcLedgerCanisterId, + target_id: icpLedgerCanisterId, + last_price: "-1", // Invalid: negative price + volume_usd_24H: "1000", + }; + + vi.spyOn(icpSwapApi, "queryIcpSwapTickers").mockResolvedValue([ + ckusdcTicker, + ]); + + await expect(icpSwapTickerProvider()).rejects.toThrow( + ProviderErrors.INVALID_ICP_PRICE + ); + }); + it("should throw error when ckUSDC ticker has invalid price (Infinity)", async () => { const icpLedgerCanisterId = LEDGER_CANISTER_ID.toText(); const ckusdcLedgerCanisterId = CKUSDC_LEDGER_CANISTER_ID.toText();