Skip to content
Draft
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
1 change: 1 addition & 0 deletions headers/csps/chainflip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const rpcOrigin = env.VITE_CHAINFLIP_RPC_URL
export const csp: Csp = {
'connect-src': [
'https://explorer-service-processor.chainflip.io/graphql',
'https://lp-service.chainflip.io/graphql',
'https://chainflip-broker.io/',
rpcOrigin,
rpcOrigin.replace('https://', 'wss://'),
Expand Down
24 changes: 22 additions & 2 deletions src/assets/translations/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -2552,7 +2552,23 @@
"currentLtv": "Current LTV",
"borrowCapacity": "Borrow Capacity",
"borrowPowerUsed": "Borrow Power Used",
"availableToBorrow": "Available to Borrow"
"availableToBorrow": "Available to Borrow",
"addFunds": "Add Funds",
"addFundsDescription": "Add funds to your state chain balance to get started with Chainflip Lending.",
"noEarningPositions": "No earning positions yet",
"noEarningPositionsDescription": "Supply from your free balance to start earning yield from borrower demand.",
"noActiveLoans": "No Active Loans",
"noActiveLoansDescription": "Provide collateral first, then open a loan to borrow against it.",
"provideCollateral": "Provide collateral to start borrowing",
"provideCollateralDescription": "Move assets from your free balance into collateral. Your collateral determines how much you can borrow.",
"topUpAsset": "Top-up Asset",
"cantRepay": "Can't Repay?",
"startLiquidation": "Start Liquidation",
"currentRate": "Current Rate",
"ofCurrentPool": "Of current pool",
"forImmediateSupply": "For immediate supply",
"supplyRateHistory": "Supply APY History",
"borrowRateHistory": "Borrow Rate History"
},
"supplyApy": "Supply APY",
"supplyApyTooltip": "Annual percentage yield earned by supplying assets to this pool.",
Expand Down Expand Up @@ -2890,7 +2906,11 @@
"volLiqCollateralSold": "Collateral sold",
"volLiqDescription": "Selling collateral to bring your position back to a healthy LTV. This process stops automatically once your LTV is restored, or you can stop it at any time.",
"volLiqStop": "Stop Liquidation"
}
},
"liquidationLtv": "Liquidation LTV",
"liquidationLtvTooltip": "LTV ratio at which a position will be liquidated",
"maxLtv": "Max LTV",
"maxLtvTooltip": "Maximum loan-to-value ratio at which new loans can be created"
},
"chart": {
"interval": {
Expand Down
185 changes: 185 additions & 0 deletions src/components/SimpleChart/RateChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { Heading, useColorModeValue, useToken } from '@chakra-ui/react'
import styled from '@emotion/styled'
import type { HistogramData, MouseEventParams, UTCTimestamp } from 'lightweight-charts'
import { createChart, CrosshairMode, LineStyle } from 'lightweight-charts'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'

import { ChartHeader } from './ChartHeader'
import type { ChartInterval } from './utils'
import { formatHistoryDuration, formatTickMarks } from './utils'

import { semanticTokens } from '@/theme/semanticTokens'
import { opacify } from '@/theme/utils'

const surfaceColors = semanticTokens.colors.background.surface
const textColors = semanticTokens.colors.text
const borderColors = semanticTokens.colors.border

const currentLocale = window.navigator.languages[0]

const percentFormatter = (value: number) =>
Intl.NumberFormat(currentLocale, {
style: 'percent',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value / 100)

const ChartDiv = styled.div<{ height?: number }>`
${({ height }) => height && `height: ${height}px`};
width: 100%;
position: relative;

/* Hide TradingView attribution */
a[href*='tradingview'] {
display: none !important;
}
`

type RateChartProps = {
/** Values should be percentages, e.g. 3.47 for 3.47% */
data: HistogramData[]
height: number
interval: ChartInterval
}

export const RateChart = ({ data, height, interval }: RateChartProps) => {
const [crosshairData, setCrosshairData] = useState<HistogramData | undefined>()
const chartContainerRef = useRef<HTMLDivElement | null>(null)

const [
surfaceLight,
surfaceDark,
lightText,
darkText,
lightBorder,
darkBorder,
boldBorderLight,
boldBorderDark,
brandColor,
] = useToken('colors', [
surfaceColors.raised.hover.default,
surfaceColors.raised.hover._dark,
textColors.subtle.default,
textColors.subtle._dark,
borderColors.subtle.default,
borderColors.subtle._dark,
borderColors.bold.default,
borderColors.bold._dark,
'blue.500',
])

const textColor = useColorModeValue(lightText, darkText)
const lineColor = useColorModeValue(lightBorder, darkBorder)
const boldBorder = useColorModeValue(boldBorderLight, boldBorderDark)
const surfaceColor = useColorModeValue(surfaceLight, surfaceDark)
const lastPrice = data[data.length - 1]

useEffect(() => {
if (!chartContainerRef.current || !data) return

const chart = createChart(chartContainerRef.current, {
grid: {
vertLines: { color: lineColor },
horzLines: { color: lineColor },
},
layout: {
background: { color: 'transparent' },
textColor,
},
watermark: { visible: false },
width: chartContainerRef.current.offsetWidth,
height: chartContainerRef.current.offsetHeight,
localization: { priceFormatter: percentFormatter },
autoSize: true,
timeScale: {
tickMarkFormatter: formatTickMarks,
timeVisible: true,
borderVisible: false,
ticksVisible: false,
fixLeftEdge: true,
fixRightEdge: true,
minBarSpacing: 4,
},
rightPriceScale: {
borderVisible: false,
scaleMargins: { top: 0.3, bottom: 0 },
},
handleScale: { axisPressedMouseMove: false },
handleScroll: { vertTouchDrag: false },
crosshair: {
horzLine: {
visible: true,
style: LineStyle.Solid,
width: 1,
color: boldBorder,
labelVisible: false,
labelBackgroundColor: surfaceColor,
},
mode: CrosshairMode.Magnet,
vertLine: {
visible: true,
style: LineStyle.Solid,
width: 1,
color: boldBorder,
labelVisible: false,
labelBackgroundColor: surfaceColor,
},
},
})

const series = chart.addHistogramSeries({
color: opacify(80, brandColor),
priceFormat: { type: 'custom', formatter: percentFormatter },
})

series.setData(data)
chart.timeScale().fitContent()

const handleResize = () => {
chart.applyOptions({
width: chartContainerRef.current?.offsetWidth,
height: chartContainerRef.current?.offsetHeight,
})
}

const handleCrosshairMove = (event: MouseEventParams) => {
if (event.time) {
setCrosshairData(event.seriesData.get(series) as HistogramData)
} else {
setCrosshairData(undefined)
}
}

chart.subscribeCrosshairMove(handleCrosshairMove)
window.addEventListener('resize', handleResize)

return () => {
window.removeEventListener('resize', handleResize)
chart.remove()
}
}, [boldBorder, brandColor, data, lineColor, surfaceColor, textColor])

const handleTouchMove = useCallback(
(e: React.TouchEvent<HTMLDivElement>) => e.preventDefault(),
[],
)

const headerValue = useMemo(
() => (
<Heading as='h3' lineHeight={1}>
{percentFormatter(crosshairData?.value ?? lastPrice?.value ?? 0)}
</Heading>
),
[crosshairData?.value, lastPrice?.value],
)

return (
<ChartDiv ref={chartContainerRef} height={height} onTouchMove={handleTouchMove}>
<ChartHeader
value={headerValue}
time={crosshairData?.time as UTCTimestamp}
timePlaceholder={formatHistoryDuration(interval)}
/>
</ChartDiv>
)
}
60 changes: 60 additions & 0 deletions src/lib/chainflip/lpServiceApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import axios from 'axios'

const LP_SERVICE_GRAPHQL_URL = 'https://lp-service.chainflip.io/graphql'
const LP_SERVICE_REQUEST_TIMEOUT_MS = 10_000

export type LendingPoolStatPoint = {
timestamp: string
avgInterestRateBps: number
avgUtilisationRateBps: number
projectedApy: string
}

type LendingPoolStatsResponse = {
data: {
allLendingPoolStats: {
nodes: LendingPoolStatPoint[]
}
}
}

const LENDING_POOL_STATS_QUERY = `
query GetLendingPoolStats($asset: ChainflipAsset!, $since: Datetime!) {
allLendingPoolStats(
filter: {
asset: { equalTo: $asset }
timestamp: { greaterThanOrEqualTo: $since }
}
orderBy: TIMESTAMP_ASC
) {
nodes {
timestamp
avgInterestRateBps
avgUtilisationRateBps
projectedApy
}
}
}
`

// Convert ChainflipAssetSymbol (e.g. "USDC") to lp-service GraphQL enum casing (e.g. "Usdc")
const toGraphqlAsset = (asset: string): string => asset.charAt(0) + asset.slice(1).toLowerCase()

export const queryLendingPoolStats = async (
asset: string,
since: Date,
): Promise<LendingPoolStatPoint[]> => {
const { data } = await axios.post<LendingPoolStatsResponse>(
LP_SERVICE_GRAPHQL_URL,
{
query: LENDING_POOL_STATS_QUERY,
variables: {
asset: toGraphqlAsset(asset),
since: since.toISOString(),
},
},
{ timeout: LP_SERVICE_REQUEST_TIMEOUT_MS },
)

return data.data.allLendingPoolStats.nodes
}
Loading
Loading