diff --git a/src/components/ChainSelector/ChainSelector.tsx b/src/components/ChainSelector/ChainSelector.tsx index 44006774200..84d754dbc6c 100644 --- a/src/components/ChainSelector/ChainSelector.tsx +++ b/src/components/ChainSelector/ChainSelector.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from "preact/hooks" import { clsx } from "~/lib/clsx/clsx.ts" import { Chain } from "~/features/data/chains.ts" +import { chainMatchesFeedTypeTag } from "~/features/feeds/utils/chainFilters.ts" import styles from "./ChainSelector.module.css" interface ChainSelectorProps { @@ -33,18 +34,7 @@ export function ChainSelector({ // Filter chains based on dataFeedType and search term const filteredChains = chains.filter((chain) => { - // Filter by dataFeedType first - const matchesDataFeedType = (() => { - if (dataFeedType.includes("streams")) return chain.tags?.includes("streams") ?? false - if (dataFeedType === "smartdata") return chain.tags?.includes("smartData") ?? false - if (dataFeedType === "rates") return chain.tags?.includes("rates") ?? false - if (dataFeedType === "usGovernmentMacroeconomicData") - return chain.tags?.includes("usGovernmentMacroeconomicData") ?? false - if (dataFeedType === "tokenizedEquity") return chain.tags?.includes("tokenizedEquity") ?? false - return chain.tags?.includes("default") ?? false - })() - - // Filter by search term + const matchesDataFeedType = chainMatchesFeedTypeTag(chain, dataFeedType as never) const matchesSearch = !searchTerm || chain.label.toLowerCase().includes(searchTerm.toLowerCase()) return matchesDataFeedType && matchesSearch diff --git a/src/config/sidebar.ts b/src/config/sidebar.ts index b541d804798..a33a3cd520a 100644 --- a/src/config/sidebar.ts +++ b/src/config/sidebar.ts @@ -1175,6 +1175,10 @@ export const SIDEBAR: Partial> = { title: "Market Hours", url: "data-streams/market-hours", }, + { + title: "Selecting Quality Data Streams", + url: "data-streams/selecting-data-streams", + }, { title: "Deprecating Streams", url: "data-streams/deprecating-streams", diff --git a/src/content/data-streams/llms-full.txt b/src/content/data-streams/llms-full.txt index 5fd74f10adc..7590dca6e42 100644 --- a/src/content/data-streams/llms-full.txt +++ b/src/content/data-streams/llms-full.txt @@ -6293,6 +6293,20 @@ Source: https://docs.chain.link/data-streams/rwa-streams --- +# Selecting Quality Data Streams +Source: https://docs.chain.link/data-streams/selecting-data-streams + +When you design your applications, consider the quality of the data that you use. Ultimately you are responsible for identifying and assessing the accuracy, availability, and quality of data that you choose to consume via the Chainlink Network. Note that all streams contain some inherent risk. Read the [Data Streams Best Practices](/data-streams/concepts/best-practices) and [Developer Responsibilities](/data-streams/developer-responsibilities) sections when making design decisions. + +For a summary of data sourcing models by asset type, see [Data Sources](/data-streams/data-sources). + + + +--- + # SmartData Streams Source: https://docs.chain.link/data-streams/smartdata-streams diff --git a/src/content/data-streams/selecting-data-streams.mdx b/src/content/data-streams/selecting-data-streams.mdx new file mode 100644 index 00000000000..0f1a17c759c --- /dev/null +++ b/src/content/data-streams/selecting-data-streams.mdx @@ -0,0 +1,21 @@ +--- +section: dataStreams +date: Last Modified +title: "Selecting Quality Data Streams" +metadata: + excerpt: "Learn how to assess data streams that you use in your applications." +--- + +import { Aside } from "@components" +import MarketPricingRiskTiers from "@features/feeds/components/MarketPricingRiskTiers.astro" + +When you design your applications, consider the quality of the data that you use. Ultimately you are responsible for identifying and assessing the accuracy, availability, and quality of data that you choose to consume via the Chainlink Network. Note that all streams contain some inherent risk. Read the [Data Streams Best Practices](/data-streams/concepts/best-practices) and [Developer Responsibilities](/data-streams/developer-responsibilities) sections when making design decisions. + +For a summary of data sourcing models by asset type, see [Data Sources](/data-streams/data-sources). + + + + diff --git a/src/db/feedCategories.ts b/src/db/feedCategories.ts index 396b0a120b8..c0e5877b092 100644 --- a/src/db/feedCategories.ts +++ b/src/db/feedCategories.ts @@ -1,3 +1,8 @@ +import { + getMarketPricingRiskTerms, + tierAnchor, + type MarketPricingRiskProduct, +} from "../features/feeds/content/marketPricingRiskTerms.ts" import { supabase } from "./supabase.js" /* =========================== @@ -26,63 +31,102 @@ export type FeedTierResult = { final: string | null } export const FEED_CATEGORY_CONFIG = { low: { key: "low", - name: "Low Market Risk", + name: "Low Market Pricing Risk", icon: "🟢", - title: "Low Market Risk - Feeds that deliver a market price for liquid assets with robust market structure.", - link: "/data-feeds/selecting-data-feeds#-low-market-risk-feeds", + title: + "Low Market Pricing Risk - Feeds that follow a standardized workflow to report market prices for liquid assets with robust market structure.", + link: "/data-feeds/selecting-data-feeds#-low-market-pricing-risk-feeds", }, medium: { key: "medium", - name: "Medium Market Risk", + name: "Medium Market Pricing Risk", icon: "🟡", title: - "Medium Market Risk - Feeds that deliver a market price for assets that show signs of liquidity-related risk or other market structure-related risk.", - link: "/data-feeds/selecting-data-feeds#-medium-market-risk-feeds", + "Medium Market Pricing Risk - Feeds that report market prices for asset pairs that may have features making them more challenging to reliably price, or potentially subject them to volatility.", + link: "/data-feeds/selecting-data-feeds#-medium-market-pricing-risk-feeds", }, high: { key: "high", - name: "High Market Risk", + name: "High Market Pricing Risk", icon: "🟠", title: - "High Market Risk - Feeds that deliver a heightened degree of some of the risk factors associated with Medium Market Risk Feeds, or a separate risk that makes the market price subject to uncertainty or volatile. In using a high market risk data feed you acknowledge that you understand the risks associated with such a feed and that you are solely responsible for monitoring and mitigating such risks.", - link: "/data-feeds/selecting-data-feeds#-high-market-risk-feeds", + "High Market Pricing Risk - Feeds for pairs that often exhibit a heightened degree of Medium Market Pricing Risk factors, or separate risks that make the market price subject to uncertainty or volatility.", + link: "/data-feeds/selecting-data-feeds#-high-market-pricing-risk-feeds", }, veryhigh: { key: "veryhigh", - name: "Very High Market Risk", + name: "Very High Market Pricing Risk", icon: "🔴", title: - "Very High Market Risk - Feeds with significant risk factors that require careful consideration. Users must thoroughly evaluate and understand all associated risks before use.", - link: "/data-feeds/selecting-data-feeds#-very-high-market-risk-feeds", + "Very High Market Pricing Risk - Feeds that price assets with quotes subject to extreme levels of risk, greater than those outlined for High Market Pricing Risk feeds.", + link: "/data-feeds/selecting-data-feeds#-very-high-market-pricing-risk-feeds", }, new: { key: "new", - name: "New Token", + name: "New Token Feeds", icon: "🆕", title: - "New Token - Tokens without the historical data required to implement a risk assessment framework may be launched in this category. Users must understand the additional market and volatility risks inherent with such assets. Users of New Token Feeds are responsible for independently verifying the liquidity and stability of the assets priced by feeds that they use.", + "New Token Feeds - Tokens without the historical data required to implement a risk assessment framework may be launched in this category. Users must understand the additional market and volatility risks inherent with such assets.", link: "/data-feeds/selecting-data-feeds#-new-token-feeds", }, custom: { key: "custom", - name: "Custom", + name: "Custom Feeds", icon: "🔵", title: - "Custom - Feeds built to serve a specific use case or rely on external contracts or data sources. These might not be suitable for general use or your use case's risk parameters. Users must evaluate the properties of a feed to make sure it aligns with their intended use case.", + "Custom Feeds - Feeds built to serve a specific use case and might not be suitable for general use or your use case's risk parameters.", link: "/data-feeds/selecting-data-feeds#-custom-feeds", }, deprecating: { key: "deprecating", name: "Deprecating", icon: "⭕", - title: - "Deprecating - These feeds are scheduled for deprecation. See the [Deprecation](/data-feeds/deprecating-feeds) page to learn more.", - link: "/data-feeds/deprecating-feeds", + title: "Deprecating - These feeds are scheduled for deprecation.", + link: "/data-feeds/selecting-data-feeds#-deprecating", }, } as const export type CategoryKey = keyof typeof FEED_CATEGORY_CONFIG +const TIER_ANCHOR_KEY: Record = { + low: "low", + medium: "medium", + high: "high", + veryhigh: "very-high", + new: "new-token", + custom: "custom", + deprecating: "deprecating", +} + +const RISK_DOC_BASE_PATH: Record = { + feeds: "/data-feeds/selecting-data-feeds", + streams: "/data-streams/selecting-data-streams", +} + +export function getRiskCategoryLink(key: CategoryKey, product: MarketPricingRiskProduct = "feeds"): string { + const base = RISK_DOC_BASE_PATH[product] + + if (key === "deprecating") { + return `${base}#-deprecating` + } + + return `${base}${tierAnchor(TIER_ANCHOR_KEY[key], getMarketPricingRiskTerms(product))}` +} + +export function getRiskCategoryTitle(key: CategoryKey, product: MarketPricingRiskProduct = "feeds"): string { + const title = FEED_CATEGORY_CONFIG[key].title + + if (product === "feeds") { + return title + } + + return title + .replace(/\bFeeds\b/g, "Streams") + .replace(/\bFeed\b/g, "Stream") + .replace(/\bfeed\b/g, "stream") + .replace(/\bfeeds\b/g, "streams") +} + /* =========================== Small helpers =========================== */ @@ -103,13 +147,23 @@ const resolveRiskStatus = ( shutdownDate?: string, fallbackCategory?: string ): string | null => { - if (dbTier != null) return dbTier + // Deprecating feeds always show the deprecating icon, even when a DB risk tier exists. if (shutdownDate) return "deprecating" + if (dbTier != null) return dbTier if (fallbackCategory && FALLBACK_ONLY_CATEGORIES.has(fallbackCategory.toLowerCase())) return fallbackCategory.toLowerCase() return null } +/** Client-side helper for resolving the displayed feed category. */ +export function resolveFeedCategory( + dbTier: string | null | undefined, + shutdownDate?: string, + fallbackCategory?: string +): string | null { + return resolveRiskStatus(dbTier, shutdownDate, fallbackCategory) +} + const defaultCategoryList = () => Object.values(FEED_CATEGORY_CONFIG).map(({ key, name }) => ({ key, name })) /* =========================== @@ -148,7 +202,8 @@ export async function getFeedCategories() { /** * Batch lookup: returns a Map of `${address}-${network}` → { final }. - * Uses DB risk_status when present. If absent, infers "deprecating" from shutdownDate. + * Uses DB risk_status when present, unless the feed has a shutdownDate (deprecating). + * If absent, infers "deprecating" from shutdownDate. * Returns null when neither is available. */ export async function getFeedRiskTiersBatch(feedRequests: FeedRequest[]): Promise> { diff --git a/src/db/streamCategories.ts b/src/db/streamCategories.ts new file mode 100644 index 00000000000..0e42f4a7e3e --- /dev/null +++ b/src/db/streamCategories.ts @@ -0,0 +1,78 @@ +import { resolveFeedCategory, type FeedTierResult } from "./feedCategories.js" +import { supabase } from "./supabase.js" + +const TABLE = "prod_streams_risk_docs" +const ADDRESS_BATCH_SIZE = 100 + +type StreamRiskRow = { + stream_proxy_address: string + risk_status: string | null +} + +export type StreamRiskRequest = { + streamProxyAddress: string + shutdownDate?: string +} + +const normalizeAddress = (value: string) => value.toLowerCase() + +async function queryStreamRiskByAddresses(addresses: string[]): Promise> { + const lookup = new Map() + + if (!supabase || addresses.length === 0) return lookup + + for (let i = 0; i < addresses.length; i += ADDRESS_BATCH_SIZE) { + const chunk = addresses.slice(i, i + ADDRESS_BATCH_SIZE) + + try { + const { data, error } = await supabase + .from(TABLE) + .select("stream_proxy_address, risk_status") + .in("stream_proxy_address", chunk) + .limit(1000) + + if (error) continue + ;(data as StreamRiskRow[] | null)?.forEach((row) => { + lookup.set(normalizeAddress(row.stream_proxy_address), row.risk_status ?? null) + }) + } catch { + continue + } + } + + return lookup +} + +/** + * Batch lookup: returns Map of normalized stream_proxy_address → { final }. + * All stream risk docs are keyed by address; network is not used for matching. + */ +export async function getStreamRiskTiersBatch(requests: StreamRiskRequest[]): Promise> { + const out = new Map() + + if (requests.length === 0) return out + + const finish = (lookup: Map) => { + requests.forEach(({ streamProxyAddress, shutdownDate }) => { + const normalizedAddress = normalizeAddress(streamProxyAddress) + const dbTier = lookup.get(normalizedAddress) ?? null + out.set(normalizedAddress, { final: resolveFeedCategory(dbTier, shutdownDate) }) + }) + } + + if (!supabase) { + finish(new Map()) + return out + } + + const addresses = Array.from(new Set(requests.map((r) => normalizeAddress(r.streamProxyAddress)))) + + try { + const lookup = await queryStreamRiskByAddresses(addresses) + finish(lookup) + return out + } catch { + finish(new Map()) + return out + } +} diff --git a/src/features/data/chains.ts b/src/features/data/chains.ts index 3e58e2e2d72..93f4cc9f364 100644 --- a/src/features/data/chains.ts +++ b/src/features/data/chains.ts @@ -486,6 +486,7 @@ export const CHAINS: Chain[] = [ networkType: "mainnet", rddUrl: "https://reference-data-directory.vercel.app/feeds-monad-mainnet.json", queryString: "monad-mainnet", + tags: ["smartData"], }, { name: "Monad Testnet", @@ -493,6 +494,7 @@ export const CHAINS: Chain[] = [ networkType: "testnet", rddUrl: "https://reference-data-directory.vercel.app/feeds-monad-testnet.json", queryString: "monad-testnet", + tags: ["smartData"], }, ], }, @@ -679,7 +681,7 @@ export const CHAINS: Chain[] = [ title: "Solana Data Feeds", img: "/assets/chains/solana.svg", networkStatusUrl: "https://status.solana.com/", - tags: ["default", "smartData"], + tags: ["default"], supportedFeatures: ["feeds"], networks: [ { @@ -688,7 +690,6 @@ export const CHAINS: Chain[] = [ networkType: "mainnet", rddUrl: "https://reference-data-directory.vercel.app/feeds-solana-mainnet.json", queryString: "solana-mainnet", - tags: ["smartData"], }, { name: "Solana Devnet", diff --git a/src/features/feeds/components/FeedList.module.css b/src/features/feeds/components/FeedList.module.css index 5ade30ead15..f49d0919330 100644 --- a/src/features/feeds/components/FeedList.module.css +++ b/src/features/feeds/components/FeedList.module.css @@ -54,10 +54,50 @@ div.shutDate > hr { } .tableFilters { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--space-3x); +} + +.filterControls { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-4x); + min-width: 0; +} + +.filterControls:empty { + display: none; +} + +.tableSearch { + position: relative; + display: flex; + align-items: center; + gap: var(--space-3x); + width: 100%; + min-width: 0; + order: -1; +} + +.tableSearch .filterDropdown_searchInput { + border: 1px solid var(--gray-300); + border-radius: var(--border-radius-primary); + background-color: var(--white); + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06); + font-size: 0.9375rem; +} + +.tableSearch .filterDropdown_searchInput:focus { + outline: none; + border-color: var(--blue-500); + box-shadow: 0 0 0 3px var(--blue-100); +} + +.tableSearch .clearFilterBtn { + margin-left: 0; } .streamNetworkSelector { @@ -137,12 +177,6 @@ div.shutDate > hr { opacity: 0.65; } -.searchAndCheckbox { - display: flex; - align-items: center; - gap: var(--space-4x); -} - .filterDropdown_details { display: inline-block; position: relative; @@ -153,9 +187,9 @@ div.shutDate > hr { .filterDropdown_search { position: relative; - max-width: 420px; display: flex; align-items: center; + width: 100%; } .filterDropdown_searchInput { @@ -172,18 +206,68 @@ div.shutDate > hr { .detailsLabel { margin: 0; - display: inline-block; + display: inline-flex; + align-items: center; + gap: var(--space-2x); cursor: pointer; + user-select: none; + color: var(--color-text-primary); + font-size: 0.875rem; + white-space: nowrap; +} + +.filterCheckboxGroup { + display: inline-flex; + align-items: center; + gap: var(--space-1x); + white-space: nowrap; +} + +.filterHelpLink { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + border: 1px solid var(--gray-400); + color: var(--gray-500); + font-size: 11px; + font-weight: 600; + line-height: 1; + text-decoration: none; + flex-shrink: 0; +} + +.filterHelpLink:hover { + color: var(--gray-700); + border-color: var(--gray-500); +} + +.feedCheckbox { + appearance: auto; + -webkit-appearance: checkbox; + width: var(--space-4x); + height: var(--space-4x); + min-width: var(--space-4x); + flex-shrink: 0; + cursor: pointer; + accent-color: var(--color-fill-highlight); } .checkboxContainer { display: flex; - gap: 16px; + flex-wrap: nowrap; + gap: var(--space-4x); align-items: center; - margin: 8px 0; + margin: 0; } @media screen and (max-width: 450px) { + .filterControls, + .checkboxContainer { + flex-wrap: wrap; + } .filterDropdown_details, .filterDropdown_search { padding-left: 0px; @@ -194,13 +278,12 @@ div.shutDate > hr { gap: var(--space-2x); } - .tableFilters > * { + .filterControls { width: 100%; - max-width: 100%; } .clearFilterBtn { - margin-left: var(--space-2x); + margin-left: 0; } .filterDropdown_search { diff --git a/src/features/feeds/components/FeedList.tsx b/src/features/feeds/components/FeedList.tsx index 7fcd8124c72..42019b8db85 100644 --- a/src/features/feeds/components/FeedList.tsx +++ b/src/features/feeds/components/FeedList.tsx @@ -9,29 +9,30 @@ import { useGetChainMetadata } from "./useGetChainMetadata.ts" import { ChainMetadata } from "~/features/data/api/index.ts" import useQueryString from "~/hooks/useQueryString.ts" import { RefObject } from "preact" -import { getFeedCategories } from "../../../db/feedCategories.js" import SectionWrapper from "~/components/SectionWrapper/SectionWrapper.tsx" import button from "@chainlink/design-system/button.module.css" import { updateTableOfContents } from "~/components/TableOfContents/tocStore.ts" import { ChainSelector } from "~/components/ChainSelector/ChainSelector.tsx" import { chainHasVisibleFeeds, isFeedVisible, networkHasVisibleFeeds } from "../utils/feedVisibility.ts" +import { chainHasSvrFeeds } from "../utils/svrDetection.ts" +import { + filterChainsByFeedTypeTag, + networkMatchesFeedTypeTag, + shouldFilterSelectableChainsByVisibleFeeds, + shouldRenderNetworkSection, +} from "../utils/chainFilters.ts" +import { getFeedAssetType, getSchemaVersion } from "../utils/feedMetadata.ts" +import { getAddrPerPage, SMART_DATA_CATEGORY_OPTIONS } from "../constants.ts" +import { + type DataFeedType, + type SchemaFilterValue, + type StreamsRwaFeedTypeValue, + type TradingHoursFilterValue, + getFeedTypeFlags, +} from "../types.ts" import { updateUrlClean } from "./urlStateHelpers.ts" -export type DataFeedType = - | "default" - | "smartdata" - | "rates" - | "usGovernmentMacroeconomicData" - | "tokenizedEquity" - | "streamsCrypto" - | "streamsRwa" - | "streamsNav" - | "streamsExRate" - | "streamsBacked" - -type SchemaFilterValue = "all" | "v8" | "v11" -type StreamsRwaFeedTypeValue = "all" | "datalink" | "equities" | "forex" -type TradingHoursFilterValue = "all" | "regular" | "extended" | "overnight" +export type { DataFeedType } from "../types.ts" type FilterOption = { label: string @@ -82,20 +83,7 @@ type StreamFeedMetadata = ChainMetadata & { } } -const getStreamSchemaVersion = (feed: StreamFeedMetadata): string | undefined => { - if (feed.docs?.schema) return feed.docs.schema - - const clicProductName = feed.docs?.clicProductName - if (!clicProductName) return undefined - - const match = clicProductName.match(/-0(\d{2})$/) - if (!match) return undefined - - if (match[1] === "04" || match[1] === "08") return "v8" - if (match[1] === "11") return "v11" - - return undefined -} +const getStreamSchemaVersion = (feed: StreamFeedMetadata): string | undefined => getSchemaVersion(feed) const is24x5StreamFeed = (feed: StreamFeedMetadata): boolean => { const schemaVersion = getStreamSchemaVersion(feed) @@ -177,16 +165,9 @@ export const FeedList = ({ forceStreamCategoryFilter?: StreamsRwaFeedTypeValue tokenizedEquityProvider?: string }) => { - const isStreams = - dataFeedType === "streamsCrypto" || - dataFeedType === "streamsRwa" || - dataFeedType === "streamsNav" || - dataFeedType === "streamsExRate" || - dataFeedType === "streamsBacked" + const feedTypeFlags = getFeedTypeFlags(dataFeedType) + const { isStreams, isSmartData, isRates, isUSGovernmentMacroeconomicData } = feedTypeFlags const isDeprecating = ecosystem === "deprecating" - const isRates = dataFeedType === "rates" - const isSmartData = dataFeedType === "smartdata" - const isUSGovernmentMacroeconomicData = dataFeedType === "usGovernmentMacroeconomicData" const chains = isDeprecating && isStreams ? ALL_CHAINS : CHAINS // Get network from URL parameters or fall back to initialNetwork @@ -422,7 +403,7 @@ export const FeedList = ({ setCurrentPage(pageStr) updateUrlClean({ page: pageNumber === 1 ? undefined : pageNumber }) } - const addrPerPage = ecosystem === "deprecating" && isStreams ? 10 : ecosystem === "deprecating" ? 10000 : 8 + const addrPerPage = getAddrPerPage(ecosystem, isStreams) const currentPageNum = Number(currentPage) || 1 const lastAddr = currentPageNum * addrPerPage const firstAddr = lastAddr - addrPerPage @@ -434,54 +415,16 @@ export const FeedList = ({ setTestnetCurrentPage(pageStr) updateUrlClean({ testnetPage: pageNumber === 1 ? undefined : pageNumber }) } - const testnetAddrPerPage = ecosystem === "deprecating" && isStreams ? 10 : ecosystem === "deprecating" ? 10000 : 8 + const testnetAddrPerPage = getAddrPerPage(ecosystem, isStreams) const testnetPageNum = Number(testnetCurrentPage) || 1 const testnetLastAddr = testnetPageNum * testnetAddrPerPage const testnetFirstAddr = testnetLastAddr - testnetAddrPerPage - // Dynamic feed categories loaded from Supabase - const [dataFeedCategory, setDataFeedCategory] = useState([ - { key: "low", name: "Low Market Risk" }, - { key: "medium", name: "Medium Market Risk" }, - { key: "high", name: "High Market Risk" }, - { key: "veryhigh", name: "Very High Market Risk" }, - { key: "custom", name: "Custom" }, - { key: "new", name: "New Token" }, - { key: "deprecating", name: "Deprecating" }, - ]) - - // Load dynamic categories from Supabase on component mount - useEffect(() => { - const loadCategories = async () => { - try { - const categories = await getFeedCategories() - setDataFeedCategory(categories) - } catch (error) {} - } - - loadCategories() - }, []) - const smartDataTypes = [ - { key: "Proof of Reserve", name: "Proof of Reserve" }, - { key: "NAVLink", name: "NAVLink" }, - { key: "SmartAUM", name: "SmartAUM" }, - { key: "Stablecoin Stability Assessment", name: "Stablecoin Stability Assessment" }, - ] + const smartDataTypes = [...SMART_DATA_CATEGORY_OPTIONS] const [streamsChain] = useState(initialNetwork) const activeChain = isStreams ? streamsChain : currentNetwork - // Filter chains by dataFeedType tag to get only chains that support this feed type - const filteredChainsByTag = useMemo(() => { - return chains.filter((chain) => { - if (dataFeedType.includes("streams")) return chain.tags?.includes("streams") ?? false - if (dataFeedType === "smartdata") return chain.tags?.includes("smartData") ?? false - if (dataFeedType === "rates") return chain.tags?.includes("rates") ?? false - if (dataFeedType === "usGovernmentMacroeconomicData") - return chain.tags?.includes("usGovernmentMacroeconomicData") ?? false - if (dataFeedType === "tokenizedEquity") return chain.tags?.includes("tokenizedEquity") ?? false - return chain.tags?.includes("default") ?? false - }) - }, [chains, dataFeedType]) + const filteredChainsByTag = useMemo(() => filterChainsByFeedTypeTag(chains, dataFeedType), [chains, dataFeedType]) const requestedChain = useMemo(() => { const requestedNetwork = @@ -498,14 +441,18 @@ export const FeedList = ({ const metadataCache = chainMetadata.cache ?? initialCache const selectableChains = useMemo(() => { - if (!isDeprecating || isStreams || !metadataCache) return filteredChainsByTag + if (isStreams || !metadataCache) return filteredChainsByTag + + if (!shouldFilterSelectableChainsByVisibleFeeds(dataFeedType, ecosystem)) { + return filteredChainsByTag + } return filteredChainsByTag.filter((chain) => chainHasVisibleFeeds((metadataCache as Record)[chain.page], dataFeedType, ecosystem, { tokenizedEquityProvider, }) ) - }, [filteredChainsByTag, metadataCache, isDeprecating, isStreams, dataFeedType, ecosystem, tokenizedEquityProvider]) + }, [filteredChainsByTag, metadataCache, isStreams, dataFeedType, ecosystem, tokenizedEquityProvider]) const availableChainsForSelection = selectableChains.length > 0 ? selectableChains : filteredChainsByTag @@ -531,6 +478,79 @@ export const FeedList = ({ ...chainMetadata, processedData: selectedChainProcessedData, } + + const chainHasSvr = useMemo(() => { + if (isStreams || isSmartData || isUSGovernmentMacroeconomicData) return false + if (!currentChainMetadata.processedData) return false + + return chainHasSvrFeeds(currentChainMetadata.processedData, dataFeedType, ecosystem, { + tokenizedEquityProvider, + }) + }, [ + currentChainMetadata.processedData, + isStreams, + isSmartData, + isUSGovernmentMacroeconomicData, + dataFeedType, + ecosystem, + tokenizedEquityProvider, + ]) + + useEffect(() => { + if (!chainHasSvr && showOnlySVR) { + setShowOnlySVR(false) + } + }, [chainHasSvr, showOnlySVR]) + + const availableAssetTypes = useMemo(() => { + if (isStreams || isSmartData) return [] + + const visibilityOptions = { + tokenizedEquityProvider, + streamCategoryFilter: isStreams ? forceStreamCategoryFilter : undefined, + } + + const types = new Set() + + currentChainMetadata.processedData?.networks?.forEach((network) => { + if (network.networkType !== selectedNetworkType) return + + network.metadata?.forEach((feed) => { + if (!isFeedVisible(feed, dataFeedType, ecosystem, visibilityOptions)) return + + const assetType = getFeedAssetType(feed) + if (assetType) types.add(assetType) + }) + }) + + return [...types].sort((a, b) => a.localeCompare(b)).map((assetType) => ({ key: assetType, name: assetType })) + }, [ + currentChainMetadata.processedData, + selectedNetworkType, + dataFeedType, + ecosystem, + tokenizedEquityProvider, + isStreams, + isSmartData, + forceStreamCategoryFilter, + ]) + + useEffect(() => { + if (isStreams || isSmartData || availableAssetTypes.length === 0) return + + const validKeys = new Set(availableAssetTypes.map((option) => option.key)) + const current = Array.isArray(selectedFeedCategories) + ? selectedFeedCategories + : selectedFeedCategories + ? [selectedFeedCategories] + : [] + const filtered = current.filter((category) => validKeys.has(category)) + + if (filtered.length !== current.length) { + setSelectedFeedCategories(filtered.length > 0 ? filtered : []) + } + }, [availableAssetTypes, selectedChain.page, selectedNetworkType, isStreams, isSmartData]) + const wrapperRef = useRef(null) // scroll handler @@ -750,14 +770,7 @@ export const FeedList = ({ // Filter networks by feed type const filteredNetworks = currentChainMetadata.processedData.networks - .filter((network) => { - if (isStreams) return network.tags?.includes("streams") - if (isSmartData) return network.tags?.includes("smartData") - if (isRates) return network.tags?.includes("rates") - if (isUSGovernmentMacroeconomicData) return network.tags?.includes("usGovernmentMacroeconomicData") - - return true - }) + .filter((network) => networkMatchesFeedTypeTag(network, dataFeedType)) .filter((network) => { // Ensure the network has at least one visible feed for the current dataFeedType const visibilityOptions = { @@ -1030,18 +1043,21 @@ export const FeedList = ({ {currentChainMetadata.error &&

There was an error loading the streams...

} -
- { - setSearchValue((event.target as HTMLInputElement).value) - setCurrentPage("1") - }} - /> -
+
+
+ { + setSearchValue((event.target as HTMLInputElement).value) + setCurrentPage("1") + }} + /> +
+
{filteredMainnetStreams.length > 0 ? ( <>
@@ -1084,23 +1100,26 @@ export const FeedList = ({ -
- { - setTestnetSearchValue((event.target as HTMLInputElement).value) - setTestnetCurrentPage("1") - }} - /> -
+
+
+ { + setTestnetSearchValue((event.target as HTMLInputElement).value) + setTestnetCurrentPage("1") + }} + /> +
+
{filteredTestnetStreams.length > 0 ? ( <>
- + {paginatedTestnetStreams.map((stream, index) => ( @@ -1209,11 +1228,142 @@ export const FeedList = ({ idOverride={streamsMainnetSectionTitle.toLowerCase().replace(/\s+/g, "-")} >
-
+
+ {dataFeedType === "streamsCrypto" && ( +
+ + +
+ )} + {dataFeedType === "streamsRwa" && ( + <> + {!forceStreamCategoryFilter && !show24x5Feeds && ( + <> + handleDropdownToggle("main-schema", isOpen)} + onClose={closeAllDropdowns} + label="Filter schema" + options={schemaFilterOptions} + value={rwaSchemaFilter} + groupId="schema-main" + onSelect={(next) => { + setRwaSchemaFilter(next) + setCurrentPage("1") + }} + /> + handleDropdownToggle("main-feed-type", isOpen)} + onClose={closeAllDropdowns} + label="Filter category" + options={feedTypeFilterOptions} + value={streamCategoryFilter} + groupId="feed-type-main" + onSelect={(next) => { + setStreamCategoryFilter(next) + setCurrentPage("1") + }} + /> + + )} + {!force24x5Only && !forceStreamCategoryFilter && ( +
+ +
+ )} + {show24x5Feeds && ( + handleDropdownToggle("main-trading-hours", isOpen)} + onClose={closeAllDropdowns} + label="Time segment" + options={tradingHoursFilterOptions} + value={tradingHoursFilter} + groupId="trading-hours-main" + onSelect={(next) => { + setTradingHoursFilter(next) + setCurrentPage("1") + }} + /> + )} + {(searchValue || + rwaSchemaFilter !== "all" || + (!forceStreamCategoryFilter && streamCategoryFilter !== "all") || + show24x5Feeds) && ( + + )} + + )} +
+ { closeAllDropdowns() @@ -1222,134 +1372,6 @@ export const FeedList = ({ }} /> - {dataFeedType === "streamsCrypto" && ( -
- - -
- )} - {dataFeedType === "streamsRwa" && ( - <> - {!forceStreamCategoryFilter && !show24x5Feeds && ( - <> - handleDropdownToggle("main-schema", isOpen)} - onClose={closeAllDropdowns} - label="Filter schema" - options={schemaFilterOptions} - value={rwaSchemaFilter} - groupId="schema-main" - onSelect={(next) => { - setRwaSchemaFilter(next) - setCurrentPage("1") - }} - /> - handleDropdownToggle("main-feed-type", isOpen)} - onClose={closeAllDropdowns} - label="Filter category" - options={feedTypeFilterOptions} - value={streamCategoryFilter} - groupId="feed-type-main" - onSelect={(next) => { - setStreamCategoryFilter(next) - setCurrentPage("1") - }} - /> - - )} - {!force24x5Only && !forceStreamCategoryFilter && ( -
- -
- )} - {show24x5Feeds && ( - handleDropdownToggle("main-trading-hours", isOpen)} - onClose={closeAllDropdowns} - label="Time segment" - options={tradingHoursFilterOptions} - value={tradingHoursFilter} - groupId="trading-hours-main" - onSelect={(next) => { - setTradingHoursFilter(next) - setCurrentPage("1") - }} - /> - )} - {(searchValue || - rwaSchemaFilter !== "all" || - (!forceStreamCategoryFilter && streamCategoryFilter !== "all") || - show24x5Feeds) && ( - - )} - - )}
{currentChainMetadata.loading || !currentChainMetadata.processedData ? (

Loading...

@@ -1397,11 +1419,140 @@ export const FeedList = ({ idOverride={streamsTestnetSectionTitle.toLowerCase().replace(/\s+/g, "-")} >
-
+
+ {dataFeedType === "streamsCrypto" && ( +
+ + +
+ )} + {dataFeedType === "streamsRwa" && ( + <> + {!forceStreamCategoryFilter && !show24x5FeedsTestnet && ( + <> + handleDropdownToggle("test-schema", isOpen)} + onClose={closeAllDropdowns} + label="Filter schema" + options={schemaFilterOptions} + value={testnetRwaSchemaFilter} + groupId="schema-testnet" + onSelect={(next) => { + setTestnetRwaSchemaFilter(next) + setTestnetCurrentPage("1") + }} + /> + handleDropdownToggle("test-feed-type", isOpen)} + onClose={closeAllDropdowns} + label="Filter category" + options={feedTypeFilterOptions} + value={testnetStreamCategoryFilter} + groupId="feed-type-testnet" + onSelect={(next) => { + setTestnetStreamCategoryFilter(next) + setTestnetCurrentPage("1") + }} + /> + + )} + {!force24x5Only && !forceStreamCategoryFilter && ( +
+ +
+ )} + {show24x5FeedsTestnet && ( + handleDropdownToggle("test-trading-hours", isOpen)} + onClose={closeAllDropdowns} + label="Time segment" + options={tradingHoursFilterOptions} + value={testnetTradingHoursFilter} + groupId="trading-hours-testnet" + onSelect={(next) => { + setTestnetTradingHoursFilter(next) + setTestnetCurrentPage("1") + }} + /> + )} + {(testnetSearchValue || + testnetRwaSchemaFilter !== "all" || + (!forceStreamCategoryFilter && testnetStreamCategoryFilter !== "all") || + show24x5FeedsTestnet) && ( + + )} + + )} +
+ { closeAllDropdowns() @@ -1410,132 +1561,6 @@ export const FeedList = ({ }} /> - {dataFeedType === "streamsCrypto" && ( -
- - -
- )} - {dataFeedType === "streamsRwa" && ( - <> - {!forceStreamCategoryFilter && !show24x5FeedsTestnet && ( - <> - handleDropdownToggle("test-schema", isOpen)} - onClose={closeAllDropdowns} - label="Filter schema" - options={schemaFilterOptions} - value={testnetRwaSchemaFilter} - groupId="schema-testnet" - onSelect={(next) => { - setTestnetRwaSchemaFilter(next) - setTestnetCurrentPage("1") - }} - /> - handleDropdownToggle("test-feed-type", isOpen)} - onClose={closeAllDropdowns} - label="Filter category" - options={feedTypeFilterOptions} - value={testnetStreamCategoryFilter} - groupId="feed-type-testnet" - onSelect={(next) => { - setTestnetStreamCategoryFilter(next) - setTestnetCurrentPage("1") - }} - /> - - )} - {!force24x5Only && !forceStreamCategoryFilter && ( -
- -
- )} - {show24x5FeedsTestnet && ( - handleDropdownToggle("test-trading-hours", isOpen)} - onClose={closeAllDropdowns} - label="Time segment" - options={tradingHoursFilterOptions} - value={testnetTradingHoursFilter} - groupId="trading-hours-testnet" - onSelect={(next) => { - setTestnetTradingHoursFilter(next) - setTestnetCurrentPage("1") - }} - /> - )} - {(testnetSearchValue || - testnetRwaSchemaFilter !== "all" || - (!forceStreamCategoryFilter && testnetStreamCategoryFilter !== "all") || - show24x5FeedsTestnet) && ( - - )} - - )}
{currentChainMetadata.loading || !currentChainMetadata.processedData ? (

Loading...

@@ -1612,30 +1637,22 @@ export const FeedList = ({ {(() => { // Handle regular network processing return currentChainMetadata.processedData?.networks - ?.filter((network: { metadata: unknown[]; tags: string | string[]; networkType: string }) => { - if (isDeprecating) { - const foundDeprecated = networkHasVisibleFeeds(network, dataFeedType, ecosystem, { - tokenizedEquityProvider, - }) - if (foundDeprecated && network.networkType === selectedNetworkType) { - netCount++ - } - return foundDeprecated && network.networkType === selectedNetworkType - } - - if (isStreams) return network.tags?.includes("streams") && network.networkType === selectedNetworkType - - if (isSmartData) return network.tags?.includes("smartData") && network.networkType === selectedNetworkType - - if (isRates) return network.tags?.includes("rates") && network.networkType === selectedNetworkType + ?.filter((network: ChainNetwork) => { + const hasVisibleFeeds = networkHasVisibleFeeds(network, dataFeedType, ecosystem, { + tokenizedEquityProvider, + }) - if (isUSGovernmentMacroeconomicData) - return ( - network.tags?.includes("usGovernmentMacroeconomicData") && network.networkType === selectedNetworkType - ) + if (isDeprecating && hasVisibleFeeds && network.networkType === selectedNetworkType) { + netCount++ + } - // Filter by selected network type (mainnet/testnet) - return network.networkType === selectedNetworkType + return shouldRenderNetworkSection( + network, + dataFeedType, + selectedNetworkType, + isDeprecating, + hasVisibleFeeds + ) }) .map((network: ChainNetwork) => { return ( @@ -1703,125 +1720,134 @@ export const FeedList = ({ )}
- {!isStreams && !isSmartData && ( -
- setShowCategoriesDropdown((prev) => !prev)}> - Data Feed Categories - - -
- )} - {isSmartData && ( -
- setShowCategoriesDropdown((prev) => !prev)}> - SmartData Type - - -
- )} -
-
- { - closeAllDropdowns() - setSearchValue((event.target as HTMLInputElement).value) - setCurrentPage("1") - }} - /> - {searchValue && ( - - )} - +
+ {!isStreams && !isSmartData && availableAssetTypes.length > 1 && ( +
+ setShowCategoriesDropdown((prev) => !prev)}> + Asset Type + + +
+ )} + {isSmartData && ( +
+ setShowCategoriesDropdown((prev) => !prev)}> + SmartData Type + + +
+ )} {!isStreams && ( )} -
-
{!isStreams && isSmartData && ( )} - {!isStreams && !isSmartData && !isUSGovernmentMacroeconomicData && ( - + {!isStreams && !isSmartData && !isUSGovernmentMacroeconomicData && chainHasSvr && ( + + + + ? + + )}
+
+ { + closeAllDropdowns() + setSearchValue((event.target as HTMLInputElement).value) + setCurrentPage("1") + }} + /> + {searchValue && ( + + )} +
- {isSmartData && ( -
- setShowCategoriesDropdown((prev) => !prev)}> - SmartData Type - - -
- )} -
-
- { - setTestnetSearchValue((event.target as HTMLInputElement).value) - setTestnetCurrentPage("1") - }} - /> - {testnetSearchValue && ( - - )} - +
+ {isSmartData && ( +
+ setShowCategoriesDropdown((prev) => !prev)}> + SmartData Type + + +
+ )} -
-
{!isStreams && isSmartData && ( )}
+
+ { + setTestnetSearchValue((event.target as HTMLInputElement).value) + setTestnetCurrentPage("1") + }} + /> + {testnetSearchValue && ( + + )} +
)} {isStreams && (
-
+ { setTestnetSearchValue((event.target as HTMLInputElement).value) setTestnetCurrentPage("1") diff --git a/src/features/feeds/components/FeedPage.astro b/src/features/feeds/components/FeedPage.astro index 04ccfd26192..bd1b60d5fdf 100644 --- a/src/features/feeds/components/FeedPage.astro +++ b/src/features/feeds/components/FeedPage.astro @@ -19,13 +19,14 @@ export type Props = { defaultNetworkTableExpanded?: boolean } import { getServerSideChainMetadata } from "~/features/data/api/backend" -import { CHAINS } from "~/features/data/chains" +import { CHAINS, ALL_CHAINS } from "~/features/data/chains" import { CheckHeartbeat } from "./pause-notice/CheckHeartbeat" import { FeedDataItem, monitoredFeeds } from "~/features/data" const { initialNetwork, ecosystem, dataFeedType, allowNetworkTableExpansion, defaultNetworkTableExpanded } = Astro.props -const initialCache = await getServerSideChainMetadata(CHAINS) +const isDeprecating = ecosystem === "deprecating" +const initialCache = await getServerSideChainMetadata([...CHAINS, ...ALL_CHAINS], isDeprecating) const feedItems: FeedDataItem[] = monitoredFeeds.mainnet --- @@ -76,105 +77,98 @@ const feedItems: FeedDataItem[] = monitoredFeeds.mainnet prior to the date of deprecation.

+ ) : dataFeedType === "streamsCrypto" || + dataFeedType === "streamsRwa" || + dataFeedType === "streamsNav" || + dataFeedType === "streamsExRate" || + dataFeedType === "streamsBacked" ? ( + <> +

+ To learn how to use Data Streams, see the{" "} + Fetch and decode reports tutorial. +

+

+ LINK Token Contracts |{" "} + Supported networks & verifier proxies +

+ + + ) : dataFeedType === "smartdata" ? ( + <> + +

+ SmartData Feeds documentation |{" "} + LINK token addresses & faucets{" "} +

+

Before using feeds, review the risk considerations and disclaimer below.

+ + ) : dataFeedType === "rates" ? ( + <> +

+ Learn to use data feeds |{" "} + LINK token addresses & faucets{" "} +

+ + ) : dataFeedType === "usGovernmentMacroeconomicData" ? ( + <> + +

+ About these feeds{" "} + | Learn to use data feeds |{" "} + LINK token addresses & faucets{" "} +

+

+ Before using feeds in production, review the best practices below. +

+ ) : ( <> - {dataFeedType === "streamsCrypto" || - dataFeedType === "streamsRwa" || - dataFeedType === "streamsNav" || - dataFeedType === "streamsExRate" || - dataFeedType === "streamsBacked" ? ( - <> -

- To learn how to use Data Streams, see the{" "} - Fetch and decode reports tutorial. -

-

- For LINK token and Faucet details, see the{" "} - LINK Token Contracts page. -

- - - - ) : dataFeedType === "smartdata" ? ( - <> -

- To learn how to use these feeds, see the SmartData Feeds documentation. -

-

- For LINK token and Faucet details, see the{" "} - LINK Token Contracts page. -

- - - - ) : dataFeedType === "rates" ? ( - <> -

- To learn how to use these feeds, see the Using Data Feeds guide. -

-

- For LINK token and Faucet details, see the{" "} - LINK Token Contracts page. -

- - ) : dataFeedType === "usGovernmentMacroeconomicData" ? ( - <> -

- To learn more about the U.S. Government Macroeconomic data feeds, see the{" "} - blog article. -

-

- To learn how to use these feeds, see the Using Data Feeds guide. -

-

- For LINK token and Faucet details, see the{" "} - LINK Token Contracts page. -

- - - - - ) : ( - <> -

- To learn how to use these feeds, see the Using Data Feeds guide. -

-

- For LINK token and Faucet details, see the{" "} - LINK Token Contracts page. -

- - - - - )} + +

+ Learn to use data feeds |{" "} + LINK token addresses & faucets{" "} +

+

+ Before using feeds in production, review the best practices below. +

) } + + { dataFeedType === "smartdata" ? ( <> +
+ +const getFeedTableColSpan = (isStreams: boolean, showRiskColumn: boolean) => + (isStreams ? 2 : 5) + (showRiskColumn ? 1 : 0) + +const RiskCell = ({ + riskTier, + product = "feeds", +}: { + riskTier?: string | null + product?: MarketPricingRiskProduct +}) => { + const [tooltipPos, setTooltipPos] = useState | null>(null) + const normalizedKey = normalizeRiskKey(riskTier) + const category = normalizedKey ? FEED_CATEGORY_CONFIG[normalizedKey as CategoryKey] : undefined + const tooltipText = category ? getRiskTooltipText(normalizedKey as CategoryKey, product) : "" + const riskLink = category ? getRiskCategoryLink(normalizedKey as CategoryKey, product) : "" + + useEffect(() => { + if (!tooltipPos || typeof document === "undefined") return + + const container = document.createElement("div") + document.body.appendChild(container) + + render( +
+

{tooltipText}

+

Click to view the risk selection page.

+
, + container + ) + + return () => { + render(null, container) + container.remove() + } + }, [tooltipPos, tooltipText]) + + if (!category) { + return ( +
+ ) + } + + const showTooltip = (event: Event) => { + setTooltipPos(getRiskTooltipPosition(event.currentTarget as HTMLElement)) + } + + const hideTooltip = () => setTooltipPos(null) + return ( - - - {category.icon} - - + ) } @@ -308,10 +409,12 @@ const DefaultTHead = ({ showExtraDetails, networkName, dataFeedType, + showRiskColumn = true, }: { showExtraDetails: boolean networkName: string dataFeedType: string + showRiskColumn?: boolean }) => { const isAptosNetwork = networkName === "Aptos Mainnet" || networkName === "Aptos Testnet" const isUSGovernmentMacroeconomicData = dataFeedType === "usGovernmentMacroeconomicData" @@ -319,6 +422,7 @@ const DefaultTHead = ({ return ( + {showRiskColumn && } @@ -329,10 +433,23 @@ const DefaultTHead = ({ ) } -// Contact email for tokenized equity feeds (can be updated as needed) -const TOKENIZED_EQUITY_CONTACT_EMAIL = "chainlink_data_feeds@smartcontract.com" +const HiddenAddressContact = ({ className }: { className?: string }) => ( + + Contact us:{" "} + + {TOKENIZED_EQUITY_CONTACT_EMAIL} + + +) -const DefaultTr = ({ network, metadata, showExtraDetails, batchedCategoryData, dataFeedType }) => { +const DefaultTr = ({ + network, + metadata, + showExtraDetails, + batchedCategoryData, + dataFeedType, + showRiskColumn = true, +}) => { // Use the pre-computed finalCategory from enriched metadata // (already includes deprecating status and Supabase risk tier) const finalTier = metadata.finalCategory ?? null @@ -347,7 +464,7 @@ const DefaultTr = ({ network, metadata, showExtraDetails, batchedCategoryData, d // Any feed with a calculated price, or one explicitly listed in CONTACT_EMAIL_PROXY_ADDRESSES, // should have its address hidden and show a contact email instead. - const hideAddress = shouldHideAddress(metadata) + const hideAddress = shouldHideAddress(metadata, finalTier) // Stablecoin price-bound note: only when the source marks the feed as explicitly capped const stablecoinBound = @@ -363,12 +480,10 @@ const DefaultTr = ({ network, metadata, showExtraDetails, batchedCategoryData, d : metadata.feedType return ( + {showRiskColumn && } + {showRiskColumn && } @@ -592,7 +702,7 @@ const SmartDataTHead = ({ showExtraDetails }: { showExtraDetails: boolean }) => ) -const SmartDataTr = ({ network, metadata, showExtraDetails, batchedCategoryData }) => { +const SmartDataTr = ({ network, metadata, showExtraDetails, batchedCategoryData, showRiskColumn = true }) => { // Check if this is an MVR feed const hasDecoding = Array.isArray(metadata.docs?.decoding) && metadata.docs.decoding.length > 0 const isMVRFlagSet = metadata.docs?.isMVR === true @@ -610,8 +720,11 @@ const SmartDataTr = ({ network, metadata, showExtraDetails, batchedCategoryData ? getMaxSubmissionValueBound(metadata.maxSubmissionValue, metadata.decimals) : null + const hideAddress = shouldHideAddress(metadata, finalTier) + return ( + {showRiskColumn && } + {showRiskColumn && } @@ -1181,7 +1299,8 @@ const streamsCategoryMap = { }, } -export const StreamsTr = ({ metadata, isMainnet }) => { +export const StreamsTr = ({ metadata, isMainnet, showRiskColumn = isMainnet }) => { + const finalTier = metadata.finalCategory // Determine if stream is deprecating const isDeprecating = !!metadata.docs?.shutdownDate @@ -1194,52 +1313,55 @@ export const StreamsTr = ({ metadata, isMainnet }) => { return ( + {showRiskColumn && } - - {slicedFilteredMetadata.map((metadata) => ( <> - {isStreams && } + {isStreams && } {isSmartData && ( )} {(isDefault || isUSGovernmentMacroeconomicData) && ( @@ -1842,6 +1783,7 @@ export const TestnetTable = ({ showExtraDetails={showExtraDetails} dataFeedType={dataFeedType} batchedCategoryData={batchedCategoryData} + showRiskColumn={showRiskColumn} /> )} {isRates && ( @@ -1851,6 +1793,7 @@ export const TestnetTable = ({ showExtraDetails={showExtraDetails} dataFeedType={dataFeedType} batchedCategoryData={batchedCategoryData} + showRiskColumn={showRiskColumn} /> )} diff --git a/src/features/feeds/components/useBatchedFeedCategories.ts b/src/features/feeds/components/useBatchedFeedCategories.ts index cb534b9b8d4..957ec84a80a 100644 --- a/src/features/feeds/components/useBatchedFeedCategories.ts +++ b/src/features/feeds/components/useBatchedFeedCategories.ts @@ -30,6 +30,7 @@ export type FeedCategoryData = { type BatchedFeedCategoriesState = { data: Map isLoading: boolean + isReady: boolean error: string | null } @@ -41,23 +42,24 @@ export function useBatchedFeedCategories(network: ChainNetwork | null): BatchedF const [state, setState] = useState({ data: new Map(), isLoading: false, + isReady: false, error: null, }) useEffect(() => { if (!network || !network.metadata) { - setState({ data: new Map(), isLoading: false, error: null }) + setState({ data: new Map(), isLoading: false, isReady: true, error: null }) return } // Only load batch data for mainnet networks if (network.networkType !== "mainnet") { - setState({ data: new Map(), isLoading: false, error: null }) + setState({ data: new Map(), isLoading: false, isReady: true, error: null }) return } const loadBatchedCategories = async () => { - setState((prev) => ({ ...prev, isLoading: true, error: null })) + setState((prev) => ({ ...prev, isLoading: true, isReady: false, error: null })) const networkKey = getNetworkIdentifier(network) @@ -89,7 +91,7 @@ export function useBatchedFeedCategories(network: ChainNetwork | null): BatchedF }) if (feedRequests.length === 0) { - setState({ data: new Map(), isLoading: false, error: null }) + setState({ data: new Map(), isLoading: false, isReady: true, error: null }) return } // Batched DB lookup (returns Map) @@ -98,12 +100,14 @@ export function useBatchedFeedCategories(network: ChainNetwork | null): BatchedF setState({ data: batchResults, isLoading: false, + isReady: true, error: null, }) } catch (error) { setState((prev) => ({ ...prev, isLoading: false, + isReady: true, error: error instanceof Error ? error.message : "Unknown error occurred", })) } diff --git a/src/features/feeds/components/useBatchedStreamCategories.ts b/src/features/feeds/components/useBatchedStreamCategories.ts new file mode 100644 index 00000000000..14ad6654f1d --- /dev/null +++ b/src/features/feeds/components/useBatchedStreamCategories.ts @@ -0,0 +1,72 @@ +import { useEffect, useState } from "preact/hooks" +import { getStreamRiskTiersBatch } from "~/db/streamCategories.js" +import type { FeedTierResult } from "~/db/feedCategories.js" +import type { ChainNetwork } from "~/features/data/chains.ts" + +export type StreamCategoryData = FeedTierResult + +type BatchedStreamCategoriesState = { + data: Map + isLoading: boolean + isReady: boolean + error: string | null +} + +/** Batch-load stream risk tiers for verifier streams on a network. */ +export function useBatchedStreamCategories(network: ChainNetwork | null): BatchedStreamCategoriesState { + const [state, setState] = useState({ + data: new Map(), + isLoading: false, + isReady: false, + error: null, + }) + + useEffect(() => { + if (!network?.metadata?.length) { + setState({ data: new Map(), isLoading: false, isReady: true, error: null }) + return + } + + const loadBatchedCategories = async () => { + setState((prev) => ({ ...prev, isLoading: true, isReady: false, error: null })) + + const streamRequests = network.metadata + ?.filter((metadata) => metadata.contractType === "verifier" && metadata.feedId) + .map((metadata) => ({ + streamProxyAddress: metadata.feedId as string, + shutdownDate: metadata.docs?.shutdownDate, + })) + + if (!streamRequests?.length) { + setState({ data: new Map(), isLoading: false, isReady: true, error: null }) + return + } + + try { + const batchResults = await getStreamRiskTiersBatch(streamRequests) + setState({ data: batchResults, isLoading: false, isReady: true, error: null }) + } catch (error) { + setState((prev) => ({ + ...prev, + isLoading: false, + isReady: true, + error: error instanceof Error ? error.message : "Unknown error occurred", + })) + } + } + + loadBatchedCategories() + }, [network?.name, network?.networkType, network?.queryString, network?.metadata?.length]) + + return state +} + +export function getStreamCategoryFromBatch( + batchData: Map, + streamProxyAddress: string +): StreamCategoryData { + if (!batchData?.size) return { final: null } + + const key = streamProxyAddress.toLowerCase() + return batchData.get(key) ?? { final: null } +} diff --git a/src/features/feeds/constants.ts b/src/features/feeds/constants.ts new file mode 100644 index 00000000000..9702d2281a7 --- /dev/null +++ b/src/features/feeds/constants.ts @@ -0,0 +1,27 @@ +/** Contact email shown when a feed address is intentionally hidden. */ +export const TOKENIZED_EQUITY_CONTACT_EMAIL = "chainlink_data_feeds@smartcontract.com" + +/** Static SmartData category filters shown in the UI. */ +export const SMART_DATA_CATEGORY_OPTIONS = [ + { key: "Proof of Reserve", name: "Proof of Reserve" }, + { key: "NAVLink", name: "NAVLink" }, + { key: "SmartAUM", name: "SmartAUM" }, + { key: "Stablecoin Stability Assessment", name: "Stablecoin Stability Assessment" }, +] as const + +/** Default risk categories before Supabase categories load. */ +export const DEFAULT_FEED_CATEGORY_OPTIONS = [ + { key: "low", name: "Low Market Pricing Risk" }, + { key: "medium", name: "Medium Market Pricing Risk" }, + { key: "high", name: "High Market Pricing Risk" }, + { key: "veryhigh", name: "Very High Market Pricing Risk" }, + { key: "custom", name: "Custom Feeds" }, + { key: "new", name: "New Token Feeds" }, + { key: "deprecating", name: "Deprecating" }, +] as const + +export function getAddrPerPage(ecosystem: string, isStreams: boolean): number { + if (ecosystem === "deprecating" && isStreams) return 10 + if (ecosystem === "deprecating") return 10000 + return 8 +} diff --git a/src/features/feeds/content/marketPricingRiskTerms.ts b/src/features/feeds/content/marketPricingRiskTerms.ts new file mode 100644 index 00000000000..736c2ecf390 --- /dev/null +++ b/src/features/feeds/content/marketPricingRiskTerms.ts @@ -0,0 +1,69 @@ +export type MarketPricingRiskProduct = "feeds" | "streams" + +export type MarketPricingRiskTerms = { + productName: string + productPlural: string + productSingular: string + categoriesHeading: string + categoriesIntro: string + categoriesListIntro: string + sectionIntro: string + newTokenListLabel: string + customListLabel: string + deprecatingPage: string + riskMitigationHref: string + evaluatingSourcesHref: string + customSectionHref: string + anchorSuffix: string + contactRefId: string +} + +const FEEDS_TERMS: MarketPricingRiskTerms = { + productName: "Data Feeds", + productPlural: "feeds", + productSingular: "feed", + categoriesHeading: "Data Feed Categories", + categoriesIntro: + "This categorization is put in place to inform users about the intended use cases of feeds and help highlight some of the inherent market integrity risks surrounding the data quality of these feeds.", + categoriesListIntro: + "Data feeds are grouped into the following categories based on the level of market pricing risk, based on multiple factors, from lowest to highest:", + sectionIntro: + "These subsections describe standard market price feeds at each pricing risk level and correspond to the [category list](#data-feed-categories) above.", + newTokenListLabel: "New Token Feeds", + customListLabel: "Custom Feeds", + deprecatingPage: "/data-feeds/deprecating-feeds", + riskMitigationHref: "#risk-mitigation", + evaluatingSourcesHref: "#evaluating-data-sources-and-risks", + customSectionHref: "#-custom-feeds", + anchorSuffix: "feeds", + contactRefId: "DataFeed", +} + +const STREAMS_TERMS: MarketPricingRiskTerms = { + productName: "Data Streams", + productPlural: "streams", + productSingular: "stream", + categoriesHeading: "Data Stream Categories", + categoriesIntro: + "This categorization is put in place to inform users about the intended use cases of streams and help highlight some of the inherent market integrity risks surrounding the data quality of these streams.", + categoriesListIntro: + "Data streams are grouped into the following categories based on the level of market pricing risk, based on multiple factors, from lowest to highest:", + sectionIntro: + "These subsections describe standard market price streams at each pricing risk level and correspond to the [category list](#data-stream-categories) above.", + newTokenListLabel: "New Token Streams", + customListLabel: "Custom Streams", + deprecatingPage: "/data-streams/deprecating-streams", + riskMitigationHref: "/data-streams/concepts/best-practices", + evaluatingSourcesHref: "/data-streams/developer-responsibilities#market-integrity-risks", + customSectionHref: "#-custom-streams", + anchorSuffix: "streams", + contactRefId: "DataStreams", +} + +export function getMarketPricingRiskTerms(product: MarketPricingRiskProduct): MarketPricingRiskTerms { + return product === "streams" ? STREAMS_TERMS : FEEDS_TERMS +} + +export function tierAnchor(tier: string, terms: MarketPricingRiskTerms): string { + return `#-${tier}-market-pricing-risk-${terms.anchorSuffix}` +} diff --git a/src/features/feeds/content/marketPricingRiskTiers.ts b/src/features/feeds/content/marketPricingRiskTiers.ts new file mode 100644 index 00000000000..32e1c73d7b4 --- /dev/null +++ b/src/features/feeds/content/marketPricingRiskTiers.ts @@ -0,0 +1,144 @@ +import type { MarketPricingRiskTerms } from "./marketPricingRiskTerms.ts" +import { tierAnchor } from "./marketPricingRiskTerms.ts" + +export type MarketPricingRiskTierBlock = { + id: string + icon: string + title: string + leadParagraphs: string[] + bullets?: string[] + closingParagraphs: string[] +} + +function riskFooter(terms: MarketPricingRiskTerms, tierLabel: string, includeCustom = true): string { + const customNote = includeCustom + ? ` For users integrating a custom ${terms.productSingular}, please review the [${terms.customListLabel}](${terms.customSectionHref}) section for additional considerations.` + : "" + + return `Developers remain responsible for ensuring that protocol [risk parameters are configured appropriately](${terms.riskMitigationHref}) and that the operation and performance of ${tierLabel} match expectations.${customNote}` +} + +export function getMarketPricingRiskTiers(terms: MarketPricingRiskTerms): MarketPricingRiskTierBlock[] { + const shutdownPolicyLink = + terms.anchorSuffix === "feeds" + ? "[Data Feed Shutdown Policy](#data-feed-shutdown-policy)" + : `[stream deprecation policy](${terms.deprecatingPage})` + + return [ + { + id: tierAnchor("low", terms).slice(1), + icon: "🟢", + title: `Low Market Pricing Risk ${capitalize(terms.productPlural)}`, + leadParagraphs: [ + `These are data ${terms.productPlural} that follow a standardized data ${terms.productPlural} workflow to report market prices for an asset pair. Chainlink node operators each query several sources for the market price and aggregate the estimates provided by those sources.`, + `Low Market Pricing Risk ${terms.productPlural} have the following characteristics:`, + ], + bullets: [ + `More resilient to disruption than other ${terms.productPlural}`, + "Leverage multiple data sources when they are available", + "Higher volumes across multiple markets enables price discovery", + ], + closingParagraphs: [ + `While Market Pricing Risk may be categorized as low, other risks might still exist based on your use case, data provider availability or performance, the blockchain on which the ${terms.productSingular} is deployed, and the conditions on that chain. ${riskFooter(terms, `Low Market Pricing Risk ${terms.productName.toLowerCase()}`)}`, + ], + }, + { + id: tierAnchor("medium", terms).slice(1), + icon: "🟡", + title: `Medium Market Pricing Risk ${capitalize(terms.productPlural)}`, + leadParagraphs: [ + `These ${terms.productPlural} also follow a standardized data ${terms.productPlural} workflow to report market prices for an asset pair. The pair in question may have features that make it more challenging to reliably price, or potentially subject it to volatility, which may pose a risk in some use cases. While the architecture of these ${terms.productPlural} is resilient and distributed, these ${terms.productPlural} carry additional Market Pricing Risk.`, + `Types of Market Pricing Risk that may lead to a ${terms.productSingular} being categorized as Medium Market Pricing Risk include:`, + ], + bullets: [ + "Lower or inconsistent asset volume may result in periods of low liquidity in the market for such assets. This, in turn, can lead to volatile price movements.", + "A spread between the price for this asset on different trading venues or liquidity pools.", + `Market Concentration Risk: If the volume for a given asset is excessively concentrated on a single exchange, that trading venue could become a single point of failure for the ${terms.productSingular}.`, + "Cross-Rate Risk: The base asset trades in large volumes against assets that are not pegged to the quote asset. As a result, the price of this specific asset pair may fluctuate even if the underlying asset is not being traded.", + "The asset is going through a significant market event such as a token or liquidity migration.", + "The asset has a high spread between data providers, the root cause of which is often one of the above factors.", + "The availability of pricing sources may be subject to change based on concentration, trading venue location, and currency pairs.", + ], + closingParagraphs: [riskFooter(terms, `Medium Market Pricing Risk ${terms.productName.toLowerCase()}`)], + }, + { + id: tierAnchor("high", terms).slice(1), + icon: "🟠", + title: `High Market Pricing Risk ${capitalize(terms.productPlural)}`, + leadParagraphs: [ + `These ${terms.productPlural} also follow a standardized data ${terms.productPlural} workflow to report market prices for an asset pair. However, the pair in question often exhibits a heightened degree of some of the risk factors outlined under Medium Market Pricing Risk, or a separate risk that makes the market price subject to uncertainty or volatility. In using a High Market Pricing Risk data ${terms.productSingular} you acknowledge that you understand the risks associated with such a ${terms.productSingular} and that you are solely responsible for monitoring and mitigating such risks.`, + ], + closingParagraphs: [ + `${riskFooter(terms, `High Market Pricing Risk ${terms.productName.toLowerCase()}`)} High Market Pricing Risk ${terms.productPlural} may be deprecated. See the ${shutdownPolicyLink} for more information.`, + ], + }, + { + id: tierAnchor("very-high", terms).slice(1), + icon: "🔴", + title: `Very High Market Pricing Risk ${capitalize(terms.productPlural)}`, + leadParagraphs: [ + `Very High Market Pricing Risk ${terms.productPlural} price assets with quotes that are subject to extreme levels of risk, greater than those outlined above for High Market Pricing Risk. Types of Market Pricing Risk that may lead to a ${terms.productSingular} being categorized as Very High Market Pricing Risk include, but are not limited to:`, + ], + bullets: [ + "The asset is going through a significant market event such as a hack, bridge failure, or a delisting from a major exchange.", + "The asset or project is being deprecated in the market.", + "Volumes have dropped to extremely low levels.", + "Reliable pricing sources for asset are extremely limited.", + ], + closingParagraphs: [ + `Users should wind down their reliance on these ${terms.productPlural} and/or implement strict capital and risk management policies accounting for extreme price and market structure volatility. Very High Market Pricing Risk ${terms.productPlural} will be wound down over time in accordance with the ${shutdownPolicyLink}. In using a Very High Market Pricing Risk data ${terms.productSingular} you acknowledge that you understand the risks associated with such a ${terms.productSingular} and that you are solely responsible for monitoring and mitigating such risks. You understand that Chainlink may not provide separate monitoring for these ${terms.productPlural}. ${riskFooter(terms, `Very High Market Pricing Risk ${terms.productName.toLowerCase()}`)}`, + ], + }, + { + id: tierAnchor("new-token", terms).slice(1), + icon: "🆕", + title: terms.newTokenListLabel, + leadParagraphs: [ + `When a token is newly launched, the historical data required to implement a rigorous risk assessment framework that would allow the categorization of a market data ${terms.productSingular} for that token as Low, Medium, or High Pricing Risk is unavailable. Consistent price discovery may involve an indeterminate amount of time. Users must understand the additional [market and volatility risks](${terms.evaluatingSourcesHref}) inherent with such assets. Users of ${terms.newTokenListLabel} are responsible for independently verifying the liquidity and stability of the assets priced by the ${terms.productPlural} that they use. At the end of a probationary period, the status of ${terms.newTokenListLabel} may be adjusted to Very High, High, Medium, or Low Market Pricing Risk, or in rare cases be deprecated entirely.`, + ], + closingParagraphs: [riskFooter(terms, terms.newTokenListLabel)], + }, + { + id: tierAnchor("custom", terms).slice(1), + icon: "🔵", + title: terms.customListLabel, + leadParagraphs: [ + `${terms.customListLabel} are built to serve a specific use case and might not be suitable for general use or your use case's risk parameters. Users must evaluate the properties of a ${terms.productSingular} to make sure it aligns with their intended use case. [Contact the Chainlink Labs team](https://chain.link/contact?ref_id=${terms.contactRefId}) if you want more detail on any specific ${terms.productPlural} in this category.`, + ], + closingParagraphs: + terms.anchorSuffix === "feeds" + ? [ + `${terms.customListLabel} have the following categories and compositions:`, + "If you plan on using one of these feeds and would like to get a more detailed understanding, [contact the Chainlink Labs team](https://chain.link/contact?ref_id=DataFeed). Using feeds that were not specifically designed for your use case involves risk. Their use might pose risks that could result in harm to your project. Users are responsible for thoroughly vetting and validating such deployments and determining their suitability. You bear responsibility for any manner in which you use the Chainlink Network, its software, and documentation.", + ] + : [ + `If you plan on using one of these ${terms.productPlural} and would like to get a more detailed understanding, [contact the Chainlink Labs team](https://chain.link/contact?ref_id=${terms.contactRefId}). Using ${terms.productPlural} that were not specifically designed for your use case involves risk. Users are responsible for thoroughly vetting and validating such deployments and determining their suitability.`, + ], + }, + { + id: "-deprecating", + icon: "⭕", + title: "Deprecating", + leadParagraphs: [ + `These ${terms.productPlural} are being deprecated. To find the deprecation dates for specific ${terms.productPlural}, see the [${terms.productName} Scheduled For Deprecation](${terms.deprecatingPage}) page.`, + ], + closingParagraphs: [], + }, + ] +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1) +} + +export function getCategoryListItems(terms: MarketPricingRiskTerms) { + return [ + { icon: "🟢", label: "Low Market Pricing Risk", href: tierAnchor("low", terms) }, + { icon: "🟡", label: "Medium Market Pricing Risk", href: tierAnchor("medium", terms) }, + { icon: "🟠", label: "High Market Pricing Risk", href: tierAnchor("high", terms) }, + { icon: "🔴", label: "Very High Market Pricing Risk", href: tierAnchor("very-high", terms) }, + { icon: "🆕", label: terms.newTokenListLabel, href: tierAnchor("new-token", terms) }, + { icon: "🔵", label: terms.customListLabel, href: tierAnchor("custom", terms) }, + { icon: "⭕", label: "Deprecating", href: "#-deprecating" }, + ] +} diff --git a/src/features/feeds/hooks/useFilteredFeedMetadata.ts b/src/features/feeds/hooks/useFilteredFeedMetadata.ts new file mode 100644 index 00000000000..cc62135b448 --- /dev/null +++ b/src/features/feeds/hooks/useFilteredFeedMetadata.ts @@ -0,0 +1,53 @@ +import type { ChainNetwork } from "~/features/data/chains.ts" +import { getFeedTypeFlags } from "../types.ts" +import { useBatchedFeedCategories } from "../components/useBatchedFeedCategories.ts" +import { useBatchedStreamCategories } from "../components/useBatchedStreamCategories.ts" +import { getFeedContractAddress } from "../utils/feedMetadata.ts" +import { filterFeedTableRows, type FeedTableFilterParams } from "../utils/tableFilters.ts" + +type UseFilteredFeedMetadataParams = Omit & { + network: ChainNetwork +} + +function networkNeedsRiskBatch( + network: ChainNetwork, + isStreams: boolean, + searchVariant: UseFilteredFeedMetadataParams["searchVariant"] +): boolean { + if (searchVariant === "testnet") return false + if (!network.metadata?.length) return false + + if (isStreams) { + return network.metadata.some((metadata) => metadata.contractType === "verifier" && metadata.feedId) + } + + if (network.networkType !== "mainnet") return false + + return network.metadata.some((metadata) => !!getFeedContractAddress(network, metadata)) +} + +/** Loads batched risk categories and returns filtered, sorted feed rows for a table. */ +export function useFilteredFeedMetadata(params: UseFilteredFeedMetadataParams) { + const { network, dataFeedType, searchVariant, ...rest } = params + const { isStreams } = getFeedTypeFlags(dataFeedType, searchVariant) + const feedCategories = useBatchedFeedCategories(isStreams ? null : network) + const streamCategories = useBatchedStreamCategories(isStreams ? network : null) + const batchState = isStreams ? streamCategories : feedCategories + const batchedCategoryData = batchState.data + const needsRiskBatch = networkNeedsRiskBatch(network, isStreams, searchVariant) + const isBatchLoading = needsRiskBatch && !batchState.isReady + + const filteredMetadata = filterFeedTableRows({ + network, + batchedCategoryData, + dataFeedType, + searchVariant, + ...rest, + }) + + return { + batchedCategoryData, + filteredMetadata, + isBatchLoading, + } +} diff --git a/src/features/feeds/types.ts b/src/features/feeds/types.ts new file mode 100644 index 00000000000..3de502bbf2b --- /dev/null +++ b/src/features/feeds/types.ts @@ -0,0 +1,51 @@ +/** + * Shared feed page types and helpers. + * + * Import from here instead of FeedList.tsx to avoid circular dependencies + * between UI components and visibility/filter utilities. + */ + +export type DataFeedType = + | "default" + | "smartdata" + | "rates" + | "usGovernmentMacroeconomicData" + | "tokenizedEquity" + | "streamsCrypto" + | "streamsRwa" + | "streamsNav" + | "streamsExRate" + | "streamsBacked" + +export type SchemaFilterValue = "all" | "v8" | "v11" +export type StreamsRwaFeedTypeValue = "all" | "datalink" | "equities" | "forex" +export type TradingHoursFilterValue = "all" | "regular" | "extended" | "overnight" + +/** Flags derived from the active feed page type. Used by tables and FeedList. */ +export interface FeedTypeFlags { + isStreams: boolean + isSmartData: boolean + isRates: boolean + isUSGovernmentMacroeconomicData: boolean + /** Standard price feeds table (excludes streams/smartdata/macro; testnet also excludes rates). */ + isDefaultTable: boolean +} + +export function getFeedTypeFlags(dataFeedType: string, environment: "mainnet" | "testnet" = "mainnet"): FeedTypeFlags { + const isStreams = + dataFeedType === "streamsCrypto" || + dataFeedType === "streamsRwa" || + dataFeedType === "streamsNav" || + dataFeedType === "streamsExRate" || + dataFeedType === "streamsBacked" + const isSmartData = dataFeedType === "smartdata" + const isRates = dataFeedType === "rates" + const isUSGovernmentMacroeconomicData = dataFeedType === "usGovernmentMacroeconomicData" + + const isDefaultTable = + environment === "testnet" + ? !isSmartData && !isRates && !isStreams && !isUSGovernmentMacroeconomicData + : !isStreams && !isSmartData && !isUSGovernmentMacroeconomicData + + return { isStreams, isSmartData, isRates, isUSGovernmentMacroeconomicData, isDefaultTable } +} diff --git a/src/features/feeds/utils/chainFilters.ts b/src/features/feeds/utils/chainFilters.ts new file mode 100644 index 00000000000..d5b8be874d7 --- /dev/null +++ b/src/features/feeds/utils/chainFilters.ts @@ -0,0 +1,65 @@ +import type { Chain, ChainNetwork } from "~/features/data/chains.ts" +import type { DataFeedType } from "../types.ts" + +type Taggable = { tags?: string[] } + +/** Map feed page type → chain/network tag used in chains.ts. */ +const FEED_TYPE_TAG: Partial> = { + smartdata: "smartData", + rates: "rates", + usGovernmentMacroeconomicData: "usGovernmentMacroeconomicData", + tokenizedEquity: "tokenizedEquity", +} + +export function chainMatchesFeedTypeTag(chain: Taggable, dataFeedType: DataFeedType): boolean { + if (dataFeedType.includes("streams")) return chain.tags?.includes("streams") ?? false + + const tag = FEED_TYPE_TAG[dataFeedType] + if (tag) return chain.tags?.includes(tag) ?? false + + return chain.tags?.includes("default") ?? false +} + +export function filterChainsByFeedTypeTag(chains: Chain[], dataFeedType: DataFeedType): Chain[] { + return chains.filter((chain) => chainMatchesFeedTypeTag(chain, dataFeedType)) +} + +export function networkMatchesFeedTypeTag(network: Taggable, dataFeedType: DataFeedType): boolean { + if (dataFeedType.includes("streams")) return network.tags?.includes("streams") ?? false + + const tag = FEED_TYPE_TAG[dataFeedType] + if (tag) return network.tags?.includes(tag) ?? false + + return true +} + +/** Whether a network section should render for the current page + mainnet/testnet selection. */ +export function shouldRenderNetworkSection( + network: ChainNetwork, + dataFeedType: DataFeedType, + selectedNetworkType: "mainnet" | "testnet", + isDeprecating: boolean, + hasVisibleFeeds: boolean +): boolean { + if (network.networkType !== selectedNetworkType) return false + if (isDeprecating) return hasVisibleFeeds + + if (dataFeedType.includes("streams")) return network.tags?.includes("streams") ?? false + if (dataFeedType === "smartdata") return network.tags?.includes("smartData") ?? false + if (dataFeedType === "rates") return network.tags?.includes("rates") ?? false + if (dataFeedType === "usGovernmentMacroeconomicData") { + return network.tags?.includes("usGovernmentMacroeconomicData") ?? false + } + + return true +} + +export function shouldFilterSelectableChainsByVisibleFeeds(dataFeedType: DataFeedType, ecosystem: string): boolean { + return ( + ecosystem === "deprecating" || + dataFeedType === "smartdata" || + dataFeedType === "rates" || + dataFeedType === "usGovernmentMacroeconomicData" || + dataFeedType === "tokenizedEquity" + ) +} diff --git a/src/features/feeds/utils/feedMetadata.ts b/src/features/feeds/utils/feedMetadata.ts new file mode 100644 index 00000000000..390763c73ec --- /dev/null +++ b/src/features/feeds/utils/feedMetadata.ts @@ -0,0 +1,166 @@ +import { resolveFeedCategory } from "~/db/feedCategories.js" +import type { ChainNetwork } from "~/features/data/chains.ts" +import type { TradingHoursFilterValue } from "../types.ts" +import type { FeedCategoryData } from "../components/useBatchedFeedCategories.ts" +import { getFeedCategoryFromBatch, getNetworkIdentifier } from "../components/useBatchedFeedCategories.ts" +import { getStreamCategoryFromBatch } from "../components/useBatchedStreamCategories.ts" + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type FeedMetadata = any + +/** Normalize category keys for comparison (e.g. "very high" → "veryhigh"). */ +export function normalizeCategoryKey(value?: string | null): string | undefined { + return value?.toLowerCase().replace(/\s+/g, "") +} + +/** Normalize user search input and feed fields for substring matching. */ +export function normalizeSearchText(value?: string | null): string { + return (value ?? "").toLowerCase().replaceAll(" ", "") +} + +/** + * Resolve RWA stream schema version from docs.schema or clicProductName suffix. + * Shared by feed visibility, stream filters, and table rendering. + */ +export function getSchemaVersion(feed: FeedMetadata): string | undefined { + if (feed.docs?.schema) return feed.docs.schema + + const clicProductName = feed.docs?.clicProductName + if (!clicProductName) return undefined + + const match = clicProductName.match(/-0(\d{2})$/) + if (!match) return undefined + + if (match[1] === "04" || match[1] === "08") return "v8" + if (match[1] === "11") return "v11" + + return undefined +} + +export function getFeedContractAddress(network: ChainNetwork, metadata: FeedMetadata): string | undefined { + const isAptos = network.name.toLowerCase().includes("aptos") + if (isAptos) return metadata.proxyAddress ?? undefined + return metadata.contractAddress ?? metadata.proxyAddress ?? undefined +} + +/** Attach Supabase/RDD-derived finalCategory used for risk icons and category filters. */ +export function enrichFeedWithCategory( + metadata: FeedMetadata, + network: ChainNetwork, + batchedCategoryData: Map +): FeedMetadata { + const isStream = metadata.contractType === "verifier" && metadata.feedId + + if (isStream) { + const batchFinal = + batchedCategoryData?.size && metadata.feedId + ? getStreamCategoryFromBatch(batchedCategoryData, metadata.feedId).final + : null + + const finalCategory = resolveFeedCategory(batchFinal, metadata.docs?.shutdownDate, metadata.feedCategory) + return { ...metadata, finalCategory } + } + + const contractAddress = getFeedContractAddress(network, metadata) + const networkIdentifier = getNetworkIdentifier(network) + + const batchFinal = + contractAddress && batchedCategoryData?.size + ? getFeedCategoryFromBatch(batchedCategoryData, contractAddress, networkIdentifier).final + : null + + const finalCategory = resolveFeedCategory(batchFinal, metadata.docs?.shutdownDate, metadata.feedCategory) + + return { ...metadata, finalCategory } +} + +function matchesTradingHours(metadata: FeedMetadata, tradingHoursFilter: TradingHoursFilterValue): boolean { + if (tradingHoursFilter === "all") return true + + const assetSubClass = metadata.docs?.assetSubClass + const clicProductName = metadata.docs?.clicProductName || "" + + const isRegularHours = + assetSubClass === "Regular Hours" || + (clicProductName.includes("RegularHours") && + !clicProductName.includes("ExtendedHours") && + !clicProductName.includes("OvernightHours")) + const isExtendedHours = assetSubClass === "Extended Hours" || clicProductName.includes("ExtendedHours") + const isOvernightHours = assetSubClass === "Overnight Hours" || clicProductName.includes("OvernightHours") + + if (tradingHoursFilter === "regular") return isRegularHours + if (tradingHoursFilter === "extended") return isExtendedHours + if (tradingHoursFilter === "overnight") return isOvernightHours + + return true +} + +/** 24/5 stream filter used by FeedList and feed tables. */ +export function matches24x5StreamFilter( + metadata: FeedMetadata, + show24x5Feeds: boolean | undefined, + tradingHoursFilter: TradingHoursFilterValue | undefined +): boolean { + if (!show24x5Feeds) return true + + const schemaVersion = getSchemaVersion(metadata) + const feedType = metadata.feedType || metadata.docs?.feedType + const is24x5Feed = (feedType === "Equities" || feedType === "Forex") && schemaVersion === "v11" + + if (!is24x5Feed) return false + + return matchesTradingHours(metadata, tradingHoursFilter ?? "all") +} + +/** Canonical asset type label for filtering (matches the "Asset type" column in tables). */ +export function getFeedAssetType(metadata: FeedMetadata): string | undefined { + const raw = metadata.feedType || metadata.docs?.feedType || metadata.docs?.assetClass + if (!raw || typeof raw !== "string") return undefined + + if (raw.toLowerCase() === "crypto") return "Crypto" + + return raw.trim() +} + +/** Category chip filter for SmartData product types vs asset-type feeds. */ +export function matchesSelectedFeedCategories( + metadata: FeedMetadata, + selectedFeedCategories: string[], + isSmartData: boolean +): boolean { + if (isSmartData) { + if (selectedFeedCategories.includes("MVR") && metadata.docs?.isMVR) return true + + return ( + selectedFeedCategories.length === 0 || + (metadata.docs?.productType && selectedFeedCategories.includes(metadata.docs.productType)) || + (metadata.docs?.assetClass && selectedFeedCategories.includes(metadata.docs.assetClass)) + ) + } + + if (selectedFeedCategories.length === 0) return true + + const assetType = getFeedAssetType(metadata) + return assetType !== undefined && selectedFeedCategories.includes(assetType) +} + +export type FeedSearchVariant = "mainnet" | "testnet" + +/** Search box filter shared by mainnet and testnet tables. */ +export function matchesFeedSearch(metadata: FeedMetadata, searchValue: string, variant: FeedSearchVariant): boolean { + if (!searchValue) return true + + const query = normalizeSearchText(searchValue) + const fields = [metadata.name, metadata.proxyAddress, metadata.assetName, metadata.feedType, metadata.feedId] + + if (variant === "mainnet") { + fields.push( + metadata.secondaryProxyAddress, + metadata.docs?.porType, + metadata.docs?.porAuditor, + metadata.docs?.porSource + ) + } + + return fields.some((field) => normalizeSearchText(field).includes(query)) +} diff --git a/src/features/feeds/utils/feedVisibility.ts b/src/features/feeds/utils/feedVisibility.ts index 8293a0e5653..5d793e8bc3e 100644 --- a/src/features/feeds/utils/feedVisibility.ts +++ b/src/features/feeds/utils/feedVisibility.ts @@ -1,4 +1,5 @@ -import type { DataFeedType } from "../components/FeedList.tsx" +import type { DataFeedType } from "../types.ts" +import { getSchemaVersion, normalizeCategoryKey } from "./feedMetadata.ts" /** * Proxy addresses (lowercase) for feeds that should display the contact email @@ -9,55 +10,21 @@ import type { DataFeedType } from "../components/FeedList.tsx" * applies automatically to any feed with productSubType === "calculatedPrice"; * this list covers one-off exceptions (e.g. a specific DAI feed on a chain * that does not carry that productSubType). - * - * Example: - * "0xabc123..." — the proxyAddress of the feed, lower-cased */ export const CONTACT_EMAIL_PROXY_ADDRESSES = new Set([ - // add lowercase proxy addresses here, e.g.: - // "0x000000000000000000000000000000000000dead", "0x0101166b3b000332000000000000000000000000000000000000000000000000", ]) /** * Returns true when the feed's contract address should be hidden and replaced * with the data-feeds contact email in the UI. - * - * Two conditions trigger hiding: - * 1. The feed's productSubType is "calculatedPrice" (blanket rule for all - * calculated-price feeds). - * 2. The feed's proxyAddress appears in CONTACT_EMAIL_PROXY_ADDRESSES (used - * for one-off overrides on a per-feed basis). */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function shouldHideAddress(feed: any): boolean { +export function shouldHideAddress(feed: any, riskTier?: string | null): boolean { if (feed.docs?.productSubType === "calculatedPrice") return true const proxy: string | null | undefined = feed.proxyAddress - return proxy != null && CONTACT_EMAIL_PROXY_ADDRESSES.has(proxy.toLowerCase()) -} - -/** - * Helper function to extract schema version from feed metadata - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function getSchemaVersion(feed: any): string | undefined { - // First try to get from docs.schema - if (feed.docs?.schema) { - return feed.docs.schema - } - - // Fallback: parse from clicProductName - const clicProductName = feed.docs?.clicProductName - if (clicProductName) { - const match = clicProductName.match(/-0(\d{2})$/) - if (match) { - const version = match[1] - if (version === "04" || version === "08") return "v8" - if (version === "11") return "v11" - } - } - - return undefined + if (proxy != null && CONTACT_EMAIL_PROXY_ADDRESSES.has(proxy.toLowerCase())) return true + return normalizeCategoryKey(riskTier) === "veryhigh" } /** @@ -65,8 +32,6 @@ function getSchemaVersion(feed: any): string | undefined { * * streamsRwa is the catch-all for ALL Datalink feeds (used by the dedicated * Datalink streams page). Every other stream type opts in via this map. - * To surface Datalink on a new stream page, add one entry here — nothing - * else in the visibility logic needs to change. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any const DATALINK_STREAM_MATCH: Partial boolean>> = { @@ -76,31 +41,20 @@ const DATALINK_STREAM_MATCH: Partial boolean>> = { streamsBacked: (feed) => feed.docs?.assetClass === "Tokenized Equities", } -/** - * Determines if a feed should be visible based on: - * - Hidden flags (feedCategory === "hidden" or docs.hidden) - * - Data feed type filtering (streams, smartdata, rates, etc.) - * - Ecosystem filtering (deprecating) - * - * This logic is shared between table filtering and network availability checks. - */ export interface FeedVisibilityOptions { showOnlyDEXFeeds?: boolean showOnlyDatalinkFeeds?: boolean streamCategoryFilter?: string rwaSchemaFilter?: string showOnlyMVRFeeds?: boolean - tokenizedEquityProvider?: string // Filter tokenized equity feeds by provider (e.g., "ondo") + tokenizedEquityProvider?: string } /** - * Determines if a feed should be visible based on: - * - Hidden flags (feedCategory === "hidden" or docs.hidden) - * - Data feed type filtering (streams, smartdata, rates, etc.) - * - Ecosystem filtering (deprecating) - * - Optional filters (DEX only, MVR only, schema version, etc.) + * Determines if a feed should be visible based on hidden flags, feed page type, + * ecosystem (deprecating), and optional UI filters. * - * This logic is shared between table filtering and network availability checks. + * Shared between table filtering and network availability checks. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function isFeedVisible( @@ -112,8 +66,6 @@ export function isFeedVisible( // =========================================================================== // 1. Universal Exclusions // =========================================================================== - // Tokenized equity feeds are allowed to bypass the hidden flag since they are - // marked hidden in the general feed list but should show on their dedicated page const isTokenizedEquity = dataFeedType === "tokenizedEquity" if (feed.docs?.hidden && !isTokenizedEquity) return false @@ -131,7 +83,6 @@ export function isFeedVisible( // =========================================================================== // 2. Ecosystem-Specific Logic // =========================================================================== - // If we are in the "deprecating" ecosystem view, ONLY show feeds with an RDD shutdown date. if (isDeprecating && !feed.docs?.shutdownDate) return false let isVisible = false @@ -139,20 +90,14 @@ export function isFeedVisible( // =========================================================================== // 3. Data Feed Type Logic (Base Visibility) // =========================================================================== - // Determine if the feed belongs to the requested category (Streams, SmartData, etc.) - if (isStreams) { - // Streams feeds must be verified contracts if (feed.contractType !== "verifier") return false const isDatalink = feed.docs?.feedType === "Datalink" if (isDatalink) { - // streamsRwa is the catch-all for ALL Datalink (powers the dedicated Datalink page). - // Every other stream type opts in via DATALINK_STREAM_MATCH above. isVisible = dataFeedType === "streamsRwa" || (DATALINK_STREAM_MATCH[dataFeedType]?.(feed) ?? false) } else { - // Native (non-Datalink) stream visibility per page if (dataFeedType === "streamsCrypto") { isVisible = ["Crypto", "Crypto-DEX"].includes(feed.docs?.feedType) } else if (dataFeedType === "streamsRwa") { @@ -166,7 +111,6 @@ export function isFeedVisible( } } } else if (isSmartData) { - // SmartData feeds (excluding DS delivery channel) if (feed.docs?.deliveryChannelCode === "DS") isVisible = false else isVisible = @@ -179,17 +123,11 @@ export function isFeedVisible( } else if (isRates) { isVisible = feed.docs?.productType === "Rates" || feed.docs?.productSubType === "Realized Volatility" } else if (isTokenizedEquity) { - // Tokenized equity feeds (Ondo and other providers) - // Only show true tokenized equity feeds (primaryTokenizedPrice) on this page. - // Generic equity price feeds (e.g. RefPrice) are excluded — they are not - // tokenized equity instruments and should not appear here. isVisible = feed.docs?.assetClass === "Equity" && feed.contractType !== "verifier" && feed.docs?.productTypeCode === "primaryTokenizedPrice" } else { - // Default data feeds (Standard Price Feeds) - // Exclude all special types to leave only the standard feeds isVisible = !feed.docs?.porType && feed.contractType !== "verifier" && @@ -206,19 +144,14 @@ export function isFeedVisible( // =========================================================================== // 4. Optional Filters (User Selection) // =========================================================================== - // Apply additional filters selected by the user in the UI - - // Filter: Show only DEX feeds (Streams Crypto) if (dataFeedType === "streamsCrypto" && options.showOnlyDEXFeeds) { if (feed.docs?.feedType !== "Crypto-DEX") return false } - // Filter: Show only Datalink feeds (any stream type) if (isStreams && options.showOnlyDatalinkFeeds) { if (feed.docs?.feedType !== "Datalink") return false } - // Filter: RWA Category & Schema (Streams RWA) if (dataFeedType === "streamsRwa") { if (options.streamCategoryFilter === "datalink" && feed.docs.feedType !== "Datalink") return false if (options.streamCategoryFilter === "equities" && feed.docs.feedType !== "Equities") return false @@ -229,26 +162,18 @@ export function isFeedVisible( if (options.rwaSchemaFilter === "v11" && schemaVersion !== "v11") return false } - // Filter: Show only MVR feeds (SmartData) if (isSmartData && options.showOnlyMVRFeeds) { if (feed.docs?.isMVR !== true) return false } - // Filter: Tokenized equity feeds by provider if (isTokenizedEquity && options.tokenizedEquityProvider) { const provider = options.tokenizedEquityProvider.toLowerCase() if (provider === "ondo") { - // Ondo tokenized equity feeds are identified by BOTH: - // 1. "Ondo" in assetName — distinguishes from other tokenized equity providers - // 2. productTypeCode "primaryTokenizedPrice" — distinguishes from ONDO token feeds - // Neither signal alone is sufficient: other providers may share the productTypeCode, - // and ONDO governance token feeds may contain "Ondo" in the asset name. const assetName = (feed.assetName || "").toLowerCase() const isOndoFeed = assetName.includes("ondo") && feed.docs?.productTypeCode === "primaryTokenizedPrice" if (!isOndoFeed) return false } - // Add more provider patterns here as needed } return true diff --git a/src/features/feeds/utils/svrDetection.ts b/src/features/feeds/utils/svrDetection.ts index c43e65bf466..78a07babf95 100644 --- a/src/features/feeds/utils/svrDetection.ts +++ b/src/features/feeds/utils/svrDetection.ts @@ -1,8 +1,39 @@ import { type ChainMetadata } from "~/features/data/api/index.ts" +import type { DataFeedType } from "../types.ts" +import { type FeedVisibilityOptions, isFeedVisible } from "./feedVisibility.ts" // This file contains *temporary* functions to detect SVR feeds based on their metadata // These functions are used to identify specific types of SVR feeds based on their metadata properties +export function isSvrFeed(metadata: ChainMetadata): boolean { + return !!metadata?.secondaryProxyAddress +} + +export function networkHasSvrFeeds( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + network: any, + dataFeedType: DataFeedType, + ecosystem = "", + options: FeedVisibilityOptions = {} +): boolean { + return ( + network?.metadata?.some( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (feed: any) => isSvrFeed(feed) && isFeedVisible(feed, dataFeedType, ecosystem, options) + ) ?? false + ) +} + +export function chainHasSvrFeeds( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + chain: any, + dataFeedType: DataFeedType, + ecosystem = "", + options: FeedVisibilityOptions = {} +): boolean { + return chain?.networks?.some((network: any) => networkHasSvrFeeds(network, dataFeedType, ecosystem, options)) ?? false +} + /** * Determines if a feed is a Shared SVR feed based on its path * @param metadata - The feed metadata object diff --git a/src/features/feeds/utils/tableFilters.ts b/src/features/feeds/utils/tableFilters.ts new file mode 100644 index 00000000000..607622ef8cc --- /dev/null +++ b/src/features/feeds/utils/tableFilters.ts @@ -0,0 +1,59 @@ +import type { ChainNetwork } from "~/features/data/chains.ts" +import type { SchemaFilterValue, StreamsRwaFeedTypeValue, TradingHoursFilterValue } from "../types.ts" +import { getFeedTypeFlags } from "../types.ts" +import { + enrichFeedWithCategory, + matches24x5StreamFilter, + matchesFeedSearch, + matchesSelectedFeedCategories, + type FeedSearchVariant, +} from "./feedMetadata.ts" +import { isFeedVisible, type FeedVisibilityOptions } from "./feedVisibility.ts" +import type { FeedCategoryData } from "../components/useBatchedFeedCategories.ts" + +export interface FeedTableFilterParams { + network: ChainNetwork + batchedCategoryData: Map + dataFeedType: string + ecosystem: string + selectedFeedCategories: string[] + searchValue: string + searchVariant: FeedSearchVariant + showOnlySVR?: boolean + visibilityOptions?: FeedVisibilityOptions + show24x5Feeds?: boolean + tradingHoursFilter?: TradingHoursFilterValue + streamCategoryFilter?: StreamsRwaFeedTypeValue + rwaSchemaFilter?: SchemaFilterValue +} + +/** Shared mainnet/testnet row pipeline: enrich → visibility → UI filters → search. */ +export function filterFeedTableRows({ + network, + batchedCategoryData, + dataFeedType, + ecosystem, + selectedFeedCategories, + searchValue, + searchVariant, + showOnlySVR = false, + visibilityOptions = {}, + show24x5Feeds, + tradingHoursFilter, +}: FeedTableFilterParams) { + const { isSmartData } = getFeedTypeFlags(dataFeedType, searchVariant) + + const enrichedMetadata = (network.metadata ?? []).map((metadata) => + enrichFeedWithCategory(metadata, network, batchedCategoryData) + ) + + return enrichedMetadata + .sort((a, b) => (a.name.toUpperCase() < b.name.toUpperCase() ? -1 : 1)) + .filter((metadata) => { + if (showOnlySVR && !metadata.secondaryProxyAddress) return false + return isFeedVisible(metadata, dataFeedType as never, ecosystem, visibilityOptions) + }) + .filter((metadata) => matches24x5StreamFilter(metadata, show24x5Feeds, tradingHoursFilter)) + .filter((metadata) => matchesSelectedFeedCategories(metadata, selectedFeedCategories, isSmartData)) + .filter((metadata) => matchesFeedSearch(metadata, searchValue, searchVariant)) +}
Risk + — + + +
{isUSGovernmentMacroeconomicData ? "Feed" : "Pair"} Deviation Heartbeat
-
- {getFeedCategoryElement(finalTier || undefined)} - {metadata.name} -
+
{metadata.name}
{metadata.secondaryProxyAddress && (
{hideAddress ? ( - // Calculated-price feeds show a contact email instead of proxy address - - Contact us:{" "} - - {TOKENIZED_EQUITY_CONTACT_EMAIL} - - + ) : (
SmartData Feed Deviation Heartbeat
{feedItems.map((feedItem: FeedDataItem) => { const [feedAddress] = Object.keys(feedItem) @@ -628,9 +741,7 @@ const SmartDataTr = ({ network, metadata, showExtraDetails, batchedCategoryData } return "" })} -
- {getFeedCategoryElement(finalTier || undefined)} {metadata.name} -
+
{metadata.name}
{metadata.docs.shutdownDate && (

@@ -685,29 +796,35 @@ const SmartDataTr = ({ network, metadata, showExtraDetails, batchedCategoryData
- - {metadata.proxyAddress ?? metadata.transmissionsAccount} - - + {hideAddress ? ( + + ) : ( + <> + + {metadata.proxyAddress ?? metadata.transmissionsAccount} + + + + )}
@@ -1165,9 +1282,10 @@ export const StreamsNetworkAddressesTable = ({ ) } -export const StreamsTHead = () => ( +export const StreamsTHead = ({ showRiskColumn = true }: { showRiskColumn?: boolean } = {}) => (
Stream Details
- {metadata.pair[0]}/{metadata.pair[1]} - {metadata.feedType === "Crypto-DEX" && ( - - DEX State Price - - )} - {metadata.feedType === "Datalink" && ( - - Datalink - - )} - {isCalculatedStream && ( - - Calculated - +
+ {metadata.pair[0]}/{metadata.pair[1]} + {metadata.feedType === "Crypto-DEX" && ( + + DEX State Price + + )} + {metadata.feedType === "Datalink" && ( + + Datalink + + )} + {isCalculatedStream && ( + + Calculated + + )} +
+ {metadata.docs.shutdownDate && ( +
+
+ + Deprecating: + +
+ {metadata.docs.shutdownDate} +
)}
- {metadata.docs.shutdownDate && ( -
-
- - Deprecating: - -
- {metadata.docs.shutdownDate} -
- )}
@@ -1425,138 +1547,32 @@ export const MainnetTable = ({ }) => { if (!network.metadata) return null - const { data: batchedCategoryData, isLoading: isBatchLoading } = useBatchedFeedCategories(network) - - const isStreams = - dataFeedType === "streamsCrypto" || - dataFeedType === "streamsRwa" || - dataFeedType === "streamsNav" || - dataFeedType === "streamsExRate" || - dataFeedType === "streamsBacked" - const isSmartData = dataFeedType === "smartdata" - const isUSGovernmentMacroeconomicData = dataFeedType === "usGovernmentMacroeconomicData" - const isDefault = !isStreams && !isSmartData && !isUSGovernmentMacroeconomicData - - // Enrich metadata with final category from Supabase. - // Deprecating is inferred from shutdownDate when no DB risk status is present. - const enrichedMetadata = network.metadata.map((metadata) => { - const isAptos = network.name.toLowerCase().includes("aptos") - const contractAddress = isAptos ? metadata.proxyAddress : metadata.contractAddress || metadata.proxyAddress - const networkIdentifier = getNetworkIdentifier(network) - - const finalCategory = - contractAddress && batchedCategoryData?.size - ? getFeedCategoryFromBatch(batchedCategoryData, contractAddress, networkIdentifier).final - : null - - return { ...metadata, finalCategory } + const feedTypeFlags = getFeedTypeFlags(dataFeedType, "mainnet") + const { isStreams, isSmartData, isUSGovernmentMacroeconomicData, isDefaultTable: isDefault } = feedTypeFlags + + const { batchedCategoryData, filteredMetadata, isBatchLoading } = useFilteredFeedMetadata({ + network, + dataFeedType, + ecosystem, + selectedFeedCategories, + searchValue, + searchVariant: "mainnet", + showOnlySVR, + show24x5Feeds, + tradingHoursFilter, + visibilityOptions: { + showOnlyDEXFeeds, + showOnlyDatalinkFeeds, + streamCategoryFilter, + rwaSchemaFilter, + showOnlyMVRFeeds, + tokenizedEquityProvider, + }, }) - const filteredMetadata = enrichedMetadata - .sort((a, b) => (a.name.toUpperCase() < b.name.toUpperCase() ? -1 : 1)) - .filter((metadata) => { - if (showOnlySVR && !metadata.secondaryProxyAddress) { - return false - } - - // Use shared visibility logic with filters - return isFeedVisible(metadata, dataFeedType as any, ecosystem, { - showOnlyDEXFeeds, - showOnlyDatalinkFeeds, - streamCategoryFilter, - rwaSchemaFilter, - showOnlyMVRFeeds, - tokenizedEquityProvider, - }) - }) - .filter((metadata) => { - // When 24/5 checkbox is checked, ONLY show 24/5 feeds - if (show24x5Feeds) { - const schemaVersion = getSchemaVersion(metadata) - const feedType = metadata.feedType || metadata.docs?.feedType - - // 24/5 feeds are Equities/Forex with v11 schema - const is24x5Feed = (feedType === "Equities" || feedType === "Forex") && schemaVersion === "v11" - - if (!is24x5Feed) return false - - // Apply trading hours sub-filter - if (tradingHoursFilter && tradingHoursFilter !== "all") { - const assetSubClass = (metadata.docs as any)?.assetSubClass - const clicProductName = (metadata.docs as any)?.clicProductName || "" - - // Check both assetSubClass and clicProductName for hours identification - const isRegularHours = - assetSubClass === "Regular Hours" || - (clicProductName.includes("RegularHours") && - !clicProductName.includes("ExtendedHours") && - !clicProductName.includes("OvernightHours")) - const isExtendedHours = assetSubClass === "Extended Hours" || clicProductName.includes("ExtendedHours") - const isOvernightHours = assetSubClass === "Overnight Hours" || clicProductName.includes("OvernightHours") - - if (tradingHoursFilter === "regular" && !isRegularHours) return false - if (tradingHoursFilter === "extended" && !isExtendedHours) return false - if (tradingHoursFilter === "overnight" && !isOvernightHours) return false - } - } - - return true - }) - .filter((metadata) => { - if (isSmartData) { - // Include MVR category in SmartData filter - if (selectedFeedCategories.includes("MVR") && metadata.docs?.isMVR) { - return true - } - - const included = - selectedFeedCategories.length === 0 || - (metadata.docs.productType && selectedFeedCategories.includes(metadata.docs.productType)) || - (metadata.docs.assetClass && selectedFeedCategories.includes(metadata.docs.assetClass)) - - return included - } - // Filter by final category (Supabase risk tier takes precedence over RDD) - // Normalize spaces for comparison (e.g., "very high" → "veryhigh") - const normalizedFinalCategory = metadata.finalCategory?.toLowerCase().replace(/\s+/g, "") - return ( - selectedFeedCategories.length === 0 || - (normalizedFinalCategory !== undefined && - selectedFeedCategories.map((cat) => cat.toLowerCase().replace(/\s+/g, "")).includes(normalizedFinalCategory)) - ) - }) - .filter( - (metadata) => - metadata.name.toLowerCase().replaceAll(" ", "").includes(searchValue.toLowerCase().replaceAll(" ", "")) || - metadata.proxyAddress - ?.toLowerCase() - .replaceAll(" ", "") - .includes(searchValue.toLowerCase().replaceAll(" ", "")) || - metadata.secondaryProxyAddress - ?.toLowerCase() - .replaceAll(" ", "") - .includes(searchValue.toLowerCase().replaceAll(" ", "")) || - metadata.assetName.toLowerCase().replaceAll(" ", "").includes(searchValue.toLowerCase().replaceAll(" ", "")) || - metadata.feedType.toLowerCase().replaceAll(" ", "").includes(searchValue.toLowerCase().replaceAll(" ", "")) || - metadata.docs.porType - ?.toLowerCase() - .replaceAll(" ", "") - .includes(searchValue.toLowerCase().replaceAll(" ", "")) || - metadata.docs.porAuditor - ?.toLowerCase() - .replaceAll(" ", "") - .includes(searchValue.toLowerCase().replaceAll(" ", "")) || - metadata.docs.porSource - ?.toLowerCase() - .replaceAll(" ", "") - .includes(searchValue.toLowerCase().replaceAll(" ", "")) || - metadata.feedId?.toLowerCase().replaceAll(" ", "").includes(searchValue.toLowerCase().replaceAll(" ", "")) - ) - const slicedFilteredMetadata = filteredMetadata.slice(firstAddr, lastAddr) - // For non-streams tables, wait for batch categories to load to prevent icon flashing - if (!isStreams && isBatchLoading) { + if (isBatchLoading) { return

Loading...

} @@ -1567,7 +1583,7 @@ export const MainnetTable = ({ {slicedFilteredMetadata.length === 0 ? (
+ { if (!network.metadata) return null - const { data: batchedCategoryData, isLoading: isBatchLoading } = useBatchedFeedCategories(network) - - const isStreams = - dataFeedType === "streamsCrypto" || - dataFeedType === "streamsRwa" || - dataFeedType === "streamsNav" || - dataFeedType === "streamsExRate" || - dataFeedType === "streamsBacked" - const isSmartData = dataFeedType === "smartdata" - const isRates = dataFeedType === "rates" - const isUSGovernmentMacroeconomicData = dataFeedType === "usGovernmentMacroeconomicData" - const isDefault = !isSmartData && !isRates && !isStreams && !isUSGovernmentMacroeconomicData - - // Enrich metadata with final category from Supabase. - // Deprecating is inferred from shutdownDate when no DB risk status is present. - const enrichedMetadata = network.metadata.map((metadata) => { - const isAptos = network.name.toLowerCase().includes("aptos") - const contractAddress = isAptos ? metadata.proxyAddress : metadata.contractAddress || metadata.proxyAddress - const networkIdentifier = getNetworkIdentifier(network) - - const finalCategory = - contractAddress && batchedCategoryData?.size - ? getFeedCategoryFromBatch(batchedCategoryData, contractAddress, networkIdentifier).final - : null - - return { ...metadata, finalCategory } + const { + isStreams, + isSmartData, + isRates, + isUSGovernmentMacroeconomicData, + isDefaultTable: isDefault, + } = getFeedTypeFlags(dataFeedType, "testnet") + + const { batchedCategoryData, filteredMetadata, isBatchLoading } = useFilteredFeedMetadata({ + network, + dataFeedType, + ecosystem, + selectedFeedCategories, + searchValue, + searchVariant: "testnet", + show24x5Feeds, + tradingHoursFilter, + visibilityOptions: { + showOnlyDEXFeeds, + showOnlyDatalinkFeeds, + streamCategoryFilter, + rwaSchemaFilter, + showOnlyMVRFeeds, + tokenizedEquityProvider, + }, }) - const filteredMetadata = enrichedMetadata - .sort((a, b) => (a.name.toUpperCase() < b.name.toUpperCase() ? -1 : 1)) - .filter((metadata) => { - // Use shared visibility logic with filters - return isFeedVisible(metadata, dataFeedType as any, ecosystem, { - showOnlyDEXFeeds, - showOnlyDatalinkFeeds, - streamCategoryFilter, - rwaSchemaFilter, - showOnlyMVRFeeds, - tokenizedEquityProvider, - }) - }) - .filter((metadata) => { - // When 24/5 checkbox is checked, ONLY show 24/5 feeds - if (show24x5Feeds) { - const schemaVersion = getSchemaVersion(metadata) - const feedType = metadata.feedType || metadata.docs?.feedType - - // 24/5 feeds are Equities/Forex with v11 schema - const is24x5Feed = (feedType === "Equities" || feedType === "Forex") && schemaVersion === "v11" - - if (!is24x5Feed) return false - - // Apply trading hours sub-filter - if (tradingHoursFilter && tradingHoursFilter !== "all") { - const assetSubClass = (metadata.docs as any)?.assetSubClass - const clicProductName = (metadata.docs as any)?.clicProductName || "" - - // Check both assetSubClass and clicProductName for hours identification - const isRegularHours = - assetSubClass === "Regular Hours" || - (clicProductName.includes("RegularHours") && - !clicProductName.includes("ExtendedHours") && - !clicProductName.includes("OvernightHours")) - const isExtendedHours = assetSubClass === "Extended Hours" || clicProductName.includes("ExtendedHours") - const isOvernightHours = assetSubClass === "Overnight Hours" || clicProductName.includes("OvernightHours") - - if (tradingHoursFilter === "regular" && !isRegularHours) return false - if (tradingHoursFilter === "extended" && !isExtendedHours) return false - if (tradingHoursFilter === "overnight" && !isOvernightHours) return false - } - } - - return true - }) - .filter((metadata) => { - if (isSmartData) { - if (selectedFeedCategories.includes("MVR") && metadata.docs?.isMVR) { - return true - } - - const included = - selectedFeedCategories.length === 0 || - (metadata.docs.productType && selectedFeedCategories.includes(metadata.docs.productType)) || - (metadata.docs.assetClass && selectedFeedCategories.includes(metadata.docs.assetClass)) - - return included - } - // Filter by final category (Supabase risk tier takes precedence over RDD) - // Normalize spaces for comparison (e.g., "very high" → "veryhigh") - const normalizedFinalCategory = metadata.finalCategory?.toLowerCase().replace(/\s+/g, "") - return ( - selectedFeedCategories.length === 0 || - (normalizedFinalCategory !== undefined && - selectedFeedCategories.map((cat) => cat.toLowerCase().replace(/\s+/g, "")).includes(normalizedFinalCategory)) - ) - }) - .filter( - (pair) => - !searchValue || - pair.name?.toLowerCase().replaceAll(" ", "").includes(searchValue.toLowerCase().replaceAll(" ", "")) || - pair.proxyAddress?.toLowerCase().replaceAll(" ", "").includes(searchValue.toLowerCase().replaceAll(" ", "")) || - pair.assetName?.toLowerCase().replaceAll(" ", "").includes(searchValue.toLowerCase().replaceAll(" ", "")) || - pair.feedType?.toLowerCase().replaceAll(" ", "").includes(searchValue.toLowerCase().replaceAll(" ", "")) || - pair.feedId?.toLowerCase().replaceAll(" ", "").includes(searchValue.toLowerCase().replaceAll(" ", "")) - ) - const slicedFilteredMetadata = filteredMetadata.slice(firstAddr, lastAddr) - // For non-streams tables, wait for batch categories to load to prevent icon flashing - if (!isStreams && isBatchLoading) { + const showRiskColumn = false + + if (isBatchLoading) { return

Loading...

} @@ -1795,7 +1733,7 @@ export const TestnetTable = ({ {slicedFilteredMetadata.length === 0 ? (
+ ) : ( <> - {isStreams && } - {isSmartData && } + {isStreams && } + {isSmartData && } {(isDefault || isUSGovernmentMacroeconomicData) && ( )} {isRates && ( @@ -1821,18 +1760,20 @@ export const TestnetTable = ({ showExtraDetails={showExtraDetails} networkName={network.name} dataFeedType={dataFeedType} + showRiskColumn={showRiskColumn} /> )}