From b3bf2d90058017e24a8bc363bbf2565734dee5a6 Mon Sep 17 00:00:00 2001 From: Tim Black Date: Sat, 4 Apr 2026 22:50:58 -0700 Subject: [PATCH 1/3] Adding proper styling to Pool pages and a chart for rate history, attempting to hit some of the GraphQL endpoints from Chainflip. --- headers/csps/chainflip.ts | 1 + src/assets/translations/en/main.json | 22 +- src/components/SimpleChart/RateChart.tsx | 178 +++ src/lib/chainflip/lpServiceApi.ts | 60 + src/pages/ChainflipLending/Pool/Pool.tsx | 1340 ++++++++++------- .../Pool/components/BorrowRateChart.tsx | 80 + .../components/DashboardSidebar.tsx | 25 +- src/react-queries/queries/chainflipLending.ts | 8 +- 8 files changed, 1164 insertions(+), 550 deletions(-) create mode 100644 src/components/SimpleChart/RateChart.tsx create mode 100644 src/lib/chainflip/lpServiceApi.ts create mode 100644 src/pages/ChainflipLending/Pool/components/BorrowRateChart.tsx diff --git a/headers/csps/chainflip.ts b/headers/csps/chainflip.ts index 33351efcfab..c5519ca4983 100644 --- a/headers/csps/chainflip.ts +++ b/headers/csps/chainflip.ts @@ -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://'), diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 2e5746644e9..fc4e57c1b37 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2552,7 +2552,21 @@ "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" }, "supplyApy": "Supply APY", "supplyApyTooltip": "Annual percentage yield earned by supplying assets to this pool.", @@ -2890,7 +2904,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": { diff --git a/src/components/SimpleChart/RateChart.tsx b/src/components/SimpleChart/RateChart.tsx new file mode 100644 index 00000000000..9b672534638 --- /dev/null +++ b/src/components/SimpleChart/RateChart.tsx @@ -0,0 +1,178 @@ +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; +` + +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() + const chartContainerRef = useRef(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, + }, + 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, + }, + 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) => e.preventDefault(), + [], + ) + + const headerValue = useMemo( + () => ( + + {percentFormatter(crosshairData?.value ?? lastPrice?.value ?? 0)} + + ), + [crosshairData?.value, lastPrice?.value], + ) + + return ( + + + + ) +} diff --git a/src/lib/chainflip/lpServiceApi.ts b/src/lib/chainflip/lpServiceApi.ts new file mode 100644 index 00000000000..99ae3447de4 --- /dev/null +++ b/src/lib/chainflip/lpServiceApi.ts @@ -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 => { + const { data } = await axios.post( + 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 +} diff --git a/src/pages/ChainflipLending/Pool/Pool.tsx b/src/pages/ChainflipLending/Pool/Pool.tsx index 604fbd821d1..927e340bb19 100644 --- a/src/pages/ChainflipLending/Pool/Pool.tsx +++ b/src/pages/ChainflipLending/Pool/Pool.tsx @@ -4,23 +4,28 @@ import { Button, Card, CardBody, + Divider, Flex, Heading, HStack, + Icon, SimpleGrid, Skeleton, Stack, TabPanel, TabPanels, Tabs, + Tag, + TagLabel, Tooltip, VStack, } from '@chakra-ui/react' import type { AccountId, AssetId } from '@shapeshiftoss/caip' -import { ethAssetId, fromAssetId } from '@shapeshiftoss/caip' +import { ethAssetId } from '@shapeshiftoss/caip' import { BigAmount } from '@shapeshiftoss/utils' import type { Property } from 'csstype' import React, { useCallback, useMemo, useState } from 'react' +import { FaInfoCircle } from 'react-icons/fa' import { useTranslate } from 'react-polyglot' import { useLocation, useNavigate } from 'react-router-dom' @@ -41,33 +46,32 @@ import { bnOrZero } from '@/lib/bignumber/bignumber' import { CHAINFLIP_LENDING_ASSET_BY_ASSET_ID } from '@/lib/chainflip/constants' import { permillToDecimal } from '@/lib/chainflip/utils' import { useChainflipLendingAccount } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' +import { NextStepsArt, NextStepsCard } from '@/pages/ChainflipLending/components/DashboardSidebar' import { useChainflipAccount } from '@/pages/ChainflipLending/hooks/useChainflipAccount' import { useChainflipLendingPools } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' import { useChainflipLoanAccount } from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' import { useChainflipLtvThresholds } from '@/pages/ChainflipLending/hooks/useChainflipLtvThresholds' import { useChainflipSafeModeStatuses } from '@/pages/ChainflipLending/hooks/useChainflipSafeModeStatuses' import { useChainflipSupplyPositions } from '@/pages/ChainflipLending/hooks/useChainflipSupplyPositions' +import { BorrowRateChart } from '@/pages/ChainflipLending/Pool/components/BorrowRateChart' +import { LtvGauge } from '@/pages/ChainflipLending/Pool/components/Borrow/LtvGauge' import { selectAssetById } from '@/state/slices/assetsSlice/selectors' import { selectMarketDataByAssetIdUserCurrency } from '@/state/slices/marketDataSlice/selectors' -import { selectAccountIdsByAccountNumberAndChainId } from '@/state/slices/portfolioSlice/selectors' -import { selectPortfolioCryptoBalanceByFilter } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' const flexDirPool: ResponsiveValue = { base: 'column', lg: 'row' } const actionColumnMaxWidth = { base: '100%', lg: '500px' } enum PoolTabIndex { - Deposit = 0, - Supply = 1, - Collateral = 2, - ManageLoan = 3, + Supply = 0, + Collateral = 1, + Borrow = 2, } const ACTION_TAB_ITEMS = [ - { label: 'chainflipLending.depositToChainflip', index: PoolTabIndex.Deposit }, { label: 'chainflipLending.supply.title', index: PoolTabIndex.Supply }, { label: 'chainflipLending.collateral.title', index: PoolTabIndex.Collateral }, - { label: 'chainflipLending.manageLoan', index: PoolTabIndex.ManageLoan }, + { label: 'chainflipLending.borrow.title', index: PoolTabIndex.Borrow }, ] type PoolHeaderProps = { @@ -131,27 +135,14 @@ type StatBoxProps = { const StatBox: React.FC = ({ label, tooltip, children, isLoading }) => ( - {tooltip ? ( - - + + + {label} - - ) : ( - - {label} - - )} + {tooltip && } + + {children} ) @@ -160,8 +151,8 @@ export const Pool = () => { const translate = useTranslate() const location = useLocation() const { dispatch: walletDispatch } = useWallet() - const { accountId, accountNumber, setAccountId } = useChainflipLendingAccount() - const [actionTabIndex, setActionTabIndex] = useState(PoolTabIndex.Deposit) + const { accountId, setAccountId } = useChainflipLendingAccount() + const [actionTabIndex, setActionTabIndex] = useState(PoolTabIndex.Supply) const chainflipLendingModal = useModal('chainflipLending') const handleConnectWallet = useCallback( @@ -192,41 +183,6 @@ export const Pool = () => { }, [loanAccount?.liquidation_status]) const { freeBalances } = useChainflipAccount() - const accountIdsByAccountNumberAndChainId = useAppSelector( - selectAccountIdsByAccountNumberAndChainId, - ) - - const chainId = useMemo(() => { - try { - return fromAssetId(poolAssetId).chainId - } catch { - return undefined - } - }, [poolAssetId]) - - const poolChainAccountId = useMemo(() => { - if (!chainId) return undefined - const byChainId = accountIdsByAccountNumberAndChainId[accountNumber] - return byChainId?.[chainId]?.[0] - }, [accountIdsByAccountNumberAndChainId, accountNumber, chainId]) - - const balanceFilter = useMemo( - () => ({ assetId: poolAssetId, accountId: poolChainAccountId ?? '' }), - [poolAssetId, poolChainAccountId], - ) - const walletBalanceCryptoBaseUnit = useAppSelector(state => - selectPortfolioCryptoBalanceByFilter(state, balanceFilter), - ).toBaseUnit() - - const walletBalanceCryptoPrecision = useMemo( - () => - BigAmount.fromBaseUnit({ - value: walletBalanceCryptoBaseUnit, - precision: asset?.precision ?? 0, - }).toPrecision(), - [walletBalanceCryptoBaseUnit, asset?.precision], - ) - const poolData = useMemo(() => pools.find(p => p.assetId === poolAssetId), [pools, poolAssetId]) const supplyPosition = useMemo( @@ -265,11 +221,6 @@ export const Pool = () => { return bnOrZero(freeBalanceCryptoPrecision).times(marketData.price).toFixed(2) }, [freeBalanceCryptoPrecision, marketData?.price]) - const walletBalanceFiat = useMemo(() => { - if (!marketData?.price) return undefined - return bnOrZero(walletBalanceCryptoPrecision).times(marketData.price).toFixed(2) - }, [walletBalanceCryptoPrecision, marketData?.price]) - const hasSupplyPosition = useMemo( () => bnOrZero(supplyPosition?.totalAmountCryptoPrecision).gt(0), [supplyPosition], @@ -390,18 +341,27 @@ export const Pool = () => { return 'green.500' }, [currentLtvDecimal]) - const borrowCapacityFiat = useMemo(() => { - if (!thresholds) return '0' - const maxBorrow = bnOrZero(totalCollateralFiat).times(thresholds.target) - return maxBorrow.minus(userBorrowedFiat).toFixed(2) - }, [totalCollateralFiat, userBorrowedFiat, thresholds]) + const maxLtvDecimal = useMemo(() => thresholds?.target ?? 0, [thresholds]) + + const liquidationLtvDecimal = useMemo( + () => thresholds?.softLiquidation ?? 0, + [thresholds], + ) + + const ltvZoneLabel = useMemo(() => { + if (!thresholds) return '' + if (currentLtvDecimal >= thresholds.softLiquidation) + return translate('chainflipLending.ltv.liquidation') + if (currentLtvDecimal >= thresholds.target) return translate('chainflipLending.ltv.risky') + if (currentLtvDecimal >= thresholds.lowLtv) return translate('chainflipLending.ltv.optimal') + return translate('chainflipLending.ltv.conservative') + }, [currentLtvDecimal, thresholds, translate]) - const borrowPowerUsedPercent = useMemo(() => { - if (!thresholds) return '0' - const maxBorrow = bnOrZero(totalCollateralFiat).times(thresholds.target) - if (maxBorrow.isZero()) return '0' - return bnOrZero(userBorrowedFiat).div(maxBorrow).toFixed(4) - }, [totalCollateralFiat, userBorrowedFiat, thresholds]) + // True only when user has funded CF account but has no positions of any kind + const hasNoPositions = useMemo( + () => !hasFreeBalance && !hasSupplyPosition && !hasCollateral && !hasLoans, + [hasFreeBalance, hasSupplyPosition, hasCollateral, hasLoans], + ) const handleVoluntaryLiquidation = useCallback( (action: 'initiate' | 'stop') => { @@ -468,13 +428,13 @@ export const Pool = () => { > @@ -485,10 +445,13 @@ export const Pool = () => { > + + {translate('chainflipLending.pool.currentRate')} + { > @@ -516,43 +479,79 @@ export const Pool = () => { tooltip={translate('chainflipLending.totalBorrowedTooltip')} isLoading={isLoading} > - + + + {translate('chainflipLending.pool.currentRate')} + - + + + {translate('chainflipLending.pool.ofCurrentPool')} + + + + - + + + + - + + + + + + {translate('chainflipLending.pool.forImmediateSupply')} + + + {cfAsset && } { alignSelf='flex-start' gap={4} > - - - - {actionTabHeader} - - - - - - {translate('chainflipLending.pool.freeBalance')} - - - {freeBalanceFiat !== undefined && ( - - )} - - - - - - {translate('chainflipLending.pool.walletBalance')} - - - {walletBalanceFiat !== undefined && ( - - )} - - - - - span': { display: 'block' } }}> - - - - - span': { display: 'block' } }}> - - + + + ) : hasNoPositions ? ( + // No free balance and no positions — prompt user to add funds + + + + + + {translate('chainflipLending.dashboard.yourNextSteps')} + + + {translate('chainflipLending.pool.addFundsDescription')} + + + + + + + + ) : ( + // 3-tab action panel + + + + {actionTabHeader} + + {/* ── Supply tab ── */} + + + {!hasSupplyPosition ? ( + // Empty state + + + + {translate('chainflipLending.pool.noEarningPositions')} + + + {translate('chainflipLending.pool.noEarningPositionsDescription')} + + + - {translate('common.withdraw')} - - - - - - - - - - - {translate('chainflipLending.pool.freeBalance')} - - - {freeBalanceFiat !== undefined && ( - - )} - - - - - - {translate('chainflipLending.supplied')} - - + + + + ) : ( + // Supply position + + + + {translate('chainflipLending.supplied')} + + + + + + + {poolData?.supplyApy && ( + + + + + + )} + + + + + + span': { display: 'block' } }}> + + + + + span': { display: 'block' } }}> + + + + + + + )} + + {/* Inline free balance row */} + + + + {translate('chainflipLending.pool.freeBalance')} + - + {freeBalanceFiat !== undefined && ( + + )} - - - - span': { display: 'block' } }}> - - - - - span': { display: 'block' } }}> - - + + + span': { display: 'block' } }}> + - {translate('common.withdraw')} - - - - - - - - - - - {translate('chainflipLending.collateral.title')} - - - - - - - {hasLoans && ( - - - {translate('chainflipLending.pool.currentLtv')} - - - {currentLtvPercent}% - - - )} - {hasCollateral && ( + + + + + + + + {/* ── Collateral tab ── */} + + + {!hasPoolCollateral ? ( + // Empty state + + + + {translate('chainflipLending.pool.provideCollateral')} + + + {translate('chainflipLending.pool.provideCollateralDescription')} + + + + + + + ) : ( + // Collateral position + + + + {translate('chainflipLending.collateral.title')} + + + + + + + + + {translate('chainflipLending.pool.topUpAsset')} + + + {asset.symbol} + + + + span': { display: 'block' } }}> + + + + + span': { display: 'block' } }}> + + + + + + + )} + + {/* Inline free balance row */} + - {translate('chainflipLending.pool.borrowCapacity')} + {translate('chainflipLending.pool.freeBalance')} - + + {freeBalanceFiat !== undefined && ( + + )} + + - )} - - span': { display: 'block' } }}> - - - - - span': { display: 'block' } }}> - - + + + span': { display: 'block' } }}> + - {translate('chainflipLending.collateral.remove')} - - - - - - - - - - - {translate('chainflipLending.borrow.borrowed')} - - - - {hasLoans && ( - - - {translate('chainflipLending.pool.currentLtv')} - - - {currentLtvPercent}% - - - )} - {hasCollateral && ( - - - {translate('chainflipLending.pool.availableToBorrow')} - - - - )} - {hasCollateral && ( + + + + + + + + {/* ── Borrow tab ── */} + + + {!hasLoans ? ( + // Empty state + + + + {translate('chainflipLending.pool.noActiveLoans')} + + + {translate('chainflipLending.pool.noActiveLoansDescription')} + + + + + + + ) : ( + // Active loan + + + + {translate('chainflipLending.borrow.borrowed')} + + + + {poolData?.borrowRate && ( + + + + + + )} + + + + span': { display: 'block' } }}> + + + + + span': { display: 'block' } }}> + + + + + + {/* LTV gauge */} + + + + {translate('chainflipLending.pool.currentLtv')} + + + {currentLtvPercent}%{' '} + + {ltvZoneLabel} + + + + + + + + + + + + )} + + {/* Inline free balance row */} + - {translate('chainflipLending.pool.borrowPowerUsed')} + {translate('chainflipLending.pool.freeBalance')} - - - )} - - - {translate('chainflipLending.pool.freeBalance')} - - - {freeBalanceFiat !== undefined && ( - + {freeBalanceFiat !== undefined && ( + + )} + - )} - - - - - span': { display: 'block' } }}> - - - - - span': { display: 'block' } }}> - - + + + span': { display: 'block' } }}> + - {translate('chainflipLending.repay.title')} - - - - - - - - - - - - {!accountId && ( - - - + + + + + + + + )} - {hasLoans && accountId && ( - + {/* Voluntary liquidation active card */} + {hasLoans && accountId && isVoluntaryLiquidationActive && ( + - {isVoluntaryLiquidationActive && ( - - {translate('chainflipLending.voluntaryLiquidation.inProgress')} - - )} + + {translate('chainflipLending.voluntaryLiquidation.inProgress')} + { > )} + + {/* Next steps guidance card */} + diff --git a/src/pages/ChainflipLending/Pool/components/BorrowRateChart.tsx b/src/pages/ChainflipLending/Pool/components/BorrowRateChart.tsx new file mode 100644 index 00000000000..3ae2ad6bccf --- /dev/null +++ b/src/pages/ChainflipLending/Pool/components/BorrowRateChart.tsx @@ -0,0 +1,80 @@ +import { Button, ButtonGroup, Center, Flex, Stack } from '@chakra-ui/react' +import { useQuery } from '@tanstack/react-query' +import type { SingleValueData, UTCTimestamp } from 'lightweight-charts' +import { useMemo, useState } from 'react' + +import { ChartSkeleton } from '@/components/SimpleChart/LoadingChart' +import { RateChart } from '@/components/SimpleChart/RateChart' +import type { ChartInterval } from '@/components/SimpleChart/utils' +import type { LendingPoolStatPoint } from '@/lib/chainflip/lpServiceApi' +import type { ChainflipAssetSymbol } from '@/lib/chainflip/types' +import { reactQueries } from '@/react-queries' + +// lp-service uses permill (1/1_000_000) despite the "Bps" naming +const PERMILL_DIVISOR = 1_000_000 + +type ChartWindowKey = '1w' | '1m' | '6m' | 'ytd' + +const WINDOW_PARAMS_BY_KEY: Record = { + '1w': { sinceMs: 7 * 24 * 60 * 60 * 1000, chartInterval: 'day' }, + '1m': { sinceMs: 30 * 24 * 60 * 60 * 1000, chartInterval: 'week' }, + '6m': { sinceMs: 180 * 24 * 60 * 60 * 1000, chartInterval: 'month' }, + ytd: { sinceMs: null, chartInterval: 'year' }, +} + +const getSinceIso = (windowKey: ChartWindowKey): string => { + const { sinceMs } = WINDOW_PARAMS_BY_KEY[windowKey] + if (sinceMs === null) { + const now = new Date() + return new Date(now.getFullYear(), 0, 1).toISOString() + } + return new Date(Date.now() - sinceMs).toISOString() +} + +const toChartData = (nodes: LendingPoolStatPoint[]): SingleValueData[] => + nodes.map(node => ({ + time: (new Date(node.timestamp).getTime() / 1000) as UTCTimestamp, + value: (node.avgInterestRateBps / PERMILL_DIVISOR) * 100, + })) + +type BorrowRateChartProps = { + asset: ChainflipAssetSymbol +} + +export const BorrowRateChart = ({ asset }: BorrowRateChartProps) => { + const [selectedWindow, setSelectedWindow] = useState('1m') + + const sinceIso = useMemo(() => getSinceIso(selectedWindow), [selectedWindow]) + const { chartInterval } = WINDOW_PARAMS_BY_KEY[selectedWindow] + + const { data: nodes, isLoading } = useQuery({ + ...reactQueries.chainflipLending.lendingPoolStats(asset, sinceIso), + select: toChartData, + }) + + const chartBody = useMemo(() => { + if (isLoading) return + return + }, [nodes, isLoading, chartInterval]) + + return ( + + + + {(Object.keys(WINDOW_PARAMS_BY_KEY) as ChartWindowKey[]).map(key => ( + + ))} + + +
+ {chartBody} +
+
+ ) +} diff --git a/src/pages/ChainflipLending/components/DashboardSidebar.tsx b/src/pages/ChainflipLending/components/DashboardSidebar.tsx index eb82aa1d439..493fb1ef702 100644 --- a/src/pages/ChainflipLending/components/DashboardSidebar.tsx +++ b/src/pages/ChainflipLending/components/DashboardSidebar.tsx @@ -102,7 +102,7 @@ export const BorrowingPowerCard = memo(() => { ) }) -const NextStepsArt = memo(({ colorScheme }: { colorScheme: 'green' | 'purple' }) => { +export const NextStepsArt = memo(({ colorScheme }: { colorScheme: 'green' | 'purple' }) => { const isGreen = colorScheme === 'green' return ( @@ -174,7 +174,11 @@ const NextStepsArt = memo(({ colorScheme }: { colorScheme: 'green' | 'purple' }) ) }) -export const NextStepsCard = memo(() => { +type NextStepsCardProps = { + assetId?: AssetId +} + +export const NextStepsCard = memo(({ assetId }: NextStepsCardProps) => { const translate = useTranslate() const chainflipLendingModal = useModal('chainflipLending') const { freeBalances } = useChainflipFreeBalances() @@ -190,20 +194,19 @@ export const NextStepsCard = memo(() => { const hasCollateral = collateralWithFiat.length > 0 const hasLoans = loansWithFiat.length > 0 + const targetAssetId = useMemo(() => assetId ?? LENDING_ASSET_IDS[0], [assetId]) + const handleSupply = useCallback(() => { - const firstAssetId = LENDING_ASSET_IDS[0] - if (firstAssetId) chainflipLendingModal.open({ mode: 'supply', assetId: firstAssetId }) - }, [chainflipLendingModal]) + if (targetAssetId) chainflipLendingModal.open({ mode: 'supply', assetId: targetAssetId }) + }, [chainflipLendingModal, targetAssetId]) const handleAddCollateral = useCallback(() => { - const firstAssetId = LENDING_ASSET_IDS[0] - if (firstAssetId) chainflipLendingModal.open({ mode: 'addCollateral', assetId: firstAssetId }) - }, [chainflipLendingModal]) + if (targetAssetId) chainflipLendingModal.open({ mode: 'addCollateral', assetId: targetAssetId }) + }, [chainflipLendingModal, targetAssetId]) const handleBorrow = useCallback(() => { - const firstAssetId = LENDING_ASSET_IDS[0] - if (firstAssetId) chainflipLendingModal.open({ mode: 'borrow', assetId: firstAssetId }) - }, [chainflipLendingModal]) + if (targetAssetId) chainflipLendingModal.open({ mode: 'borrow', assetId: targetAssetId }) + }, [chainflipLendingModal, targetAssetId]) // Hide when user has completed all steps if (hasSupply && hasCollateral && hasLoans) return null diff --git a/src/react-queries/queries/chainflipLending.ts b/src/react-queries/queries/chainflipLending.ts index 636a3e39d4b..c6510f02ddd 100644 --- a/src/react-queries/queries/chainflipLending.ts +++ b/src/react-queries/queries/chainflipLending.ts @@ -1,5 +1,6 @@ import { createQueryKeys } from '@lukemorales/query-key-factory' +import { queryLendingPoolStats } from '@/lib/chainflip/lpServiceApi' import { cfAccountInfo, cfEnvironment, @@ -12,7 +13,7 @@ import { cfSafeModeStatuses, stateGetRuntimeVersion, } from '@/lib/chainflip/rpc' -import type { ChainflipAsset } from '@/lib/chainflip/types' +import type { ChainflipAsset, ChainflipAssetSymbol } from '@/lib/chainflip/types' export const chainflipLending = createQueryKeys('chainflipLending', { environment: () => ({ @@ -55,4 +56,9 @@ export const chainflipLending = createQueryKeys('chainflipLending', { queryKey: ['accountInfo', scAccountId], queryFn: () => cfAccountInfo(scAccountId), }), + // sinceIso: ISO 8601 date string computed by the caller from a UI window key + lendingPoolStats: (asset: ChainflipAssetSymbol, sinceIso: string) => ({ + queryKey: ['lendingPoolStats', asset, sinceIso], + queryFn: () => queryLendingPoolStats(asset, new Date(sinceIso)), + }), }) From c1b44868a1ec4da9dc5119fc30e05e004f4c315a Mon Sep 17 00:00:00 2001 From: Tim Black Date: Sat, 4 Apr 2026 23:08:18 -0700 Subject: [PATCH 2/3] fixing the charts bar display --- src/components/SimpleChart/RateChart.tsx | 7 +++ .../Pool/components/BorrowRateChart.tsx | 44 +++++++++++++------ 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/components/SimpleChart/RateChart.tsx b/src/components/SimpleChart/RateChart.tsx index 9b672534638..a1c5a50e7d7 100644 --- a/src/components/SimpleChart/RateChart.tsx +++ b/src/components/SimpleChart/RateChart.tsx @@ -28,6 +28,11 @@ 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 = { @@ -81,6 +86,7 @@ export const RateChart = ({ data, height, interval }: RateChartProps) => { background: { color: 'transparent' }, textColor, }, + watermark: { visible: false }, width: chartContainerRef.current.offsetWidth, height: chartContainerRef.current.offsetHeight, localization: { priceFormatter: percentFormatter }, @@ -92,6 +98,7 @@ export const RateChart = ({ data, height, interval }: RateChartProps) => { ticksVisible: false, fixLeftEdge: true, fixRightEdge: true, + minBarSpacing: 4, }, rightPriceScale: { borderVisible: false, diff --git a/src/pages/ChainflipLending/Pool/components/BorrowRateChart.tsx b/src/pages/ChainflipLending/Pool/components/BorrowRateChart.tsx index 3ae2ad6bccf..601d70eb716 100644 --- a/src/pages/ChainflipLending/Pool/components/BorrowRateChart.tsx +++ b/src/pages/ChainflipLending/Pool/components/BorrowRateChart.tsx @@ -1,6 +1,6 @@ import { Button, ButtonGroup, Center, Flex, Stack } from '@chakra-ui/react' import { useQuery } from '@tanstack/react-query' -import type { SingleValueData, UTCTimestamp } from 'lightweight-charts' +import type { HistogramData, UTCTimestamp } from 'lightweight-charts' import { useMemo, useState } from 'react' import { ChartSkeleton } from '@/components/SimpleChart/LoadingChart' @@ -12,14 +12,16 @@ import { reactQueries } from '@/react-queries' // lp-service uses permill (1/1_000_000) despite the "Bps" naming const PERMILL_DIVISOR = 1_000_000 +const SECONDS_PER_DAY = 86400 +const SECONDS_PER_WEEK = SECONDS_PER_DAY * 7 type ChartWindowKey = '1w' | '1m' | '6m' | 'ytd' -const WINDOW_PARAMS_BY_KEY: Record = { - '1w': { sinceMs: 7 * 24 * 60 * 60 * 1000, chartInterval: 'day' }, - '1m': { sinceMs: 30 * 24 * 60 * 60 * 1000, chartInterval: 'week' }, - '6m': { sinceMs: 180 * 24 * 60 * 60 * 1000, chartInterval: 'month' }, - ytd: { sinceMs: null, chartInterval: 'year' }, +const WINDOW_PARAMS_BY_KEY: Record = { + '1w': { sinceMs: 7 * 24 * 60 * 60 * 1000, chartInterval: 'day', bucketSecs: SECONDS_PER_DAY }, + '1m': { sinceMs: 30 * 24 * 60 * 60 * 1000, chartInterval: 'week', bucketSecs: SECONDS_PER_DAY }, + '6m': { sinceMs: 180 * 24 * 60 * 60 * 1000, chartInterval: 'month', bucketSecs: SECONDS_PER_WEEK }, + ytd: { sinceMs: null, chartInterval: 'year', bucketSecs: SECONDS_PER_WEEK }, } const getSinceIso = (windowKey: ChartWindowKey): string => { @@ -31,25 +33,39 @@ const getSinceIso = (windowKey: ChartWindowKey): string => { return new Date(Date.now() - sinceMs).toISOString() } -const toChartData = (nodes: LendingPoolStatPoint[]): SingleValueData[] => - nodes.map(node => ({ - time: (new Date(node.timestamp).getTime() / 1000) as UTCTimestamp, - value: (node.avgInterestRateBps / PERMILL_DIVISOR) * 100, - })) +const bucketData = (nodes: LendingPoolStatPoint[], bucketSecs: number): HistogramData[] => { + const buckets = new Map() + for (const node of nodes) { + const t = Math.floor(new Date(node.timestamp).getTime() / 1000) + const bucketStart = Math.floor(t / bucketSecs) * bucketSecs + const values = buckets.get(bucketStart) ?? [] + values.push((node.avgInterestRateBps / PERMILL_DIVISOR) * 100) + buckets.set(bucketStart, values) + } + return Array.from(buckets.entries()) + .sort(([a], [b]) => a - b) + .map(([time, values]) => ({ + time: time as UTCTimestamp, + value: values.reduce((sum, v) => sum + v, 0) / values.length, + })) +} + +const toChartData = (nodes: LendingPoolStatPoint[], bucketSecs: number): HistogramData[] => + bucketData(nodes, bucketSecs) type BorrowRateChartProps = { asset: ChainflipAssetSymbol } export const BorrowRateChart = ({ asset }: BorrowRateChartProps) => { - const [selectedWindow, setSelectedWindow] = useState('1m') + const [selectedWindow, setSelectedWindow] = useState('1w') const sinceIso = useMemo(() => getSinceIso(selectedWindow), [selectedWindow]) - const { chartInterval } = WINDOW_PARAMS_BY_KEY[selectedWindow] + const { chartInterval, bucketSecs } = WINDOW_PARAMS_BY_KEY[selectedWindow] const { data: nodes, isLoading } = useQuery({ ...reactQueries.chainflipLending.lendingPoolStats(asset, sinceIso), - select: toChartData, + select: (data: LendingPoolStatPoint[]) => toChartData(data, bucketSecs), }) const chartBody = useMemo(() => { From 51ff06f32a1e898b93ecc151a13239cf37ca7a12 Mon Sep 17 00:00:00 2001 From: Tim Black Date: Thu, 9 Apr 2026 13:15:24 -0700 Subject: [PATCH 3/3] final chart polish, rate components, stop here for now --- src/assets/translations/en/main.json | 4 +- src/pages/ChainflipLending/Pool/Pool.tsx | 50 +++++++++- .../Pool/components/SupplyRateChart.tsx | 99 +++++++++++++++++++ 3 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 src/pages/ChainflipLending/Pool/components/SupplyRateChart.tsx diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index fc4e57c1b37..a082144d6b0 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2566,7 +2566,9 @@ "startLiquidation": "Start Liquidation", "currentRate": "Current Rate", "ofCurrentPool": "Of current pool", - "forImmediateSupply": "For immediate supply" + "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.", diff --git a/src/pages/ChainflipLending/Pool/Pool.tsx b/src/pages/ChainflipLending/Pool/Pool.tsx index 927e340bb19..9f61322547e 100644 --- a/src/pages/ChainflipLending/Pool/Pool.tsx +++ b/src/pages/ChainflipLending/Pool/Pool.tsx @@ -4,11 +4,13 @@ import { Button, Card, CardBody, + Collapse, Divider, Flex, Heading, HStack, Icon, + IconButton, SimpleGrid, Skeleton, Stack, @@ -18,6 +20,7 @@ import { Tag, TagLabel, Tooltip, + useDisclosure, VStack, } from '@chakra-ui/react' import type { AccountId, AssetId } from '@shapeshiftoss/caip' @@ -25,7 +28,7 @@ import { ethAssetId } from '@shapeshiftoss/caip' import { BigAmount } from '@shapeshiftoss/utils' import type { Property } from 'csstype' import React, { useCallback, useMemo, useState } from 'react' -import { FaInfoCircle } from 'react-icons/fa' +import { FaChartLine, FaInfoCircle } from 'react-icons/fa' import { useTranslate } from 'react-polyglot' import { useLocation, useNavigate } from 'react-router-dom' @@ -54,6 +57,7 @@ import { useChainflipLtvThresholds } from '@/pages/ChainflipLending/hooks/useCha import { useChainflipSafeModeStatuses } from '@/pages/ChainflipLending/hooks/useChainflipSafeModeStatuses' import { useChainflipSupplyPositions } from '@/pages/ChainflipLending/hooks/useChainflipSupplyPositions' import { BorrowRateChart } from '@/pages/ChainflipLending/Pool/components/BorrowRateChart' +import { SupplyRateChart } from '@/pages/ChainflipLending/Pool/components/SupplyRateChart' import { LtvGauge } from '@/pages/ChainflipLending/Pool/components/Borrow/LtvGauge' import { selectAssetById } from '@/state/slices/assetsSlice/selectors' import { selectMarketDataByAssetIdUserCurrency } from '@/state/slices/marketDataSlice/selectors' @@ -154,6 +158,8 @@ export const Pool = () => { const { accountId, setAccountId } = useChainflipLendingAccount() const [actionTabIndex, setActionTabIndex] = useState(PoolTabIndex.Supply) const chainflipLendingModal = useModal('chainflipLending') + const supplyChart = useDisclosure({ defaultIsOpen: true }) + const borrowChart = useDisclosure({ defaultIsOpen: true }) const handleConnectWallet = useCallback( () => walletDispatch({ type: WalletActions.SET_WALLET_MODAL, payload: true }), @@ -465,6 +471,26 @@ export const Pool = () => { /> + {cfAsset && ( + <> + + + + {translate('chainflipLending.pool.supplyRateHistory')} + + } + size='sm' + variant={supplyChart.isOpen ? 'solid' : 'ghost'} + onClick={supplyChart.onToggle} + /> + + + + + + )} @@ -548,10 +574,28 @@ export const Pool = () => { + {cfAsset && ( + <> + + + + {translate('chainflipLending.pool.borrowRateHistory')} + + } + size='sm' + variant={borrowChart.isOpen ? 'solid' : 'ghost'} + onClick={borrowChart.onToggle} + /> + + + + + + )} - - {cfAsset && } = { + '1w': { sinceMs: 7 * 24 * 60 * 60 * 1000, chartInterval: 'day', bucketSecs: SECONDS_PER_DAY }, + '1m': { sinceMs: 30 * 24 * 60 * 60 * 1000, chartInterval: 'week', bucketSecs: SECONDS_PER_DAY }, + '6m': { + sinceMs: 180 * 24 * 60 * 60 * 1000, + chartInterval: 'month', + bucketSecs: SECONDS_PER_WEEK, + }, + ytd: { sinceMs: null, chartInterval: 'year', bucketSecs: SECONDS_PER_WEEK }, +} + +const getSinceIso = (windowKey: ChartWindowKey): string => { + const { sinceMs } = WINDOW_PARAMS_BY_KEY[windowKey] + if (sinceMs === null) { + const now = new Date() + return new Date(now.getFullYear(), 0, 1).toISOString() + } + return new Date(Date.now() - sinceMs).toISOString() +} + +// projectedApy is already a percent string (e.g. "2.817300..."), bucket by averaging +const bucketData = (nodes: LendingPoolStatPoint[], bucketSecs: number): HistogramData[] => { + const buckets = new Map() + for (const node of nodes) { + const t = Math.floor(new Date(node.timestamp).getTime() / 1000) + const bucketStart = Math.floor(t / bucketSecs) * bucketSecs + const values = buckets.get(bucketStart) ?? [] + values.push(parseFloat(node.projectedApy)) + buckets.set(bucketStart, values) + } + return Array.from(buckets.entries()) + .sort(([a], [b]) => a - b) + .map(([time, values]) => ({ + time: time as UTCTimestamp, + value: values.reduce((sum, v) => sum + v, 0) / values.length, + })) +} + +type SupplyRateChartProps = { + asset: ChainflipAssetSymbol +} + +export const SupplyRateChart = ({ asset }: SupplyRateChartProps) => { + const [selectedWindow, setSelectedWindow] = useState('1w') + + const sinceIso = useMemo(() => getSinceIso(selectedWindow), [selectedWindow]) + const { chartInterval, bucketSecs } = WINDOW_PARAMS_BY_KEY[selectedWindow] + + const { data: nodes, isLoading } = useQuery({ + ...reactQueries.chainflipLending.lendingPoolStats(asset, sinceIso), + select: (data: LendingPoolStatPoint[]) => bucketData(data, bucketSecs), + }) + + const chartBody = useMemo(() => { + if (isLoading) return + return + }, [nodes, isLoading, chartInterval]) + + return ( + + + + {(Object.keys(WINDOW_PARAMS_BY_KEY) as ChartWindowKey[]).map(key => ( + + ))} + + +
+ {chartBody} +
+
+ ) +}