From f74de1c24f37b59eaf76193fba80ef6e77e9d5eb Mon Sep 17 00:00:00 2001 From: 0xMegie Date: Sat, 20 Jun 2026 09:04:55 +0100 Subject: [PATCH 1/4] Fix pool value unit scaling --- .../features/pools/components/gm-pool-row.tsx | 8 ++- .../src/features/pools/lib/pool-math.test.ts | 59 +++++++++++++++++++ apps/web/src/features/pools/lib/pool-math.ts | 31 ++++++++-- 3 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/features/pools/lib/pool-math.test.ts diff --git a/apps/web/src/features/pools/components/gm-pool-row.tsx b/apps/web/src/features/pools/components/gm-pool-row.tsx index d04cd55..cbc0765 100644 --- a/apps/web/src/features/pools/components/gm-pool-row.tsx +++ b/apps/web/src/features/pools/components/gm-pool-row.tsx @@ -3,6 +3,8 @@ import { usePoolRowData } from "../hooks/use-pool-row-data" import { getComposition, getEstimatedApy, + getFundingRatePerHourPct, + getOpenInterestUsd, getPoolTvlUsd, rawToDisplay, } from "../lib/pool-math" @@ -42,9 +44,9 @@ export function GmPoolRow({ market, variant }: GmPoolRowProps) { const composition = getComposition(poolValue) const tvlUsd = getPoolTvlUsd(poolValue) const apy = getEstimatedApy(poolValue) - const openInterestUsd = - rawToDisplay(data?.openInterest?.long) + rawToDisplay(data?.openInterest?.short) - const funding = rawToDisplay(data?.fundingInfo?.fundingFactorPerSecond) + const openInterestUsd = getOpenInterestUsd(data?.openInterest) + // Display funding as an hourly percentage, matching the trade page convention. + const funding = getFundingRatePerHourPct(data?.fundingInfo?.fundingFactorPerSecond) const userGmBalance = data?.userGmBalance ?? 0n const hasUserGm = userGmBalance > 0n const hasFailures = (data?.failures.length ?? 0) > 0 diff --git a/apps/web/src/features/pools/lib/pool-math.test.ts b/apps/web/src/features/pools/lib/pool-math.test.ts new file mode 100644 index 0000000..8467167 --- /dev/null +++ b/apps/web/src/features/pools/lib/pool-math.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest" + +import { + getFundingRatePerHourPct, + getOpenInterestUsd, + getPoolTvlUsd, + rawToDisplay, + usdRawToDisplay, +} from "./pool-math" + +const USD_SCALE = 10n ** 30n +const TOKEN_SCALE = 10n ** 7n + +describe("pool math scale conversions", () => { + it("converts 30-decimal USD values in bigint space", () => { + expect(usdRawToDisplay(60_000n * USD_SCALE)).toBe(60_000) + expect( + getPoolTvlUsd({ + poolValue: 1_234_567n * USD_SCALE, + longTokenAmount: 0n, + shortTokenAmount: 0n, + longTokenUsd: 0n, + shortTokenUsd: 0n, + longPnl: 0n, + shortPnl: 0n, + netPnl: 0n, + totalBorrowingFees: 0n, + impactPoolAmount: 0n, + }), + ).toBe(1_234_567) + }) + + it("keeps 7-decimal token values on token precision", () => { + expect(rawToDisplay(42n * TOKEN_SCALE)).toBe(42) + expect(rawToDisplay(123_456_789n)).toBe(12.3456789) + }) + + it("converts missing and zero values to zero", () => { + expect(rawToDisplay(undefined)).toBe(0) + expect(usdRawToDisplay(null)).toBe(0) + expect(getOpenInterestUsd(null)).toBe(0) + }) + + it("preserves negative USD values intentionally", () => { + expect(usdRawToDisplay(-1_725n * USD_SCALE)).toBe(-1_725) + }) + + it("converts open interest and funding from 30-decimal USD precision", () => { + expect( + getOpenInterestUsd({ + long: 20_000n * USD_SCALE, + short: 15_000n * USD_SCALE, + }), + ).toBe(35_000) + + const oneBasisPointPerHour = USD_SCALE / 10_000n / 3600n + expect(getFundingRatePerHourPct(oneBasisPointPerHour)).toBeCloseTo(0.01) + }) +}) diff --git a/apps/web/src/features/pools/lib/pool-math.ts b/apps/web/src/features/pools/lib/pool-math.ts index 2abba85..ee2fb3b 100644 --- a/apps/web/src/features/pools/lib/pool-math.ts +++ b/apps/web/src/features/pools/lib/pool-math.ts @@ -1,19 +1,40 @@ import { fromSorobanAmount } from "@/shared/lib/bignum" import type { PoolValueInfo } from "@/lib/contracts" -const DEFAULT_DECIMALS = 7 +export const TOKEN_DECIMALS = 7 +export const USD_DECIMALS = 30 -export function rawToDisplay(raw: bigint | undefined | null, decimals = DEFAULT_DECIMALS): number { +const SECONDS_PER_HOUR = 3600n + +export function rawToDisplay(raw: bigint | undefined | null, decimals = TOKEN_DECIMALS): number { return fromSorobanAmount(raw ?? 0n, decimals) } +export function usdRawToDisplay(raw: bigint | undefined | null): number { + return fromSorobanAmount(raw ?? 0n, USD_DECIMALS) +} + export function getPoolTvlUsd(poolValue: PoolValueInfo | null | undefined): number { - return rawToDisplay(poolValue?.poolValue) + return usdRawToDisplay(poolValue?.poolValue) +} + +export function getOpenInterestUsd( + openInterest: { long: bigint; short: bigint } | null | undefined, +): number { + return usdRawToDisplay(openInterest?.long) + usdRawToDisplay(openInterest?.short) +} + +export function getFundingRatePerHourPct( + fundingFactorPerSecond: bigint | null | undefined, +): number { + const perHourFactor = (fundingFactorPerSecond ?? 0n) * SECONDS_PER_HOUR + + return usdRawToDisplay(perHourFactor) * 100 } export function getComposition(poolValue: PoolValueInfo | null | undefined) { - const longUsd = rawToDisplay(poolValue?.longTokenUsd) - const shortUsd = rawToDisplay(poolValue?.shortTokenUsd) + const longUsd = usdRawToDisplay(poolValue?.longTokenUsd) + const shortUsd = usdRawToDisplay(poolValue?.shortTokenUsd) const usdTotal = longUsd + shortUsd if (usdTotal > 0) { From 3f499fda48beb8c58042011295c4d9346ef61b90 Mon Sep 17 00:00:00 2001 From: 0xMegie Date: Sat, 20 Jun 2026 09:06:13 +0100 Subject: [PATCH 2/4] Polish GM pool row values --- .../features/pools/components/gm-pool-row.tsx | 135 ++++++++++++++---- 1 file changed, 104 insertions(+), 31 deletions(-) diff --git a/apps/web/src/features/pools/components/gm-pool-row.tsx b/apps/web/src/features/pools/components/gm-pool-row.tsx index cbc0765..3cb1304 100644 --- a/apps/web/src/features/pools/components/gm-pool-row.tsx +++ b/apps/web/src/features/pools/components/gm-pool-row.tsx @@ -21,6 +21,56 @@ type GmPoolRowProps = { variant: "desktop" | "mobile" } +function formatCompactUsd(value: number) { + return formatUsd(value, { compact: true }) +} + +function ValueCell({ + value, + title, + isLoading, + className = "", +}: { + value: string + title?: string + isLoading?: boolean + className?: string +}) { + if (isLoading) { + return + } + + return ( + + {value} + + ) +} + +function MobileStat({ + label, + value, + title, + isLoading, +}: { + label: string + value: string + title?: string + isLoading?: boolean +}) { + return ( +
+
{label}
+
+ +
+
+ ) +} + function PoolIdentity({ market }: { market: PoolMarketConfig }) { return (
@@ -50,13 +100,26 @@ export function GmPoolRow({ market, variant }: GmPoolRowProps) { const userGmBalance = data?.userGmBalance ?? 0n const hasUserGm = userGmBalance > 0n const hasFailures = (data?.failures.length ?? 0) > 0 + const failureTitle = hasFailures ? `Unavailable reads: ${data?.failures.join(", ")}` : undefined + const tvlLabel = formatCompactUsd(tvlUsd) + const tvlTitle = formatUsd(tvlUsd) + const openInterestLabel = formatCompactUsd(openInterestUsd) + const openInterestTitle = formatUsd(openInterestUsd) + const fundingLabel = funding === 0 ? "—" : formatPct(funding, { decimals: 4 }) + const apyLabel = apy == null ? "Est. pending" : `${formatPct(apy, { sign: false })} est.` + const userGmLabel = formatToken(rawToDisplay(userGmBalance), "GM", { decimals: 4 }) + const userGmTitle = formatToken(Number(formatSorobanAmount(userGmBalance, 7, 7)), "GM", { + decimals: 7, + }) if (variant === "mobile") { return ( -
+
- {isLoading ? :

{formatUsd(tvlUsd)}

} +

+ +

-
-
Open interest
-
{formatUsd(openInterestUsd)}
-
-
-
APY
-
{apy == null ? "Est. pending" : `${formatPct(apy, { sign: false })} est.`}
-
-
-
Your GM
-
{formatToken(rawToDisplay(userGmBalance), "GM", { decimals: 4 })}
-
-
-
Funding
-
{funding === 0 ? "—" : formatPct(funding, { decimals: 4 })}
-
+ + + +
{hasFailures ? ( -

- Some live reads are unavailable. +

+ Partial data

) : null}
@@ -103,12 +167,12 @@ export function GmPoolRow({ market, variant }: GmPoolRowProps) { } return ( - + - - {isLoading ? : formatUsd(tvlUsd)} + + - {formatUsd(openInterestUsd)} - - {funding === 0 ? "—" : formatPct(funding, { decimals: 4 })} + + + + + - - {apy == null ? "Est. pending" : `${formatPct(apy, { sign: false })} est.`} + + - - {formatToken(Number(formatSorobanAmount(userGmBalance, 7, 4)), "GM", { decimals: 4 })} + + {hasFailures ? ( -

+

Partial data

) : null} From 2673e9e4b321a9e6704b6d49b81a78e0bb8ded26 Mon Sep 17 00:00:00 2001 From: 0xMegie Date: Sat, 20 Jun 2026 09:07:51 +0100 Subject: [PATCH 3/4] Modernize GM pools table UX --- .../features/pools/components/gm-pool-row.tsx | 14 +- .../pools/components/gm-pools-table.tsx | 150 ++++++++++++++++-- apps/web/src/features/pools/lib/pool-math.ts | 2 +- 3 files changed, 153 insertions(+), 13 deletions(-) diff --git a/apps/web/src/features/pools/components/gm-pool-row.tsx b/apps/web/src/features/pools/components/gm-pool-row.tsx index 3cb1304..c49e1bb 100644 --- a/apps/web/src/features/pools/components/gm-pool-row.tsx +++ b/apps/web/src/features/pools/components/gm-pool-row.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react" import { Skeleton } from "@workspace/ui/components/skeleton" import { usePoolRowData } from "../hooks/use-pool-row-data" import { @@ -19,6 +20,13 @@ import { useWalletStore } from "@/features/wallet/store/wallet-store" type GmPoolRowProps = { market: PoolMarketConfig variant: "desktop" | "mobile" + onMetricsChange?: (marketToken: string, metrics: PoolRowMetrics) => void +} + +export type PoolRowMetrics = { + tvlUsd: number + openInterestUsd: number + apy: number | null } function formatCompactUsd(value: number) { @@ -86,7 +94,7 @@ function PoolIdentity({ market }: { market: PoolMarketConfig }) { ) } -export function GmPoolRow({ market, variant }: GmPoolRowProps) { +export function GmPoolRow({ market, variant, onMetricsChange }: GmPoolRowProps) { const address = useWalletStore((state) => state.address) const isConnected = useWalletStore((state) => state.status === "connected") const { data, isLoading } = usePoolRowData(market) @@ -112,6 +120,10 @@ export function GmPoolRow({ market, variant }: GmPoolRowProps) { decimals: 7, }) + useEffect(() => { + onMetricsChange?.(market.marketToken, { tvlUsd, openInterestUsd, apy }) + }, [apy, market.marketToken, onMetricsChange, openInterestUsd, tvlUsd]) + if (variant === "mobile") { return (
diff --git a/apps/web/src/features/pools/components/gm-pools-table.tsx b/apps/web/src/features/pools/components/gm-pools-table.tsx index a2b8705..57ba9f5 100644 --- a/apps/web/src/features/pools/components/gm-pools-table.tsx +++ b/apps/web/src/features/pools/components/gm-pools-table.tsx @@ -1,15 +1,112 @@ -import type { PoolMarketConfig } from "../data/markets" +import { useCallback, useMemo, useState } from "react" import { GmPoolRow } from "./gm-pool-row" +import type { PoolRowMetrics } from "./gm-pool-row" +import type { PoolMarketConfig } from "../data/markets" type GmPoolsTableProps = { markets: Array } +type SortKey = "tvlUsd" | "openInterestUsd" | "apy" +type SortDirection = "asc" | "desc" + +type SortState = { + key: SortKey + direction: SortDirection +} + +const SORT_LABELS: Record = { + tvlUsd: "TVL", + openInterestUsd: "Open Interest", + apy: "APY", +} + +function SortHeader({ + label, + sortKey, + sort, + onSort, +}: { + label: string + sortKey: SortKey + sort: SortState + onSort: (key: SortKey) => void +}) { + const active = sort.key === sortKey + const indicator = active ? (sort.direction === "asc" ? "↑" : "↓") : "↕" + + return ( + + ) +} + export function GmPoolsTable({ markets }: GmPoolsTableProps) { + const [sort, setSort] = useState({ key: "tvlUsd", direction: "desc" }) + const [metricsByMarket, setMetricsByMarket] = useState>>({}) + + const handleSort = useCallback((key: SortKey) => { + setSort((current) => + current.key === key + ? { key, direction: current.direction === "asc" ? "desc" : "asc" } + : { key, direction: "desc" }, + ) + }, []) + + const handleMetricsChange = useCallback( + (marketToken: string, metrics: PoolRowMetrics) => { + setMetricsByMarket((current) => { + const previous = current[marketToken] + if ( + previous?.tvlUsd === metrics.tvlUsd && + previous.openInterestUsd === metrics.openInterestUsd && + previous.apy === metrics.apy + ) { + return current + } + + return { ...current, [marketToken]: metrics } + }) + }, + [], + ) + + const sortedMarkets = useMemo(() => { + return [...markets].sort((a, b) => { + const aMetrics = metricsByMarket[a.marketToken] + const bMetrics = metricsByMarket[b.marketToken] + const aValue = aMetrics?.[sort.key] ?? Number.NEGATIVE_INFINITY + const bValue = bMetrics?.[sort.key] ?? Number.NEGATIVE_INFINITY + const result = aValue - bValue + + if (result === 0) return a.label.localeCompare(b.label) + + return sort.direction === "asc" ? result : -result + }) + }, [markets, metricsByMarket, sort.direction, sort.key]) + + if (markets.length === 0) { + return ( +
+ No markets configured. +
+ ) + } + return ( <>
- +
@@ -20,29 +117,60 @@ export function GmPoolsTable({ markets }: GmPoolsTableProps) { - + - + - - - + + + - {markets.map((market) => ( - + {sortedMarkets.map((market) => ( + ))}
PoolTVL + + CompositionOpen InterestFundingAPY + + Funding / hr + + Your GM Actions
- {markets.map((market) => ( - +
+ Sorted by {SORT_LABELS[sort.key]} + +
+ {sortedMarkets.map((market) => ( + ))}
diff --git a/apps/web/src/features/pools/lib/pool-math.ts b/apps/web/src/features/pools/lib/pool-math.ts index ee2fb3b..7f4b96a 100644 --- a/apps/web/src/features/pools/lib/pool-math.ts +++ b/apps/web/src/features/pools/lib/pool-math.ts @@ -1,5 +1,5 @@ -import { fromSorobanAmount } from "@/shared/lib/bignum" import type { PoolValueInfo } from "@/lib/contracts" +import { fromSorobanAmount } from "@/shared/lib/bignum" export const TOKEN_DECIMALS = 7 export const USD_DECIMALS = 30 From a2ba68abc372147391837872992d20e8a02a6efb Mon Sep 17 00:00:00 2001 From: 0xMegie Date: Sat, 20 Jun 2026 09:08:23 +0100 Subject: [PATCH 4/4] Highlight active app nav routes --- .../pools/components/gm-pools-table.tsx | 2 +- apps/web/src/ui/Navbar.tsx | 23 +++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/web/src/features/pools/components/gm-pools-table.tsx b/apps/web/src/features/pools/components/gm-pools-table.tsx index 57ba9f5..e37bcf1 100644 --- a/apps/web/src/features/pools/components/gm-pools-table.tsx +++ b/apps/web/src/features/pools/components/gm-pools-table.tsx @@ -117,7 +117,7 @@ export function GmPoolsTable({ markets }: GmPoolsTableProps) { - + Pool diff --git a/apps/web/src/ui/Navbar.tsx b/apps/web/src/ui/Navbar.tsx index 89864d3..4fa7db9 100644 --- a/apps/web/src/ui/Navbar.tsx +++ b/apps/web/src/ui/Navbar.tsx @@ -40,7 +40,7 @@ function Logo() { - so4.market + so4.market ) @@ -64,6 +64,18 @@ const APP_LINKS: Array<{ label: string; to: "/trade" | "/pools" | "/earn" | "/re { label: "Docs", to: null }, ] +const desktopAppLinkClass = + "relative inline-flex h-8 items-center rounded-md px-2 text-[13.5px] text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground" + +const desktopAppLinkActiveClass = + "relative inline-flex h-8 items-center rounded-md bg-primary/10 px-2 text-[13.5px] font-medium text-foreground after:absolute after:inset-x-2 after:-bottom-[13px] after:h-0.5 after:rounded-full after:bg-primary" + +const mobileAppLinkClass = + "block rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground" + +const mobileAppLinkActiveClass = + "block rounded-md bg-primary/10 px-3 py-2 text-sm font-medium text-foreground ring-1 ring-primary/20" + type Props = { variant: "landing" | "app" } @@ -91,8 +103,9 @@ export function Navbar({ variant }: Props) { {to ? ( {label} @@ -176,7 +189,9 @@ export function Navbar({ variant }: Props) { {to ? ( setMobileOpen(false)} > {label}