11import { 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' ;
44import { useTranslation } from 'react-i18next' ;
55import { 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' ;
614import { toPercentage } from 'utils/utils' ;
15+ import { track } from 'utils/mixpanel' ;
716import ButtonsChart from '../ButtonsChart' ;
817import LoadingChart from '../LoadingChart' ;
918import TooltipChart from '../TooltipChart' ;
10- import { track } from 'utils/mixpanel' ;
1119
1220type 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+
1637const 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 = {
0 commit comments