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..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,8 +1,11 @@ +import { useEffect } from "react" import { Skeleton } from "@workspace/ui/components/skeleton" import { usePoolRowData } from "../hooks/use-pool-row-data" import { getComposition, getEstimatedApy, + getFundingRatePerHourPct, + getOpenInterestUsd, getPoolTvlUsd, rawToDisplay, } from "../lib/pool-math" @@ -17,6 +20,63 @@ 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) { + 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 }) { @@ -34,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) @@ -42,19 +102,36 @@ 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 + 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, + }) + + useEffect(() => { + onMetricsChange?.(market.marketToken, { tvlUsd, openInterestUsd, apy }) + }, [apy, market.marketToken, onMetricsChange, openInterestUsd, tvlUsd]) 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}
@@ -101,12 +179,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} 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..e37bcf1 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.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..7f4b96a 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" +import { fromSorobanAmount } from "@/shared/lib/bignum" + +export const TOKEN_DECIMALS = 7 +export const USD_DECIMALS = 30 -const DEFAULT_DECIMALS = 7 +const SECONDS_PER_HOUR = 3600n -export function rawToDisplay(raw: bigint | undefined | null, decimals = DEFAULT_DECIMALS): number { +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) { 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}