From 5e2a1fe2767d5c441183a8e60403d62b54c25527 Mon Sep 17 00:00:00 2001 From: Laurent Grawet Date: Fri, 2 Oct 2026 12:33:50 +0200 Subject: [PATCH 1/3] fix(web): read an LP position's money in USD, not quote units labelled as dollars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /dex position strip and the portfolio's liquidity rows prefixed every LP money figure with "$", but total_value_quote, fees_earned_quote and net_pnl_quote are denominated in the pair's *quote* — SOL on an X-SOL range. That mislabelled rather than merely rounded the figure: a 0.163 SOL range worth about $20 read as "$0.16". Both /dex surfaces already fetch the pool row, so they hold the quote token's USD price (quote_token_price_usd). When it is known the figure is converted and shown in USD; when it is not, the number is left in quote units under the quote's own symbol rather than a dollar it is not. The portfolio table goes through the page's existing display-currency seam (formatValue / formatPnlValue) so it follows the same rule as every other money cell there. --- frontend/src/components/dex/LpPositionBar.tsx | 18 ++-- frontend/src/components/dex/LpPositions.tsx | 13 +-- .../src/components/dex/lp-position.test.ts | 83 +++++++++++++++++++ frontend/src/components/dex/lp-position.ts | 54 +++++++++++- .../portfolio/PositionsTab.test.tsx | 11 +++ .../src/components/portfolio/PositionsTab.tsx | 14 ++-- frontend/src/hooks/useLpPositions.ts | 16 +++- frontend/src/pages/DexPool.tsx | 1 + frontend/src/pages/Portfolio.tsx | 7 +- 9 files changed, 198 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/dex/lp-position.test.ts diff --git a/frontend/src/components/dex/LpPositionBar.tsx b/frontend/src/components/dex/LpPositionBar.tsx index 1c184bc5f..48f3090be 100644 --- a/frontend/src/components/dex/LpPositionBar.tsx +++ b/frontend/src/components/dex/LpPositionBar.tsx @@ -1,20 +1,27 @@ import { useMemo } from "react"; import { - feeAmount, + lpFees, + lpPnl, lpStateStyle, + lpValue, rangeFraction, readLpPosition, type LpPosition, } from "./lp-position"; import { type ExecutorInfo } from "@/lib/api"; -import { formatPnl, formatPriceSig, isExecutorActive, pnlColor } from "@/lib/formatters"; +import { formatPriceSig, isExecutorActive, pnlColor } from "@/lib/formatters"; interface Props { /** The pool's executors, already scoped to this pool by the page. */ executors: ExecutorInfo[]; /** Live pool price, when the chart's own mark is fresher than the executor's. */ currentPrice: number | null; + /** + * The pool's quote-token USD price, so the range's quote-denominated money + * reads in dollars. `null` (an unresolved pool) leaves it in quote units. + */ + quoteUsd?: number | null; selectedExecutorId?: string | null; onSelect?: (executorId: string) => void; } @@ -33,6 +40,7 @@ interface Props { export function LpPositionBar({ executors, currentPrice, + quoteUsd = null, selectedExecutorId, onSelect, }: Props) { @@ -95,16 +103,16 @@ export function LpPositionBar({ {pos.valueQuote !== null && ( value - {feeAmount(pos.valueQuote)} + {lpValue(pos.valueQuote, quoteUsd, pos.quote)} )} {pos.feesQuote !== null && ( fees - {feeAmount(pos.feesQuote)} + {lpFees(pos.feesQuote, quoteUsd, pos.quote)} )} - {formatPnl(pos.pnl)} + {lpPnl(pos.pnl, quoteUsd, pos.quote)} ); diff --git a/frontend/src/components/dex/LpPositions.tsx b/frontend/src/components/dex/LpPositions.tsx index 3a6e4e69e..9ddaf09cc 100644 --- a/frontend/src/components/dex/LpPositions.tsx +++ b/frontend/src/components/dex/LpPositions.tsx @@ -1,9 +1,9 @@ import { ArrowRight } from "lucide-react"; import { useNavigate } from "react-router-dom"; -import { feeAmount, lpStateStyle } from "./lp-position"; +import { lpFees, lpPnl, lpStateStyle, lpValue } from "./lp-position"; import { useLpPositions } from "@/hooks/useLpPositions"; -import { formatPnl, formatUsd, pnlColor } from "@/lib/formatters"; +import { pnlColor } from "@/lib/formatters"; /** * The LP positions you already hold, above the pools you might enter. @@ -21,7 +21,7 @@ import { formatPnl, formatUsd, pnlColor } from "@/lib/formatters"; */ export function LpPositions({ server }: { server: string }) { const navigate = useNavigate(); - const { positions, label, dexId } = useLpPositions(server); + const { positions, label, dexId, quoteUsd } = useLpPositions(server); if (!positions.length) return null; @@ -39,6 +39,7 @@ export function LpPositions({ server }: { server: string }) {
{positions.map((pos) => { const state = lpStateStyle(pos.state); + const rate = quoteUsd(pos); return (
{pos.feesQuote !== null && (
- {feeAmount(pos.feesQuote)} fees + {lpFees(pos.feesQuote, rate, pos.quote)} fees
)} diff --git a/frontend/src/components/dex/lp-position.test.ts b/frontend/src/components/dex/lp-position.test.ts new file mode 100644 index 000000000..346ee9858 --- /dev/null +++ b/frontend/src/components/dex/lp-position.test.ts @@ -0,0 +1,83 @@ +/** + * What an LP position's money figures are *denominated in*, and how they render. + * + * The regression this pins: `total_value_quote` / `fees_earned_quote` / + * `net_pnl_quote` are all measured in the pair's quote — SOL on an `X-SOL` range — + * and the cards used to prefix every one of them with a `$`. A 0.163 SOL range + * worth about $20 read as "$0.16": not a rounding error but a wrong number, off + * by the whole SOL/USD rate. The fix prices the figure off the pool's own quote + * token, and when that price is unknown leaves it in SOL under the SOL label. + */ + +import { describe, expect, it } from "vitest"; + +import type { ExecutorInfo } from "@/lib/api"; + +import { lpFees, lpPnl, lpValue, positionQuote, readLpPosition } from "./lp-position"; + +/** The SOL price used across these cases, so the dollars are checkable by hand. */ +const SOL = 121.69; + +function lpExecutor(over: Partial = {}): ExecutorInfo { + return { + id: "e1", + type: "lp_executor", + connector: "solana-mainnet-beta", + trading_pair: "MINT-SOL", + side: "", + status: "running", + close_type: "", + pnl: 0, + volume: 0, + timestamp: 0, + controller_id: "", + cum_fees_quote: 0, + net_pnl_pct: 0, + entry_price: 0, + current_price: 0, + close_timestamp: 0, + custom_info: {}, + config: { pool_address: "POOL", connector_name: "solana-mainnet-beta" }, + ...over, + } as unknown as ExecutorInfo; +} + +describe("positionQuote", () => { + it("reads the quote off the pair and uppercases it", () => { + expect(positionQuote("oreoU2P8bN6jkk3jbaiVxYnG1dCXcYxwhwyK9jSybcp-SOL")).toBe("SOL"); + expect(positionQuote("So11111111111111111111111111111111111111112-USDC")).toBe("USDC"); + }); + + it("falls back to USDT for a pair with no quote segment", () => { + expect(positionQuote("")).toBe("USDT"); + expect(positionQuote("SOLONLY")).toBe("USDT"); + }); +}); + +describe("readLpPosition", () => { + it("carries the quote the money figures are denominated in", () => { + const pos = readLpPosition(lpExecutor({ trading_pair: "MINT-SOL" })); + expect(pos?.quote).toBe("SOL"); + }); +}); + +describe("LP money in USD when the quote's price is known", () => { + it("prices the position value, PnL and fees in dollars", () => { + expect(lpValue(0.163339, SOL, "SOL")).toBe("$19.88"); + expect(lpPnl(0.003097, SOL, "SOL")).toBe("+$0.38"); + expect(lpFees(0.00136, SOL, "SOL")).toBe("$0.17"); + }); +}); + +describe("LP money with no rate: quote units, not dollars", () => { + it("keeps the figure in SOL and labels it SOL", () => { + expect(lpValue(0.163339, null, "SOL")).toBe("0.1633 SOL"); + expect(lpPnl(-0.00025288, null, "SOL")).toBe("-0.0002529 SOL"); + }); + + it("never stamps a dollar on an unconverted figure", () => { + expect(lpValue(0.163339, null, "SOL")).not.toContain("$"); + expect(lpPnl(0.003, null, "SOL")).not.toContain("$"); + expect(lpFees(2.4e-5, null, "SOL")).not.toContain("$"); + }); +}); diff --git a/frontend/src/components/dex/lp-position.ts b/frontend/src/components/dex/lp-position.ts index acb84b7dc..558fa49ec 100644 --- a/frontend/src/components/dex/lp-position.ts +++ b/frontend/src/components/dex/lp-position.ts @@ -1,5 +1,5 @@ import { type ExecutorInfo } from "@/lib/api"; -import { formatUsd } from "@/lib/formatters"; +import { formatCurrencyPnl, formatUsd } from "@/lib/formatters"; import { num } from "./format"; @@ -28,6 +28,11 @@ function str(v: unknown): string { return typeof v === "string" ? v : ""; } +/** The quote a position's money figures are denominated in — its pair's. */ +export function positionQuote(pair: string): string { + return (pair.split("-")[1] || "USDT").trim().toUpperCase(); +} + /** * One open LP position, flattened out of the executor that holds it. * @@ -43,6 +48,11 @@ export interface LpPosition { poolAddress: string; provider: string; pair: string; + /** + * The quote asset the money figures below are denominated in — the pair's + * quote, which is also what the executor's `*_quote` fields are measured in. + */ + quote: string; /** `IN_RANGE`, `OUT_OF_RANGE`, … as the connector reports it. */ state: string; /** On-chain bounds when the venue reports them, else the requested ones. */ @@ -70,6 +80,7 @@ export function readLpPosition(ex: ExecutorInfo): LpPosition | null { poolAddress, provider: str(config.lp_provider), pair: ex.trading_pair, + quote: positionQuote(ex.trading_pair), state: str(custom.state), lowerPrice: num(custom.lower_price) ?? num(config.lower_price), upperPrice: num(custom.upper_price) ?? num(config.upper_price), @@ -86,6 +97,47 @@ export function feeAmount(val: number): string { return formatUsd(val); } +/** + * The USD price of one unit of a position's quote token, or `null` when it is + * unknown. The /dex surfaces read it off the pool row they already fetch + * (`quote_token_price_usd`), so pricing a range costs no rate lookup of its own. + */ +export type QuoteUsd = number | null; + +/** A quote-denominated amount under the quote's own symbol, rate unknown. */ +function inQuote(amount: number, quote: string): string { + const s = + amount === 0 ? "0" : amount.toLocaleString("en-US", { maximumSignificantDigits: 4 }); + return `${s} ${quote}`; +} + +/** + * An LP money figure in USD when the quote's dollar price is known, else left in + * quote units under the quote's own symbol. + * + * `total_value_quote`, `fees_earned_quote` and `net_pnl_quote` are all + * denominated in the pair's *quote* — SOL on an `X-SOL` range — but the cards + * used to prefix every one of them with a `$`. That mislabelled, rather than + * merely rounded, the whole figure: a 0.163 SOL range worth about $20 read as + * "$0.16". Converting needs the pool's own USD price, which both /dex callers + * already hold; when it is missing the number is left in SOL and *labelled* + * SOL rather than a dollar it is not. + */ +export function lpValue(amount: number, quoteUsd: QuoteUsd, quote: string): string { + return quoteUsd != null && quoteUsd > 0 ? formatUsd(amount * quoteUsd) : inQuote(amount, quote); +} + +export function lpPnl(amount: number, quoteUsd: QuoteUsd, quote: string): string { + if (quoteUsd != null && quoteUsd > 0) return formatCurrencyPnl(amount * quoteUsd); + return (amount >= 0 ? "+" : "") + inQuote(amount, quote); +} + +export function lpFees(amount: number, quoteUsd: QuoteUsd, quote: string): string { + return quoteUsd != null && quoteUsd > 0 + ? feeAmount(amount * quoteUsd) + : inQuote(amount, quote); +} + /** In range is earning; out of range is not, and is the thing worth spotting. */ export function lpStateStyle(state: string): { label: string; diff --git a/frontend/src/components/portfolio/PositionsTab.test.tsx b/frontend/src/components/portfolio/PositionsTab.test.tsx index c19622b97..2fb0a6d6d 100644 --- a/frontend/src/components/portfolio/PositionsTab.test.tsx +++ b/frontend/src/components/portfolio/PositionsTab.test.tsx @@ -97,6 +97,7 @@ function lpPosition(over: Partial = {}): LpPosition { poolAddress: "Pool123456", provider: "meteora", pair: "SOL-USDC", + quote: "USDC", state: "IN_RANGE", lowerPrice: 132.1, upperPrice: 147.9, @@ -268,6 +269,16 @@ describe("liquidity", () => { await render({ lpPositions: [lpPosition({ state: "OUT_OF_RANGE" })] }); expect(document.querySelector("[data-lp-row]")!.textContent).toContain("Out of range"); }); + + it("renders the range's money in the display currency, not a bare dollar", async () => { + // valueQuote/feesQuote/pnl are quote-denominated (SOL); the row must run + // through the page's rate seam rather than stamp them with a `$`. + await render({ lpPositions: [lpPosition()] }); + + const row = document.querySelector("[data-lp-row]")!.textContent!; + expect(row).toContain("€"); + expect(row).not.toContain("$"); + }); }); describe("the tab never mutates anything", () => { diff --git a/frontend/src/components/portfolio/PositionsTab.tsx b/frontend/src/components/portfolio/PositionsTab.tsx index f71c29be7..6fc1fa658 100644 --- a/frontend/src/components/portfolio/PositionsTab.tsx +++ b/frontend/src/components/portfolio/PositionsTab.tsx @@ -2,9 +2,9 @@ import { ArrowRight, Layers } from "lucide-react"; import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; -import { feeAmount, lpStateStyle, rangeFraction, type LpPosition } from "@/components/dex/lp-position"; +import { lpStateStyle, rangeFraction, type LpPosition } from "@/components/dex/lp-position"; import { type ConsolidatedPosition } from "@/lib/api"; -import { formatPnl, formatPriceSig, formatUsd, pnlColor } from "@/lib/formatters"; +import { formatPriceSig, pnlColor } from "@/lib/formatters"; /** The quote a hold's numbers are denominated in — its pair's, as elsewhere. */ function quoteOf(pair: string): string { @@ -294,13 +294,17 @@ export function PositionsTab({ - {pos.valueQuote === null ? "—" : formatUsd(pos.valueQuote)} + {pos.valueQuote === null + ? "—" + : formatValue(pos.valueQuote, pos.quote)} - {pos.feesQuote === null ? "—" : feeAmount(pos.feesQuote)} + {pos.feesQuote === null + ? "—" + : formatValue(pos.feesQuote, pos.quote)} - {formatPnl(pos.pnl)} + {formatPnlValue(pos.pnl, pos.quote)} diff --git a/frontend/src/hooks/useLpPositions.ts b/frontend/src/hooks/useLpPositions.ts index 517fcbf6c..e8730e08c 100644 --- a/frontend/src/hooks/useLpPositions.ts +++ b/frontend/src/hooks/useLpPositions.ts @@ -68,6 +68,12 @@ export function useLpPositions(server: string | null): { label: (pos: LpPosition) => string; /** The venue as the pool row names it — a fallback for a missing `provider`. */ dexId: (pos: LpPosition) => string; + /** + * The USD price of the position's quote token, or `null` when the pool row + * did not carry one — the caller then leaves the figure in quote units rather + * than stamping it with a dollar it may not be. + */ + quoteUsd: (pos: LpPosition) => number | null; isLoading: boolean; } { const { data: executors = [], isLoading } = useQuery({ @@ -137,5 +143,13 @@ export function useLpPositions(server: string | null): { [pools], ); - return { positions, label, dexId, isLoading }; + // The pool's own `quote_token_price_usd` prices the executor's quote- + // denominated figures (SOL on an X-SOL range) in dollars, so the cards need + // no rates query of their own. Absent for a pool that did not resolve. + const quoteUsd = useMemo( + () => (pos: LpPosition) => pools[pos.poolAddress]?.quote_token_price_usd ?? null, + [pools], + ); + + return { positions, label, dexId, quoteUsd, isLoading }; } diff --git a/frontend/src/pages/DexPool.tsx b/frontend/src/pages/DexPool.tsx index 2d9f057fe..09654fc35 100644 --- a/frontend/src/pages/DexPool.tsx +++ b/frontend/src/pages/DexPool.tsx @@ -534,6 +534,7 @@ export function DexPool() { setSelectedExecutorId((prev) => (prev === id ? null : id))} /> diff --git a/frontend/src/pages/Portfolio.tsx b/frontend/src/pages/Portfolio.tsx index 858001eaf..ecc4d0835 100644 --- a/frontend/src/pages/Portfolio.tsx +++ b/frontend/src/pages/Portfolio.tsx @@ -875,8 +875,13 @@ export function Portfolio() { for (const pos of holds) { quotes.add(pos.trading_pair?.split("-")[1] || "USDT"); } + // The liquidity table's money is quote-denominated too (SOL on an X-SOL + // range); without its quote here the row falls back to the quote's symbol. + for (const pos of lpPositions) { + quotes.add(pos.quote); + } return Array.from(quotes); - }, [controllers, executorsList, holds]); + }, [controllers, executorsList, holds, lpPositions]); const { convert, formatValueDetailed, From 098a49d79bda07871d7d8d10435770598cfe0d39 Mon Sep 17 00:00:00 2001 From: Laurent Grawet Date: Fri, 2 Oct 2026 13:13:38 +0200 Subject: [PATCH 2/3] fix(web): keep a quote's own ticker when it cannot be converted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a quote with no rate path (SOL, ETH, …) the fallback labelled the still-quote-denominated figure `$`, so a 0.163 SOL range read `$0.16` — a dollar it is not. The fallback now keeps the quote's own ticker, suffixed where the /dex surfaces already put it, while a quote the dashboard has a symbol for (or a USD-pegged stablecoin) keeps its prefix. --- .../portfolio/PositionsTab.test.tsx | 24 +++++++++++++++-- frontend/src/lib/rates.test.ts | 14 ++++++++-- frontend/src/lib/rates.ts | 27 ++++++++++++------- 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/portfolio/PositionsTab.test.tsx b/frontend/src/components/portfolio/PositionsTab.test.tsx index 2fb0a6d6d..20fea45cf 100644 --- a/frontend/src/components/portfolio/PositionsTab.test.tsx +++ b/frontend/src/components/portfolio/PositionsTab.test.tsx @@ -22,6 +22,8 @@ import { MemoryRouter, useLocation } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ConsolidatedPosition, ExecutorInfo } from "@/lib/api"; +import { formatCurrencyPnl, formatCurrencyVolume } from "@/lib/formatters"; +import { formatWithRate } from "@/lib/rates"; import type { LpPosition } from "@/components/dex/lp-position"; import { useLpPositions } from "@/hooks/useLpPositions"; import { PositionsTab } from "./PositionsTab"; @@ -133,6 +135,9 @@ interface RenderOpts { holds?: ConsolidatedPosition[]; lpPositions?: LpPosition[]; isLoading?: boolean; + /** Override the euro stand-in to exercise the real rate seam's fallback. */ + formatValue?: (val: number, quote?: string) => string; + formatPnlValue?: (val: number, quote?: string) => string; } async function render(opts: RenderOpts = {}) { @@ -151,8 +156,8 @@ async function render(opts: RenderOpts = {}) { isLoading={opts.isLoading ?? false} // Halve the value, so a row that skipped conversion is visible. convert={(value) => ({ value: value / 2, converted: true })} - formatValue={(val) => fmtValue(val / 2)} - formatPnlValue={(val) => fmtPnl(val / 2)} + formatValue={opts.formatValue ?? ((val) => fmtValue(val / 2))} + formatPnlValue={opts.formatPnlValue ?? ((val) => fmtPnl(val / 2))} /> , @@ -279,6 +284,21 @@ describe("liquidity", () => { expect(row).toContain("€"); expect(row).not.toContain("$"); }); + + it("labels an unconvertible SOL range with SOL, not a dollar", async () => { + // P1: with no rate path for SOL the seam must not stamp the quote- + // denominated figure `$` — it keeps the quote's own ticker. + const rates = {}; // no path for SOL → the fallback branch + await render({ + lpPositions: [lpPosition({ pair: "TOKEN-SOL", quote: "SOL" })], + formatValue: (val, quote) => formatWithRate(formatCurrencyVolume, rates, "EUR")(val, quote), + formatPnlValue: (val, quote) => formatWithRate(formatCurrencyPnl, rates, "EUR")(val, quote), + }); + + const row = document.querySelector("[data-lp-row]")!.textContent!; + expect(row).toContain("SOL ⚠"); + expect(row).not.toContain("$"); + }); }); describe("the tab never mutates anything", () => { diff --git a/frontend/src/lib/rates.test.ts b/frontend/src/lib/rates.test.ts index f0d527229..cf12305f4 100644 --- a/frontend/src/lib/rates.test.ts +++ b/frontend/src/lib/rates.test.ts @@ -77,9 +77,19 @@ describe("formatWithRate", () => { expect(out).not.toContain("€"); }); - it("falls back to $ for a quote it has no symbol for", () => { + it("keeps a stablecoin's $ even before a rate lands", () => { + // USDC has no display symbol of its own, but it *is* a dollar: suffixing + // the common X-USDC row "USDC" would only mislabel it the other way. const fmt = formatWithRate(formatCurrency, {}, "EUR"); - expect(fmt(500, "PLN")).toBe(`${formatCurrency(500, "$")} ⚠`); + expect(fmt(500, "USDC")).toBe(`${formatCurrency(500, "$")} ⚠`); + }); + + it("labels a quote it has no symbol for with the quote's own ticker", () => { + // 0.163 SOL read `$0.16 ⚠` — a dollar it is not. The ticker goes *after* + // the number, where the /dex surfaces already put it. + const fmt = formatWithRate(formatCurrency, {}, "EUR"); + expect(fmt(500, "PLN")).toBe("500.00 PLN ⚠"); + expect(fmt(0.163, "SOL")).toBe("0.16 SOL ⚠"); }); it("leaves the unconverted value untouched — only its label changes", () => { diff --git a/frontend/src/lib/rates.ts b/frontend/src/lib/rates.ts index 468b5a1f7..24e06546d 100644 --- a/frontend/src/lib/rates.ts +++ b/frontend/src/lib/rates.ts @@ -71,19 +71,26 @@ export function resolveSymbol(rates: RateTable, currency: DisplayCurrency): stri } /** - * The symbol of a value that could *not* be converted. It is still in quote - * units, so it keeps the quote's own symbol — `$` for a quote the dashboard - * has no symbol for. Relabelling it with the display currency would not be a - * formatting detail; it would be a wrong number on screen. + * How a value that could *not* be converted must be labelled. It is still in + * quote units, so it carries the *quote's* own label and never the display + * currency's. A quote the dashboard has a symbol for (USDT, BTC, BRL, EUR) — + * or a USD-pegged stablecoin, which *is* a dollar — is prefixed with that + * symbol. Any other quote (SOL, ETH, …) has no symbol, so it keeps its own + * ticker, *suffixed*: a 0.163 SOL range stamped `$0.16` is the mislabelling + * this rule exists to prevent, not a rounding detail. */ -function quoteSymbol(quote?: string): string { - return CURRENCY_SYMBOLS[normalizeQuote(quote) as DisplayCurrency] || "$"; +function quoteLabel(quote?: string): { label: string; prefix: boolean } { + const q = normalizeQuote(quote); + const symbol = CURRENCY_SYMBOLS[q as DisplayCurrency]; + if (symbol) return { label: symbol, prefix: true }; + if (STABLECOINS.has(q)) return { label: "$", prefix: true }; + return { label: q, prefix: false }; } /** * `fmt` bound to a rate table and a display currency: converts and labels with * the display currency, or leaves the value in quote units under the quote's - * symbol with the `⚠` marker. + * own label (see `quoteLabel`) with the `⚠` marker. */ export function formatWithRate( fmt: (val: number, symbol?: string) => string, @@ -92,8 +99,8 @@ export function formatWithRate( ): (val: number, quote?: string) => string { return (val: number, quote?: string): string => { const rate = rateFor(rates, currency, quote); - return rate != null - ? fmt(val / rate, CURRENCY_SYMBOLS[currency]) - : `${fmt(val, quoteSymbol(quote))} ⚠`; + if (rate != null) return fmt(val / rate, CURRENCY_SYMBOLS[currency]); + const { label, prefix } = quoteLabel(quote); + return prefix ? `${fmt(val, label)} ⚠` : `${fmt(val, "")} ${label} ⚠`; }; } From d0ac88b9e19c041a04500555e14c773d07cb8952 Mon Sep 17 00:00:00 2001 From: Laurent Grawet Date: Fri, 2 Oct 2026 13:22:28 +0200 Subject: [PATCH 3/3] fix(web): read an LP portfolio past 30 ranges without dropping prices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool query sliced to the first 30 addresses on GeckoTerminal's request cap, so a portfolio of more than 30 ranges left the rest without a pool row — and, now that the /dex cards and the portfolio price in USD, in quote units. The cap is per request, not per portfolio: the backend already splits on the same bound, so the hook chunks each network's addresses into 30-address requests instead of truncating. --- frontend/src/hooks/useLpPositions.test.tsx | 48 ++++++++++++++++++++++ frontend/src/hooks/useLpPositions.ts | 30 ++++++++++---- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/frontend/src/hooks/useLpPositions.test.tsx b/frontend/src/hooks/useLpPositions.test.tsx index fa504ee64..5022cca2d 100644 --- a/frontend/src/hooks/useLpPositions.test.tsx +++ b/frontend/src/hooks/useLpPositions.test.tsx @@ -70,6 +70,20 @@ function Harness() { return null; } +/** Renders the USD price the hook resolved for each range, in its own order. */ +function PriceProbe() { + const { positions, quoteUsd } = useLpPositions("srv"); + return ( +
+ {positions.map((p) => ( + + {quoteUsd(p) ?? "none"} + + ))} +
+ ); +} + let container: HTMLDivElement; let root: Root; let client: QueryClient; @@ -190,4 +204,38 @@ describe("useLpPositions", () => { await poll([executor("1", "base", "pool-a", 900)]); expect(getDexPoolsByAddress).toHaveBeenCalledTimes(2); }); + + it("reads a portfolio past 30 ranges in batches, pricing the ones a single request would drop", async () => { + // GeckoTerminal takes 30 addresses per request, so 31 ranges must become two + // requests — the old code sliced to the first 30 and left the rest unpriced. + const many = Array.from({ length: 31 }, (_, i) => + executor(`e${i}`, "solana", `pool-${i}`, 1000 - i), + ); + getExecutors.mockResolvedValue(many); + getDexPoolsByAddress.mockImplementation( + async (_server: string, _network: string, addresses: string[]) => ({ + pools: addresses.map((address) => ({ address, quote_token_price_usd: 2.5 })), + }), + ); + + await act(async () => { + root.render( + + + , + ); + }); + await settle(); + await settle(); + + // No request carries more than 30 addresses, and every range is asked for. + const batches = getDexPoolsByAddress.mock.calls.map((c) => (c[2] as string[]).length); + expect([...batches].sort((a, b) => a - b)).toEqual([1, 30]); + const asked = getDexPoolsByAddress.mock.calls.flatMap((c) => c[2] as string[]); + expect(new Set(asked).size).toBe(31); + + // The 31st range — the one the slice dropped — shows its USD price rather + // than falling back to quote units. + expect(document.querySelector('[data-pool="pool-30"]')?.textContent).toBe("2.5"); + }); }); diff --git a/frontend/src/hooks/useLpPositions.ts b/frontend/src/hooks/useLpPositions.ts index e8730e08c..c5d15ab91 100644 --- a/frontend/src/hooks/useLpPositions.ts +++ b/frontend/src/hooks/useLpPositions.ts @@ -12,8 +12,20 @@ import { import { api, type PoolSummary } from "@/lib/api"; import { isExecutorActive } from "@/lib/formatters"; -/** GeckoTerminal's multi-pool endpoint, which the labels come from, caps here. */ -const MAX_LABELLED_POOLS = 30; +/** + * GeckoTerminal takes at most 30 addresses per request, so a larger portfolio is + * read as several requests rather than truncated. The backend splits on the same + * bound (`fetch_pools_by_addresses`); chunking here too, instead of sending every + * address in one `addresses=` list, is what keeps the request URL short. + */ +const POOLS_PER_REQUEST = 30; + +/** `items` split into consecutive runs of at most `size`. */ +function batches(items: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} /** * The pool-label query key as a *set*, not as a ranking. @@ -106,7 +118,7 @@ export function useLpPositions(server: string | null): { // not silently fall back to mint addresses. const byNetwork = useMemo(() => { const groups: Record = {}; - for (const pos of positions.slice(0, MAX_LABELLED_POOLS)) { + for (const pos of positions) { const list = (groups[pos.network] ??= []); if (!list.includes(pos.poolAddress)) list.push(pos.poolAddress); } @@ -117,11 +129,13 @@ export function useLpPositions(server: string | null): { queryKey: ["dex-lp-pools", server, lpPoolsKey(byNetwork)], queryFn: async () => { const entries = await Promise.all( - Object.entries(byNetwork).map(([network, addresses]) => - api - .getDexPoolsByAddress(server!, network, addresses) - .then((r) => r.pools) - .catch(() => [] as PoolSummary[]), + Object.entries(byNetwork).flatMap(([network, addresses]) => + batches(addresses, POOLS_PER_REQUEST).map((batch) => + api + .getDexPoolsByAddress(server!, network, batch) + .then((r) => r.pools) + .catch(() => [] as PoolSummary[]), + ), ), ); const map: Record = {};