@@ -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) {
-
+
| Pool |
- TVL |
+
+
+ |
Composition |
- Open Interest |
- Funding |
- APY |
+
+
+ |
+ Funding / hr |
+
+
+ |
Your GM |
Actions |
- {markets.map((market) => (
-
+ {sortedMarkets.map((market) => (
+
))}
- {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}
|