-
Notifications
You must be signed in to change notification settings - Fork 496
feat: protocol-wide deposit and loan totals on the markets header #3107
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| // The v3 app's own data sources are market-scoped, so whole-protocol figures | ||
| // (every Aave version, every chain) come from TokenLogic's markets snapshot. | ||
| const TOKEN_LOGIC_URL = 'https://api.tokenlogic.xyz/v1/aave/markets/latest'; | ||
|
|
||
| // TokenLogic answers in 2-3s when healthy; fetch has no timeout of its own. | ||
| const REQUEST_TIMEOUT_MS = 5_000; | ||
|
|
||
| export type TokenLogicMarketRow = { | ||
| protocol: string; | ||
| reserve_symbol: string; | ||
| /** Token units, not USD. Only `reserve_price` makes reserves comparable. */ | ||
| deposits: number; | ||
| borrows: number; | ||
| /** USD per token unit, serialised as a string. */ | ||
| reserve_price: string; | ||
| }; | ||
|
|
||
| export type ProtocolBreakdownEntry = { | ||
| protocol: string; | ||
| deposits: number; | ||
| loans: number; | ||
| }; | ||
|
|
||
| export type ProtocolTotals = { | ||
| deposits: number; | ||
| loans: number; | ||
| /** Per-protocol figures, largest deposits first. */ | ||
| breakdown: ProtocolBreakdownEntry[]; | ||
| }; | ||
|
|
||
| // Number(null) and Number('') are a finite 0, so emptiness is rejected explicitly. | ||
| // A real 0 still passes: many reserves legitimately carry borrows: 0. | ||
| const numeric = (raw: unknown, field: string, row: TokenLogicMarketRow) => { | ||
| const where = `${row.protocol}/${row.reserve_symbol}`; | ||
| if (raw == null || (typeof raw === 'string' && raw.trim() === '')) { | ||
| throw new Error(`TokenLogic returned an empty ${field} for ${where}`); | ||
| } | ||
| const value = Number(raw); | ||
| if (!Number.isFinite(value)) { | ||
| throw new Error(`TokenLogic returned a non-numeric ${field} for ${where}: ${raw}`); | ||
| } | ||
| return value; | ||
| }; | ||
|
|
||
| export const aggregateProtocolTotals = (rows: TokenLogicMarketRow[]): ProtocolTotals => { | ||
| if (!rows.length) throw new Error('TokenLogic returned no market rows'); | ||
|
|
||
| const byProtocol = new Map<string, ProtocolBreakdownEntry>(); | ||
| let deposits = 0; | ||
| let loans = 0; | ||
|
|
||
| for (const row of rows) { | ||
| const price = numeric(row.reserve_price, 'reserve_price', row); | ||
| const rowDeposits = numeric(row.deposits, 'deposits', row) * price; | ||
| const rowLoans = numeric(row.borrows, 'borrows', row) * price; | ||
|
|
||
| deposits += rowDeposits; | ||
| loans += rowLoans; | ||
|
|
||
| const entry = byProtocol.get(row.protocol) ?? { protocol: row.protocol, deposits: 0, loans: 0 }; | ||
| entry.deposits += rowDeposits; | ||
| entry.loans += rowLoans; | ||
| byProtocol.set(row.protocol, entry); | ||
| } | ||
|
|
||
| // Zero deposits is not a state a live lending protocol can be in (a zero price on | ||
| // every row would produce it), so it is treated as a failed read, not a figure. | ||
| if (deposits <= 0) throw new Error('TokenLogic returned zero total deposits'); | ||
|
|
||
| const breakdown = [...byProtocol.values()].sort((a, b) => b.deposits - a.deposits); | ||
| return { deposits, loans, breakdown }; | ||
| }; | ||
|
|
||
| export const fetchProtocolTotals = async (apiKey: string): Promise<ProtocolTotals> => { | ||
| const response = await fetch(TOKEN_LOGIC_URL, { | ||
| headers: { Authorization: `Bearer ${apiKey}` }, | ||
| signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error(`HTTP error: TokenLogic responded ${response.status}`); | ||
| } | ||
|
|
||
| const body = (await response.json()) as { data?: TokenLogicMarketRow[] }; | ||
| return aggregateProtocolTotals(body.data ?? []); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import type { NextApiRequest, NextApiResponse } from 'next'; | ||
| import { fetchProtocolTotals, ProtocolTotals } from 'pages/api/ProtocolTotalsService'; | ||
|
|
||
| // These totals move by a fraction of a percent an hour, so the CDN serves one | ||
| // upstream read to every visitor for half an hour and keeps TokenLogic clear of | ||
| // any rate limit. Errors are never cached. | ||
| const CACHE_CONTROL = 'public, s-maxage=1800, stale-while-revalidate=3600'; | ||
|
|
||
| type ApiResponse = ProtocolTotals | { error: string }; | ||
|
|
||
| /** | ||
| * GET /api/protocol-totals | ||
| * | ||
| * Whole-protocol deposit and loan totals in USD across every Aave version and chain, | ||
| * with a per-protocol breakdown. Server-only: the TokenLogic key never reaches the client. | ||
| */ | ||
| export default async function handler(req: NextApiRequest, res: NextApiResponse<ApiResponse>) { | ||
| if (req.method !== 'GET') { | ||
| return res.status(405).json({ error: 'Method not allowed' }); | ||
| } | ||
|
|
||
| // The CDN cache is keyed by URL, so a unique query string would force a fresh | ||
| // upstream read on every request. There are no parameters to accept. | ||
| if (Object.keys(req.query).length > 0) { | ||
| return res.status(400).json({ error: 'Query parameters are not supported' }); | ||
| } | ||
|
|
||
| const apiKey = process.env.TL_API_KEY; | ||
| if (!apiKey) { | ||
| // A configuration state, not a failed read: the client renders no figures. | ||
| return res.status(503).json({ error: 'Protocol totals are not configured' }); | ||
| } | ||
|
|
||
| try { | ||
| const totals = await fetchProtocolTotals(apiKey); | ||
| res.setHeader('Cache-Control', CACHE_CONTROL); | ||
| return res.status(200).json(totals); | ||
| } catch (error) { | ||
| console.error('Protocol totals unavailable:', error); | ||
| return res.status(502).json({ error: 'Failed to fetch data from external service' }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { useQuery } from '@tanstack/react-query'; | ||
| import type { ProtocolTotals } from 'pages/api/ProtocolTotalsService'; | ||
|
|
||
| export type { ProtocolBreakdownEntry, ProtocolTotals } from 'pages/api/ProtocolTotalsService'; | ||
|
|
||
| /** | ||
| * Whole-protocol deposit and loan totals across every Aave version and chain. | ||
| * Errors when the reading is unavailable so callers render nothing rather than zeros. | ||
| */ | ||
| export const useProtocolTotals = () => { | ||
| return useQuery<ProtocolTotals>({ | ||
| queryKey: ['protocol-totals'], | ||
| queryFn: async () => { | ||
| const response = await fetch('/api/protocol-totals'); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The production workflow in Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. app.aave.com is served from Vercel with API routes (e.g. /api/coingecko-categories/ returns 200 there), so this route is reachable in production. The static export only feeds the IPFS mirror, where the stats stay hidden, same as the existing CoinGecko categories route. Documented in the PR description. |
||
| if (!response.ok) throw new Error(`Protocol totals unavailable (${response.status})`); | ||
| return (await response.json()) as ProtocolTotals; | ||
| }, | ||
| staleTime: 1000 * 60 * 30, | ||
| refetchOnWindowFocus: false, | ||
| retry: 1, | ||
| }); | ||
| }; | ||
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| import { Trans } from '@lingui/macro'; | ||
| import { Box, Divider, Typography } from '@mui/material'; | ||
| import { useMemo } from 'react'; | ||
| import { PageHeaderStat } from 'src/components/PageHeader/PageHeaderStat'; | ||
| import { FormattedNumber } from 'src/components/primitives/FormattedNumber'; | ||
| import { TextWithTooltip } from 'src/components/TextWithTooltip'; | ||
| import { ProtocolBreakdownEntry, useProtocolTotals } from 'src/hooks/useProtocolTotals'; | ||
|
|
||
| import { groupByVersion } from './protocolTotalsBreakdown'; | ||
|
|
||
| const Figure = ({ value }: { value: number }) => ( | ||
| <FormattedNumber value={value} symbol="USD" variant="statValue" visibleDecimals={2} compact /> | ||
| ); | ||
|
|
||
| const BreakdownTooltip = ({ | ||
| rows, | ||
| field, | ||
| }: { | ||
| rows: ProtocolBreakdownEntry[]; | ||
| field: 'deposits' | 'loans'; | ||
| }) => ( | ||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, minWidth: 180 }}> | ||
| <Typography variant="description" sx={{ color: 'fg-3' }}> | ||
| <Trans>Across every Aave version and network</Trans> | ||
| </Typography> | ||
| {rows.map((row) => ( | ||
| <Box | ||
| key={row.protocol} | ||
| sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 3 }} | ||
| > | ||
| <Typography variant="description">{row.protocol}</Typography> | ||
| <FormattedNumber | ||
| value={row[field]} | ||
| symbol="USD" | ||
| variant="description" | ||
| visibleDecimals={2} | ||
| compact | ||
| /> | ||
| </Box> | ||
| ))} | ||
| </Box> | ||
| ); | ||
|
|
||
| /** | ||
| * Whole-protocol deposit and loan totals for the markets header, ahead of the | ||
| * market-specific stats. Renders nothing until a reading is available, so an | ||
| * unconfigured or failing source leaves the header exactly as it was. | ||
| */ | ||
| export const ProtocolTotalsStats = () => { | ||
| const { data } = useProtocolTotals(); | ||
| const rows = useMemo(() => (data ? groupByVersion(data.breakdown) : []), [data]); | ||
|
|
||
| if (!data) return null; | ||
|
|
||
| return ( | ||
| <> | ||
| <PageHeaderStat | ||
| label={ | ||
| <TextWithTooltip text={<Trans>Aave total deposits</Trans>} variant="inherit"> | ||
| <BreakdownTooltip rows={rows} field="deposits" /> | ||
| </TextWithTooltip> | ||
| } | ||
| > | ||
| <Figure value={data.deposits} /> | ||
| </PageHeaderStat> | ||
| <PageHeaderStat | ||
| label={ | ||
| <TextWithTooltip text={<Trans>Aave total loans</Trans>} variant="inherit"> | ||
| <BreakdownTooltip rows={rows} field="loans" /> | ||
| </TextWithTooltip> | ||
| } | ||
| > | ||
| <Figure value={data.loans} /> | ||
| </PageHeaderStat> | ||
| <Divider | ||
| orientation="vertical" | ||
| flexItem | ||
| sx={{ display: { xs: 'none', md: 'block' }, borderColor: 'divider' }} | ||
| /> | ||
| </> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import type { ProtocolBreakdownEntry } from 'src/hooks/useProtocolTotals'; | ||
|
|
||
| const VERSION_LABELS: Record<string, string> = { | ||
| v1: 'Aave V1', | ||
| v2: 'Aave V2', | ||
| v3: 'Aave V3', | ||
| v4: 'Aave V4', | ||
| }; | ||
|
|
||
| // TokenLogic's protocol ids are free-form; collapse them onto the Aave version they | ||
| // name so the breakdown reads "Aave V3 / Aave V4" instead of vendor identifiers. | ||
| // `_` counts as a word character, so `\b` can't delimit ids like aave_v4_ethereum. | ||
| const VERSION_PATTERN = /(?<![a-z0-9])v[1-4](?![0-9])/; | ||
|
|
||
| export const protocolLabel = (protocol: string) => { | ||
| const version = protocol.toLowerCase().match(VERSION_PATTERN)?.[0]; | ||
| return (version && VERSION_LABELS[version]) || protocol; | ||
| }; | ||
|
|
||
| /** Sums per-protocol entries into one row per Aave version, largest deposits first. */ | ||
| export const groupByVersion = (breakdown: ProtocolBreakdownEntry[]) => { | ||
| const grouped = new Map<string, ProtocolBreakdownEntry>(); | ||
| for (const entry of breakdown) { | ||
| const label = protocolLabel(entry.protocol); | ||
| const current = grouped.get(label) ?? { protocol: label, deposits: 0, loans: 0 }; | ||
| current.deposits += entry.deposits; | ||
| current.loans += entry.loans; | ||
| grouped.set(label, current); | ||
| } | ||
| return [...grouped.values()].sort((a, b) => b.deposits - a.deposits); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In server-hosted deployments, this public route ignores
req.querywhile its CDN cache is keyed by the requested URL. A caller can therefore request/api/protocol-totals?nonce=<unique>repeatedly, forcing a fresh authenticated TokenLogic request each time and defeating the cache intended to protect the upstream quota. Reject unexpected query parameters or cache the upstream result under a fixed server-side key.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in ab13ba6: the route now rejects any query string with 400 before reaching TokenLogic, so the CDN cache key cannot be varied.