From 356c683401acf99d216139da5acb2742b2b0e8e8 Mon Sep 17 00:00:00 2001 From: Yusef Habib Fernandez Date: Fri, 4 Sep 2026 20:18:03 +0200 Subject: [PATCH 1/5] fix(sns): read the swap participant count only from the certified state The project detail page read the participant count from `https://.raw.icp0.io/metrics` when `get_derived_state` returned no `direct_participant_count`. The raw gateway skips response certification, so one replica could answer with any count. Delete the raw metrics path. `direct_participant_count` from the certified `get_derived_state` call is now the only source of the count. --- CHANGELOG-Nns-Dapp-unreleased.md | 4 + frontend/src/lib/api/sns-swap-metrics.api.ts | 28 ---- .../project-detail/ProjectCommitment.svelte | 3 - frontend/src/lib/pages/ProjectDetail.svelte | 16 -- .../lib/services/sns-swap-metrics.services.ts | 49 ------ .../src/lib/stores/sns-swap-metrics.store.ts | 52 ------ frontend/src/lib/utils/sns-swap.utils.ts | 37 +---- frontend/src/lib/utils/sns.utils.ts | 22 --- .../lib/api/sns-swap-metrics.api.spec.ts | 24 --- .../project-detail/ProjectCommitment.spec.ts | 35 ++-- .../src/tests/lib/pages/ProjectDetail.spec.ts | 149 ++---------------- .../sns-swap-metrics.services.spec.ts | 80 ---------- .../lib/stores/sns-swap-metrics.store.spec.ts | 19 --- .../tests/lib/utils/sns-swap.utils.spec.ts | 110 ++----------- .../src/tests/lib/utils/sns.utils.spec.ts | 36 ----- .../ProjectCommitment.page-object.ts | 4 + 16 files changed, 62 insertions(+), 606 deletions(-) delete mode 100644 frontend/src/lib/api/sns-swap-metrics.api.ts delete mode 100644 frontend/src/lib/services/sns-swap-metrics.services.ts delete mode 100644 frontend/src/lib/stores/sns-swap-metrics.store.ts delete mode 100644 frontend/src/tests/lib/api/sns-swap-metrics.api.spec.ts delete mode 100644 frontend/src/tests/lib/services/sns-swap-metrics.services.spec.ts delete mode 100644 frontend/src/tests/lib/stores/sns-swap-metrics.store.spec.ts diff --git a/CHANGELOG-Nns-Dapp-unreleased.md b/CHANGELOG-Nns-Dapp-unreleased.md index 2f5f7a2595e..92b5216c01a 100644 --- a/CHANGELOG-Nns-Dapp-unreleased.md +++ b/CHANGELOG-Nns-Dapp-unreleased.md @@ -38,6 +38,10 @@ proposal is successful, the changes it released will be moved from this file to #### Security +- Read the SNS swap participant count only from the certified swap canister + state. Before, a swap without that field read the count from the unverified + raw metrics page. + #### Not Published ### Operations diff --git a/frontend/src/lib/api/sns-swap-metrics.api.ts b/frontend/src/lib/api/sns-swap-metrics.api.ts deleted file mode 100644 index 7277c113cae..00000000000 --- a/frontend/src/lib/api/sns-swap-metrics.api.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { logWithTimestamp } from "$lib/utils/dev.utils"; -import type { Principal } from "@icp-sdk/core/principal"; - -export const querySnsSwapMetrics = async ({ - swapCanisterId, -}: { - swapCanisterId: Principal; -}): Promise => { - logWithTimestamp("Loading SNS metrics..."); - - try { - // TODO: switch to a metrics canister. Otherwise not testable on testnet. - const url = `https://${swapCanisterId.toText()}.raw.icp0.io/metrics`; - const response = await fetch(url); - - if (!response.ok) { - throw new Error("response not ok"); - } - - const allMetrics = await response.text(); - - logWithTimestamp("Loading SNS metrics completed"); - - return allMetrics; - } catch (err) { - logWithTimestamp("Error getting SNS metrics", err); - } -}; diff --git a/frontend/src/lib/components/project-detail/ProjectCommitment.svelte b/frontend/src/lib/components/project-detail/ProjectCommitment.svelte index 48cf3cddb5c..5e7896f7a12 100644 --- a/frontend/src/lib/components/project-detail/ProjectCommitment.svelte +++ b/frontend/src/lib/components/project-detail/ProjectCommitment.svelte @@ -5,7 +5,6 @@ import NfCommitmentProgressBar from "$lib/components/project-detail/NfCommitmentProgressBar.svelte"; import { getMaxNeuronsFundParticipation } from "$lib/getters/sns-summary"; import { i18n } from "$lib/stores/i18n"; - import { snsSwapMetricsStore } from "$lib/stores/sns-swap-metrics.store"; import { PROJECT_DETAIL_CONTEXT_KEY, type ProjectDetailContext, @@ -54,8 +53,6 @@ let saleBuyerCount: number | undefined; $: saleBuyerCount = swapSaleBuyerCount({ - rootCanisterId: $projectDetailStore?.summary?.rootCanisterId, - swapMetrics: $snsSwapMetricsStore, derivedState: summary.derived, }); diff --git a/frontend/src/lib/pages/ProjectDetail.svelte b/frontend/src/lib/pages/ProjectDetail.svelte index dea1253a4ac..463f6ccf9eb 100644 --- a/frontend/src/lib/pages/ProjectDetail.svelte +++ b/frontend/src/lib/pages/ProjectDetail.svelte @@ -16,7 +16,6 @@ hidePollingToast, restoreSnsSaleParticipation, } from "$lib/services/sns-sale.services"; - import { loadSnsSwapMetrics } from "$lib/services/sns-swap-metrics.services"; import { loadSnsDerivedState, loadSnsLifecycle, @@ -39,7 +38,6 @@ } from "$lib/types/project-detail.context"; import { SaleStep } from "$lib/types/sale"; import { userCountryIsNeeded } from "$lib/utils/projects.utils"; - import { hasBuyersCount } from "$lib/utils/sns-swap.utils"; import { getCommitmentE8s } from "$lib/utils/sns.utils"; import { Principal } from "@icp-sdk/core/principal"; import { SnsSwapLifecycle } from "@icp-sdk/canisters/sns"; @@ -183,28 +181,14 @@ }); } - let derivedStateHasBuyersCount: boolean | undefined; - $: derivedStateHasBuyersCount = hasBuyersCount( - $projectDetailStore?.summary?.derived - ); let areWatchersSet = false; let unsubscribeWatchCommitment: () => void | undefined; $: if ( nonNullish(rootCanisterId) && nonNullish(swapCanisterId) && - nonNullish(derivedStateHasBuyersCount) && !areWatchersSet ) { - if (!derivedStateHasBuyersCount) { - // TODO: Remove once Dragginz, OC and SONIC support new fields in in SnsGetDerivedStateResponse - loadSnsSwapMetrics({ - rootCanisterId: Principal.fromText(rootCanisterId), - swapCanisterId, - forceFetch: false, - }); - } - if (enableOpenProjectWatchers) { areWatchersSet = true; unsubscribeWatchCommitment?.(); diff --git a/frontend/src/lib/services/sns-swap-metrics.services.ts b/frontend/src/lib/services/sns-swap-metrics.services.ts deleted file mode 100644 index 4b023afff24..00000000000 --- a/frontend/src/lib/services/sns-swap-metrics.services.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { querySnsSwapMetrics } from "$lib/api/sns-swap-metrics.api"; -import { snsSwapMetricsStore } from "$lib/stores/sns-swap-metrics.store"; -import { parseSnsSwapSaleBuyerCount } from "$lib/utils/sns.utils"; -import type { Principal } from "@icp-sdk/core/principal"; -import { get } from "svelte/store"; - -/** - * Get metrics from the store or fetch it - * @param rootCanisterId - */ -export const loadSnsSwapMetrics = async ({ - rootCanisterId, - swapCanisterId, - forceFetch, -}: { - rootCanisterId: Principal; - swapCanisterId: Principal; - forceFetch: boolean; -}): Promise => { - const store = get(snsSwapMetricsStore); - - // skip update when data is available - if (!forceFetch && store[rootCanisterId.toText()] !== undefined) { - return; - } - - if (store[rootCanisterId.toText()] === undefined) { - // mark in progress to avoid multiple load - snsSwapMetricsStore.setMetrics({ - rootCanisterId, - metrics: null, - }); - } - - const rawMetrics = await querySnsSwapMetrics({ swapCanisterId }); - if (rawMetrics === undefined) { - return; - } - - const saleBuyerCount = parseSnsSwapSaleBuyerCount(rawMetrics); - if (saleBuyerCount === undefined) { - return; - } - - snsSwapMetricsStore.setMetrics({ - rootCanisterId, - metrics: { saleBuyerCount }, - }); -}; diff --git a/frontend/src/lib/stores/sns-swap-metrics.store.ts b/frontend/src/lib/stores/sns-swap-metrics.store.ts deleted file mode 100644 index 04bfcd9f6ef..00000000000 --- a/frontend/src/lib/stores/sns-swap-metrics.store.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { RootCanisterIdText } from "$lib/types/sns"; -import type { Principal } from "@icp-sdk/core/principal"; -import type { Readable } from "svelte/store"; -import { writable } from "svelte/store"; - -/** - * undefined - not initialized, null - not found - */ -export interface SnsSwapMetrics { - saleBuyerCount: number; -} - -export type SnsSwapMetricsStoreData = Record< - RootCanisterIdText, - SnsSwapMetrics | undefined | null ->; - -export interface SnsSwapMetricsStore extends Readable { - setMetrics: (data: { - rootCanisterId: Principal; - metrics: SnsSwapMetrics | undefined | null; - }) => void; - reset: () => void; -} - -const initSnsSwapMetricsStore = (): SnsSwapMetricsStore => { - const { subscribe, update, set } = writable({}); - - return { - subscribe, - - setMetrics({ - rootCanisterId, - metrics, - }: { - rootCanisterId: Principal; - metrics: SnsSwapMetrics | null | undefined; - }) { - update((currentState: SnsSwapMetricsStoreData) => ({ - ...currentState, - [rootCanisterId.toText()]: metrics, - })); - }, - - // Used in tests - reset() { - set({}); - }, - }; -}; - -export const snsSwapMetricsStore = initSnsSwapMetricsStore(); diff --git a/frontend/src/lib/utils/sns-swap.utils.ts b/frontend/src/lib/utils/sns-swap.utils.ts index 7be1c7a83ff..39faed5facc 100644 --- a/frontend/src/lib/utils/sns-swap.utils.ts +++ b/frontend/src/lib/utils/sns-swap.utils.ts @@ -1,38 +1,17 @@ -import type { SnsSwapMetricsStoreData } from "$lib/stores/sns-swap-metrics.store"; import { fromNullable, nonNullish } from "@dfinity/utils"; import type { SnsSwapDid } from "@icp-sdk/canisters/sns"; -import type { Principal } from "@icp-sdk/core/principal"; +/** + * Returns the number of direct participants of a swap. + * + * The value comes from the certified `get_derived_state` call. It is undefined + * when the swap canister does not report the field. + */ export const swapSaleBuyerCount = ({ - swapMetrics, - rootCanisterId, derivedState: { direct_participant_count }, }: { - swapMetrics: SnsSwapMetricsStoreData; - rootCanisterId: Principal | undefined; derivedState: SnsSwapDid.DerivedState; }): number | undefined => { - if (nonNullish(fromNullable(direct_participant_count))) { - return Number(fromNullable(direct_participant_count)); - } - return rootCanisterId === undefined - ? undefined - : swapMetrics?.[rootCanisterId.toText()]?.saleBuyerCount; -}; - -/** - * Returns whether the derived state has a buyers count. - * - * It returns undefined if the derived state is undefined or null. - * - * If the field is not set, we want to trigger a call to the raw canister metrics. - * Therefore, we don't want to return `false` while the derived state is not present. - */ -export const hasBuyersCount = ( - derived: SnsSwapDid.DerivedState | undefined | null -): undefined | boolean => { - if (derived === undefined || derived === null) { - return undefined; - } - return nonNullish(fromNullable(derived.direct_participant_count)); + const count = fromNullable(direct_participant_count); + return nonNullish(count) ? Number(count) : undefined; }; diff --git a/frontend/src/lib/utils/sns.utils.ts b/frontend/src/lib/utils/sns.utils.ts index 06cf85f3626..fdc061e594f 100644 --- a/frontend/src/lib/utils/sns.utils.ts +++ b/frontend/src/lib/utils/sns.utils.ts @@ -100,28 +100,6 @@ export const hasOpenTicketInProcess = ({ return { status: "loading" }; }; -/** - * Parse the `sale_buyer_count` value from metrics text. - * - * @example text - * ... - * # TYPE sale_buyer_count gauge - * sale_buyer_count 33 1677707139456 - * # HELP sale_cf_participants_count - * ... - */ -export const parseSnsSwapSaleBuyerCount = ( - text: string -): number | undefined => { - const value = Number( - text - .split("\n") - ?.find((line) => line.startsWith("sale_buyer_count ")) - ?.split(/\s/)?.[1] - ); - return isNaN(value) ? undefined : value; -}; - /** * An SNS is in finalization state if: * diff --git a/frontend/src/tests/lib/api/sns-swap-metrics.api.spec.ts b/frontend/src/tests/lib/api/sns-swap-metrics.api.spec.ts deleted file mode 100644 index 561feee2bb9..00000000000 --- a/frontend/src/tests/lib/api/sns-swap-metrics.api.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { querySnsSwapMetrics } from "$lib/api/sns-swap-metrics.api"; -import { mockPrincipal } from "$tests/mocks/auth.store.mock"; - -describe("sns-swap-metrics.api", () => { - it("should query raw metrics with swapCanisterId", async () => { - const mockFetch = vi.fn(); - mockFetch.mockReturnValueOnce( - Promise.resolve({ - ok: true, - text: () => Promise.resolve("test"), - }) - ); - global.fetch = mockFetch; - - const swapCanisterId = mockPrincipal; - const result = await querySnsSwapMetrics({ swapCanisterId }); - - expect(result).toEqual("test"); - expect(mockFetch).toBeCalledTimes(1); - expect(mockFetch).toBeCalledWith( - expect.stringContaining(swapCanisterId.toText()) - ); - }); -}); diff --git a/frontend/src/tests/lib/components/project-detail/ProjectCommitment.spec.ts b/frontend/src/tests/lib/components/project-detail/ProjectCommitment.spec.ts index 85a7af7b773..5835608fe9d 100644 --- a/frontend/src/tests/lib/components/project-detail/ProjectCommitment.spec.ts +++ b/frontend/src/tests/lib/components/project-detail/ProjectCommitment.spec.ts @@ -1,5 +1,4 @@ import ProjectCommitment from "$lib/components/project-detail/ProjectCommitment.svelte"; -import { snsSwapMetricsStore } from "$lib/stores/sns-swap-metrics.store"; import type { SnsSwapCommitment } from "$lib/types/sns"; import type { SnsSummaryWrapper } from "$lib/types/sns-summary-wrapper"; import { @@ -37,29 +36,35 @@ describe("ProjectCommitment", () => { expect(await po.getMinCommitment()).toEqual("200.00 ICP"); }); - it("should render total participants from swap metrics", async () => { - snsSwapMetricsStore.setMetrics({ - rootCanisterId: mockSnsFullProject.swapCommitment.rootCanisterId, - metrics: { - saleBuyerCount, - }, - }); + it("should not render total participants when the derived state has none", async () => { const summaryWithoutBuyers = createSummary({ lifecycle: SnsSwapLifecycle.Open, buyersCount: null, }); const po = renderComponent(summaryWithoutBuyers); - expect(await po.getParticipantsCount()).toEqual(saleBuyerCount); + expect(await po.hasParticipantsCount()).toBe(false); }); - it("should render total participants from derived state", async () => { - snsSwapMetricsStore.setMetrics({ - rootCanisterId: mockSnsFullProject.swapCommitment.rootCanisterId, - metrics: { - saleBuyerCount: 0, - }, + it("should not render success message when the derived state has no participants count", async () => { + const directCommitment = 30000000000n; + const summary = createSummary({ + lifecycle: SnsSwapLifecycle.Open, + currentTotalCommitment: directCommitment, + neuronsFundCommitment: 0n, + directCommitment, + minDirectParticipation: 10000000000n, + maxDirectParticipation: 100000000000n, + buyersCount: null, + minParticipants: 100, }); + const po = renderComponent(summary); + + expect(await po.hasParticipantsCount()).toBe(false); + expect(await po.getGoalReachedMessage()).toEqual(null); + }); + + it("should render total participants from derived state", async () => { const summaryWithBuyersCount = createSummary({ lifecycle: SnsSwapLifecycle.Open, buyersCount: BigInt(saleBuyerCount), diff --git a/frontend/src/tests/lib/pages/ProjectDetail.spec.ts b/frontend/src/tests/lib/pages/ProjectDetail.spec.ts index e9c777f237e..e9edf0e904b 100644 --- a/frontend/src/tests/lib/pages/ProjectDetail.spec.ts +++ b/frontend/src/tests/lib/pages/ProjectDetail.spec.ts @@ -3,7 +3,6 @@ import * as locationApi from "$lib/api/location.api"; import * as nnsDappApi from "$lib/api/nns-dapp.api"; import * as proposalsApi from "$lib/api/proposals.api"; import * as snsSaleApi from "$lib/api/sns-sale.api"; -import * as snsMetricsApi from "$lib/api/sns-swap-metrics.api"; import * as snsApi from "$lib/api/sns.api"; import { SECONDS_IN_DAY } from "$lib/constants/constants"; import { AppPath } from "$lib/constants/routes.constants"; @@ -64,14 +63,6 @@ vi.mock("$lib/api/sns.api", async (importOriginal) => { }; }); -vi.mock("$lib/api/sns-swap-metrics.api", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - }; -}); - vi.mock("$lib/api/sns-sale.api", async (importOriginal) => { const actual = await importOriginal(); return { @@ -110,11 +101,6 @@ describe("ProjectDetail", () => { const userCountryCode = "CH"; const notUserCountryCode = "US"; const newBalance = 10_000_000_000n; - const saleBuyerCount = 1_000_000; - const rawMetricsText = ` -# TYPE sale_buyer_count gauge -sale_buyer_count ${saleBuyerCount} 1677707139456 -# HELP sale_cf_participants_count`; const now = Date.now(); const nowInSeconds = Math.floor(now / 1000); @@ -145,10 +131,6 @@ sale_buyer_count ${saleBuyerCount} 1677707139456 vi.spyOn(snsSaleApi, "queryFinalizationStatus").mockResolvedValue( snsFinalizationStatusResponseMock ); - - vi.spyOn(snsMetricsApi, "querySnsSwapMetrics").mockResolvedValue( - rawMetricsText - ); }); const renderComponent = ({ @@ -177,7 +159,6 @@ sale_buyer_count ${saleBuyerCount} 1677707139456 setNoIdentity(); }); - // TODO: Remove once all SNSes support buyers count in derived state describe("Open project without buyers count on derived state", () => { const props = { rootCanisterId: rootCanisterId.toText(), @@ -194,15 +175,23 @@ sale_buyer_count ${saleBuyerCount} 1677707139456 ]); }); - it("should fetch swap metrics on load", async () => { - expect(snsMetricsApi.querySnsSwapMetrics).toBeCalledTimes(0); + it("should not fetch the swap metrics from the raw domain", async () => { renderComponent(props); await runResolvedPromises(); - expect(snsMetricsApi.querySnsSwapMetrics).toBeCalledWith({ - swapCanisterId, - }); - expect(snsMetricsApi.querySnsSwapMetrics).toBeCalledTimes(1); + + const fetchedUrls = vi + .mocked(global.fetch) + .mock.calls.map(([url]) => String(url)); + expect( + fetchedUrls.filter((url) => url.includes("raw.icp0.io")) + ).toEqual([]); + }); + + it("should render status section", async () => { + const po = renderComponent(props); + + expect(await po.getProjectStatusSectionPo().isPresent()).toBe(true); }); }); @@ -221,19 +210,6 @@ sale_buyer_count ${saleBuyerCount} 1677707139456 ]); }); - it("should NOT start watching swap metrics", async () => { - renderComponent(props); - - await runResolvedPromises(); - expect(snsMetricsApi.querySnsSwapMetrics).toBeCalledTimes(0); - - const retryDelay = WATCH_SALE_STATE_EVERY_MILLISECONDS; - await advanceTime(retryDelay); - await runResolvedPromises(); - - expect(snsMetricsApi.querySnsSwapMetrics).toBeCalledTimes(0); - }); - it("should start watching derived state and stop on unmounting", async () => { let unmount: () => void; const unmountWhen = new Promise((resolve) => { @@ -287,38 +263,6 @@ sale_buyer_count ${saleBuyerCount} 1677707139456 }); }); - // TODO: Remove once all SNSes support buyers count in derived state - describe("Committed project without buyers in derived state", () => { - const props = { - rootCanisterId: rootCanisterId.toText(), - }; - beforeEach(() => { - setSnsProjects([ - { - rootCanisterId, - lifecycle: SnsSwapLifecycle.Committed, - directParticipantCount: [], - certified: true, - }, - ]); - }); - - it("should query metrics but not watch them", async () => { - const po = renderComponent(props); - - expect(await po.getProjectStatusSectionPo().isPresent()).toBe(true); - expect(snsMetricsApi.querySnsSwapMetrics).toBeCalledTimes(1); - - expect(snsMetricsApi.querySnsSwapMetrics).toBeCalledTimes(1); - - const retryDelay = WATCH_SALE_STATE_EVERY_MILLISECONDS; - - // Even after waiting a long time there shouldn't be more calls. - await advanceTime(99 * retryDelay); - expect(snsMetricsApi.querySnsSwapMetrics).toBeCalledTimes(1); - }); - }); - describe("Committed project with buyers count in derived state", () => { const props = { rootCanisterId: rootCanisterId.toText(), @@ -335,20 +279,6 @@ sale_buyer_count ${saleBuyerCount} 1677707139456 ]); }); - it("should NOT query metrics nor watch them", async () => { - const po = renderComponent(props); - - expect(await po.getProjectStatusSectionPo().isPresent()).toBe(true); - - expect(snsMetricsApi.querySnsSwapMetrics).toBeCalledTimes(0); - - const retryDelay = WATCH_SALE_STATE_EVERY_MILLISECONDS; - - // Even after waiting a long time there shouldn't be more calls. - await advanceTime(99 * retryDelay); - expect(snsMetricsApi.querySnsSwapMetrics).toBeCalledTimes(0); - }); - it("should not query total commitments, nor start watching them", async () => { const po = renderComponent(props); @@ -752,43 +682,6 @@ sale_buyer_count ${saleBuyerCount} 1677707139456 }); }); - describe("Committed project", () => { - const props = { - rootCanisterId: rootCanisterId.toText(), - }; - beforeEach(() => { - setSnsProjects([ - { - rootCanisterId, - lifecycle: SnsSwapLifecycle.Committed, - directParticipantCount: [], - certified: true, - }, - ]); - vi.spyOn(snsApi, "querySnsSwapCommitment").mockResolvedValue({ - rootCanisterId, - myCommitment: { - icp: [], - has_created_neuron_recipes: [], - }, - }); - }); - - it("should query metrics but not watch them", async () => { - const po = renderComponent(props); - - expect(await po.getProjectStatusSectionPo().isPresent()).toBe(true); - - expect(snsMetricsApi.querySnsSwapMetrics).toBeCalledTimes(1); - - const retryDelay = WATCH_SALE_STATE_EVERY_MILLISECONDS; - - // Even after waiting a long time there shouldn't be more calls. - await advanceTime(99 * retryDelay); - expect(snsMetricsApi.querySnsSwapMetrics).toBeCalledTimes(1); - }); - }); - describe("Committed project with buyers count in state", () => { const props = { rootCanisterId: rootCanisterId.toText(), @@ -820,20 +713,6 @@ sale_buyer_count ${saleBuyerCount} 1677707139456 }); }); - it("should NOT query metrics nor watch them", async () => { - const po = renderComponent(props); - - expect(await po.getProjectStatusSectionPo().isPresent()).toBe(true); - - expect(snsMetricsApi.querySnsSwapMetrics).toBeCalledTimes(0); - - const retryDelay = WATCH_SALE_STATE_EVERY_MILLISECONDS; - - // Even after waiting a long time there shouldn't be more calls. - await advanceTime(99 * retryDelay); - expect(snsMetricsApi.querySnsSwapMetrics).toBeCalledTimes(0); - }); - it("should not query total commitments, nor start watching them", async () => { const po = renderComponent(props); diff --git a/frontend/src/tests/lib/services/sns-swap-metrics.services.spec.ts b/frontend/src/tests/lib/services/sns-swap-metrics.services.spec.ts deleted file mode 100644 index b255bd0965d..00000000000 --- a/frontend/src/tests/lib/services/sns-swap-metrics.services.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import * as snsSwapMetrics from "$lib/api/sns-swap-metrics.api"; -import { loadSnsSwapMetrics } from "$lib/services/sns-swap-metrics.services"; -import { snsSwapMetricsStore } from "$lib/stores/sns-swap-metrics.store"; -import { mockPrincipal } from "$tests/mocks/auth.store.mock"; -import { Principal } from "@icp-sdk/core/principal"; -import { get } from "svelte/store"; - -describe("sns-swap-metrics", () => { - describe("loadSnsSwapMetrics", () => { - const rootCanisterId = mockPrincipal; - const swapCanisterId = Principal.fromText("aaaaa-aa"); - const saleBuyerCount = 1_000_000; - const rawMetricsText = ` -# TYPE sale_buyer_count gauge -sale_buyer_count ${saleBuyerCount} 1677707139456 -# HELP sale_cf_participants_count`; - - it("should call querySnsSwapMetrics api and load metrics in the store", async () => { - const querySnsSwapMetricsSpy = vi - .spyOn(snsSwapMetrics, "querySnsSwapMetrics") - .mockResolvedValue(rawMetricsText); - await loadSnsSwapMetrics({ - rootCanisterId, - swapCanisterId, - forceFetch: false, - }); - - expect(querySnsSwapMetricsSpy).toBeCalledTimes(1); - expect(querySnsSwapMetricsSpy).toBeCalledWith({ - swapCanisterId, - }); - expect( - get(snsSwapMetricsStore)[rootCanisterId.toText()]?.saleBuyerCount - ).toEqual(saleBuyerCount); - }); - - it("should skip querySnsSwapMetrics call when metrics available in store", async () => { - snsSwapMetricsStore.setMetrics({ - rootCanisterId, - metrics: { saleBuyerCount: 123 }, - }); - - const querySnsSwapMetricsSpy = vi - .spyOn(snsSwapMetrics, "querySnsSwapMetrics") - .mockResolvedValue(rawMetricsText); - await loadSnsSwapMetrics({ - rootCanisterId, - swapCanisterId, - forceFetch: false, - }); - - expect(querySnsSwapMetricsSpy).not.toBeCalled(); - }); - - it("should respect forceFetch flag", async () => { - snsSwapMetricsStore.setMetrics({ - rootCanisterId, - metrics: { saleBuyerCount: 123 }, - }); - const querySnsSwapMetricsSpy = vi - .spyOn(snsSwapMetrics, "querySnsSwapMetrics") - .mockResolvedValue(rawMetricsText); - - expect( - get(snsSwapMetricsStore)[rootCanisterId.toText()]?.saleBuyerCount - ).not.toEqual(saleBuyerCount); - - await loadSnsSwapMetrics({ - rootCanisterId, - swapCanisterId, - forceFetch: true, - }); - - expect(querySnsSwapMetricsSpy).toBeCalledTimes(1); - expect( - get(snsSwapMetricsStore)[rootCanisterId.toText()]?.saleBuyerCount - ).toEqual(saleBuyerCount); - }); - }); -}); diff --git a/frontend/src/tests/lib/stores/sns-swap-metrics.store.spec.ts b/frontend/src/tests/lib/stores/sns-swap-metrics.store.spec.ts deleted file mode 100644 index 3c559dd393b..00000000000 --- a/frontend/src/tests/lib/stores/sns-swap-metrics.store.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { snsSwapMetricsStore } from "$lib/stores/sns-swap-metrics.store"; -import { mockPrincipal } from "$tests/mocks/auth.store.mock"; -import { get } from "svelte/store"; - -describe("snsSwapMetricsStore", () => { - const metrics = { - saleBuyerCount: 123, - }; - - it("should set metrics", () => { - snsSwapMetricsStore.setMetrics({ - rootCanisterId: mockPrincipal, - metrics, - }); - - const $snsSwapMetricsStore = get(snsSwapMetricsStore); - expect($snsSwapMetricsStore[mockPrincipal.toText()]).toEqual(metrics); - }); -}); diff --git a/frontend/src/tests/lib/utils/sns-swap.utils.spec.ts b/frontend/src/tests/lib/utils/sns-swap.utils.spec.ts index 1d8633c6a74..295c550b16b 100644 --- a/frontend/src/tests/lib/utils/sns-swap.utils.spec.ts +++ b/frontend/src/tests/lib/utils/sns-swap.utils.spec.ts @@ -1,120 +1,34 @@ -import { hasBuyersCount, swapSaleBuyerCount } from "$lib/utils/sns-swap.utils"; -import { mockPrincipal } from "$tests/mocks/auth.store.mock"; +import { swapSaleBuyerCount } from "$lib/utils/sns-swap.utils"; import { mockDerived } from "$tests/mocks/sns-projects.mock"; import type { SnsSwapDid } from "@icp-sdk/canisters/sns"; -import { Principal } from "@icp-sdk/core/principal"; describe("sns-swap utils", () => { describe("swapSaleBuyerCount", () => { - describe("derived state does NOT have buyers count", () => { + it("should return undefined when the derived state has no participant count", () => { const derivedState: SnsSwapDid.DerivedState = { ...mockDerived, direct_participant_count: [], }; - it("should return undefined if rootCanisterId or metrics are not defined", () => { - expect( - swapSaleBuyerCount({ - rootCanisterId: undefined, - swapMetrics: undefined, - derivedState, - }) - ).toBeUndefined(); - expect( - swapSaleBuyerCount({ - rootCanisterId: undefined, - swapMetrics: null, - derivedState, - }) - ).toBeUndefined(); - expect( - swapSaleBuyerCount({ - rootCanisterId: mockPrincipal, - swapMetrics: undefined, - derivedState, - }) - ).toBeUndefined(); - expect( - swapSaleBuyerCount({ - rootCanisterId: mockPrincipal, - swapMetrics: null, - derivedState, - }) - ).toBeUndefined(); - expect( - swapSaleBuyerCount({ - rootCanisterId: undefined, - swapMetrics: { ["aaaaa-aa"]: { saleBuyerCount: 10 } }, - derivedState, - }) - ).toBeUndefined(); - }); - it("should return undefined if root canister id is not in the metrics store", () => { - expect( - swapSaleBuyerCount({ - rootCanisterId: Principal.fromText( - "xlmdg-vkosz-ceopx-7wtgu-g3xmd-koiyc-awqaq-7modz-zf6r6-364rh-oqe" - ), - swapMetrics: { ["aaaaa-aa"]: { saleBuyerCount: 10 } }, - derivedState, - }) - ).toBeUndefined(); - }); - - it("should return sale count of the selected root canister id", () => { - const saleCount = 100; - expect( - swapSaleBuyerCount({ - rootCanisterId: mockPrincipal, - swapMetrics: { - [mockPrincipal.toText()]: { saleBuyerCount: saleCount }, - }, - derivedState, - }) - ).toBe(saleCount); - }); + expect(swapSaleBuyerCount({ derivedState })).toBeUndefined(); }); - describe("derived state has buyers count", () => { - const participantsCount = 100; + it("should return the participant count of the derived state", () => { const derivedState: SnsSwapDid.DerivedState = { ...mockDerived, - direct_participant_count: [BigInt(participantsCount)], + direct_participant_count: [30n], }; - it("should return sale count of the derived store ignoring the swap metrics data", () => { - expect( - swapSaleBuyerCount({ - rootCanisterId: mockPrincipal, - swapMetrics: { - [mockPrincipal.toText()]: { saleBuyerCount: 400 }, - }, - derivedState, - }) - ).toBe(participantsCount); - }); - }); - }); - describe("hasBuyersCount", () => { - const derivedWithBuyersCount: SnsSwapDid.DerivedState = { - ...mockDerived, - direct_participant_count: [100n], - }; - const derivedWithoutBuyersCount: SnsSwapDid.DerivedState = { - ...mockDerived, - direct_participant_count: [], - }; - it("should return undefined if derived state is undefined or null", () => { - expect(hasBuyersCount(undefined)).toBeUndefined(); - expect(hasBuyersCount(null)).toBeUndefined(); + expect(swapSaleBuyerCount({ derivedState })).toBe(30); }); - it("should return true if derived state has buyers count", () => { - expect(hasBuyersCount(derivedWithBuyersCount)).toBe(true); - }); + it("should return zero when the derived state reports no participant", () => { + const derivedState: SnsSwapDid.DerivedState = { + ...mockDerived, + direct_participant_count: [0n], + }; - it("should return false if derived state has no buyers count", () => { - expect(hasBuyersCount(derivedWithoutBuyersCount)).toBe(false); + expect(swapSaleBuyerCount({ derivedState })).toBe(0); }); }); }); diff --git a/frontend/src/tests/lib/utils/sns.utils.spec.ts b/frontend/src/tests/lib/utils/sns.utils.spec.ts index c8a950a8067..e6969b35998 100644 --- a/frontend/src/tests/lib/utils/sns.utils.spec.ts +++ b/frontend/src/tests/lib/utils/sns.utils.spec.ts @@ -13,7 +13,6 @@ import { isSnsGenericNervousSystemTypeProposal, isSnsLedgerCanisterId, isSnsNativeNervousSystemFunction, - parseSnsSwapSaleBuyerCount, swapEndedMoreThanOneWeekAgo, } from "$lib/utils/sns.utils"; import { mockIdentity, mockPrincipal } from "$tests/mocks/auth.store.mock"; @@ -192,41 +191,6 @@ describe("sns-utils", () => { }); }); - describe("parseSnsSwapSaleBuyerCount", () => { - const saleBuyerCount = 1_000_000; - const RAW_METRICS = ` -# TYPE sale_buyer_count gauge -sale_buyer_count ${saleBuyerCount} 1677707139456 -# HELP sale_cf_participants_count`; - - it("returns sale_buyer_count value", () => { - expect(parseSnsSwapSaleBuyerCount(RAW_METRICS)).toEqual(saleBuyerCount); - }); - - it("returns undefined when sale_buyer_count not found", () => { - const WRONG_METRICS = ` -# TYPE sale_buyer_count gauge -sale_participants_count ${saleBuyerCount} 1677707139456 -# HELP sale_cf_participants_count`; - expect(parseSnsSwapSaleBuyerCount(WRONG_METRICS)).toBeUndefined(); - }); - - it("returns false on unknown error", () => { - const error = new Error("Fake the swap has already reached its target"); - expect(isInternalRefreshBuyerTokensError(error)).toBe(false); - }); - - it("returns false on not error argument", () => { - expect(isInternalRefreshBuyerTokensError(null)).toBe(false); - expect(isInternalRefreshBuyerTokensError(undefined)).toBe(false); - expect( - isInternalRefreshBuyerTokensError( - "The swap has already reached its target" - ) - ).toBe(false); - }); - }); - describe("isSnsFinalizing", () => { it("returns true if finalizing", () => { const finalizingResponse = createFinalizationStatusMock(true); diff --git a/frontend/src/tests/page-objects/ProjectCommitment.page-object.ts b/frontend/src/tests/page-objects/ProjectCommitment.page-object.ts index 665276d1729..9f2df0a847d 100644 --- a/frontend/src/tests/page-objects/ProjectCommitment.page-object.ts +++ b/frontend/src/tests/page-objects/ProjectCommitment.page-object.ts @@ -31,6 +31,10 @@ export class ProjectCommitmentPo extends BasePageObject { return Number(await this.getText("sns-project-current-sale-buyer-count")); } + hasParticipantsCount(): Promise { + return this.isPresent("sns-project-current-sale-buyer-count"); + } + async getGoalReachedMessage(): Promise { return this.getText("min-participation-reached"); } From dd09a391e09622a3325e39ac5ad52d783909184b Mon Sep 17 00:00:00 2001 From: Yusef Habib Fernandez Date: Fri, 4 Sep 2026 20:27:16 +0200 Subject: [PATCH 2/5] test(sns): add an e2e spec for the swap participant count The spec opens a sale that accepts participation, reads the participant count, participates, and checks that the count rises by one. It also records every network request and checks that no request goes to a raw metrics URL. --- .../e2e/sns-swap-participant-count.spec.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 frontend/src/tests/e2e/sns-swap-participant-count.spec.ts diff --git a/frontend/src/tests/e2e/sns-swap-participant-count.spec.ts b/frontend/src/tests/e2e/sns-swap-participant-count.spec.ts new file mode 100644 index 00000000000..00f7f972dce --- /dev/null +++ b/frontend/src/tests/e2e/sns-swap-participant-count.spec.ts @@ -0,0 +1,80 @@ +import { AppPo } from "$tests/page-objects/App.page-object"; +import { ProjectCommitmentPo } from "$tests/page-objects/ProjectCommitment.page-object"; +import { PlaywrightPageObjectElement } from "$tests/page-objects/playwright.page-object"; +import { + disableCssAnimations, + signInWithNewUser, + step, +} from "$tests/utils/e2e.test-utils"; +import { expect, test } from "@playwright/test"; + +// The swap participant count must come from the certified `get_derived_state` +// call. The removed code read it from `https://.raw.icp0.io/metrics`, +// which the raw gateway serves without response certification. +const RAW_METRICS_PATTERN = /\.raw\.(icp0\.io|ic0\.app)\/metrics/; + +// playwright.config.ts sets expect.timeout to 0, so every poll needs its own. +const POLL_TIMEOUT = 60_000; + +test("Test SNS swap participant count", async ({ page, context }) => { + const requestedUrls: string[] = []; + page.on("request", (request) => requestedUrls.push(request.url())); + + const rawMetricsRequests = () => + requestedUrls.filter((url) => RAW_METRICS_PATTERN.test(url)); + + await page.goto("/"); + await disableCssAnimations(page); + + const pageElement = PlaywrightPageObjectElement.fromPage(page); + const appPo = new AppPo(pageElement); + const projectCommitmentPo = ProjectCommitmentPo.under(pageElement); + const projectDetail = appPo.getProjectDetailPo(); + + step("Open the detail page of a sale that accepts participation"); + await appPo.goToLaunchpad(); + await appPo.getLaunchpad2Po().getUpcomingLaunchesCardListPo().waitFor(); + const upcomingLaunchesCards = await appPo + .getLaunchpad2Po() + .getUpcomingLaunchesCardListPo() + .getCardEntries(); + await upcomingLaunchesCards[0].click(); + + await projectDetail.waitForContentLoaded(); + expect(await projectDetail.getStatus()).toBe("Accepting Participation"); + + step("The page shows a participant count"); + await expect + .poll(() => projectCommitmentPo.hasParticipantsCount(), { + timeout: POLL_TIMEOUT, + }) + .toBe(true); + const countBeforeParticipation = + await projectCommitmentPo.getParticipantsCount(); + expect(Number.isNaN(countBeforeParticipation)).toBe(false); + + step("The page requests no metrics from the raw domain"); + expect(rawMetricsRequests()).toEqual([]); + + step("Sign in and get some ICP to participate in the sale"); + await signInWithNewUser({ page, context }); + await appPo.goBack(); + await appPo.getIcpTokens(20); + await upcomingLaunchesCards[0].click(); + await projectDetail.waitForContentLoaded(); + + step("Participate in the sale"); + expect(await projectDetail.hasCommitmentAmount()).toBe(false); + await projectDetail.participate({ amount: 5, acceptConditions: true }); + expect(await projectDetail.getCommitmentAmount()).toBe("5.00"); + + step("The participant count rises by one, so it follows the swap state"); + await expect + .poll(() => projectCommitmentPo.getParticipantsCount(), { + timeout: POLL_TIMEOUT, + }) + .toBe(countBeforeParticipation + 1); + + step("No request went to the raw metrics domain at any point"); + expect(rawMetricsRequests()).toEqual([]); +}); From 0351a14d2c13bf456b8202baeb63677af049de8a Mon Sep 17 00:00:00 2001 From: Yusef Habib Fernandez Date: Fri, 4 Sep 2026 22:31:37 +0200 Subject: [PATCH 3/5] test(sns): await the step helper calls in the swap participant count spec step() is async; a call without await lets Playwright run the next step before the report closes the previous one. --- .../tests/e2e/sns-swap-participant-count.spec.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/src/tests/e2e/sns-swap-participant-count.spec.ts b/frontend/src/tests/e2e/sns-swap-participant-count.spec.ts index 00f7f972dce..0042c25030d 100644 --- a/frontend/src/tests/e2e/sns-swap-participant-count.spec.ts +++ b/frontend/src/tests/e2e/sns-swap-participant-count.spec.ts @@ -31,7 +31,7 @@ test("Test SNS swap participant count", async ({ page, context }) => { const projectCommitmentPo = ProjectCommitmentPo.under(pageElement); const projectDetail = appPo.getProjectDetailPo(); - step("Open the detail page of a sale that accepts participation"); + await step("Open the detail page of a sale that accepts participation"); await appPo.goToLaunchpad(); await appPo.getLaunchpad2Po().getUpcomingLaunchesCardListPo().waitFor(); const upcomingLaunchesCards = await appPo @@ -43,7 +43,7 @@ test("Test SNS swap participant count", async ({ page, context }) => { await projectDetail.waitForContentLoaded(); expect(await projectDetail.getStatus()).toBe("Accepting Participation"); - step("The page shows a participant count"); + await step("The page shows a participant count"); await expect .poll(() => projectCommitmentPo.hasParticipantsCount(), { timeout: POLL_TIMEOUT, @@ -53,28 +53,30 @@ test("Test SNS swap participant count", async ({ page, context }) => { await projectCommitmentPo.getParticipantsCount(); expect(Number.isNaN(countBeforeParticipation)).toBe(false); - step("The page requests no metrics from the raw domain"); + await step("The page requests no metrics from the raw domain"); expect(rawMetricsRequests()).toEqual([]); - step("Sign in and get some ICP to participate in the sale"); + await step("Sign in and get some ICP to participate in the sale"); await signInWithNewUser({ page, context }); await appPo.goBack(); await appPo.getIcpTokens(20); await upcomingLaunchesCards[0].click(); await projectDetail.waitForContentLoaded(); - step("Participate in the sale"); + await step("Participate in the sale"); expect(await projectDetail.hasCommitmentAmount()).toBe(false); await projectDetail.participate({ amount: 5, acceptConditions: true }); expect(await projectDetail.getCommitmentAmount()).toBe("5.00"); - step("The participant count rises by one, so it follows the swap state"); + await step( + "The participant count rises by one, so it follows the swap state" + ); await expect .poll(() => projectCommitmentPo.getParticipantsCount(), { timeout: POLL_TIMEOUT, }) .toBe(countBeforeParticipation + 1); - step("No request went to the raw metrics domain at any point"); + await step("No request went to the raw metrics domain at any point"); expect(rawMetricsRequests()).toEqual([]); }); From 6c33257da88847309942bfafd44418e32189a4d3 Mon Sep 17 00:00:00 2001 From: Yusef Habib Fernandez Date: Fri, 4 Sep 2026 22:53:49 +0200 Subject: [PATCH 4/5] test(sns): return null safely when the goal-reached banner is absent getGoalReachedMessage called getText, which times out in the Playwright backend when the element is not present. Check isPresent first and return null, so the helper behaves the same in Jest and Playwright. --- .../src/tests/page-objects/ProjectCommitment.page-object.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/tests/page-objects/ProjectCommitment.page-object.ts b/frontend/src/tests/page-objects/ProjectCommitment.page-object.ts index 9f2df0a847d..41e66d44f45 100644 --- a/frontend/src/tests/page-objects/ProjectCommitment.page-object.ts +++ b/frontend/src/tests/page-objects/ProjectCommitment.page-object.ts @@ -35,7 +35,10 @@ export class ProjectCommitmentPo extends BasePageObject { return this.isPresent("sns-project-current-sale-buyer-count"); } - async getGoalReachedMessage(): Promise { + async getGoalReachedMessage(): Promise { + if (!(await this.isPresent("min-participation-reached"))) { + return null; + } return this.getText("min-participation-reached"); } From 4ce6e466fb2e9ebdd29ffc0e9d10c063afb927e3 Mon Sep 17 00:00:00 2001 From: Yusef Habib Fernandez Date: Fri, 4 Sep 2026 23:40:54 +0200 Subject: [PATCH 5/5] test(sns): tolerate concurrent participation in the swap count spec Another e2e file participates in the same first upcoming sale, so a worker running both files at once can raise the count by more than one. Check for at least one more, not exactly one more. --- .../src/tests/e2e/sns-swap-participant-count.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/tests/e2e/sns-swap-participant-count.spec.ts b/frontend/src/tests/e2e/sns-swap-participant-count.spec.ts index 0042c25030d..dba474acc03 100644 --- a/frontend/src/tests/e2e/sns-swap-participant-count.spec.ts +++ b/frontend/src/tests/e2e/sns-swap-participant-count.spec.ts @@ -68,14 +68,15 @@ test("Test SNS swap participant count", async ({ page, context }) => { await projectDetail.participate({ amount: 5, acceptConditions: true }); expect(await projectDetail.getCommitmentAmount()).toBe("5.00"); - await step( - "The participant count rises by one, so it follows the swap state" - ); + await step("The participant count rises, so it follows the swap state"); + // Another worker can also participate in this sale at the same time + // (sns-participation.spec.ts uses the same first upcoming launch), so the + // count can rise by more than one. Check for at least one, not exactly one. await expect .poll(() => projectCommitmentPo.getParticipantsCount(), { timeout: POLL_TIMEOUT, }) - .toBe(countBeforeParticipation + 1); + .toBeGreaterThanOrEqual(countBeforeParticipation + 1); await step("No request went to the raw metrics domain at any point"); expect(rawMetricsRequests()).toEqual([]);