+
Price Feeds
+
+
+
activeFeeds.length,
+ )}
+ />
+ }
+ href={`#${PRICE_FEEDS_ANCHOR}`}
+ />
+
+
+
+ getNumFeedsByAssetClass(activeFeeds),
+ )}
+ />
+
+
}>
+
}
+ recentlyAddedPromise={priceFeeds.then(({ activeFeeds }) =>
+ filterFeeds(
+ activeFeeds,
+ priceFeedsStaticConfig.featuredRecentlyAdded,
+ ).map(({ product, symbol }) => ({
+ id: product.price_account,
+ symbol,
+ priceFeedName: (
+
+ {product.display_symbol}
+
+ ),
+ })),
+ )}
+ />
+
+
}
+ toolbar={
+
+ comingSoon.map(({ symbol, product }) => ({
+ symbol,
+ id: product.price_account,
+ displaySymbol: product.display_symbol,
+ assetClassAsString: product.asset_type,
+ priceFeedName: (
+
+ {product.display_symbol}
+
+ ),
+ assetClass: (
+
+ {product.asset_type.toUpperCase()}
+
+ ),
+ })),
+ )}
+ />
+ }
+ >
+ }
+ comingSoonPromise={priceFeeds.then(({ comingSoon }) =>
+ [
+ ...filterFeeds(
+ comingSoon,
+ priceFeedsStaticConfig.featuredComingSoon,
+ ),
+ ...comingSoon.filter(
+ ({ symbol }) =>
+ !priceFeedsStaticConfig.featuredComingSoon.includes(symbol),
+ ),
+ ]
+ .slice(0, 5)
+ .map(({ product }) => ({
+ priceFeedName: (
+
+ {product.display_symbol}
+
+ ),
+ })),
+ )}
+ />
+
+ }
+ priceFeedsPromise={priceFeeds.then(({ activeFeeds }) =>
+ activeFeeds.map(({ symbol, product, price }) => ({
+ symbol,
+ id: product.price_account,
+ displaySymbol: product.display_symbol,
+ assetClassAsString: product.asset_type,
+ exponent: price.exponent,
+ numPublishers: price.numQuoters,
+ weeklySchedule: product.weekly_schedule,
+ priceFeedName: (
+
+ {product.display_symbol}
+
+ ),
+ assetClass: (
+
+ {product.asset_type.toUpperCase()}
+
+ ),
+ priceFeedId: (
+
+ {toTruncatedHex(product.price_account)}
+
+ ),
+ })),
+ )}
+ />
+
+
+ );
+};
+
+const PriceFeedNameAndAssetClass = ({
+ children,
+ assetClass,
+}: {
+ children: string;
+ assetClass: string;
+}) => (
+
-
-
- {firstPart}
- {parts.map((part, i) => (
-
- /
- {part}
-
- ))}
-
+
+ {firstPart}
+ {parts.map((part, i) => (
+
+ /
+ {part}
+
+ ))}
);
};
-const AssetType = ({ children }: { children: string }) => (
-
{children}
-);
+const toHex = (value: string) => toHexString(base58.decode(value));
+
+const toTruncatedHex = (value: string) => {
+ const hex = toHex(value);
+ return `${hex.slice(0, 6)}...${hex.slice(-4)}`;
+};
+
+const toHexString = (byteArray: Uint8Array) =>
+ `0x${Array.from(byteArray, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
const getPriceFeeds = async () => {
const data = await client.getData();
- return priceFeedsSchema.parse(
+ const priceFeeds = priceFeedsSchema.parse(
data.symbols.map((symbol) => ({
symbol,
product: data.productFromSymbol.get(symbol),
+ price: data.productPrice.get(symbol),
})),
);
+ const activeFeeds = priceFeeds.filter((feed) => isActive(feed));
+ const comingSoon = priceFeeds.filter((feed) => !isActive(feed));
+ return { activeFeeds, comingSoon };
+};
+
+const getNumFeedsByAssetClass = (
+ feeds: { product: { asset_type: string } }[],
+): Record
=> {
+ const classes: Record = {};
+ for (const feed of feeds) {
+ const assetType = feed.product.asset_type;
+ classes[assetType] = (classes[assetType] ?? 0) + 1;
+ }
+ return classes;
};
+const filterFeeds = (
+ feeds: T[],
+ symbols: string[],
+): T[] =>
+ symbols.map((symbol) => {
+ const feed = feeds.find((feed) => feed.symbol === symbol);
+ if (feed) {
+ return feed;
+ } else {
+ throw new NoSuchFeedError(symbol);
+ }
+ });
+
+const isActive = (feed: { price: { minPublishers: number } }) =>
+ feed.price.minPublishers <= 50;
+
const priceFeedsSchema = z.array(
z.object({
symbol: z.string(),
@@ -75,6 +305,19 @@ const priceFeedsSchema = z.array(
display_symbol: z.string(),
asset_type: z.string(),
price_account: z.string(),
+ weekly_schedule: z.string().optional(),
+ }),
+ price: z.object({
+ exponent: z.number(),
+ numQuoters: z.number(),
+ minPublishers: z.number(),
}),
}),
);
+
+class NoSuchFeedError extends Error {
+ constructor(symbol: string) {
+ super(`No feed exists named ${symbol}`);
+ this.name = "NoSuchFeedError";
+ }
+}
diff --git a/apps/insights/src/components/PriceFeeds/layout.module.scss b/apps/insights/src/components/PriceFeeds/layout.module.scss
deleted file mode 100644
index 9522112dc0..0000000000
--- a/apps/insights/src/components/PriceFeeds/layout.module.scss
+++ /dev/null
@@ -1,13 +0,0 @@
-@use "@pythnetwork/component-library/theme";
-
-.priceFeedsLayout {
- @include theme.max-width;
-
- .header {
- margin-bottom: theme.spacing(12);
- display: flex;
- flex-flow: row nowrap;
- align-items: center;
- justify-content: space-between;
- }
-}
diff --git a/apps/insights/src/components/PriceFeeds/layout.tsx b/apps/insights/src/components/PriceFeeds/layout.tsx
deleted file mode 100644
index bb32c0b233..0000000000
--- a/apps/insights/src/components/PriceFeeds/layout.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import type { ReactNode } from "react";
-
-import { EpochSelect } from "./epoch-select";
-import styles from "./layout.module.scss";
-import { H1 } from "../H1";
-
-type Props = {
- children: ReactNode | undefined;
-};
-
-export const PriceFeedsLayout = ({ children }: Props) => (
-
-
-
Price Feeds
-
-
- {children}
-
-);
diff --git a/apps/insights/src/components/PriceFeeds/loading.tsx b/apps/insights/src/components/PriceFeeds/loading.tsx
deleted file mode 100644
index 29abc67f4e..0000000000
--- a/apps/insights/src/components/PriceFeeds/loading.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { ChartLine } from "@phosphor-icons/react/dist/ssr/ChartLine";
-import { TableCard } from "@pythnetwork/component-library/TableCard";
-
-import { columns } from "./columns";
-
-export const PriceFeedsLoading = () => (
-
-);
diff --git a/apps/insights/src/components/PriceFeeds/num-active-feeds.tsx b/apps/insights/src/components/PriceFeeds/num-active-feeds.tsx
new file mode 100644
index 0000000000..4ce1e620ce
--- /dev/null
+++ b/apps/insights/src/components/PriceFeeds/num-active-feeds.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { Skeleton } from "@pythnetwork/component-library/Skeleton";
+import { Suspense, use } from "react";
+
+type Props = {
+ numFeedsPromise: Promise;
+};
+
+export const NumActiveFeeds = ({ numFeedsPromise }: Props) => (
+ }>
+
+
+);
+
+const ResolvedNumActiveFeeds = ({ numFeedsPromise }: Props) =>
+ use(numFeedsPromise);
diff --git a/apps/insights/src/components/PriceFeeds/price-feeds-card.module.scss b/apps/insights/src/components/PriceFeeds/price-feeds-card.module.scss
new file mode 100644
index 0000000000..d85c46cb50
--- /dev/null
+++ b/apps/insights/src/components/PriceFeeds/price-feeds-card.module.scss
@@ -0,0 +1,9 @@
+@use "@pythnetwork/component-library/theme";
+
+.priceFeedsCard {
+ .toolbar {
+ display: flex;
+ flex-flow: row nowrap;
+ gap: theme.spacing(2);
+ }
+}
diff --git a/apps/insights/src/components/PriceFeeds/price-feeds-card.tsx b/apps/insights/src/components/PriceFeeds/price-feeds-card.tsx
new file mode 100644
index 0000000000..c798a54f03
--- /dev/null
+++ b/apps/insights/src/components/PriceFeeds/price-feeds-card.tsx
@@ -0,0 +1,330 @@
+"use client";
+
+import { ChartLine } from "@phosphor-icons/react/dist/ssr/ChartLine";
+import { Badge } from "@pythnetwork/component-library/Badge";
+import {
+ type Props as CardProps,
+ Card,
+} from "@pythnetwork/component-library/Card";
+import { Paginator } from "@pythnetwork/component-library/Paginator";
+import { SearchInput } from "@pythnetwork/component-library/SearchInput";
+import { Select } from "@pythnetwork/component-library/Select";
+import { type ColumnConfig, Table } from "@pythnetwork/component-library/Table";
+import clsx from "clsx";
+import { usePathname } from "next/navigation";
+import { createSerializer } from "nuqs";
+import { type ReactNode, Suspense, use, useCallback, useMemo } from "react";
+import { useFilter, useCollator } from "react-aria";
+
+import styles from "./price-feeds-card.module.scss";
+import { queryParams, useQuery } from "./use-query";
+import { SKELETON_WIDTH, LivePrice, LiveConfidence } from "../LivePrices";
+
+type Props = Omit, "icon" | "title" | "toolbar" | "footer"> & {
+ priceFeedsPromise: Promise;
+ placeholderPriceFeedName: ReactNode;
+};
+
+type PriceFeed = {
+ symbol: string;
+ id: string;
+ displaySymbol: string;
+ assetClassAsString: string;
+ exponent: number;
+ numPublishers: number;
+ weeklySchedule: string | undefined;
+ priceFeedId: ReactNode;
+ priceFeedName: ReactNode;
+ assetClass: ReactNode;
+};
+
+export const PriceFeedsCard = ({
+ priceFeedsPromise,
+ className,
+ placeholderPriceFeedName,
+ ...props
+}: Props) => (
+ }
+ title={
+ <>
+ Price Feeds
+
+
+
+
+
+ >
+ }
+ toolbar={
+
+
+
+
+ >
+ }
+ >
+
+
+
+ }
+ footer={
+
+
+
+ }
+ {...props}
+ >
+
+ }
+ >
+
+
+
+);
+
+type NumFeedsProps = {
+ priceFeedsPromise: Props["priceFeedsPromise"];
+};
+
+const NumFeeds = ({ priceFeedsPromise }: NumFeedsProps) =>
+ useFilteredFeeds(priceFeedsPromise).length;
+
+type ToolbarProps = {
+ priceFeedsPromise: Props["priceFeedsPromise"];
+};
+
+const ToolbarContents = ({ priceFeedsPromise }: ToolbarProps) => {
+ const { search, assetClass, updateSearch, updateAssetClass } = useQuery();
+ const collator = useCollator();
+ const priceFeeds = use(priceFeedsPromise);
+ const assetClasses = useMemo(
+ () =>
+ [...new Set(priceFeeds.map((feed) => feed.assetClassAsString))].sort(
+ (a, b) => collator.compare(a, b),
+ ),
+ [priceFeeds, collator],
+ );
+
+ return (
+ <>
+