Skip to content

Commit 8217065

Browse files
committed
🚧 historical rates
1 parent 35d6107 commit 8217065

12 files changed

Lines changed: 862 additions & 661 deletions

File tree

components/charts/HistoricalRateChart/index.tsx

Lines changed: 188 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,141 @@
11
import { Box, Checkbox, FormControlLabel, Typography, useTheme } from '@mui/material';
2-
import useHistoricalRates from 'hooks/useHistoricalRates';
3-
import React, { FC, useCallback, useMemo, useState } from 'react';
2+
import { useQuery } from '@tanstack/react-query';
3+
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
44
import { useTranslation } from 'react-i18next';
55
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
6+
import { formatEther } from 'viem';
7+
import { usePublicClient } from 'wagmi';
8+
import { floatingDepositRates, floatingUtilization } from '@exactly/lib';
9+
10+
import { ratePreviewerAbi, ratePreviewerAddress, ratePreviewerCode } from 'generated/wagmi';
11+
import useAccountData from 'hooks/useAccountData';
12+
import { useGlobalError } from 'contexts/GlobalErrorContext';
13+
import { defaultChain } from 'utils/client';
614
import { toPercentage } from 'utils/utils';
15+
import { track } from 'utils/mixpanel';
716
import ButtonsChart from '../ButtonsChart';
817
import LoadingChart from '../LoadingChart';
918
import TooltipChart from '../TooltipChart';
10-
import { track } from 'utils/mixpanel';
1119

1220
type Props = {
1321
symbol: string;
1422
};
1523

24+
type Range = '1W' | '1M' | '3M' | '6M' | '1Y' | 'ALL';
25+
26+
const WINDOW_SECONDS: Record<Exclude<Range, 'ALL'>, number> = {
27+
'1W': 7 * 86_400,
28+
'1M': 30 * 86_400,
29+
'3M': 90 * 86_400,
30+
'6M': 180 * 86_400,
31+
'1Y': 365 * 86_400,
32+
};
33+
const SECONDS_PER_BLOCK: Record<number, number> = { 1: 12, 10: 2, 8453: 2, 11_155_420: 2, 84_532: 2 };
34+
const POINTS = 30;
35+
const CONCURRENCY = 8;
36+
1637
const HistoricalRateChart: FC<Props> = ({ symbol }) => {
1738
const { t } = useTranslation();
18-
const [showUtilization, setShowUtilization] = useState(false);
1939
const { palette } = useTheme();
20-
const { loading, rates, getRates } = useHistoricalRates(symbol);
40+
const { setIndexerError } = useGlobalError();
41+
const [showUtilization, setShowUtilization] = useState(false);
42+
const [range, setRange] = useState<Range>('1W');
43+
44+
const { marketAccount } = useAccountData(symbol);
45+
const market = marketAccount?.market;
46+
const client = usePublicClient({ chainId: defaultChain.id });
47+
const ratePreviewer = ratePreviewerAddress[defaultChain.id as keyof typeof ratePreviewerAddress];
48+
const code = ratePreviewerCode[defaultChain.id as keyof typeof ratePreviewerCode];
49+
50+
const {
51+
data: points = [],
52+
isLoading,
53+
isError,
54+
} = useQuery({
55+
queryKey: ['historicalRates', defaultChain.id, market, range],
56+
enabled: Boolean(client && market && ratePreviewer && code),
57+
staleTime: 60_000,
58+
queryFn: async () => {
59+
if (!client || !market || !ratePreviewer || !code) return [];
60+
61+
const blockTime = SECONDS_PER_BLOCK[defaultChain.id] ?? 2;
62+
const latest = await client.getBlockNumber();
63+
const anchor = Number((await client.getBlock({ blockNumber: latest })).timestamp);
64+
const span = range === 'ALL' ? latest : BigInt(Math.ceil(WINDOW_SECONDS[range] / blockTime));
65+
const from = latest > span ? latest - span : 1n;
66+
const width = latest - from;
67+
const count = width < BigInt(POINTS) ? Number(width) + 1 : POINTS;
68+
const blocks = [
69+
...new Set(
70+
Array.from({ length: count }, (_, i) => from + (width * BigInt(i)) / BigInt(Math.max(count - 1, 1))),
71+
),
72+
];
73+
74+
const series: { date: number; depositApr: number; borrowApr: number; utilization: number }[] = [];
75+
for (let i = 0; i < blocks.length; i += CONCURRENCY) {
76+
const batch = await Promise.allSettled(
77+
blocks.slice(i, i + CONCURRENCY).map(async (blockNumber) => {
78+
const snapshot = await client.readContract({
79+
address: ratePreviewer,
80+
abi: ratePreviewerAbi,
81+
functionName: 'snapshot',
82+
blockNumber,
83+
stateOverride: [{ address: ratePreviewer, code }],
84+
});
85+
const state = snapshot.find((s) => s.market.toLowerCase() === market.toLowerCase());
86+
if (!state) return undefined;
87+
const timestamp = anchor - Number(latest - blockNumber) * blockTime;
88+
const deposit = floatingDepositRates(
89+
[
90+
{
91+
...state,
92+
lastFloatingDebtUpdate: Number(state.lastFloatingDebtUpdate),
93+
lastAccumulatorAccrual: Number(state.lastAccumulatorAccrual),
94+
maxFuturePools: Number(state.maxFuturePools),
95+
},
96+
],
97+
timestamp,
98+
)[0];
99+
return {
100+
date: timestamp * 1_000,
101+
depositApr: deposit ? Number(formatEther(deposit.rate)) : 0,
102+
borrowApr: Number(formatEther(state.floatingRate)),
103+
utilization: state.floatingAssets
104+
? Number(formatEther(floatingUtilization(state.floatingAssets, state.floatingDebt)))
105+
: 0,
106+
};
107+
}),
108+
);
109+
for (const result of batch) if (result.status === 'fulfilled' && result.value) series.push(result.value);
110+
}
111+
112+
return series.sort((a, b) => a.date - b.date);
113+
},
114+
});
115+
116+
const loading = isLoading || !marketAccount;
21117

22-
const sortedRates = useMemo(() => rates.sort((a, b) => (a.date > b.date ? 1 : -1)), [rates]);
118+
useEffect(() => {
119+
if (isError) setIndexerError();
120+
}, [isError, setIndexerError]);
23121

24122
const buttons = useMemo(
25123
() => [
26-
{
27-
label: t('1W'),
28-
onClick: () => getRates(30, 3_600 * 6),
29-
},
30-
{
31-
label: t('1M'),
32-
onClick: () => getRates(30, 3_600 * 24),
33-
},
34-
{
35-
label: t('3M'),
36-
onClick: () => getRates(90, 3_600 * 24),
37-
},
124+
{ label: t('1W'), onClick: () => setRange('1W') },
125+
{ label: t('1M'), onClick: () => setRange('1M') },
126+
{ label: t('3M'), onClick: () => setRange('3M') },
127+
{ label: t('6M'), onClick: () => setRange('6M') },
128+
{ label: t('1Y'), onClick: () => setRange('1Y') },
129+
{ label: t('All'), onClick: () => setRange('ALL') },
38130
],
39-
[getRates, t],
131+
[t],
40132
);
41133

42-
const formatDate = useCallback((date: Date, year?: boolean) => {
43-
return date.toLocaleDateString('en-us', { year: year ? 'numeric' : undefined, month: 'short', day: '2-digit' });
44-
}, []);
134+
const formatDate = useCallback(
135+
(date: Date, year?: boolean) =>
136+
date.toLocaleDateString('en-us', { year: year ? 'numeric' : undefined, month: 'short', day: '2-digit' }),
137+
[],
138+
);
45139

46140
const onShowUtilizationChange = useCallback(() => {
47141
setShowUtilization((prev) => !prev);
@@ -55,7 +149,7 @@ const HistoricalRateChart: FC<Props> = ({ symbol }) => {
55149
}, [showUtilization, symbol]);
56150

57151
return (
58-
<Box display="flex" flexDirection="column" width="100%" height="100%" gap={2}>
152+
<Box data-testid="historical-rate-chart" display="flex" flexDirection="column" width="100%" height="100%" gap={2}>
59153
<Box display="flex" justifyContent="space-between">
60154
<Typography variant="h6" fontSize="16px">
61155
{t('Historical Variable Rates')}
@@ -64,89 +158,96 @@ const HistoricalRateChart: FC<Props> = ({ symbol }) => {
64158
<ButtonsChart buttons={buttons} />
65159
</Box>
66160
</Box>
67-
<ResponsiveContainer width="100%" height="100%">
161+
<Box flex={1} minHeight={0}>
68162
{loading ? (
69163
<LoadingChart />
164+
) : points.length < 2 ? (
165+
<Box display="flex" width="100%" height="100%" alignItems="center" justifyContent="center">
166+
<Typography color="grey.500" variant="subtitle2" fontSize="14px">
167+
{t('Not enough variable rate activity to chart yet.')}
168+
</Typography>
169+
</Box>
70170
) : (
71-
<LineChart data={sortedRates} margin={{ top: 5, bottom: 5 }}>
72-
<CartesianGrid horizontal vertical={false} stroke={palette.grey[300]} />
73-
<XAxis
74-
minTickGap={50}
75-
padding={{ left: 20, right: 30 }}
76-
dataKey="date"
77-
tickFormatter={(value) => (value instanceof Date ? formatDate(value as Date) : '')}
78-
stroke="#B4BABF"
79-
fontSize="12px"
80-
height={20}
81-
/>
82-
<YAxis
83-
yAxisId="left"
84-
tickFormatter={(tick) => toPercentage(tick)}
85-
axisLine={false}
86-
tick={{ fill: palette.grey[500], fontWeight: 500, fontSize: 11 }}
87-
tickLine={false}
88-
width={50}
89-
/>
90-
{showUtilization && (
171+
<ResponsiveContainer width="100%" height="100%">
172+
<LineChart data={points} margin={{ top: 5, bottom: 5 }}>
173+
<CartesianGrid horizontal vertical={false} stroke={palette.grey[300]} />
174+
<XAxis
175+
dataKey="date"
176+
type="number"
177+
domain={['dataMin', 'dataMax']}
178+
minTickGap={50}
179+
padding={{ left: 20, right: 30 }}
180+
tickFormatter={(value) =>
181+
formatDate(new Date(value as number), range === '6M' || range === '1Y' || range === 'ALL')
182+
}
183+
stroke="#B4BABF"
184+
fontSize="12px"
185+
height={20}
186+
/>
91187
<YAxis
92-
yAxisId="right"
93-
orientation="right"
94-
tickFormatter={(value) => `${((value as number) * 100).toFixed(2)}%`}
95-
tick={{ fill: palette.blue, fontWeight: 500, fontSize: 11 }}
188+
yAxisId="left"
189+
tickFormatter={(tick) => toPercentage(tick)}
96190
axisLine={false}
191+
tick={{ fill: palette.grey[500], fontWeight: 500, fontSize: 11 }}
97192
tickLine={false}
98193
width={50}
99194
/>
100-
)}
101-
<Tooltip
102-
labelFormatter={(value) => (value instanceof Date ? formatDate(value as Date, true) : '')}
103-
formatter={(value) => toPercentage(value as number)}
104-
content={<TooltipChart itemSorter={(a, b) => (a.value > b.value ? -1 : 1)} />}
105-
/>
106-
<Line
107-
yAxisId="left"
108-
type="monotone"
109-
dataKey="depositApr"
110-
name={t('Deposit APR')}
111-
stroke={palette.mode === 'light' ? 'black' : 'white'}
112-
dot={false}
113-
strokeWidth={2}
114-
/>
115-
<Line
116-
yAxisId="left"
117-
type="monotone"
118-
dataKey="borrowApr"
119-
name={t('Borrow APR')}
120-
stroke={palette.green}
121-
dot={false}
122-
strokeWidth={2}
123-
/>
124-
{showUtilization && (
195+
{showUtilization && (
196+
<YAxis
197+
yAxisId="right"
198+
orientation="right"
199+
tickFormatter={(value) => `${((value as number) * 100).toFixed(2)}%`}
200+
tick={{ fill: palette.blue, fontWeight: 500, fontSize: 11 }}
201+
axisLine={false}
202+
tickLine={false}
203+
width={50}
204+
/>
205+
)}
206+
<Tooltip
207+
labelFormatter={(value) => formatDate(new Date(value as number), true)}
208+
formatter={(value) => toPercentage(value as number)}
209+
content={<TooltipChart sortItems={(a, b) => (a.value > b.value ? -1 : 1)} />}
210+
/>
125211
<Line
126-
yAxisId="right"
212+
yAxisId="left"
127213
type="monotone"
128-
dataKey="utilization"
129-
name={t('Utilization Rate')}
130-
stroke={palette.blue}
214+
dataKey="depositApr"
215+
name={t('Deposit APR')}
216+
stroke={palette.mode === 'light' ? 'black' : 'white'}
131217
dot={false}
132-
strokeDasharray="5 5"
218+
strokeWidth={2}
133219
/>
134-
)}
135-
</LineChart>
220+
<Line
221+
yAxisId="left"
222+
type="monotone"
223+
dataKey="borrowApr"
224+
name={t('Borrow APR')}
225+
stroke={palette.green}
226+
dot={false}
227+
strokeWidth={2}
228+
/>
229+
{showUtilization && (
230+
<Line
231+
yAxisId="right"
232+
type="monotone"
233+
dataKey="utilization"
234+
name={t('Utilization Rate')}
235+
stroke={palette.blue}
236+
dot={false}
237+
strokeDasharray="5 5"
238+
/>
239+
)}
240+
</LineChart>
241+
</ResponsiveContainer>
136242
)}
137-
</ResponsiveContainer>
243+
</Box>
138244
<Box display="flex" alignItems="center" mt={-2.5} pl={1}>
139245
<FormControlLabel
140246
control={
141247
<Checkbox
142248
size="small"
143249
onChange={onShowUtilizationChange}
144-
sx={{
145-
color: palette.blue,
146-
'&.Mui-checked': {
147-
color: palette.blue,
148-
},
149-
}}
250+
sx={{ color: palette.blue, '&.Mui-checked': { color: palette.blue } }}
150251
/>
151252
}
152253
label={

components/charts/StakeChart/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ const StakeChart = () => {
117117
<Tooltip
118118
labelFormatter={(value) => formatDate(new Date((value * 1000) as number))}
119119
formatter={(value) => toPercentage(value as number)}
120-
content={<TooltipChart itemSorter={(a, b) => (a.value > b.value ? -1 : 1)} />}
120+
content={<TooltipChart sortItems={(a, b) => (a.value > b.value ? -1 : 1)} />}
121121
/>
122122
<Area
123123
yAxisId="left"

components/charts/TooltipChart/index.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export type TooltipChartProps = {
1515
labelFormatter?: (value: Date | undefined) => ReactNode;
1616
formatter?: (value: number | undefined) => ReactNode;
1717
formatterName?: (name: string | undefined) => ReactNode;
18-
itemSorter?: (a: Entry, b: Entry) => number;
18+
sortItems?: (a: Entry, b: Entry) => number;
1919
ignoreKeys?: string[];
2020
additionalInfo?: ReactNode;
2121
additionalInfoPosition?: 'top' | 'bottom';
@@ -29,15 +29,15 @@ function TooltipChart({
2929
labelFormatter,
3030
formatterName,
3131
formatter,
32-
itemSorter,
32+
sortItems,
3333
ignoreKeys,
3434
additionalInfo,
3535
opacity = 1,
3636
additionalInfoPosition = 'bottom',
3737
}: TooltipChartProps) {
3838
const sortedPayload = useMemo(
39-
() => (itemSorter && payload ? payload.sort(itemSorter) : payload),
40-
[payload, itemSorter],
39+
() => (typeof sortItems === 'function' && payload ? [...payload].sort(sortItems) : payload),
40+
[payload, sortItems],
4141
);
4242

4343
if (!active || !sortedPayload || !sortedPayload.length) return null;

contexts/GlobalErrorContext.tsx

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import Close from '@mui/icons-material/Close';
2-
import { Alert, IconButton, Link, Slide, SlideProps, Snackbar, Typography } from '@mui/material';
2+
import { Alert, IconButton, Slide, SlideProps, Snackbar, Typography } from '@mui/material';
33
import React, { createContext, useState, useCallback, PropsWithChildren, FC, useContext, ReactNode } from 'react';
44
import { Trans } from 'react-i18next';
55

@@ -25,20 +25,7 @@ export const GlobalErrorProvider: FC<PropsWithChildren> = ({ children }) => {
2525
const setIndexerError = useCallback(() => {
2626
setError(
2727
<Typography>
28-
<Trans
29-
i18nKey="Whoops! Our <1>indexer node</1> is currently experiencing issues and some information may not be displayed."
30-
components={{
31-
1: (
32-
<Link
33-
href="https://status.thegraph.com/"
34-
target="_blank"
35-
rel="noopener noreferrer"
36-
style={{ textDecoration: 'none' }}
37-
sx={{ color: 'blue' }}
38-
/>
39-
),
40-
}}
41-
/>
28+
<Trans i18nKey="Whoops! We're having trouble loading some data right now. Please try again shortly." />
4229
</Typography>,
4330
);
4431
}, []);

0 commit comments

Comments
 (0)