Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ MONAD_RPC_API_KEY=
FAMILY_API_KEY=
FAMILY_API_URL=
COINGECKO_API_KEY=
# TokenLogic REST API (api.tokenlogic.xyz), used by /api/protocol-totals for whole-protocol figures
TL_API_KEY=
PLAIN_API_KEY=
COMPLIANCE_API_URL=
COMPLIANCE_SECRET=
Expand Down
86 changes: 86 additions & 0 deletions pages/api/ProtocolTotalsService.ts
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 ?? []);
};
42 changes: 42 additions & 0 deletions pages/api/protocol-totals.ts
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);
Comment on lines +35 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent query strings from bypassing the upstream cache

In server-hosted deployments, this public route ignores req.query while 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

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.

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' });
}
}
22 changes: 22 additions & 0 deletions src/hooks/useProtocolTotals.ts
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');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fetch totals from a production-reachable endpoint

The production workflow in .github/workflows/build-test-deploy.yml invokes the composite build action, whose default command is build:static, and deploys the resulting out directory to IPFS for app.aave.com. That export contains no pages/api functions, so in production this same-origin request returns 404 and ProtocolTotalsStats renders nothing. The totals must come from an endpoint deployed independently of the static bundle or be included in the exported data.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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,
});
};
2 changes: 1 addition & 1 deletion src/locales/el/messages.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/locales/en/messages.js

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions src/locales/en/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,10 @@ msgstr "I fully understand the risks of migrating."
msgid "The underlying asset cannot be rescued"
msgstr "The underlying asset cannot be rescued"

#: src/modules/markets/ProtocolTotalsStats.tsx
msgid "Aave total deposits"
msgstr "Aave total deposits"

#: src/components/infoTooltips/MigrationDisabledTooltip.tsx
msgid "Asset cannot be migrated to {marketName} V3 Market due to E-mode restrictions. You can disable or manage E-mode categories in your <0>V3 Dashboard</0>"
msgstr "Asset cannot be migrated to {marketName} V3 Market due to E-mode restrictions. You can disable or manage E-mode categories in your <0>V3 Dashboard</0>"
Expand Down Expand Up @@ -1213,6 +1217,10 @@ msgstr "Swapping {0} collateral"
msgid "Rewards can be claimed through"
msgstr "Rewards can be claimed through"

#: src/modules/markets/ProtocolTotalsStats.tsx
msgid "Aave total loans"
msgstr "Aave total loans"

#: pages/500.page.tsx
msgid "Sorry, an unexpected error happened. In the meantime you may try reloading the page, or come back later."
msgstr "Sorry, an unexpected error happened. In the meantime you may try reloading the page, or come back later."
Expand Down Expand Up @@ -4256,6 +4264,10 @@ msgstr "Your {networkName} wallet is empty. Get free test {0} at"
msgid "tokens, please go to the"
msgstr "tokens, please go to the"

#: src/modules/markets/ProtocolTotalsStats.tsx
msgid "Across every Aave version and network"
msgstr "Across every Aave version and network"

#: src/components/transactions/Repay/RepayTypeSelector.tsx
msgid "Repay with"
msgstr "Repay with"
Expand Down
2 changes: 1 addition & 1 deletion src/locales/es/messages.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/locales/fr/messages.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/modules/markets/MarketsTopPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { PageHeader } from '../../components/PageHeader/PageHeader';
import { PageHeaderStat } from '../../components/PageHeader/PageHeaderStat';
import { FormattedNumber } from '../../components/primitives/FormattedNumber';
import { useAppDataContext } from '../../hooks/app-data-provider/useAppDataProvider';
import { ProtocolTotalsStats } from './ProtocolTotalsStats';

export const MarketsTopPanel = () => {
const { market, totalBorrows, loading } = useAppDataContext();
Expand All @@ -16,6 +17,7 @@ export const MarketsTopPanel = () => {
title={<MarketSwitcher />}
containerProps={marketContainerProps}
>
<ProtocolTotalsStats />
<PageHeaderStat label={<Trans>Total market size</Trans>} loading={loading}>
<FormattedNumber
value={Number(market?.totalMarketSize)}
Expand Down
82 changes: 82 additions & 0 deletions src/modules/markets/ProtocolTotalsStats.tsx
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' }}
/>
</>
);
};
31 changes: 31 additions & 0 deletions src/modules/markets/protocolTotalsBreakdown.ts
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);
};
Loading
Loading