Pārlūkot izejas kodu

feat: publisher conformance report

Alexandru Cambose 2 mēneši atpakaļ
vecāks
revīzija
742cee6680

+ 4 - 2
apps/insights/package.json

@@ -32,6 +32,8 @@
     "bs58": "catalog:",
     "clsx": "catalog:",
     "cryptocurrency-icons": "catalog:",
+    "date-fns": "catalog:",
+    "csv-stringify": "catalog:",
     "dnum": "catalog:",
     "ioredis": "^5.7.0",
     "lightweight-charts": "catalog:",
@@ -46,8 +48,8 @@
     "superjson": "catalog:",
     "swr": "catalog:",
     "zod": "catalog:",
-    "zod-validation-error": "catalog:",
-    "zod-search-params": "catalog:"
+    "zod-search-params": "catalog:",
+    "zod-validation-error": "catalog:"
   },
   "devDependencies": {
     "@cprussin/eslint-config": "catalog:",

+ 1 - 1
apps/insights/src/app/api/pyth/get-feeds/route.ts

@@ -37,5 +37,5 @@ export const GET = async (request: NextRequest) => {
 
 const queryParamsSchema = z.object({
   cluster: z.enum(CLUSTER_NAMES).transform((value) => toCluster(value)),
-  excludePriceComponents: z.boolean(),
+  excludePriceComponents: z.boolean().optional().default(false),
 });

+ 1 - 1
apps/insights/src/components/PriceFeed/conformance-reports.module.scss → apps/insights/src/components/ConformanceReport/conformance-report.module.scss

@@ -1,4 +1,4 @@
-.conformanceReports {
+.conformanceReport {
   display: flex;
   gap: 0.5rem;
 }

+ 100 - 0
apps/insights/src/components/ConformanceReport/conformance-report.tsx

@@ -0,0 +1,100 @@
+"use client";
+
+import { Download } from "@phosphor-icons/react/dist/ssr/Download";
+import { Button } from "@pythnetwork/component-library/Button";
+import { Select } from "@pythnetwork/component-library/Select";
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+import { useAlert } from "@pythnetwork/component-library/useAlert";
+import { useLogger } from "@pythnetwork/component-library/useLogger";
+import { useState } from "react";
+
+import styles from "./conformance-report.module.scss";
+import type { Interval } from "./types";
+import { INTERVALS } from "./types";
+import { useDownloadReportForFeed } from "./use-download-report-for-feed";
+import { useDownloadReportForPublisher } from "./use-download-report-for-publisher";
+
+type ConformanceReportProps =
+  | { isLoading: true }
+  | {
+      isLoading?: false | undefined;
+      symbol?: string;
+      cluster: "pythnet" | "pythtest-conformance";
+      publisher?: string;
+    };
+
+const ConformanceReport = (props: ConformanceReportProps) => {
+  const [timeframe, setTimeframe] = useState<Interval>(INTERVALS[0]);
+  const [isGeneratingReport, setIsGeneratingReport] = useState(false);
+  const { open } = useAlert();
+  const downloadReportForFeed = useDownloadReportForFeed();
+  const downloadReportForPublisher = useDownloadReportForPublisher();
+  const logger = useLogger();
+
+  const downloadReport = async () => {
+    if (props.isLoading) {
+      return;
+    }
+    if (props.symbol && props.publisher) {
+      await downloadReportForFeed({
+        symbol: props.symbol,
+        publisher: props.publisher,
+        timeframe,
+        cluster: props.cluster,
+      });
+    }
+
+    if (props.publisher) {
+      await downloadReportForPublisher({
+        publisher: props.publisher,
+        cluster: props.cluster,
+        interval: timeframe,
+      });
+    }
+  };
+
+  const handleReport = () => {
+    setIsGeneratingReport(true);
+    downloadReport()
+      .catch((error: unknown) => {
+        open({
+          title: "Error",
+          contents: "Error generating conformance report",
+        });
+        logger.error(error);
+      })
+      .finally(() => {
+        setIsGeneratingReport(false);
+      });
+  };
+
+  if (props.isLoading) {
+    return <Skeleton width={100} />;
+  }
+
+  return (
+    <div className={styles.conformanceReport}>
+      <Select
+        options={INTERVALS.map((interval) => ({ id: interval }))}
+        placement="bottom end"
+        selectedKey={timeframe}
+        onSelectionChange={setTimeframe}
+        size="sm"
+        label="Timeframe"
+        variant="outline"
+        hideLabel
+      />
+      <Button
+        variant="outline"
+        size="sm"
+        onClick={handleReport}
+        afterIcon={<Download key="download" />}
+        isPending={isGeneratingReport}
+      >
+        Report
+      </Button>
+    </div>
+  );
+};
+
+export default ConformanceReport;

+ 1 - 0
apps/insights/src/components/ConformanceReport/constants.ts

@@ -0,0 +1 @@
+export const WEB_API_BASE_URL = "https://web-api.pyth.network";

+ 2 - 0
apps/insights/src/components/ConformanceReport/types.ts

@@ -0,0 +1,2 @@
+export const INTERVALS = ["24H", "48H", "72H", "1W", "1M"] as const;
+export type Interval = (typeof INTERVALS)[number];

+ 52 - 0
apps/insights/src/components/ConformanceReport/use-download-report-for-feed.tsx

@@ -0,0 +1,52 @@
+import { useCallback } from "react";
+
+import { WEB_API_BASE_URL } from "./constants";
+import { useDownloadBlob } from "../../hooks/use-download-blob";
+
+const PYTHTEST_CONFORMANCE_REFERENCE_PUBLISHER =
+  "HUZu4xMSHbxTWbkXR6jkGdjvDPJLjrpSNXSoUFBRgjWs";
+
+export const useDownloadReportForFeed = () => {
+  const download = useDownloadBlob();
+
+  return useCallback(
+    async ({
+      symbol,
+      publisher,
+      timeframe,
+      cluster,
+    }: {
+      symbol: string;
+      publisher: string;
+      timeframe: string;
+      cluster: string;
+    }) => {
+      const url = new URL("/metrics/conformance", WEB_API_BASE_URL);
+      url.searchParams.set("symbol", symbol);
+      url.searchParams.set("range", timeframe);
+      url.searchParams.set("cluster", cluster);
+      url.searchParams.set("publisher", publisher);
+
+      if (cluster === "pythtest-conformance") {
+        url.searchParams.set(
+          "pythnet_aggregate_publisher",
+          PYTHTEST_CONFORMANCE_REFERENCE_PUBLISHER,
+        );
+      }
+
+      const response = await fetch(url, {
+        headers: new Headers({
+          Accept: "application/octet-stream",
+        }),
+      });
+      const blob = await response.blob();
+      download(
+        blob,
+        `${publisher}-${symbol
+          .split("/")
+          .join("")}-${timeframe}-${cluster}-conformance-report.tsv`,
+      );
+    },
+    [download],
+  );
+};

+ 221 - 0
apps/insights/src/components/ConformanceReport/use-download-report-for-publisher.tsx

@@ -0,0 +1,221 @@
+import { stringify as stringifyCsv } from "csv-stringify/sync";
+import {
+  addDays,
+  differenceInDays,
+  format,
+  isBefore,
+  startOfMonth,
+  startOfWeek,
+  subMonths,
+} from "date-fns";
+import { useCallback } from "react";
+import { parse } from "superjson";
+import { z } from "zod";
+
+import { WEB_API_BASE_URL } from "./constants";
+import type { Interval } from "./types";
+import { useDownloadBlob } from "../../hooks/use-download-blob";
+import { priceFeedsSchema } from "../../schemas/pyth";
+
+// If interval is 'daily', set interval_days=1
+// If interval is 'weekly', get the previous Sunday and set interval_days=7
+// If interval is 'monthly', get the 15th of the current month and set interval_day to the
+// difference between the 15th of the current month and the 15th of the previous month which is 28-31 days.
+const getRankingDateAndIntervalDays = (date: Date, interval: Interval) => {
+  switch (interval) {
+    case "24H": {
+      return {
+        date,
+        intervalDays: 1,
+      };
+    }
+    case "48H": {
+      return {
+        date,
+        intervalDays: 2,
+      };
+    }
+    case "72H": {
+      return {
+        date,
+        intervalDays: 3,
+      };
+    }
+    case "1W": {
+      return {
+        date: startOfWeek(date),
+        intervalDays: 7,
+      };
+    }
+    case "1M": {
+      const monthStart = startOfMonth(date);
+      let midMonth = addDays(monthStart, 14);
+      if (isBefore(date, midMonth)) {
+        midMonth = subMonths(midMonth, 1);
+      }
+      const midMonthBefore = subMonths(midMonth, 1);
+      return {
+        date: midMonth,
+        intervalDays: differenceInDays(midMonth, midMonthBefore),
+      };
+    }
+  }
+};
+
+const getFeeds = async (cluster: string) => {
+  const url = new URL(`/api/pyth/get-feeds`, globalThis.window.origin);
+  url.searchParams.set("cluster", cluster);
+  const data = await fetch(url);
+  const rawData = await data.text();
+  const parsedData = parse(rawData);
+  return priceFeedsSchema.element.array().parse(parsedData);
+};
+
+const PublisherQualityScoreSchema = z.object({
+  symbol: z.string(),
+  uptime_score: z.string(),
+  deviation_penalty: z.string(),
+  deviation_score: z.string(),
+  stalled_penalty: z.string(),
+  stalled_score: z.string(),
+  final_score: z.string(),
+});
+
+const PublisherQuantityScoreSchema = z.object({
+  numSymbols: z.number(),
+  rank: z.number(),
+  symbols: z.array(z.string()),
+  timestamp: z.string(),
+});
+
+const fetchRankingData = async (
+  cluster: string,
+  publisher: string,
+  interval: Interval,
+) => {
+  const { date, intervalDays } = getRankingDateAndIntervalDays(
+    new Date(),
+    interval,
+  );
+  const quantityRankUrl = new URL(
+    `/publisher_ranking?publisher=${publisher}&cluster=${cluster}`,
+    WEB_API_BASE_URL,
+  );
+  quantityRankUrl.searchParams.set("cluster", cluster);
+  quantityRankUrl.searchParams.set("publisher", publisher);
+  const qualityRankUrl = new URL(
+    `/publisher_quality_ranking_score`,
+    WEB_API_BASE_URL,
+  );
+  qualityRankUrl.searchParams.set("cluster", cluster);
+  qualityRankUrl.searchParams.set("publisher", publisher);
+  qualityRankUrl.searchParams.set("date", format(date, "yyyy-MM-dd"));
+  qualityRankUrl.searchParams.set("interval_days", intervalDays.toString());
+
+  const [quantityRankRes, qualityRankRes] = await Promise.all([
+    fetch(quantityRankUrl),
+    fetch(qualityRankUrl),
+  ]);
+
+  return {
+    quantityRankData: PublisherQuantityScoreSchema.array().parse(
+      await quantityRankRes.json(),
+    ),
+    qualityRankData: PublisherQualityScoreSchema.array().parse(
+      await qualityRankRes.json(),
+    ),
+  };
+};
+const csvHeaders = [
+  "priceFeed",
+  "assetType",
+  "description",
+  "status",
+  "permissioned",
+  "uptime_score",
+  "deviation_penalty",
+  "deviation_score",
+  "stalled_penalty",
+  "stalled_score",
+  "final_score",
+];
+
+export const useDownloadReportForPublisher = () => {
+  const download = useDownloadBlob();
+
+  return useCallback(
+    async ({
+      publisher,
+      cluster,
+      interval,
+    }: {
+      publisher: string;
+      cluster: string;
+      interval: Interval;
+    }) => {
+      const [rankingData, allFeeds] = await Promise.all([
+        fetchRankingData(cluster, publisher, interval),
+        getFeeds(cluster),
+      ]);
+
+      const isPermissioned = (feed: string) =>
+        allFeeds
+          .find((f) => f.symbol === feed)
+          ?.price.priceComponents.some((c) => c.publisher === publisher);
+
+      const getPriceFeedData = (feed: string) => {
+        const rankData = rankingData.qualityRankData.find(
+          (obj) => obj.symbol === feed,
+        );
+        const feedMetadata = allFeeds.find((f) => f.symbol === feed);
+        return {
+          priceFeed: feedMetadata?.product.display_symbol ?? "",
+          assetType: feedMetadata?.product.asset_type ?? "",
+          description: feedMetadata?.product.description ?? "",
+          ...rankData,
+        };
+      };
+
+      const activePriceFeeds = rankingData.quantityRankData[0]?.symbols ?? [];
+
+      const allSymbols = allFeeds
+        .flatMap((feed) => feed.symbol)
+        .filter((symbol: string) => symbol && !symbol.includes("NULL"));
+      // filter out inactive price feeds
+      const inactivePriceFeeds = allSymbols
+        .filter((symbol) => {
+          const meta = allFeeds.find((f) => f.symbol === symbol);
+          return (
+            meta !== undefined &&
+            !activePriceFeeds.includes(symbol) &&
+            meta.price.numComponentPrices > 0
+          );
+        })
+        .sort((a, b) => {
+          const aSplit = a.split(".");
+          const bSplit = b.split(".");
+          const aLast = aSplit.at(-1);
+          const bLast = bSplit.at(-1);
+          return aLast?.localeCompare(bLast ?? "") ?? 0;
+        });
+      const data = [
+        ...activePriceFeeds.map((feed) => ({
+          ...getPriceFeedData(feed),
+          status: "active",
+          permissioned: "permissioned",
+        })),
+        ...inactivePriceFeeds.map((feed) => ({
+          ...getPriceFeedData(feed),
+          status: "inactive",
+          permissioned: isPermissioned(feed)
+            ? "permissioned"
+            : "unpermissioned",
+        })),
+      ];
+      const csv = stringifyCsv(data, { header: true, columns: csvHeaders });
+      const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
+      download(blob, `${publisher}-${cluster}-price-feeds.csv`);
+    },
+    [download],
+  );
+};

+ 12 - 6
apps/insights/src/components/PriceComponentDrawer/index.tsx

@@ -15,32 +15,33 @@ import { useLogger } from "@pythnetwork/component-library/useLogger";
 import { useMountEffect } from "@react-hookz/web";
 import dynamic from "next/dynamic";
 import { useRouter } from "next/navigation";
-import { useQueryState, parseAsString } from "nuqs";
+import { parseAsString, useQueryState } from "nuqs";
 import type { ReactNode } from "react";
 import {
   Suspense,
-  useState,
   useCallback,
   useMemo,
-  useTransition,
   useRef,
+  useState,
+  useTransition,
 } from "react";
 import {
   RouterProvider,
   useDateFormatter,
   useNumberFormatter,
 } from "react-aria";
-import { ResponsiveContainer, Tooltip, Line, XAxis, YAxis } from "recharts";
+import { Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
 import type { CategoricalChartState } from "recharts/types/chart/types";
 import { z } from "zod";
 
-import styles from "./index.module.scss";
 import { Cluster, ClusterToName } from "../../services/pyth";
 import type { Status } from "../../status";
-import { LiveConfidence, LivePrice, LiveComponentValue } from "../LivePrices";
+import ConformanceReport from "../ConformanceReport/conformance-report";
+import { LiveComponentValue, LiveConfidence, LivePrice } from "../LivePrices";
 import { PriceName } from "../PriceName";
 import { Score } from "../Score";
 import { Status as StatusComponent } from "../Status";
+import styles from "./index.module.scss";
 
 const LineChart = dynamic(
   () => import("recharts").then((recharts) => recharts.LineChart),
@@ -274,6 +275,11 @@ type HeadingExtraProps = {
 const HeadingExtra = ({ status, ...props }: HeadingExtraProps) => {
   return (
     <>
+      <ConformanceReport
+        symbol={props.symbol}
+        publisher={props.publisherKey}
+        cluster={ClusterToName[props.cluster]}
+      />
       <div className={styles.bigScreenBadges}>
         <StatusComponent status={status} />
       </div>

+ 0 - 122
apps/insights/src/components/PriceFeed/conformance-reports.tsx

@@ -1,122 +0,0 @@
-"use client";
-
-import { Download } from "@phosphor-icons/react/dist/ssr/Download";
-import { Button } from "@pythnetwork/component-library/Button";
-import { Select } from "@pythnetwork/component-library/Select";
-import { Skeleton } from "@pythnetwork/component-library/Skeleton";
-import { useAlert } from "@pythnetwork/component-library/useAlert";
-import { useState } from "react";
-
-import styles from "./conformance-reports.module.scss";
-
-const PYTHTEST_CONFORMANCE_REFERENCE_PUBLISHER =
-  "HUZu4xMSHbxTWbkXR6jkGdjvDPJLjrpSNXSoUFBRgjWs";
-
-const download = (blob: Blob, filename: string) => {
-  const url = globalThis.URL.createObjectURL(blob);
-  const a = document.createElement("a");
-  a.href = url;
-  a.download = filename;
-  document.body.append(a);
-  a.click();
-  a.remove();
-};
-
-type ConformanceReportsProps =
-  | { isLoading: true }
-  | {
-      isLoading?: false | undefined;
-      symbol: string;
-      cluster: string;
-      publisher: string;
-    };
-
-const ConformanceReports = (props: ConformanceReportsProps) => {
-  const [timeframe, setTimeframe] = useState("24H");
-  const [isGeneratingReport, setIsGeneratingReport] = useState(false);
-  const { open } = useAlert();
-
-  const downloadReport = async () => {
-    if (props.isLoading) {
-      return;
-    }
-    const url = new URL(
-      "/pyth/metrics/conformance",
-      "https://web-api.pyth.network/",
-    );
-    url.searchParams.set("symbol", props.symbol);
-    url.searchParams.set("range", timeframe);
-    url.searchParams.set("cluster", "pythnet");
-    url.searchParams.set("publisher", props.publisher);
-
-    if (props.cluster === "pythtest-conformance") {
-      url.searchParams.set(
-        "pythnet_aggregate_publisher",
-        PYTHTEST_CONFORMANCE_REFERENCE_PUBLISHER,
-      );
-    }
-
-    const response = await fetch(url, {
-      headers: new Headers({
-        Accept: "application/octet-stream",
-      }),
-    });
-    const blob = await response.blob();
-    download(
-      blob,
-      `${props.publisher}-${props.symbol
-        .split("/")
-        .join("")}-${timeframe}-${props.cluster}-conformance-report.tsv`,
-    );
-  };
-
-  const handleReport = () => {
-    setIsGeneratingReport(true);
-    try {
-      downloadReport().catch(() => {
-        open({
-          title: "Error",
-          contents: "Error generating conformance report",
-        });
-      });
-    } finally {
-      setIsGeneratingReport(false);
-    }
-  };
-  if (props.isLoading) {
-    return <Skeleton width={100} />;
-  }
-  return (
-    <div className={styles.conformanceReports}>
-      <Select
-        options={[
-          { id: "24H" },
-          { id: "48H" },
-          { id: "72H" },
-          { id: "1W" },
-          { id: "1M" },
-        ]}
-        placement="bottom end"
-        selectedKey={timeframe}
-        onSelectionChange={(value) => {
-          setTimeframe(value);
-        }}
-        size="sm"
-        label="Timeframe"
-        variant="outline"
-        hideLabel
-      />
-      <Button
-        variant="outline"
-        size="sm"
-        onClick={handleReport}
-        afterIcon={<Download key="download" />}
-        isPending={isGeneratingReport}
-      >
-        Report
-      </Button>
-    </div>
-  );
-};
-
-export default ConformanceReports;

+ 0 - 9
apps/insights/src/components/PriceFeed/header.tsx

@@ -18,7 +18,6 @@ import {
 import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PriceFeedTag } from "../PriceFeedTag";
 import { PriceName } from "../PriceName";
-import ConformanceReports from "./conformance-reports";
 import { getFeed } from "./get-feed";
 import styles from "./header.module.scss";
 import { PriceFeedSelect } from "./price-feed-select";
@@ -175,14 +174,6 @@ const PriceFeedHeaderImpl = (props: PriceFeedHeaderImplProps) => (
         >
           Reference Data
         </Button>
-        <ConformanceReports
-          isLoading={props.isLoading}
-          {...(!props.isLoading && {
-            symbol: props.feed.symbol,
-            cluster: Cluster.Pythnet,
-            publisher: props.feed.product.price_account,
-          })}
-        />
       </div>
     </div>
     <Cards>

+ 5 - 0
apps/insights/src/components/Publisher/layout.tsx

@@ -45,6 +45,7 @@ import { SemicircleMeter } from "../SemicircleMeter";
 import { TabPanel, TabRoot, Tabs } from "../Tabs";
 import { TokenIcon } from "../TokenIcon";
 import { OisApyHistory } from "./ois-apy-history";
+import ConformanceReport from "../ConformanceReport/conformance-report";
 
 type Props = {
   children: ReactNode;
@@ -85,6 +86,10 @@ export const PublisherLayout = async ({ children, params }: Props) => {
                 icon: <PublisherIcon knownPublisher={knownPublisher} />,
               })}
             />
+            <ConformanceReport
+              publisher={key}
+              cluster={ClusterToName[parsedCluster]}
+            />
           </div>
 
           <Cards className={styles.stats ?? ""}>

+ 13 - 0
apps/insights/src/hooks/use-download-blob.ts

@@ -0,0 +1,13 @@
+import { useCallback } from "react";
+
+export const useDownloadBlob = () => {
+  return useCallback((blob: Blob, filename: string) => {
+    const url = globalThis.URL.createObjectURL(blob);
+    const a = document.createElement("a");
+    a.href = url;
+    a.download = filename;
+    document.body.append(a);
+    a.click();
+    a.remove();
+  }, []);
+};

+ 37 - 0
apps/insights/src/schemas/pyth.ts

@@ -0,0 +1,37 @@
+import { z } from "zod";
+
+export const priceFeedsSchema = z.array(
+  z.object({
+    symbol: z.string(),
+    product: z.object({
+      display_symbol: z.string(),
+      asset_type: z.string(),
+      description: z.string(),
+      price_account: z.string(),
+      base: z.string().optional(),
+      country: z.string().optional(),
+      quote_currency: z.string().optional(),
+      tenor: z.string().optional(),
+      cms_symbol: z.string().optional(),
+      cqs_symbol: z.string().optional(),
+      nasdaq_symbol: z.string().optional(),
+      generic_symbol: z.string().optional(),
+      weekly_schedule: z.string().optional(),
+      schedule: z.string().optional(),
+      contract_id: z.string().optional(),
+    }),
+    price: z.object({
+      exponent: z.number(),
+      numComponentPrices: z.number(),
+      numQuoters: z.number(),
+      minPublishers: z.number(),
+      lastSlot: z.bigint(),
+      validSlot: z.bigint(),
+      priceComponents: z.array(
+        z.object({
+          publisher: z.string(),
+        }),
+      ),
+    }),
+  }),
+);

+ 2 - 1
apps/insights/src/server/pyth.ts

@@ -4,7 +4,8 @@ import { z } from "zod";
 import { DEFAULT_CACHE_TTL } from "../cache";
 import { VERCEL_REQUEST_HEADERS } from "../config/server";
 import { getHost } from "../get-host";
-import { Cluster, ClusterToName, priceFeedsSchema } from "../services/pyth";
+import { priceFeedsSchema } from "../schemas/pyth";
+import { Cluster, ClusterToName } from "../services/pyth";
 
 export async function getPublishersForFeedRequest(
   cluster: Cluster,

+ 4 - 3
apps/insights/src/services/pyth/get-feeds.ts

@@ -1,9 +1,10 @@
-import { Cluster, priceFeedsSchema } from ".";
-import { getPythMetadataCached } from "./get-metadata";
+import { Cluster } from ".";
+import { getPythMetadata } from "./get-metadata";
 import { redisCache } from "../../cache";
+import { priceFeedsSchema } from "../../schemas/pyth";
 
 const _getFeeds = async (cluster: Cluster) => {
-  const unfilteredData = await getPythMetadataCached(cluster);
+  const unfilteredData = await getPythMetadata(cluster);
   const filtered = unfilteredData.symbols
     .filter(
       (symbol) =>

+ 3 - 3
apps/insights/src/services/pyth/get-metadata.ts

@@ -1,11 +1,11 @@
 import { clients, Cluster } from ".";
 import { memoryOnlyCache } from "../../cache";
 
-const getPythMetadata = async (cluster: Cluster) => {
+const _getPythMetadata = async (cluster: Cluster) => {
   return clients[cluster].getData();
 };
 
-export const getPythMetadataCached = memoryOnlyCache.define(
+export const getPythMetadata = memoryOnlyCache.define(
   "getPythMetadata",
-  getPythMetadata,
+  _getPythMetadata,
 ).getPythMetadata;

+ 2 - 2
apps/insights/src/services/pyth/get-publishers-for-cluster.ts

@@ -1,9 +1,9 @@
 import { Cluster } from ".";
-import { getPythMetadataCached } from "./get-metadata";
+import { getPythMetadata } from "./get-metadata";
 import { redisCache } from "../../cache";
 
 const _getPublishersForCluster = async (cluster: Cluster) => {
-  const data = await getPythMetadataCached(cluster);
+  const data = await getPythMetadata(cluster);
   const result: Record<string, string[]> = {};
   for (const key of data.productPrice.keys()) {
     const price = data.productPrice.get(key);

+ 0 - 37
apps/insights/src/services/pyth/index.ts

@@ -5,7 +5,6 @@ import {
 } from "@pythnetwork/client";
 import type { PythPriceCallback } from "@pythnetwork/client/lib/PythConnection";
 import { Connection, PublicKey } from "@solana/web3.js";
-import { z } from "zod";
 
 import { PYTHNET_RPC, PYTHTEST_CONFORMANCE_RPC } from "../../config/isomorphic";
 
@@ -61,42 +60,6 @@ export const clients = {
   [Cluster.PythtestConformance]: mkClient(Cluster.PythtestConformance),
 } as const;
 
-export const priceFeedsSchema = z.array(
-  z.object({
-    symbol: z.string(),
-    product: z.object({
-      display_symbol: z.string(),
-      asset_type: z.string(),
-      description: z.string(),
-      price_account: z.string(),
-      base: z.string().optional(),
-      country: z.string().optional(),
-      quote_currency: z.string().optional(),
-      tenor: z.string().optional(),
-      cms_symbol: z.string().optional(),
-      cqs_symbol: z.string().optional(),
-      nasdaq_symbol: z.string().optional(),
-      generic_symbol: z.string().optional(),
-      weekly_schedule: z.string().optional(),
-      schedule: z.string().optional(),
-      contract_id: z.string().optional(),
-    }),
-    price: z.object({
-      exponent: z.number(),
-      numComponentPrices: z.number(),
-      numQuoters: z.number(),
-      minPublishers: z.number(),
-      lastSlot: z.bigint(),
-      validSlot: z.bigint(),
-      priceComponents: z.array(
-        z.object({
-          publisher: z.string(),
-        }),
-      ),
-    }),
-  }),
-);
-
 export const getAssetPricesFromAccounts = (
   cluster: Cluster,
   ...args: Parameters<(typeof clients)[Cluster]["getAssetPricesFromAccounts"]>

+ 23 - 0
pnpm-lock.yaml

@@ -180,6 +180,12 @@ catalogs:
     css-loader:
       specifier: ^7.1.2
       version: 7.1.2
+    csv-stringify:
+      specifier: ^6.6.0
+      version: 6.6.0
+    date-fns:
+      specifier: ^4.1.0
+      version: 4.1.0
     dnum:
       specifier: ^2.14.0
       version: 2.14.0
@@ -949,6 +955,12 @@ importers:
       cryptocurrency-icons:
         specifier: 'catalog:'
         version: 0.18.1
+      csv-stringify:
+        specifier: 'catalog:'
+        version: 6.6.0
+      date-fns:
+        specifier: 'catalog:'
+        version: 4.1.0
       dnum:
         specifier: 'catalog:'
         version: 2.14.0
@@ -12016,6 +12028,7 @@ packages:
 
   '@walletconnect/modal@2.7.0':
     resolution: {integrity: sha512-RQVt58oJ+rwqnPcIvRFeMGKuXb9qkgSmwz4noF8JZGUym3gUAzVs+uW2NQ1Owm9XOJAV+sANrtJ+VoVq1ftElw==}
+    deprecated: Please follow the migration guide on https://docs.reown.com/appkit/upgrade/wcm
 
   '@walletconnect/qrcode-modal@1.8.0':
     resolution: {integrity: sha512-BueaFefaAi8mawE45eUtztg3ZFbsAH4DDXh1UNwdUlsvFMjqcYzLUG0xZvDd6z2eOpbgDg2N3bl6gF0KONj1dg==}
@@ -14023,6 +14036,9 @@ packages:
   csstype@3.1.3:
     resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
 
+  csv-stringify@6.6.0:
+    resolution: {integrity: sha512-YW32lKOmIBgbxtu3g5SaiqWNwa/9ISQt2EcgOq0+RAIFufFp9is6tqNnKahqE5kuKvrnYAzs28r+s6pXJR8Vcw==}
+
   d3-array@3.2.4:
     resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
     engines: {node: '>=12'}
@@ -14104,6 +14120,9 @@ packages:
     resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
     engines: {node: '>=0.11'}
 
+  date-fns@4.1.0:
+    resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
+
   dateformat@4.6.3:
     resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
 
@@ -42132,6 +42151,8 @@ snapshots:
 
   csstype@3.1.3: {}
 
+  csv-stringify@6.6.0: {}
+
   d3-array@3.2.4:
     dependencies:
       internmap: 2.0.3
@@ -42214,6 +42235,8 @@ snapshots:
     dependencies:
       '@babel/runtime': 7.27.0
 
+  date-fns@4.1.0: {}
+
   dateformat@4.6.3: {}
 
   debounce-fn@4.0.0:

+ 2 - 0
pnpm-workspace.yaml

@@ -105,6 +105,8 @@ catalog:
   copyfiles: ^2.4.1
   cryptocurrency-icons: ^0.18.1
   css-loader: ^7.1.2
+  date-fns: ^4.1.0
+  csv-stringify: ^6.6.0
   dnum: ^2.14.0
   eslint: ^9.23.0
   framer-motion: ^12.6.3