diff --git a/src/constants/chains.ts b/src/constants/chains.ts new file mode 100644 index 0000000000..95f4d7e89e --- /dev/null +++ b/src/constants/chains.ts @@ -0,0 +1,104 @@ +export type Protocol = { + category?: string + chains?: string[] +} + +type LlamaChain = { name: string } +type ChainlistItem = { name?: string; chain?: string; shortName?: string } + +let _cache: { + evmSet: Set + nonEvmSet: Set + fetchedAt: number +} | null = null + +const ALIASES: Record = { + xDai: 'Gnosis', + 'Avalanche C-Chain': 'Avalanche', + RSK: 'Rootstock', + 'Fantom Opera': 'Fantom', + BSC: 'Binance Smart Chain', + OKExChain: 'OKC' +} + +function normLabel(s: string): string { + const base = (s || '').trim() + const aliased = ALIASES[base] ?? base + return aliased + .toLowerCase() + .replace(/\s+/g, ' ') + .replace(/[^\w\s-]/g, '') +} + +async function fetchLlamaChains(): Promise { + const res = await fetch('https://api.llama.fi/chains', { next: { revalidate: 60 * 60 } }) + if (!res.ok) throw new Error('Failed to fetch DefiLlama chains') + const data: LlamaChain[] = await res.json() + return Array.from(new Set(data.map((c) => c?.name).filter(Boolean) as string[])) +} + +async function fetchChainlistEvm(): Promise { + const res = await fetch('https://chainlist.org/rpcs.json', { next: { revalidate: 60 * 60 * 6 } }) + if (!res.ok) throw new Error('Failed to fetch Chainlist RPCs') + const data: ChainlistItem[] = await res.json() + const names = new Set() + for (const c of data) { + if (c.name) names.add(c.name) + if (c.chain) names.add(c.chain) + if (c.shortName) names.add(c.shortName) + } + return Array.from(names).filter(Boolean) +} +export async function fetchEvmAndNonEvmSets() { + if (_cache && Date.now() - _cache.fetchedAt < 6 * 60 * 60 * 1000) { + return _cache + } + + const [llamaChains, chainlistEvm] = await Promise.all([fetchLlamaChains(), fetchChainlistEvm()]) + + const evmSet = new Set() + const evmNorm = new Set(chainlistEvm.map(normLabel)) + + for (const name of chainlistEvm) { + evmSet.add(name) + const alias = ALIASES[name] + if (alias) evmSet.add(alias) + } + + const nonEvmSet = new Set() + for (const name of llamaChains) { + const isEvm = + evmSet.has(name) || + evmSet.has(ALIASES[name] ?? '') || + evmNorm.has(normLabel(name)) || + (ALIASES[name] ? evmNorm.has(normLabel(ALIASES[name])) : false) + if (!isEvm) nonEvmSet.add(name) + } + + _cache = { evmSet, nonEvmSet, fetchedAt: Date.now() } + return _cache +} + +export function computeCategoryIsEvm( + protocols: Protocol[], + evmSet: Set, + nonEvmSet: Set +): Record { + const categoryChains = new Map>() + + for (const p of protocols) { + if (!p?.category || !Array.isArray(p.chains)) continue + const set = categoryChains.get(p.category) ?? new Set() + for (const ch of p.chains) set.add(ch) + categoryChains.set(p.category, set) + } + + const out: Record = {} + for (const [cat, chains] of categoryChains.entries()) { + const hasNon = [...chains].some((c) => nonEvmSet.has(c) || nonEvmSet.has(ALIASES[c] ?? c)) + const hasEvm = + !hasNon && [...chains].some((c) => evmSet.has(c) || evmSet.has(ALIASES[c] ?? c) || evmSet.has(c.trim())) + out[cat] = hasNon ? false : hasEvm ? true : false + } + return out +} diff --git a/src/pages/categories.tsx b/src/pages/categories.tsx index c7aaa86c23..fecd202dc5 100644 --- a/src/pages/categories.tsx +++ b/src/pages/categories.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import { useRouter } from 'next/router' import { maxAgeForNext } from '~/api' import type { ILineAndBarChartProps } from '~/components/ECharts/types' import { BasicLink } from '~/components/Link' @@ -11,9 +12,12 @@ import { withPerformanceLogging } from '~/utils/perf' import { ColumnDef } from '@tanstack/react-table' import { Icon } from '~/components/Icon' import { CATEGORY_API, PROTOCOLS_API } from '~/constants' +import { fetchEvmAndNonEvmSets, computeCategoryIsEvm } from '~/constants/chains' import { fetchJson } from '~/utils/async' import { DEFI_SETTINGS_KEYS, useLocalStorageSettingsManager } from '~/contexts/LocalStorage' import { SelectWithCombobox } from '~/components/SelectWithCombobox' +import { Select } from '~/components/Select' +import { RowLinksWithDropdown } from '~/components/RowLinksWithDropdown' import { tvlOptions } from '~/components/Filters/options' const LineAndBarChart = React.lazy( @@ -21,7 +25,7 @@ const LineAndBarChart = React.lazy( ) as React.FC export const getStaticProps = withPerformanceLogging('categories', async () => { - const [{ protocols }, revenueData, { chart, categories: protocolsByCategory }] = await Promise.all([ + const [{ protocols }, revenueData, { chart, categories: protocolsByCategory }, allProtocols] = await Promise.all([ fetchJson(PROTOCOLS_API), getAdapterChainOverview({ adapterType: 'fees', @@ -30,7 +34,8 @@ export const getStaticProps = withPerformanceLogging('categories', async () => { excludeTotalDataChart: true, excludeTotalDataChartBreakdown: true }), - fetchJson(CATEGORY_API) + fetchJson(CATEGORY_API), + fetchJson('https://api.llama.fi/protocols') ]) const categories = {} @@ -188,12 +193,17 @@ export const getStaticProps = withPerformanceLogging('categories', async () => { } } + const { evmSet, nonEvmSet } = await fetchEvmAndNonEvmSets() + + const categoryIsEvm = computeCategoryIsEvm(allProtocols, evmSet, nonEvmSet) + return { props: { categories: Object.keys(protocolsByCategory), tableData: finalCategories.sort((a, b) => b.tvl - a.tvl), chartData, - extraTvlCharts + extraTvlCharts, + categoryIsEvm }, revalidate: maxAgeForNext([22]) } @@ -310,77 +320,143 @@ export const descriptions = { const finalTvlOptions = tvlOptions.filter((e) => !['liquidstaking', 'doublecounted'].includes(e.key)) -export default function Protocols({ categories, tableData, chartData, extraTvlCharts }) { - const [selectedCategories, setSelectedCategories] = React.useState>(categories) - const clearAll = () => { - setSelectedCategories([]) +export default function Protocols({ categories, tableData, chartData, extraTvlCharts, categoryIsEvm }) { + function isEvmCategory(cat) { + const result = categoryIsEvm && cat in categoryIsEvm ? categoryIsEvm[cat] : true + return result } - const toggleAll = () => { - setSelectedCategories(categories) - } - const selectOnlyOne = (category: string) => { - setSelectedCategories([category]) + + function getCurrentFilterLabel() { + if (['All', 'EVM', 'Non-EVM'].includes(evmFilter)) return evmFilter + if (selectedCategories.length === 1) return selectedCategories[0] + if (selectedCategories.length === categories.length) return 'All' + if (selectedCategories.length === 0) return 'None' + return `${selectedCategories.length} selected` } + + const router = useRouter() + const categoryParam = Array.isArray(router.query.category) ? router.query.category[0] : router.query.category + const evmFilter = ['EVM', 'Non-EVM'].includes(categoryParam) ? categoryParam : 'All' + const allCategoryLinks = [ + { label: 'All', to: '/categories' }, + { label: 'Non-EVM', to: '/categories/Non-EVM' }, + { label: 'EVM', to: '/categories/EVM' }, + ...categories.map((cat) => ({ label: cat, to: `/categories/${cat}` })) + ] + + const [selectedCategories, setSelectedCategories] = React.useState>(categories) + const userInteractedRef = React.useRef(false) + React.useEffect(() => { + userInteractedRef.current = false + }, [evmFilter]) + + React.useEffect(() => { + if (userInteractedRef.current) return + + let expected: string[] = categories + if (['EVM', 'Non-EVM'].includes(evmFilter)) { + if (evmFilter === 'EVM') expected = categories.filter((cat) => isEvmCategory(cat)) + else if (evmFilter === 'Non-EVM') expected = categories.filter((cat) => !isEvmCategory(cat)) + } else if ( + evmFilter === 'All' && + typeof categoryParam === 'string' && + categoryParam && + !['All', 'EVM', 'Non-EVM'].includes(categoryParam) && + categories.includes(categoryParam) + ) { + expected = [categoryParam] + } + + const isDifferent = + selectedCategories.length !== expected.length || !expected.every((cat) => selectedCategories.includes(cat)) + + if (isDifferent) { + setSelectedCategories(expected) + } + }, [evmFilter, categories, categoryParam]) + + const [visibleColumns, setVisibleColumns] = React.useState( + [ + ...categoriesColumn.map((c) => (typeof c === 'object' && 'accessorKey' in c ? (c.accessorKey as string) : '')) + ].filter(Boolean) + ) + const [tvlRange, setTvlRange] = React.useState<[number, number] | null>(null) + const [search, setSearch] = React.useState('') const [extaTvlsEnabled] = useLocalStorageSettingsManager('tvl') + const clearAll = () => setSelectedCategories([]) + const toggleAll = () => setSelectedCategories(categories) + const selectOnlyOne = (category: string) => setSelectedCategories([category]) + + const filteredCategories = React.useMemo(() => { + let base = selectedCategories + if (evmFilter === 'EVM') { + base = base.filter((cat) => isEvmCategory(cat)) + } else if (evmFilter === 'Non-EVM') { + base = base.filter((cat) => !isEvmCategory(cat)) + } + + return base + }, [selectedCategories, evmFilter]) + const charts = React.useMemo(() => { if (!Object.values(extaTvlsEnabled).some((e) => e === true)) { - if (selectedCategories.length === categories.length) { - return chartData + if (!filteredCategories.length) { + return {} } - const charts = {} for (const cat in chartData) { - if (selectedCategories.includes(cat)) { + if (filteredCategories.includes(cat)) { charts[cat] = chartData[cat] } } - return charts } - const enabledTvls = Object.entries(extaTvlsEnabled) .filter((e) => e[1] === true) .map((e) => e[0]) - + if (!filteredCategories.length) { + return {} + } const charts = {} - for (const cat in chartData) { - if (selectedCategories.includes(cat)) { + if (filteredCategories.includes(cat)) { const data = chartData[cat].data.map(([date, val], index) => { const extraTvls = enabledTvls.map((e) => extraTvlCharts?.[cat]?.[e]?.[date] ?? 0) return [date, val + extraTvls.reduce((a, b) => a + b, 0)] }) - - charts[cat] = { - ...chartData[cat], - data - } + charts[cat] = { ...chartData[cat], data } } } - return charts - }, [chartData, selectedCategories, categories, extraTvlCharts, extaTvlsEnabled]) + }, [chartData, filteredCategories, categories, extraTvlCharts, extaTvlsEnabled]) + + const filteredTableData = React.useMemo(() => { + let data = tableData.filter((row) => filteredCategories.includes(row.name)) + if (tvlRange) { + data = data.filter((row) => row.tvl >= tvlRange[0] && row.tvl <= tvlRange[1]) + } + if (search) { + data = data.filter((row) => row.name.toLowerCase().includes(search.toLowerCase())) + } + return data + }, [tableData, filteredCategories, tvlRange, search]) const finalCategoriesList = React.useMemo(() => { const enabledTvls = Object.entries(extaTvlsEnabled) .filter((e) => e[1] === true) .map((e) => e[0]) - if (enabledTvls.length === 0) { - return tableData + return filteredTableData } - const finalList = [] - - for (const cat of tableData) { + for (const cat of filteredTableData) { const subRows = [] for (const subRow of cat.subRows ?? []) { let tvl = subRow.tvl let tvlPrevDay = subRow.tvlPrevDay let tvlPrevWeek = subRow.tvlPrevWeek let tvlPrevMonth = subRow.tvlPrevMonth - for (const extra of enabledTvls) { if (subRow.extraTvls[extra]) { tvl += subRow.extraTvls[extra].tvl @@ -389,7 +465,6 @@ export default function Protocols({ categories, tableData, chartData, extraTvlCh tvlPrevMonth += subRow.extraTvls[extra].tvlPrevMonth } } - subRows.push({ ...subRow, tvl, @@ -401,12 +476,10 @@ export default function Protocols({ categories, tableData, chartData, extraTvlCh change_1m: getPercentChange(tvl, tvlPrevMonth) }) } - let tvl = cat.tvl let tvlPrevDay = cat.tvlPrevDay let tvlPrevWeek = cat.tvlPrevWeek let tvlPrevMonth = cat.tvlPrevMonth - for (const extra of enabledTvls) { if (cat.extraTvls[extra]) { tvl += cat.extraTvls[extra].tvl @@ -415,7 +488,6 @@ export default function Protocols({ categories, tableData, chartData, extraTvlCh tvlPrevMonth += cat.extraTvls[extra].tvlPrevMonth } } - finalList.push({ ...cat, tvl, @@ -428,33 +500,40 @@ export default function Protocols({ categories, tableData, chartData, extraTvlCh ...(subRows.length > 0 ? { subRows } : {}) }) } - return finalList - }, [tableData, extaTvlsEnabled]) + }, [filteredTableData, extaTvlsEnabled]) return ( -
-
-

Categories

- -
+ + + {(() => { + const label = getCurrentFilterLabel() + if (label === 'All') return null + return ( +
+ Current filter: + {label} +
+ ) + })()} +
}>
- + visibleColumns.includes(typeof col === 'object' && 'accessorKey' in col ? (col.accessorKey as string) : '') + )} columnToSearch={'name'} placeholder={'Search category...'} defaultSorting={[{ id: 'tvl', desc: true }]} + header={ +
+ setTvlRange([Number(e.target.value) || 0, tvlRange ? tvlRange[1] : Infinity])} + /> + - + setTvlRange([tvlRange ? tvlRange[0] : 0, Number(e.target.value) || Infinity])} + /> + +
+ setSearch(e.target.value)} + /> + { + userInteractedRef.current = true + setSelectedCategories(vals) + }} + label="Categories" + clearAll={clearAll} + toggleAll={toggleAll} + selectOnlyOne={selectOnlyOne} + labelType="smol" + /> +
+ } />
diff --git a/src/pages/categories/[category].tsx b/src/pages/categories/[category].tsx new file mode 100644 index 0000000000..e625179bb3 --- /dev/null +++ b/src/pages/categories/[category].tsx @@ -0,0 +1,12 @@ +import Protocols, { getStaticProps } from '../categories' + +export { getStaticProps } + +export async function getStaticPaths() { + return { + paths: [{ params: { category: 'EVM' } }, { params: { category: 'Non-EVM' } }], + fallback: 'blocking' + } +} + +export default Protocols