Skip to content

Commit 7083db5

Browse files
committed
🚧 historical rates
1 parent 35d6107 commit 7083db5

12 files changed

Lines changed: 796 additions & 608 deletions

File tree

components/charts/HistoricalRateChart/index.tsx

Lines changed: 122 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,132 @@
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';
25+
26+
const WINDOW_SECONDS: Record<Range, number> = { '1W': 7 * 86_400, '1M': 30 * 86_400, '3M': 90 * 86_400 };
27+
const SECONDS_PER_BLOCK: Record<number, number> = { 1: 12, 10: 2, 8453: 2, 11_155_420: 2, 84_532: 2 };
28+
const POINTS = 30;
29+
const CONCURRENCY = 8;
30+
1631
const HistoricalRateChart: FC<Props> = ({ symbol }) => {
1732
const { t } = useTranslation();
18-
const [showUtilization, setShowUtilization] = useState(false);
1933
const { palette } = useTheme();
20-
const { loading, rates, getRates } = useHistoricalRates(symbol);
34+
const { setIndexerError } = useGlobalError();
35+
const [showUtilization, setShowUtilization] = useState(false);
36+
const [range, setRange] = useState<Range>('1W');
37+
38+
const { marketAccount } = useAccountData(symbol);
39+
const market = marketAccount?.market;
40+
const client = usePublicClient({ chainId: defaultChain.id });
41+
const ratePreviewer = ratePreviewerAddress[defaultChain.id as keyof typeof ratePreviewerAddress];
42+
const code = ratePreviewerCode[defaultChain.id as keyof typeof ratePreviewerCode];
2143

22-
const sortedRates = useMemo(() => rates.sort((a, b) => (a.date > b.date ? 1 : -1)), [rates]);
44+
const {
45+
data: points = [],
46+
isLoading,
47+
isError,
48+
} = useQuery({
49+
queryKey: ['historicalRates', defaultChain.id, market, range],
50+
enabled: Boolean(client && market && ratePreviewer && code),
51+
staleTime: 60_000,
52+
queryFn: async () => {
53+
if (!client || !market || !ratePreviewer || !code) return [];
54+
55+
const blockTime = SECONDS_PER_BLOCK[defaultChain.id] ?? 2;
56+
const latest = await client.getBlockNumber();
57+
const anchor = Number((await client.getBlock({ blockNumber: latest })).timestamp);
58+
const span = BigInt(Math.ceil(WINDOW_SECONDS[range] / blockTime));
59+
const from = latest > span ? latest - span : 1n;
60+
const width = latest - from;
61+
const count = width < BigInt(POINTS) ? Number(width) + 1 : POINTS;
62+
const blocks = [
63+
...new Set(
64+
Array.from({ length: count }, (_, i) => from + (width * BigInt(i)) / BigInt(Math.max(count - 1, 1))),
65+
),
66+
];
67+
68+
const series: { date: number; depositApr: number; borrowApr: number; utilization: number }[] = [];
69+
for (let i = 0; i < blocks.length; i += CONCURRENCY) {
70+
const batch = await Promise.allSettled(
71+
blocks.slice(i, i + CONCURRENCY).map(async (blockNumber) => {
72+
const snapshot = await client.readContract({
73+
address: ratePreviewer,
74+
abi: ratePreviewerAbi,
75+
functionName: 'snapshot',
76+
blockNumber,
77+
stateOverride: [{ address: ratePreviewer, code }],
78+
});
79+
const state = snapshot.find((s) => s.market.toLowerCase() === market.toLowerCase());
80+
if (!state) return undefined;
81+
const timestamp = anchor - Number(latest - blockNumber) * blockTime;
82+
const deposit = floatingDepositRates(
83+
[
84+
{
85+
...state,
86+
lastFloatingDebtUpdate: Number(state.lastFloatingDebtUpdate),
87+
lastAccumulatorAccrual: Number(state.lastAccumulatorAccrual),
88+
maxFuturePools: Number(state.maxFuturePools),
89+
},
90+
],
91+
timestamp,
92+
)[0];
93+
return {
94+
date: timestamp * 1_000,
95+
depositApr: deposit ? Number(formatEther(deposit.rate)) : 0,
96+
borrowApr: Number(formatEther(state.floatingRate)),
97+
utilization: state.floatingAssets
98+
? Number(formatEther(floatingUtilization(state.floatingAssets, state.floatingDebt)))
99+
: 0,
100+
};
101+
}),
102+
);
103+
for (const result of batch) if (result.status === 'fulfilled' && result.value) series.push(result.value);
104+
}
105+
106+
return series.sort((a, b) => a.date - b.date);
107+
},
108+
});
109+
110+
const loading = isLoading || !marketAccount;
111+
112+
useEffect(() => {
113+
if (isError) setIndexerError();
114+
}, [isError, setIndexerError]);
23115

24116
const buttons = useMemo(
25117
() => [
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-
},
118+
{ label: t('1W'), onClick: () => setRange('1W') },
119+
{ label: t('1M'), onClick: () => setRange('1M') },
120+
{ label: t('3M'), onClick: () => setRange('3M') },
38121
],
39-
[getRates, t],
122+
[t],
40123
);
41124

42-
const formatDate = useCallback((date: Date, year?: boolean) => {
43-
return date.toLocaleDateString('en-us', { year: year ? 'numeric' : undefined, month: 'short', day: '2-digit' });
44-
}, []);
125+
const formatDate = useCallback(
126+
(date: Date, year?: boolean) =>
127+
date.toLocaleDateString('en-us', { year: year ? 'numeric' : undefined, month: 'short', day: '2-digit' }),
128+
[],
129+
);
45130

46131
const onShowUtilizationChange = useCallback(() => {
47132
setShowUtilization((prev) => !prev);
@@ -55,7 +140,7 @@ const HistoricalRateChart: FC<Props> = ({ symbol }) => {
55140
}, [showUtilization, symbol]);
56141

57142
return (
58-
<Box display="flex" flexDirection="column" width="100%" height="100%" gap={2}>
143+
<Box data-testid="historical-rate-chart" display="flex" flexDirection="column" width="100%" height="100%" gap={2}>
59144
<Box display="flex" justifyContent="space-between">
60145
<Typography variant="h6" fontSize="16px">
61146
{t('Historical Variable Rates')}
@@ -67,14 +152,22 @@ const HistoricalRateChart: FC<Props> = ({ symbol }) => {
67152
<ResponsiveContainer width="100%" height="100%">
68153
{loading ? (
69154
<LoadingChart />
155+
) : points.length < 2 ? (
156+
<Box display="flex" width="100%" height="100%" alignItems="center" justifyContent="center">
157+
<Typography color="grey.500" variant="subtitle2" fontSize="14px">
158+
{t('Not enough variable rate activity to chart yet.')}
159+
</Typography>
160+
</Box>
70161
) : (
71-
<LineChart data={sortedRates} margin={{ top: 5, bottom: 5 }}>
162+
<LineChart data={points} margin={{ top: 5, bottom: 5 }}>
72163
<CartesianGrid horizontal vertical={false} stroke={palette.grey[300]} />
73164
<XAxis
165+
dataKey="date"
166+
type="number"
167+
domain={['dataMin', 'dataMax']}
74168
minTickGap={50}
75169
padding={{ left: 20, right: 30 }}
76-
dataKey="date"
77-
tickFormatter={(value) => (value instanceof Date ? formatDate(value as Date) : '')}
170+
tickFormatter={(value) => formatDate(new Date(value as number))}
78171
stroke="#B4BABF"
79172
fontSize="12px"
80173
height={20}
@@ -99,9 +192,9 @@ const HistoricalRateChart: FC<Props> = ({ symbol }) => {
99192
/>
100193
)}
101194
<Tooltip
102-
labelFormatter={(value) => (value instanceof Date ? formatDate(value as Date, true) : '')}
195+
labelFormatter={(value) => formatDate(new Date(value as number), true)}
103196
formatter={(value) => toPercentage(value as number)}
104-
content={<TooltipChart itemSorter={(a, b) => (a.value > b.value ? -1 : 1)} />}
197+
content={<TooltipChart sortItems={(a, b) => (a.value > b.value ? -1 : 1)} />}
105198
/>
106199
<Line
107200
yAxisId="left"
@@ -141,12 +234,7 @@ const HistoricalRateChart: FC<Props> = ({ symbol }) => {
141234
<Checkbox
142235
size="small"
143236
onChange={onShowUtilizationChange}
144-
sx={{
145-
color: palette.blue,
146-
'&.Mui-checked': {
147-
color: palette.blue,
148-
},
149-
}}
237+
sx={{ color: palette.blue, '&.Mui-checked': { color: palette.blue } }}
150238
/>
151239
}
152240
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)