+
+ {formatted} {sym}
+
+ {showSubUsd && (
+
+ ≈ $
+ {vUsd!.toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })}
+
+ )}
+
+ );
+ };
+
return (
|
@@ -1120,8 +1204,18 @@ function LpsTab({ poolId, pool }: { poolId: string; pool: Pool | null }) {
|
- {formatWei(position.netLiquidity.toString())}
+ {fmtTok(tok0, sym0, tok0Usd)}
+ |
+
+ {fmtTok(tok1, sym1, tok1Usd)}
|
+ {showUsd && (
+
+ {totalUsd !== null
+ ? `$${totalUsd.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
+ : "—"}
+ |
+ )}
{sharePct}%
|
diff --git a/ui-dashboard/src/components/__tests__/lp-concentration-chart.test.ts b/ui-dashboard/src/components/__tests__/lp-concentration-chart.test.ts
index 3096fce..22cac32 100644
--- a/ui-dashboard/src/components/__tests__/lp-concentration-chart.test.ts
+++ b/ui-dashboard/src/components/__tests__/lp-concentration-chart.test.ts
@@ -1,41 +1,152 @@
-import { describe, it, expect } from "vitest";
-import { resolvePieLabel } from "@/components/lp-concentration-chart";
+import React from "react";
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+vi.mock("next/dynamic", () => ({
+ default: () =>
+ function MockPlot(props: {
+ data?: Array<{ labels?: string[]; values?: number[] }>;
+ }) {
+ return React.createElement(
+ "div",
+ null,
+ "plot",
+ ...(props.data?.[0]?.labels?.map((label) =>
+ React.createElement("span", { key: label }, label),
+ ) ?? []),
+ ...(props.data?.[0]?.values?.map((value) =>
+ React.createElement(
+ "span",
+ { key: `value-${String(value)}` },
+ String(value),
+ ),
+ ) ?? []),
+ );
+ },
+}));
+
+import {
+ LpConcentrationChart,
+ resolvePieLabel,
+} from "@/components/lp-concentration-chart";
import { truncateAddress } from "@/lib/format";
+import type { Pool } from "@/lib/types";
const ADDR = "0x10158838fa2ded977b8bf175ea69d17a715371c0";
const ADDR2 = "0xd363dab93e4ded977b8bf175ea69d17a715371c1";
+const BASE_POOL: Pool = {
+ id: "0xpool",
+ token0: "0xgbp",
+ token1: "0xusd",
+ source: "fpmm_factory",
+ createdAtBlock: "1",
+ createdAtTimestamp: "1700000000",
+ updatedAtBlock: "1",
+ updatedAtTimestamp: "1700000000",
+ token0Decimals: 18,
+ token1Decimals: 18,
+ oraclePrice: "1190000000000000000000000",
+ reserves0: "100000000000000000000",
+ reserves1: "119000000000000000000",
+};
+
describe("resolvePieLabel", () => {
it("returns truncated address when no getLabel provided", () => {
expect(resolvePieLabel(ADDR)).toBe(truncateAddress(ADDR));
});
it("returns named label when getLabel resolves a real name", () => {
- expect(resolvePieLabel(ADDR, () => "Team Wallet")).toBe("Team Wallet");
+ const getLabel = () => "Team Wallet";
+ expect(resolvePieLabel(ADDR, getLabel)).toBe("Team Wallet");
});
- it("returns truncated address when getLabel returns the truncated form (no label set)", () => {
- const getLabel = (a: string) => truncateAddress(a) ?? a;
+ it("returns truncated address when getLabel returns the truncated form", () => {
+ const getLabel = (address: string) => truncateAddress(address) ?? address;
expect(resolvePieLabel(ADDR, getLabel)).toBe(truncateAddress(ADDR));
});
it("does not include raw address when a named label exists", () => {
- const result = resolvePieLabel(ADDR, () => "Team Wallet");
+ const getLabel = () => "Team Wallet";
+ const result = resolvePieLabel(ADDR, getLabel);
expect(result).not.toContain("0x1015");
expect(result).toBe("Team Wallet");
});
- it("does not duplicate — unlabelled address returns truncated form only", () => {
- const getLabel = (a: string) => truncateAddress(a) ?? a;
+ it("does not duplicate unlabelled addresses", () => {
+ const getLabel = (address: string) => truncateAddress(address) ?? address;
const result = resolvePieLabel(ADDR, getLabel);
- expect(result).not.toBe(ADDR); // not the full raw address
+ expect(result).not.toBe(ADDR);
expect(result).toBe(truncateAddress(ADDR));
});
- it("two addresses with the same label both resolve to that label", () => {
- // Both get the same display name — Plotly will merge them into one slice.
- // This is an accepted trade-off for human readability.
- expect(resolvePieLabel(ADDR, () => "Shared Label")).toBe("Shared Label");
- expect(resolvePieLabel(ADDR2, () => "Shared Label")).toBe("Shared Label");
+ it("allows multiple addresses to resolve to the same human label", () => {
+ const getLabel = () => "Shared Label";
+ expect(resolvePieLabel(ADDR, getLabel)).toBe("Shared Label");
+ expect(resolvePieLabel(ADDR2, getLabel)).toBe("Shared Label");
+ });
+});
+
+describe("LpConcentrationChart", () => {
+ const positions = [
+ { address: ADDR, netLiquidity: BigInt(70) },
+ { address: ADDR2, netLiquidity: BigInt(30) },
+ ];
+
+ it("renders human labels in the visible legend when available", () => {
+ const html = renderToStaticMarkup(
+ React.createElement(LpConcentrationChart, {
+ positions,
+ totalLiquidity: BigInt(100),
+ getLabel: (address: string | null) =>
+ address === ADDR
+ ? "Treasury"
+ : (truncateAddress(address ?? "") ?? ""),
+ }),
+ );
+
+ expect(html).toContain("Legend");
+ expect(html).toContain("Treasury");
+ const legendHtml = html.split("Legend")[1] ?? "";
+ expect(legendHtml).not.toContain(ADDR);
+ });
+
+ it("renders sidebar stats and estimated TVL for USDm pairs", () => {
+ const html = renderToStaticMarkup(
+ React.createElement(LpConcentrationChart, {
+ positions,
+ totalLiquidity: BigInt(100),
+ pool: BASE_POOL,
+ sym0: "GBPm",
+ sym1: "USDm",
+ reserves0Raw: 100,
+ reserves1Raw: 119,
+ feedVal: 1.19,
+ usdmIsToken0: false,
+ }),
+ );
+
+ expect(html).toContain("Pool at a glance");
+ expect(html).toContain("Top holder");
+ expect(html).toContain("Top 3 share");
+ expect(html).toContain("Estimated TVL");
+ });
+
+ it("hides estimated TVL when no valid USDm side exists", () => {
+ const html = renderToStaticMarkup(
+ React.createElement(LpConcentrationChart, {
+ positions,
+ totalLiquidity: BigInt(100),
+ pool: { ...BASE_POOL, token1: "0xeur" },
+ sym0: "GBPm",
+ sym1: "EURm",
+ reserves0Raw: 100,
+ reserves1Raw: 90,
+ feedVal: 1.19,
+ usdmIsToken0: false,
+ }),
+ );
+
+ expect(html).not.toContain("Estimated TVL");
});
});
diff --git a/ui-dashboard/src/components/lp-concentration-chart.tsx b/ui-dashboard/src/components/lp-concentration-chart.tsx
index e1c94fa..9e70167 100644
--- a/ui-dashboard/src/components/lp-concentration-chart.tsx
+++ b/ui-dashboard/src/components/lp-concentration-chart.tsx
@@ -1,47 +1,68 @@
"use client";
import dynamic from "next/dynamic";
-import { PLOTLY_BASE_LAYOUT, PLOTLY_CONFIG } from "@/lib/plot";
import { truncateAddress } from "@/lib/format";
+import { PLOTLY_BASE_LAYOUT, PLOTLY_CONFIG } from "@/lib/plot";
+import { USDM_SYMBOLS } from "@/lib/tokens";
+import type { Pool } from "@/lib/types";
const Plot = dynamic(() => import("react-plotly.js"), { ssr: false });
-// ---------------------------------------------------------------------------
-// Pure helpers (exported for testing)
-// ---------------------------------------------------------------------------
-
-/**
- * Returns the human-readable display label for an LP pie chart entry.
- * - If getLabel resolves a named label (different from the truncated address),
- * that name is returned.
- * - Otherwise the truncated address is returned.
- */
-export function resolvePieLabel(
- addr: string,
- getLabel?: (address: string) => string,
-): string {
- const truncated = truncateAddress(addr) ?? addr;
- if (!getLabel) return truncated;
- const resolved = getLabel(addr);
- return resolved !== truncated ? resolved : truncated;
-}
-
interface LpPosition {
address: string;
netLiquidity: bigint;
}
interface LpConcentrationChartProps {
- positions: LpPosition[]; // pre-sorted descending by netLiquidity
+ positions: LpPosition[];
totalLiquidity: bigint;
- /** Optional resolver: returns a human-readable label for an address */
- getLabel?: (address: string) => string;
+ getLabel?: (address: string | null) => string;
+ pool?: Pool | null;
+ sym0?: string;
+ sym1?: string;
+ reserves0Raw?: number;
+ reserves1Raw?: number;
+ feedVal?: number | null;
+ usdmIsToken0?: boolean;
+}
+
+const PIE_COLORS = [
+ "#6366f1",
+ "#a78bfa",
+ "#34d399",
+ "#fbbf24",
+ "#f87171",
+ "#38bdf8",
+ "#fb923c",
+ "#e879f9",
+ "#4ade80",
+ "#f472b6",
+ "#64748b",
+];
+
+export function resolvePieLabel(
+ addr: string,
+ getLabel?:
+ | ((address: string | null) => string)
+ | ((address: string) => string),
+): string {
+ const truncated = truncateAddress(addr) ?? addr;
+ if (!getLabel) return truncated;
+ const resolved = (getLabel as (address: string) => string)(addr);
+ return resolved === truncated ? truncated : resolved;
}
export function LpConcentrationChart({
positions,
totalLiquidity,
getLabel,
+ pool,
+ sym0,
+ sym1,
+ reserves0Raw = 0,
+ reserves1Raw = 0,
+ feedVal = null,
+ usdmIsToken0 = false,
}: LpConcentrationChartProps) {
if (positions.length === 0 || totalLiquidity === BigInt(0)) return null;
@@ -50,16 +71,19 @@ export function LpConcentrationChart({
const rest = positions.slice(TOP_N);
const otherTotal = rest.reduce((acc, p) => acc + p.netLiquidity, BigInt(0));
- // Human-readable labels for both legend and hover. If two addresses share the
- // same label they collapse into one slice — accepted trade-off for readability.
+ const resolveLabel = (addr: string) => resolvePieLabel(addr, getLabel);
+
+ // Keep raw addresses as the slice key so Plotly never merges distinct LPs
+ // that happen to share the same human-readable label.
const labels = [
- ...top.map((p) => resolvePieLabel(p.address, getLabel)),
+ ...top.map((p) => p.address),
+ ...(otherTotal > BigInt(0) ? ["other"] : []),
+ ];
+ const displayLabels = [
+ ...top.map((p) => resolveLabel(p.address)),
...(otherTotal > BigInt(0) ? ["Other"] : []),
];
- // Scale to basis points (×10000) before converting to Number so that large
- // bigint values (which can exceed JS safe integer range) don't lose precision
- // in the relative proportions used for pie slice sizes.
const toRelative = (v: bigint) =>
Number((v * BigInt(10_000)) / totalLiquidity) / 10000;
const values = [
@@ -67,60 +91,192 @@ export function LpConcentrationChart({
...(otherTotal > BigInt(0) ? [toRelative(otherTotal)] : []),
];
- const hovertemplate = "%{label}
%{percent} of pool
";
+ const customdata = [
+ ...top.map((p) => resolveLabel(p.address)),
+ ...(otherTotal > BigInt(0) ? ["(multiple)"] : []),
+ ];
+
+ const hovertemplate =
+ "%{customdata}
%{percent} of pool
";
const trace = {
type: "pie" as const,
hole: 0.4,
labels,
values,
+ customdata,
hovertemplate,
textinfo: "percent" as const,
+ sort: false,
+ direction: "clockwise" as const,
marker: {
- colors: [
- "#6366f1",
- "#a78bfa",
- "#34d399",
- "#fbbf24",
- "#f87171",
- "#38bdf8",
- "#fb923c",
- "#e879f9",
- "#4ade80",
- "#f472b6",
- "#64748b",
- ],
+ colors: PIE_COLORS,
line: { color: "#1e293b", width: 2 },
},
};
const layout = {
...PLOTLY_BASE_LAYOUT,
- margin: { t: 8, r: 16, b: 8, l: 16 },
- showlegend: true,
- legend: {
- font: { color: "#94a3b8", size: 11 },
- bgcolor: "transparent",
- orientation: "v" as const,
- x: 1,
- y: 0.5,
- },
- height: 300,
+ margin: { t: 8, r: 8, b: 8, l: 8 },
+ showlegend: false,
+ height: 280,
autosize: true,
};
+ const totalPositions = positions.length;
+ const topShare =
+ positions.length > 0
+ ? (
+ Number(
+ (positions[0].netLiquidity * BigInt(10_000)) / totalLiquidity,
+ ) / 100
+ ).toFixed(1)
+ : "0";
+ const top3Share =
+ positions.length > 0
+ ? (
+ Number(
+ (positions
+ .slice(0, 3)
+ .reduce((acc, p) => acc + p.netLiquidity, BigInt(0)) *
+ BigInt(10_000)) /
+ totalLiquidity,
+ ) / 100
+ ).toFixed(1)
+ : "0";
+
+ const hasPoolData = Boolean(pool) && (reserves0Raw > 0 || reserves1Raw > 0);
+ const usdmIsToken1 = USDM_SYMBOLS.has(sym1 ?? "");
+ const hasUsdmSide = usdmIsToken0 !== usdmIsToken1;
+ const totalTvl: number | null =
+ hasPoolData && feedVal !== null && hasUsdmSide
+ ? usdmIsToken0
+ ? reserves0Raw + reserves1Raw * feedVal
+ : reserves0Raw * feedVal + reserves1Raw
+ : null;
+
+ const fmtUsd = (v: number) =>
+ v >= 1_000_000
+ ? `$${(v / 1_000_000).toFixed(2)}M`
+ : v >= 1_000
+ ? `$${(v / 1_000).toFixed(1)}K`
+ : `$${v.toFixed(2)}`;
+
+ const fmtReserve = (v: number, sym: string) =>
+ v >= 1_000_000
+ ? `${(v / 1_000_000).toFixed(2)}M ${sym}`
+ : v >= 1_000
+ ? `${(v / 1_000).toFixed(1)}K ${sym}`
+ : `${v.toFixed(2)} ${sym}`;
+
return (
-
-
+
+
LP Concentration
-
+
+
+
+
+
+
+
+
+ Legend
+
+
+ {displayLabels.map((label, i) => {
+ const sliceKey = labels[i] ?? `slice-${label}`;
+ return (
+ -
+
+ {label}
+
+ {(values[i] * 100).toFixed(1)}%
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+ Pool at a glance
+
+
+
+
+
+
+
+
+ {hasPoolData && (
+
+ {sym0 && (
+
+ )}
+ {sym1 && (
+
+ )}
+ {totalTvl !== null && (
+
+ )}
+
+ )}
+
+
+
+
+ );
+}
+
+function StatRow({
+ label,
+ value,
+ highlight = false,
+}: {
+ label: string;
+ value: string;
+ highlight?: boolean;
+}) {
+ return (
+
+ {label}
+
+ {value}
+
);
}