diff --git a/apps/insights/src/app/api/pyth/get-publishers/[symbol]/route.ts b/apps/insights/src/app/api/pyth/get-publishers/[symbol]/route.ts index c2dacf15a2..479e814521 100644 --- a/apps/insights/src/app/api/pyth/get-publishers/[symbol]/route.ts +++ b/apps/insights/src/app/api/pyth/get-publishers/[symbol]/route.ts @@ -8,7 +8,7 @@ import { ClusterToName, toCluster, } from "../../../../../services/pyth"; -import { getPublishersForCluster } from "../../../../../services/pyth/get-publishers-for-cluster"; +import { getPublishersByFeedForCluster } from "../../../../../services/pyth/get-publishers-for-cluster"; export const GET = async ( request: NextRequest, @@ -32,7 +32,7 @@ export const GET = async ( }); } - const map = await getPublishersForCluster(cluster); + const map = await getPublishersByFeedForCluster(cluster); return NextResponse.json(map[symbol] ?? []); }; diff --git a/apps/insights/src/app/api/pyth/get-publishers/route.ts b/apps/insights/src/app/api/pyth/get-publishers/route.ts new file mode 100644 index 0000000000..b0f758332d --- /dev/null +++ b/apps/insights/src/app/api/pyth/get-publishers/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +import type { Cluster } from "../../../../services/pyth"; +import { CLUSTER_NAMES, toCluster } from "../../../../services/pyth"; +import { getFeedsByPublisherForCluster } from "../../../../services/pyth/get-publishers-for-cluster"; + +export const GET = async (request: NextRequest) => { + const cluster = clusterSchema.safeParse( + request.nextUrl.searchParams.get("cluster"), + ); + + return cluster.success + ? NextResponse.json(await getPublishers(cluster.data)) + : new Response("Invalid params", { status: 400 }); +}; + +const clusterSchema = z + .enum(CLUSTER_NAMES) + .transform((value) => toCluster(value)); + +const getPublishers = async (cluster: Cluster) => + Object.entries(await getFeedsByPublisherForCluster(cluster)).map( + ([key, feeds]) => ({ key, permissionedFeeds: feeds.length }), + ); diff --git a/apps/insights/src/components/Publisher/get-price-feeds.tsx b/apps/insights/src/components/Publisher/get-price-feeds.tsx index 6a392badf7..61050dc8d1 100644 --- a/apps/insights/src/components/Publisher/get-price-feeds.tsx +++ b/apps/insights/src/components/Publisher/get-price-feeds.tsx @@ -1,12 +1,12 @@ import { getFeedsForPublisherRequest } from "../../server/pyth"; -import { getRankingsByPublisher } from "../../services/clickhouse"; +import { getFeedRankingsByPublisher } from "../../services/clickhouse"; import { Cluster, ClusterToName } from "../../services/pyth"; import { getStatus } from "../../status"; export const getPriceFeeds = async (cluster: Cluster, key: string) => { const [feeds, rankings] = await Promise.all([ getFeedsForPublisherRequest(cluster, key), - getRankingsByPublisher(key), + getFeedRankingsByPublisher(key), ]); return feeds.map((feed) => { const ranking = rankings.find( diff --git a/apps/insights/src/components/Publisher/layout.tsx b/apps/insights/src/components/Publisher/layout.tsx index 3765d522c1..b88745e77e 100644 --- a/apps/insights/src/components/Publisher/layout.tsx +++ b/apps/insights/src/components/Publisher/layout.tsx @@ -15,8 +15,8 @@ import { notFound } from "next/navigation"; import type { ReactNode } from "react"; import { Suspense } from "react"; +import { getPublishersWithRankings } from "../../get-publishers-with-rankings"; import { - getPublishers, getPublisherRankingHistory, getPublisherAverageScoreHistory, } from "../../services/clickhouse"; @@ -38,13 +38,13 @@ import { ExplainActive, ExplainInactive, } from "../Explanations"; +import { FormattedDate } from "../FormattedDate"; import { FormattedNumber } from "../FormattedNumber"; import { PublisherIcon } from "../PublisherIcon"; import { PublisherKey } from "../PublisherKey"; import { PublisherTag } from "../PublisherTag"; import { getPriceFeeds } from "./get-price-feeds"; import styles from "./layout.module.scss"; -import { FormattedDate } from "../FormattedDate"; import { FormattedTokens } from "../FormattedTokens"; import { SemicircleMeter } from "../SemicircleMeter"; import { TabPanel, TabRoot, Tabs } from "../Tabs"; @@ -365,7 +365,7 @@ const ActiveFeedsCard = async ({ publisherKey: string; }) => { const [publishers, priceFeeds] = await Promise.all([ - getPublishers(cluster), + getPublishersWithRankings(cluster), getPriceFeeds(cluster, publisherKey), ]); const publisher = publishers.find( @@ -391,8 +391,8 @@ type ActiveFeedsCardImplProps = isLoading?: false | undefined; cluster: Cluster; publisherKey: string; - activeFeeds: number; - inactiveFeeds: number; + activeFeeds?: number | undefined; + inactiveFeeds?: number | undefined; allFeeds: number; }; @@ -435,33 +435,27 @@ const ActiveFeedsCardImpl = (props: ActiveFeedsCardImplProps) => ( ) } miniStat1={ - props.isLoading ? ( - - ) : ( - <> - - % - - ) + } miniStat2={ - props.isLoading ? ( - - ) : ( - <> - - % - - ) + } > - {!props.isLoading && ( + {!props.isLoading && props.activeFeeds !== undefined && ( ( ); +const RankingMiniStat = ( + props: + | { isLoading: true } + | { + isLoading?: false | undefined; + stat: number | undefined; + allFeeds: number; + }, +) => { + if (props.isLoading) { + return ; + } else if (props.stat === undefined) { + // eslint-disable-next-line unicorn/no-null + return null; + } else { + return ( + <> + + % + + ); + } +}; + const OisPoolCard = async ({ publisherKey }: { publisherKey: string }) => { const [publisherPoolData, publisherCaps] = await Promise.all([ getPublisherPoolData(), diff --git a/apps/insights/src/components/Publisher/performance.tsx b/apps/insights/src/components/Publisher/performance.tsx index fde81047f5..882e0efc71 100644 --- a/apps/insights/src/components/Publisher/performance.tsx +++ b/apps/insights/src/components/Publisher/performance.tsx @@ -16,7 +16,7 @@ import type { ReactNode, ComponentProps } from "react"; import { getPriceFeeds } from "./get-price-feeds"; import styles from "./performance.module.scss"; import { TopFeedsTable } from "./top-feeds-table"; -import { getPublishers } from "../../services/clickhouse"; +import { getPublishersWithRankings } from "../../get-publishers-with-rankings"; import type { Cluster } from "../../services/pyth"; import { ClusterToName, parseCluster } from "../../services/pyth"; import { Status } from "../../status"; @@ -48,7 +48,7 @@ export const Performance = async ({ params }: Props) => { notFound(); } const [publishers, priceFeeds] = await Promise.all([ - getPublishers(parsedCluster), + getPublishersWithRankings(parsedCluster), getPriceFeeds(parsedCluster, key), ]); const slicedPublishers = sliceAround( @@ -63,7 +63,7 @@ export const Performance = async ({ params }: Props) => { prefetch: false, nameAsString: knownPublisher?.name ?? publisher.key, data: { - ranking: ( + ranking: (publisher.rank !== undefined || publisher.key === key) && ( {publisher.rank} @@ -86,7 +86,7 @@ export const Performance = async ({ params }: Props) => { {publisher.inactiveFeeds} ), - averageScore: ( + averageScore: publisher.averageScore !== undefined && ( ), name: ( diff --git a/apps/insights/src/components/Publishers/index.tsx b/apps/insights/src/components/Publishers/index.tsx index c48e901e88..e1a4cfb1e1 100644 --- a/apps/insights/src/components/Publishers/index.tsx +++ b/apps/insights/src/components/Publishers/index.tsx @@ -7,7 +7,7 @@ import { lookup as lookupPublisher } from "@pythnetwork/known-publishers"; import styles from "./index.module.scss"; import { PublishersCard } from "./publishers-card"; -import { getPublishers } from "../../services/clickhouse"; +import { getPublishersWithRankings } from "../../get-publishers-with-rankings"; import { getPublisherCaps } from "../../services/hermes"; import { Cluster } from "../../services/pyth"; import { @@ -27,12 +27,15 @@ const INITIAL_REWARD_POOL_SIZE = 60_000_000_000_000n; export const Publishers = async () => { const [pythnetPublishers, pythtestConformancePublishers, oisStats] = await Promise.all([ - getPublishers(Cluster.Pythnet), - getPublishers(Cluster.PythtestConformance), + getPublishersWithRankings(Cluster.Pythnet), + getPublishersWithRankings(Cluster.PythtestConformance), getOisStats(), ]); - const rankingTime = pythnetPublishers[0]?.timestamp; - const scoreTime = pythnetPublishers[0]?.scoreTime; + const rankedPublishers = pythnetPublishers.filter( + (publisher) => publisher.scoreTime !== undefined, + ); + const rankingTime = rankedPublishers[0]?.timestamp; + const scoreTime = rankedPublishers[0]?.scoreTime; return (
@@ -65,10 +68,10 @@ export const Publishers = async () => { corner={} className={styles.statCard ?? ""} stat={( - pythnetPublishers.reduce( - (sum, publisher) => sum + publisher.averageScore, + rankedPublishers.reduce( + (sum, publisher) => sum + (publisher.averageScore ?? 0), 0, - ) / pythnetPublishers.length + ) / rankedPublishers.length ).toFixed(2)} /> >[number]) => { +}: Awaited>[number]) => { const knownPublisher = lookupPublisher(key); return { id: key, diff --git a/apps/insights/src/components/Publishers/publishers-card.tsx b/apps/insights/src/components/Publishers/publishers-card.tsx index 4f28ca83e4..42d24906e4 100644 --- a/apps/insights/src/components/Publishers/publishers-card.tsx +++ b/apps/insights/src/components/Publishers/publishers-card.tsx @@ -45,10 +45,10 @@ type Props = { type Publisher = { id: string; - ranking: number; + ranking?: number | undefined; permissionedFeeds: number; - activeFeeds: number; - averageScore: number; + activeFeeds?: number | undefined; + averageScore?: number | undefined; } & ( | { name: string; icon: ReactNode } | { name?: undefined; icon?: undefined } @@ -100,27 +100,38 @@ const ResolvedPublishersCard = ({ filter.contains(publisher.id, search) || (publisher.name !== undefined && filter.contains(publisher.name, search)), (a, b, { column, direction }) => { + const desc = direction === "descending" ? -1 : 1; + + const sortByName = + desc * collator.compare(a.name ?? a.id, b.name ?? b.id); + + const sortByRankingField = ( + column: "ranking" | "activeFeeds" | "averageScore", + ) => { + if (a[column] === undefined) { + return b[column] === undefined ? sortByName : 1; + } else { + return b[column] === undefined ? -1 : desc * (a[column] - b[column]); + } + }; + switch (column) { + case "permissionedFeeds": { + return desc * (a[column] - b[column]); + } + case "ranking": - case "permissionedFeeds": case "activeFeeds": case "averageScore": { - return ( - (direction === "descending" ? -1 : 1) * (a[column] - b[column]) - ); + return sortByRankingField(column); } case "name": { - return ( - (direction === "descending" ? -1 : 1) * - collator.compare(a.name ?? a.id, b.name ?? b.id) - ); + return sortByName; } default: { - return ( - (direction === "descending" ? -1 : 1) * (a.ranking - b.ranking) - ); + return sortByRankingField("ranking"); } } }, @@ -144,7 +155,7 @@ const ResolvedPublishersCard = ({ textValue: publisher.name ?? id, prefetch: false, data: { - ranking: {ranking}, + ranking: ranking !== undefined && {ranking}, name: ( ), - averageScore: ( + averageScore: averageScore !== undefined && ( ), }, diff --git a/apps/insights/src/components/Root/index.tsx b/apps/insights/src/components/Root/index.tsx index 679a7b6938..35954de1a0 100644 --- a/apps/insights/src/components/Root/index.tsx +++ b/apps/insights/src/components/Root/index.tsx @@ -3,18 +3,18 @@ import { lookup as lookupPublisher } from "@pythnetwork/known-publishers"; import { NuqsAdapter } from "nuqs/adapters/next/app"; import type { ReactNode } from "react"; +import { SearchButton as SearchButtonImpl } from "./search-button"; import { AMPLITUDE_API_KEY, ENABLE_ACCESSIBILITY_REPORTING, GOOGLE_ANALYTICS_ID, } from "../../config/server"; +import { getPublishersWithRankings } from "../../get-publishers-with-rankings"; import { LivePriceDataProvider } from "../../hooks/use-live-price-data"; -import { getPublishers } from "../../services/clickhouse"; import { Cluster } from "../../services/pyth"; import { getFeeds } from "../../services/pyth/get-feeds"; import { PriceFeedIcon } from "../PriceFeedIcon"; import { PublisherIcon } from "../PublisherIcon"; -import { SearchButton as SearchButtonImpl } from "./search-button"; export const TABS = [ { segment: "", children: "Overview" }, @@ -53,7 +53,7 @@ const SearchButton = async () => { }; const getPublishersForSearchDialog = async (cluster: Cluster) => { - const publishers = await getPublishers(cluster); + const publishers = await getPublishersWithRankings(cluster); return publishers.map((publisher) => { const knownPublisher = lookupPublisher(publisher.key); diff --git a/apps/insights/src/components/Root/search-button.tsx b/apps/insights/src/components/Root/search-button.tsx index d80da0bc1c..db5ae16251 100644 --- a/apps/insights/src/components/Root/search-button.tsx +++ b/apps/insights/src/components/Root/search-button.tsx @@ -54,7 +54,7 @@ type ResolvedSearchButtonProps = { }[]; publishers: ({ publisherKey: string; - averageScore: number; + averageScore?: number | undefined; cluster: Cluster; } & ( | { name: string; icon: ReactNode } @@ -291,7 +291,9 @@ const SearchDialogContents = ({ <>
Average Score
- + {result.averageScore !== undefined && ( + + )}
)} @@ -335,7 +337,9 @@ const SearchDialogContents = ({ icon: result.icon, })} /> - + {result.averageScore !== undefined && ( + + )} )}
diff --git a/apps/insights/src/get-publishers-with-rankings.ts b/apps/insights/src/get-publishers-with-rankings.ts new file mode 100644 index 0000000000..2d26cca04b --- /dev/null +++ b/apps/insights/src/get-publishers-with-rankings.ts @@ -0,0 +1,23 @@ +import { getPublishers } from "./server/pyth"; +import { getPublisherRankings } from "./services/clickhouse"; +import type { Cluster } from "./services/pyth"; + +export const getPublishersWithRankings = async (cluster: Cluster) => { + const [publishers, publisherRankings] = await Promise.all([ + getPublishers(cluster), + getPublisherRankings(cluster), + ]); + + return publishers + .map((publisher) => ({ + ...publisher, + ...publisherRankings.find((ranking) => ranking.key === publisher.key), + })) + .toSorted((a, b) => { + if (a.rank === undefined) { + return b.rank === undefined ? a.key.localeCompare(b.key) : 1; + } else { + return b.rank === undefined ? -1 : a.rank - b.rank; + } + }); +}; diff --git a/apps/insights/src/server/pyth.ts b/apps/insights/src/server/pyth.ts index 45f1cdf1fa..4455fbd346 100644 --- a/apps/insights/src/server/pyth.ts +++ b/apps/insights/src/server/pyth.ts @@ -11,54 +11,43 @@ export async function getPublishersForFeedRequest( cluster: Cluster, symbol: string, ) { - const url = new URL( - `/api/pyth/get-publishers/${encodeURIComponent(symbol)}`, - await getHost(), + const data = await fetchPythData( + cluster, + `get-publishers/${encodeURIComponent(symbol)}`, ); - url.searchParams.set("cluster", ClusterToName[cluster]); - - const data = await fetch(url, { - next: { - revalidate: DEFAULT_NEXT_FETCH_TTL, - }, - headers: VERCEL_REQUEST_HEADERS, - }); - const parsedData: unknown = await data.json(); - return z.array(z.string()).parse(parsedData); + return z.array(z.string()).parse(await data.json()); } +export const getPublishers = async (cluster: Cluster) => { + const data = await fetchPythData(cluster, `get-publishers`); + return publishersSchema.parse(await data.json()); +}; + +const publishersSchema = z.array( + z.strictObject({ + key: z.string(), + permissionedFeeds: z.number(), + }), +); + export async function getFeedsForPublisherRequest( cluster: Cluster, publisher: string, ) { - const url = new URL( - `/api/pyth/get-feeds-for-publisher/${encodeURIComponent(publisher)}`, - await getHost(), + const data = await fetchPythData( + cluster, + `get-feeds-for-publisher/${encodeURIComponent(publisher)}`, ); - url.searchParams.set("cluster", ClusterToName[cluster]); - - const data = await fetch(url, { - next: { - revalidate: DEFAULT_NEXT_FETCH_TTL, - }, - headers: VERCEL_REQUEST_HEADERS, - }); const rawData = await data.text(); const parsedData = parse(rawData); return priceFeedsSchema.parse(parsedData); } export const getFeedsRequest = async (cluster: Cluster) => { - const url = new URL(`/api/pyth/get-feeds`, await getHost()); - url.searchParams.set("cluster", ClusterToName[cluster]); - url.searchParams.set("excludePriceComponents", "true"); - - const data = await fetch(url, { - next: { - revalidate: DEFAULT_NEXT_FETCH_TTL, - }, - headers: VERCEL_REQUEST_HEADERS, + const data = await fetchPythData(cluster, "get-feeds", { + excludePriceComponents: "true", }); + const rawData = await data.text(); const parsedData = parse(rawData); @@ -75,18 +64,10 @@ export const getFeedForSymbolRequest = async ({ symbol: string; cluster?: Cluster; }): Promise | undefined> => { - const url = new URL( - `/api/pyth/get-feeds/${encodeURIComponent(symbol)}`, - await getHost(), + const data = await fetchPythData( + cluster, + `get-feeds/${encodeURIComponent(symbol)}`, ); - url.searchParams.set("cluster", ClusterToName[cluster]); - - const data = await fetch(url, { - next: { - revalidate: DEFAULT_NEXT_FETCH_TTL, - }, - headers: VERCEL_REQUEST_HEADERS, - }); if (!data.ok) { return undefined; @@ -98,3 +79,22 @@ export const getFeedForSymbolRequest = async ({ ? undefined : priceFeedsSchema.element.parse(parsedData); }; + +const fetchPythData = async ( + cluster: Cluster, + path: string, + params?: Record, +) => { + const url = new URL(`/api/pyth/${path}`, await getHost()); + url.searchParams.set("cluster", ClusterToName[cluster]); + if (params !== undefined) { + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + } + + return await fetch(url, { + next: { revalidate: DEFAULT_NEXT_FETCH_TTL }, + headers: VERCEL_REQUEST_HEADERS, + }); +}; diff --git a/apps/insights/src/services/clickhouse.ts b/apps/insights/src/services/clickhouse.ts index d493742171..749516f957 100644 --- a/apps/insights/src/services/clickhouse.ts +++ b/apps/insights/src/services/clickhouse.ts @@ -11,15 +11,12 @@ import { CLICKHOUSE } from "../config/server"; const client = createClient(CLICKHOUSE); -const _getPublishers = async (cluster: Cluster) => +const _getPublisherRankings = async (cluster: Cluster) => safeQuery( z.array( z.strictObject({ key: z.string(), rank: z.number(), - permissionedFeeds: z - .string() - .transform((value) => Number.parseInt(value, 10)), activeFeeds: z .string() .transform((value) => Number.parseInt(value, 10)), @@ -55,7 +52,6 @@ const _getPublishers = async (cluster: Cluster) => timestamp, publisher AS key, rank, - LENGTH(symbols) AS permissionedFeeds, activeFeeds, inactiveFeeds, score_data.averageScore, @@ -74,7 +70,7 @@ const _getPublishers = async (cluster: Cluster) => }, ); -const _getRankingsByPublisher = async (publisherKey: string) => +const _getFeedRankingsByPublisher = async (publisherKey: string) => safeQuery(rankingsSchema, { query: ` WITH first_rankings AS ( @@ -303,7 +299,7 @@ const _getFeedPriceHistory = async ({ }, ); -export const _getPublisherAverageScoreHistory = async ({ +const _getPublisherAverageScoreHistory = async ({ cluster, key, }: { @@ -405,15 +401,15 @@ export const getRankingsBySymbol = redisCache.define( _getRankingsBySymbol, ).getRankingsBySymbol; -export const getRankingsByPublisher = redisCache.define( - "getRankingsByPublisher", - _getRankingsByPublisher, -).getRankingsByPublisher; +export const getFeedRankingsByPublisher = redisCache.define( + "getFeedRankingsByPublisher", + _getFeedRankingsByPublisher, +).getFeedRankingsByPublisher; -export const getPublishers = redisCache.define( - "getPublishers", - _getPublishers, -).getPublishers; +export const getPublisherRankings = redisCache.define( + "getPublisherRankings", + _getPublisherRankings, +).getPublisherRankings; export const getPublisherAverageScoreHistory = redisCache.define( "getPublisherAverageScoreHistory", diff --git a/apps/insights/src/services/pyth/get-publishers-for-cluster.ts b/apps/insights/src/services/pyth/get-publishers-for-cluster.ts index 0c6fa5e854..44d0ab53e9 100644 --- a/apps/insights/src/services/pyth/get-publishers-for-cluster.ts +++ b/apps/insights/src/services/pyth/get-publishers-for-cluster.ts @@ -2,18 +2,44 @@ import { Cluster } from "."; import { getPythMetadata } from "./get-metadata"; import { redisCache } from "../../cache"; -const _getPublishersForCluster = async (cluster: Cluster) => { +const _getPublishersByFeedForCluster = async (cluster: Cluster) => { const data = await getPythMetadata(cluster); const result: Record = {}; - for (const key of data.productPrice.keys()) { - const price = data.productPrice.get(key); - result[key] = - price?.priceComponents.map(({ publisher }) => publisher.toBase58()) ?? []; + for (const [key, price] of data.productPrice.entries()) { + result[key] = price.priceComponents.map(({ publisher }) => + publisher.toBase58(), + ); } return result; }; -export const getPublishersForCluster = redisCache.define( - "getPublishersForCluster", - _getPublishersForCluster, -).getPublishersForCluster; +/** + * Given a cluster, this function will return a record which maps each + * permissioned publisher to the list of price feed IDs for which that publisher + * is permissioned. + */ +const _getFeedsByPublisherForCluster = async (cluster: Cluster) => { + const data = await getPythMetadata(cluster); + const result: Record = {}; + for (const [symbol, price] of data.productPrice.entries()) { + for (const component of price.priceComponents) { + const publisherKey = component.publisher.toBase58(); + if (result[publisherKey] === undefined) { + result[publisherKey] = [symbol]; + } else { + result[publisherKey].push(symbol); + } + } + } + return result; +}; + +export const getPublishersByFeedForCluster = redisCache.define( + "getPublishersByFeedForCluster", + _getPublishersByFeedForCluster, +).getPublishersByFeedForCluster; + +export const getFeedsByPublisherForCluster = redisCache.define( + "getFeedsByPublisherForCluster", + _getFeedsByPublisherForCluster, +).getFeedsByPublisherForCluster;