diff --git a/web/src/components/LiquidityRangeVisualizer.tsx b/web/src/components/LiquidityRangeVisualizer.tsx new file mode 100644 index 00000000..ffd68058 --- /dev/null +++ b/web/src/components/LiquidityRangeVisualizer.tsx @@ -0,0 +1,311 @@ +import SelectedRange from "@/assets/icons/legend/selected-range.svg"; +import CurrentPrice from "@/assets/icons/legend/current-price.svg"; +import LiquidityDistribution from "@/assets/icons/legend/liquidity-distribution.svg"; +import ReactECharts from "echarts-for-react"; +import { useFeatureFlag } from "../hooks/useFeatureFlag"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import * as echarts from "echarts/core"; +import { padLiquidityPool } from "@/lib/padLiquidityPool"; +import { useStakeStore } from "@/stores/useStakeStore"; +import { fUSDC } from "@/config/tokens"; +import { StakeFormFragmentFragment } from "@/gql/graphql"; +const colorGradient = new echarts.graphic.LinearGradient( + 0, + 0, + 0, + 1, // Gradient direction from top(0,0) to bottom(0,1) + [ + { offset: 0, color: "rgba(243, 184, 216, 1)" }, + { offset: 0.25, color: "rgba(183, 147, 233,1)" }, + { offset: 0.5, color: "rgba(159, 212, 243, 1)" }, + { offset: 0.75, color: "rgba(255, 210, 196,1)" }, + { offset: 1, color: "rgba(251, 243, 243, 1)" }, + ], +); +export default function LiquidityRangeVisualizer({ + liquidityRangeType, + poolDataLiquidity, + currentPrice, + tokenDecimals, +}: { + liquidityRangeType: "full-range" | "auto" | "custom"; + poolDataLiquidity: StakeFormFragmentFragment["liquidity"]; + currentPrice: bigint; + tokenDecimals: number; +}) { + const showLiquidityVisualiser = useFeatureFlag( + "ui show liquidity visualiser", + ); + const chartRef = useRef(null); + const { token0, priceLower, priceUpper, setPriceLower, setPriceUpper } = + useStakeStore(); + + const paddedLiquidityPool = useMemo( + () => + padLiquidityPool({ + data: poolDataLiquidity, + currentPrice, + tokenDecimals, + }), + [poolDataLiquidity, currentPrice, tokenDecimals], + ); + const graphLPData = paddedLiquidityPool.paddedData; + const graphLPDataSerie = graphLPData?.map((item) => + parseFloat(item.liquidity), + ); + const graphLPDataXAxis = graphLPData?.map(({ tickLower, tickUpper }) => { + const scale = token0.decimals - fUSDC.decimals; + const priceLower = (1.0001 ** (tickLower ?? 0) * 10 ** scale).toFixed( + fUSDC.decimals, + ); + const priceHigher = (1.0001 ** (tickUpper ?? 0) * 10 ** scale).toFixed( + fUSDC.decimals, + ); + + return `${priceLower}-${priceHigher}`; + }); + + const chartStyles = { + color: { + "full-range": colorGradient, + auto: "transparent", + custom: "white", + }, + borderSet: { + "full-range": { + borderColor: colorGradient, + borderWidth: 1, + borderType: "solid", + }, + auto: { + borderColor: "#EBEBEB", + borderWidth: 1, + borderType: "dashed", + }, + custom: { + borderColor: "#EBEBEB", + borderWidth: 1, + borderType: "dashed", + }, + }, + }; + const chartOptions = useMemo( + () => ({ + grid: { + left: "0", // or a small value like '10px' + right: "0", // or a small value + top: "0", // or a small value + bottom: "0", // or a small value + }, + dataZoom: [ + { + type: "inside", + xAxisIndex: 0, + }, + ], + tooltip: { + trigger: "axis", + axisPointer: { + type: "cross", + }, + borderWidth: 0, + backgroundColor: "#EBEBEB", + textStyle: { + color: "#1E1E1E", + }, + formatter: + "
${c}
{b}
", + }, + toolbox: { + show: false, + }, + brush: { + show: liquidityRangeType === "custom", + xAxisIndex: "all", + brushLink: "all", + outOfBrush: { + color: "#1E1E1E", + }, + }, + xAxis: { + type: "category", + data: graphLPDataXAxis, + show: false, + axisPointer: { + label: { + show: false, + }, + }, + }, + yAxis: { + type: "value", + show: false, + axisPointer: { + label: { + show: false, + }, + }, + }, + series: [ + { + data: graphLPDataSerie, + type: "bar", + barWidth: "90%", + barGap: "5%", + silent: true, + itemStyle: { + color: chartStyles.color[liquidityRangeType], + borderRadius: [5, 5, 0, 0], + ...chartStyles.borderSet[liquidityRangeType], + }, + selectedMode: liquidityRangeType === "auto" ? "multiple" : false, + select: { + itemStyle: { + color: "#C0E9B6", + borderColor: "#C0E9B6", + borderWidth: 1, + }, + }, + emphasis: { + itemStyle: { + color: "white", + borderWidth: 0, + }, + }, + }, + ], + }), + [ + liquidityRangeType, + graphLPDataSerie, + graphLPDataXAxis, + chartStyles.borderSet, + chartStyles.color, + ], + ); + + const lowIndex = graphLPData?.findIndex( + (item) => + parseFloat(priceLower) <= 1.0001 ** item.tickUpper && + parseFloat(priceLower) >= 1.0001 ** item.tickLower, + ); + const highIndex = graphLPData?.findLastIndex( + (item) => + parseFloat(priceUpper) <= 1.0001 ** item.tickUpper && + parseFloat(priceUpper) >= 1.0001 ** item.tickLower, + ); + + const handleBrushEnd = useCallback( + function ({ areas }: any) { + if (!graphLPData) return; + + const lowerIndex = areas[0].coordRange[0]; + const upperIndex = areas[0].coordRange[1]; + + setPriceLower( + (1.0001 ** graphLPData[lowerIndex].tickLower).toFixed(fUSDC.decimals), + token0.decimals, + ); + setPriceUpper( + (1.0001 ** graphLPData[upperIndex].tickUpper).toFixed(fUSDC.decimals), + token0.decimals, + ); + }, + [setPriceLower, setPriceUpper, token0.decimals, graphLPData], + ); + + useEffect(() => { + const chart = chartRef.current?.getEchartsInstance(); + if (chart) { + chart.setOption(chartOptions); + chart.on("brushEnd", handleBrushEnd); + + // Clear the brush selection on every liquidityRangeType change + chart.dispatchAction({ + type: "brush", + command: "clear", + areas: [], + }); + chart.dispatchAction({ + type: "select", + seriesIndex: 0, + dataIndex: [paddedLiquidityPool.currentPriceIndex], + }); + if (liquidityRangeType === "auto") { + chart.dispatchAction({ + type: "dataZoom", + startValue: Math.min( + paddedLiquidityPool.originStartIndex, + paddedLiquidityPool.currentPriceIndex, + ), + endValue: Math.max( + paddedLiquidityPool.originEndIndex, + paddedLiquidityPool.currentPriceIndex, + ), + }); + } else { + // zoom out to full range + chart.dispatchAction({ + type: "dataZoom", + start: 0, + end: 100, + }); + + if (liquidityRangeType === "custom") { + chart.dispatchAction({ + type: "brush", + areas: [ + { + brushType: "lineX", + coordRange: [lowIndex, highIndex], + xAxisIndex: 0, + }, + ], + }); + } + } + + return () => { + chart.off("brushEnd", handleBrushEnd); + }; + } + }, [ + chartOptions, + lowIndex, + highIndex, + paddedLiquidityPool, + liquidityRangeType, + handleBrushEnd, + ]); + + return ( + showLiquidityVisualiser && ( +
+
Visualiser
+ + +
+
+ Selected Range +
+
+ Current Price +
+
+ Liquidity Distribution +
+
+
+ ) + ); +} diff --git a/web/src/components/StakeForm.tsx b/web/src/components/StakeForm.tsx index 09b658c2..8af64a58 100644 --- a/web/src/components/StakeForm.tsx +++ b/web/src/components/StakeForm.tsx @@ -6,11 +6,6 @@ import ArrowDown from "@/assets/icons/arrow-down-white.svg"; import Padlock from "@/assets/icons/padlock.svg"; import Token from "@/assets/icons/token.svg"; import { useEffect, useMemo, useRef, useState } from "react"; -import ReactECharts from "echarts-for-react"; -import * as echarts from "echarts/core"; -import SelectedRange from "@/assets/icons/legend/selected-range.svg"; -import CurrentPrice from "@/assets/icons/legend/current-price.svg"; -import LiquidityDistribution from "@/assets/icons/legend/liquidity-distribution.svg"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { @@ -61,20 +56,7 @@ import { } from "@/config/tokens"; import { getFormattedPriceFromAmount } from "@/lib/amounts"; import { TokenIcon } from "./TokenIcon"; - -const colorGradient = new echarts.graphic.LinearGradient( - 0, - 0, - 0, - 1, // Gradient direction from top(0,0) to bottom(0,1) - [ - { offset: 0, color: "rgba(243, 184, 216, 1)" }, - { offset: 0.25, color: "rgba(183, 147, 233,1)" }, - { offset: 0.5, color: "rgba(159, 212, 243, 1)" }, - { offset: 0.75, color: "rgba(255, 210, 196,1)" }, - { offset: 1, color: "rgba(251, 243, 243, 1)" }, - ], -); +import LiquidityRangeVisualizer from "./LiquidityRangeVisualizer"; type StakeFormProps = { poolId: string } & ( | { @@ -168,25 +150,12 @@ export const StakeForm = ({ mode, poolId, positionId }: StakeFormProps) => { const { data: userData } = useGraphqlUser(); const poolsData = useFragment(StakeFormFragment, data?.pools); - const poolData = poolsData?.find((pool) => pool.address === poolId); + const poolData = poolsData?.find( + (pool) => pool.address === poolId || pool.address === token0.address, + ); const dailyPrices = poolData?.priceOverTime.daily.map((price) => parseFloat(price), ); - const graphLPData = poolData?.liquidity; - const graphLPDataSerie = graphLPData?.map((item) => - parseFloat(item.liquidity), - ); - const graphLPDataXAxis = graphLPData?.map(({ tickLower, tickUpper }) => { - // const scale = token0.decimals - fUSDC.decimals; - const priceLower = (1.0001 ** (tickLower ?? 0)) - //* 10 ** scale - .toFixed(fUSDC.decimals); - const priceHigher = (1.0001 ** (tickUpper ?? 0)) - //* 10 ** scale - .toFixed(fUSDC.decimals); - - return `${priceLower}-${priceHigher}`; - }); const positionData_ = useFragment(PositionsFragment, userData?.getWallet); const positionData = positionData_?.positions.positions.find( (p) => p.positionId === positionId, @@ -214,9 +183,7 @@ export const StakeForm = ({ mode, poolId, positionId }: StakeFormProps) => { const showDynamicFeesPopup = useFeatureFlag("ui show optimising fee route"); const showSingleToken = useFeatureFlag("ui show single token stake"); const showCampaignBanner = useFeatureFlag("ui show campaign banner"); - const showLiquidityVisualiser = useFeatureFlag( - "ui show liquidity visualiser", - ); + const showBoostIncentives = useFeatureFlag("ui show boost incentives"); const onSubmit = () => { @@ -234,8 +201,6 @@ export const StakeForm = ({ mode, poolId, positionId }: StakeFormProps) => { [chainId, expectedChainId], ); - const chartRef = useRef(null); - // useSimulateContract throws if connector.account is not defined // so we must check if it exists or use a dummy address for sqrtPriceX96 const { data: connector } = useConnectorClient(); @@ -414,195 +379,9 @@ export const StakeForm = ({ mode, poolId, positionId }: StakeFormProps) => { const autoFeeTierRef = useRef(); const manualFeeTierRef = useRef(); - const chart = chartRef.current?.getEchartsInstance(); - const chartStyles = { - color: { - "full-range": colorGradient, - auto: "transparent", - custom: "white", - }, - borderSet: { - "full-range": { - borderColor: "#1E1E1E", - borderWidth: 1, - borderType: "solid", - }, - auto: { - borderColor: "#EBEBEB", - borderWidth: 1, - borderType: "dashed", - }, - custom: { - borderColor: "#EBEBEB", - borderWidth: 1, - borderType: "dashed", - }, - }, - select: { - color: { - "full-range": colorGradient, - auto: "white", - custom: "white", - }, - }, - }; - const chartOptions = { - grid: { - left: "0", // or a small value like '10px' - right: "0", // or a small value - top: "0", // or a small value - bottom: "0", // or a small value - }, - tooltip: { - trigger: "axis", // Trigger tooltip on axis movement - axisPointer: { - type: "cross", // Display crosshair style pointers - }, - borderWidth: 0, - backgroundColor: "#EBEBEB", - textStyle: { - color: "#1E1E1E", - }, - formatter: - "
${c}
{b}
", - }, - toolbox: { - show: false, - }, - brush: { - show: liquidityRangeType === "custom", - xAxisIndex: "all", - brushLink: "all", - outOfBrush: { - color: "#1E1E1E", - }, - }, - xAxis: { - type: "category", - data: graphLPDataXAxis, - show: false, - axisPointer: { - label: { - show: false, - }, - }, - }, - yAxis: { - type: "value", - show: false, - axisPointer: { - label: { - show: false, - }, - }, - }, - series: [ - { - data: graphLPDataSerie, - type: "bar", - barWidth: "90%", // Adjust bar width (can be in pixels e.g., '20px') - barGap: "5%", - silent: true, - itemStyle: { - color: chartStyles.color[liquidityRangeType], - borderRadius: [5, 5, 0, 0], - ...chartStyles.borderSet[liquidityRangeType], - }, - selectedMode: liquidityRangeType === "auto" ? "multiple" : false, - select: { - itemStyle: { - color: chartStyles.select.color[liquidityRangeType], - borderWidth: 0, - }, - }, - emphasis: { - itemStyle: { - color: "white", - borderWidth: 0, - }, - }, - }, - ], - }; - chart?.setOption(chartOptions); const { open } = useWeb3Modal(); - const lowIndex = graphLPData?.findIndex( - (item) => - parseFloat(priceLower) <= 1.0001 ** item.tickUpper && - parseFloat(priceLower) >= 1.0001 ** item.tickLower, - ); - const highIndex = graphLPData?.findLastIndex( - (item) => - parseFloat(priceUpper) <= 1.0001 ** item.tickUpper && - parseFloat(priceUpper) >= 1.0001 ** item.tickLower, - ); - - useEffect(() => { - if (chart) { - // Clear the brush selection on every liquidityRangeType change - chart.dispatchAction({ - type: "brush", - command: "clear", - areas: [], - }); - // Clear selection, again for auto range to display auto range - chart.dispatchAction({ - type: "unselect", - seriesIndex: 0, - dataIndex: Array.from( - { length: graphLPData?.length ?? 0 }, - (_, i) => i, - ), - }); - if (liquidityRangeType === "auto") { - chart.dispatchAction({ - type: "select", - seriesIndex: 0, - dataIndex: [lowIndex, highIndex], - }); - } else if (liquidityRangeType === "custom") { - chart.dispatchAction({ - type: "brush", - areas: [ - { - brushType: "lineX", - coordRange: [lowIndex, highIndex], - xAxisIndex: 0, - }, - ], - }); - } - } - }, [liquidityRangeType, chart, lowIndex, highIndex, graphLPData?.length]); - - useEffect(() => { - chart?.on("brushEnd", function ({ areas }: any) { - if (!graphLPData) return; - - const lowerIndex = areas[0].coordRange[0]; - const upperIndex = areas[0].coordRange[1]; - - setPriceLower( - (1.0001 ** graphLPData[lowerIndex].tickLower).toFixed(fUSDC.decimals), - token0.decimals, - ); - setPriceUpper( - (1.0001 ** graphLPData[upperIndex].tickUpper).toFixed(fUSDC.decimals), - token0.decimals, - ); - }); - }, [ - chart, - lowIndex, - highIndex, - graphLPData, - token0.decimals, - setPriceLower, - setPriceUpper, - ]); - return (
@@ -647,7 +426,7 @@ export const StakeForm = ({ mode, poolId, positionId }: StakeFormProps) => { selected={multiSingleToken === "multi"} onClick={() => setMultiSingleToken("multi")} > -
+
Multi-Token
@@ -657,7 +436,7 @@ export const StakeForm = ({ mode, poolId, positionId }: StakeFormProps) => { onClick={() => setMultiSingleToken("single")} variant={"iridescent"} > -
+
Single-Token
@@ -720,7 +499,7 @@ export const StakeForm = ({ mode, poolId, positionId }: StakeFormProps) => { )}
-
+
{token0Balance && ( <>
Balance: {token0Balance.formatted}
@@ -784,7 +563,7 @@ export const StakeForm = ({ mode, poolId, positionId }: StakeFormProps) => { fUSDC.decimals, )}
-
+
{token1Balance && ( <>
Balance: {token1Balance.formatted}
@@ -862,13 +641,13 @@ export const StakeForm = ({ mode, poolId, positionId }: StakeFormProps) => {
Fee Percentage
-
+
The protocol automatically adjust your fees in order to maximise rewards and reduce impermanent loss
@@ -887,52 +666,52 @@ export const StakeForm = ({ mode, poolId, positionId }: StakeFormProps) => { >
0.01%
-
+
Best for Very
Stable Pairs
-
+
(0% popularity)
0.05%
-
+
Best for
Stable Pairs
-
+
(99% popularity)
0.10%
-
+
Best for
Stable Pairs
-
+
(0% popularity)
0.15%
-
+
Best for
Stable Pairs
-
+
(0% popularity)
@@ -992,7 +771,7 @@ export const StakeForm = ({ mode, poolId, positionId }: StakeFormProps) => { value={liquidityRangeType === "full-range" ? "-∞" : priceLower} onChange={(e) => setPriceLower(e.target.value, token0.decimals)} /> -
+
fUSDC per{" "} {token0.name}
@@ -1016,43 +795,22 @@ export const StakeForm = ({ mode, poolId, positionId }: StakeFormProps) => { onChange={(e) => setPriceUpper(e.target.value, token0.decimals)} /> -
+
fUSDC per{" "} {token0.name}
- {showLiquidityVisualiser && ( -
-
Visualiser
- - -
-
- Selected Range -
-
- Current Price -
-
- Liquidity Distribution -
-
-
- )} + {poolData?.liquidity ? ( + + ) : null}
-
setBreakdownHidden((v) => !v)} @@ -1061,12 +819,12 @@ export const StakeForm = ({ mode, poolId, positionId }: StakeFormProps) => { {breakdownHidden ? ( <>
Show Breakdown
-
{"<-"}
+
{"<-"}
) : ( <>
Hide breakdown
-
{"->"}
+
{"->"}
)}
@@ -1135,7 +893,7 @@ export const StakeForm = ({ mode, poolId, positionId }: StakeFormProps) => { - + @@ -1205,7 +963,7 @@ export const StakeForm = ({ mode, poolId, positionId }: StakeFormProps) => { >
Yield Breakdown
-
+
Pool Fees
@@ -1246,7 +1004,7 @@ export const StakeForm = ({ mode, poolId, positionId }: StakeFormProps) => { @@ -1257,7 +1015,7 @@ export const StakeForm = ({ mode, poolId, positionId }: StakeFormProps) => { {showBoostIncentives && ( <> -
+
3%
diff --git a/web/src/lib/padLiquidityPool.ts b/web/src/lib/padLiquidityPool.ts new file mode 100644 index 00000000..7f02c3b5 --- /dev/null +++ b/web/src/lib/padLiquidityPool.ts @@ -0,0 +1,59 @@ +import { formatUnits } from "viem"; +import { MAX_TICK, MIN_TICK } from "./math"; +import { StakeFormFragmentFragment } from "@/gql/graphql"; + +const tickMargin = 10000; +export function padLiquidityPool({ + data, + currentPrice, + tokenDecimals, +}: { + data: StakeFormFragmentFragment["liquidity"]; + currentPrice: bigint; + tokenDecimals: number; +}) { + let originStartIndex: number = 0; + let originEndIndex: number = 0; + let currentPriceIndex: number = 0; + const paddedData = Array.from( + { length: Math.ceil((MAX_TICK - MIN_TICK) / tickMargin) }, + (_, i) => { + const _tickLower = MIN_TICK + i * tickMargin; + const _tickUpper = MIN_TICK + i * tickMargin + tickMargin; + const _lowerPrice = 1.0001 ** _tickLower; + const _upperPrice = 1.0001 ** _tickUpper; + const _currentPrice = parseFloat( + formatUnits(currentPrice ?? 0n, tokenDecimals), + ); + const originDataItem = data.find( + (e) => e.tickLower === MIN_TICK + i * tickMargin, + ); + if (!originStartIndex && originDataItem) { + originStartIndex = i; + originEndIndex = i + data.length - 1; + } + if ( + !currentPriceIndex && + _lowerPrice >= _currentPrice && + _upperPrice > _currentPrice + ) { + currentPriceIndex = i - 1; + } + return ( + originDataItem ?? { + tickLower: _tickLower, + tickUpper: _tickUpper > MAX_TICK ? MAX_TICK : _tickUpper, + liquidity: "0", + price: "0", + } + ); + }, + ); + + return { + paddedData, + originStartIndex, + originEndIndex, + currentPriceIndex, + }; +}