diff --git a/.changelog/unreleased/669-ob-022-hero-first-load-sequence.md b/.changelog/unreleased/669-ob-022-hero-first-load-sequence.md
index 18376141..b0eb5126 100644
--- a/.changelog/unreleased/669-ob-022-hero-first-load-sequence.md
+++ b/.changelog/unreleased/669-ob-022-hero-first-load-sequence.md
@@ -1,6 +1,8 @@
---
-section: Features
-description: Give the hero section a deliberate first-load sequence
+type: added
+area: trade
+pr: 669
+breaking: false
---
The landing page hero now reveals elements in a staggered sequence: title animates in, followed by CTA and stats, then feature grid. This creates visual hierarchy and guides user attention through the landing experience on first load.
diff --git a/.changelog/unreleased/738-ob-083-single-order-cancellation.md b/.changelog/unreleased/738-ob-083-single-order-cancellation.md
index cb7a8e39..2c7f115d 100644
--- a/.changelog/unreleased/738-ob-083-single-order-cancellation.md
+++ b/.changelog/unreleased/738-ob-083-single-order-cancellation.md
@@ -1,6 +1,8 @@
---
-section: Features
-description: Implement truthful single-order cancellation
+type: added
+area: trade
+pr: 738
+breaking: false
---
Order cancellation now operates on a single order with full transactional integrity. The contract validates that only the specified order is affected, preventing accidental batch or partial cancellations. A confirmation dialog and explicit completion toast confirm the action to the user.
diff --git a/.changelog/unreleased/747-ob-094-persistent-pending-terminal-toasts.md b/.changelog/unreleased/747-ob-094-persistent-pending-terminal-toasts.md
index 9f825e67..93d8f57c 100644
--- a/.changelog/unreleased/747-ob-094-persistent-pending-terminal-toasts.md
+++ b/.changelog/unreleased/747-ob-094-persistent-pending-terminal-toasts.md
@@ -1,6 +1,8 @@
---
-section: Features
-description: Keep pending toasts persistent and terminal updates dismissible
+type: added
+area: trade
+pr: 747
+breaking: false
---
Progress toasts for in-flight transactions now stay visible until the user dismisses them or a terminal outcome arrives. Terminal outcomes (success or error) are auto-dismissible but can be manually closed for faster clearing. This gives users full control over transaction visibility while maintaining clear completion signals.
diff --git a/.changelog/unreleased/748-ob-096-transaction-links-recovery.md b/.changelog/unreleased/748-ob-096-transaction-links-recovery.md
index f2cf17f1..f5f3148b 100644
--- a/.changelog/unreleased/748-ob-096-transaction-links-recovery.md
+++ b/.changelog/unreleased/748-ob-096-transaction-links-recovery.md
@@ -1,6 +1,8 @@
---
-section: Features
-description: Add contextual transaction links and safe recovery actions
+type: added
+area: trade
+pr: 748
+breaking: false
---
Transaction toasts now include contextual recovery actions: retry, cancel, view on explorer, and view account. Links to Stellar Expert provide transaction verification and debugging, while recovery actions guide users back to incomplete workflows.
diff --git a/.changelog/unreleased/816-ob-088-funding-activity-explicit-units.md b/.changelog/unreleased/816-ob-088-funding-activity-explicit-units.md
new file mode 100644
index 00000000..77249427
--- /dev/null
+++ b/.changelog/unreleased/816-ob-088-funding-activity-explicit-units.md
@@ -0,0 +1,8 @@
+---
+type: added
+area: trade
+pr: 816
+breaking: false
+---
+
+The Funding & Fees tab now shows complete fee and funding activity with explicit token symbols, timestamps, and accrual versus settlement status so entries cannot be confused with trading PnL. Claim actions appear only on settled funding rows where the contract supports them.
diff --git a/.changelog/unreleased/816-ob-089-order-vault-stub-guard.md b/.changelog/unreleased/816-ob-089-order-vault-stub-guard.md
new file mode 100644
index 00000000..c7009078
--- /dev/null
+++ b/.changelog/unreleased/816-ob-089-order-vault-stub-guard.md
@@ -0,0 +1,8 @@
+---
+type: fixed
+area: trade
+pr: 816
+breaking: false
+---
+
+Deposit and withdrawal buttons are now disabled with a clear explanation when the OrderVault contract is not deployed on the current network, preventing any action from silently reporting a successful transfer from a no-op stub.
diff --git a/.changelog/unreleased/816-ob-113-reconnect-backoff-heartbeat.md b/.changelog/unreleased/816-ob-113-reconnect-backoff-heartbeat.md
new file mode 100644
index 00000000..a1b79b0f
--- /dev/null
+++ b/.changelog/unreleased/816-ob-113-reconnect-backoff-heartbeat.md
@@ -0,0 +1,8 @@
+---
+type: fixed
+area: trade
+pr: 816
+breaking: false
+---
+
+Live market data streams now recover from disconnects and revision gaps using exponential backoff with jitter and a heartbeat watchdog, so the order book and trades tape converge to an authoritative state after any network interruption rather than remaining stale indefinitely.
diff --git a/.changelog/unreleased/816-ob-114-focus-offline-recovery.md b/.changelog/unreleased/816-ob-114-focus-offline-recovery.md
new file mode 100644
index 00000000..c96ca582
--- /dev/null
+++ b/.changelog/unreleased/816-ob-114-focus-offline-recovery.md
@@ -0,0 +1,8 @@
+---
+type: added
+area: trade
+pr: 816
+breaking: false
+---
+
+The trading page now pauses non-essential data work when the browser tab is hidden or offline, and revalidates active market and account data on return, preventing stale execution estimates and stopping offline actions from silently queueing financial submissions.
diff --git a/apps/web/src/features/trade/components/TradePage.tsx b/apps/web/src/features/trade/components/TradePage.tsx
index 42e25b9c..1a015993 100644
--- a/apps/web/src/features/trade/components/TradePage.tsx
+++ b/apps/web/src/features/trade/components/TradePage.tsx
@@ -18,15 +18,22 @@ import { MobileTradeNav, mobileViewClassName } from "./MobileTradeNav"
import { OrderBookPanel } from "./orderbook/OrderBookPanel"
import type {MobileTradeView} from "./MobileTradeNav";
import { saveReferralCode } from "@/lib/contracts"
+import { useAppFocusRecovery } from "@/shared/hooks/useAppFocusRecovery"
+import { useWalletStore } from "@/features/wallet/store/wallet-store"
const tradeRoute = getRouteApi("/trade")
export function TradePage() {
const trade = useTradeState()
const { setToTokenAddress, setTradeType } = trade
+ const account = useWalletStore((state) => state.address)
useOrderEventPolling()
+ // OB-114: Pause/reduce non-essential work when hidden or offline; revalidate
+ // active market and account data on return to prevent stale execution estimates.
+ useAppFocusRecovery({ account })
+
// Pre-fill the form from a shared deeplink (e.g. /trade?market=BTC&type=long).
const search = tradeRoute.useSearch()
const navigate = tradeRoute.useNavigate()
diff --git a/apps/web/src/features/trade/components/positions/BottomTabs.tsx b/apps/web/src/features/trade/components/positions/BottomTabs.tsx
index 06dc1798..736ddfc4 100644
--- a/apps/web/src/features/trade/components/positions/BottomTabs.tsx
+++ b/apps/web/src/features/trade/components/positions/BottomTabs.tsx
@@ -1,19 +1,13 @@
-import { useState } from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@workspace/ui/components/tabs"
-import { Button } from "@workspace/ui/components/button"
-import { DataTable } from "@workspace/ui/components/data-table"
-import { EmptyState } from "@workspace/ui/components/empty-state"
-import { Numeric } from "@workspace/ui/components/numeric"
import { usePositions } from "../../hooks/usePositions"
import { hasFrozenOrders, useOrders } from "../../hooks/useOrders"
-import { claimFundingFees } from "../../lib/stellar"
import { OrderExecutionFrozenBanner } from "./OrderExecutionFrozenBanner"
import { PositionsList } from "./PositionsList"
import { OrdersList } from "./OrdersList"
import { OrderHistoryList } from "./OrderHistoryList"
import { TradeHistoryList } from "./TradeHistoryList"
+import { FundingActivityList } from "./FundingActivityList"
import type { Position } from "../../hooks/usePositions"
-import { useWalletStore } from "@/features/wallet/store/wallet-store"
type Props = {
onSelectPosition?: (position: Position) => void
@@ -24,23 +18,11 @@ type Props = {
export function BottomTabs({ onSelectPosition, value, onValueChange }: Props) {
const { data: positions = [] } = usePositions()
const { data: orders = [] } = useOrders()
- const account = useWalletStore((state) => state.address)
- const [claimingAll, setClaimingAll] = useState(false)
- const claimablePositions = positions.filter((p) => p.fundingFeeUsd > 0)
- const totalClaimable = claimablePositions.reduce((sum, p) => sum + p.fundingFeeUsd, 0)
-
- async function handleClaimAll() {
- if (!account || claimablePositions.length === 0) return
- setClaimingAll(true)
- try {
- const marketAddresses = claimablePositions.map((p) => p.marketAddress)
- const tokens = claimablePositions.map((p) => p.collateralToken)
- await claimFundingFees(account, marketAddresses, tokens)
- } finally {
- setClaimingAll(false)
- }
- }
+ // OB-088: The claimable funding fee summary that was in this component has
+ // moved into FundingActivityList, which shows all fee/funding activity with
+ // explicit units, accrual/settlement status, and per-row claim actions.
+ // This keeps BottomTabs lean and delegates fee display to the specialised list.
return (
onValueChange?.(next as NonNullable)}>
@@ -57,8 +39,10 @@ export function BottomTabs({ onSelectPosition, value, onValueChange }: Props) {
Trades
- Claims
- {/* TODO: Show badge when claimable funding fees > 0 */}
+ {/* OB-088: Label updated to "Funding & Fees" to reflect that this tab
+ now shows the full activity history (accrued, settled, claimed) —
+ not just a snapshot of claimable amounts. */}
+ Funding & Fees
@@ -78,45 +62,12 @@ export function BottomTabs({ onSelectPosition, value, onValueChange }: Props) {
+ {/* OB-088: Replaced the ad-hoc claimable-positions table with FundingActivityList,
+ which shows all fee/funding activity from the indexer with explicit token units,
+ accrual/settlement status (so it cannot be confused with trading PnL), and
+ per-row claim actions only for settled funding-type rows. */}
- {claimablePositions.length > 0 ? (
-
- ) : (
-
- )}
+
)
diff --git a/apps/web/src/features/trade/components/positions/CollateralDialog.tsx b/apps/web/src/features/trade/components/positions/CollateralDialog.tsx
index 244630f2..9c1cbd57 100644
--- a/apps/web/src/features/trade/components/positions/CollateralDialog.tsx
+++ b/apps/web/src/features/trade/components/positions/CollateralDialog.tsx
@@ -16,6 +16,7 @@ import type { Position } from "../../hooks/usePositions"
import { formatUsd } from "@/shared/lib/format"
import { useTokenBalances } from "@/features/wallet/hooks/useTokenBalances"
import { useWalletStore } from "@/features/wallet/store/wallet-store"
+import { getOrderVaultCapability } from "../../lib/order-vault-capability"
type Props = {
position: Position | null
@@ -35,6 +36,13 @@ export function CollateralDialog({ position, mode, open, onClose, onSubmit }: Pr
const { getMidPrice } = useTokenPrices()
const queryClient = useQueryClient()
+ // OB-089: Check whether the OrderVault is a real deployed contract.
+ // Deposit (add collateral) goes through the vault; withdrawal (remove
+ // collateral) creates a DecreaseOrder and does not touch the vault directly.
+ // Only block "add" mode — "remove" is still backed by an executable path.
+ const vaultCapability = getOrderVaultCapability()
+ const isDepositBlocked = mode === "add" && !vaultCapability.isDeployed
+
// Reset inputs when opened/closed/changed
useEffect(() => {
if (open) {
@@ -63,10 +71,13 @@ export function CollateralDialog({ position, mode, open, onClose, onSubmit }: Pr
const newLeverage = newCollateralUsd > 0 ? position.sizeUsd / newCollateralUsd : 0
// Validate inputs
- let isValid = amountNum > 0
- let validationError = ""
+ // OB-089: A stub vault must never produce a successful deposit response.
+ // Setting isValid=false here ensures both the UI button and handleConfirm()
+ // are blocked — defence-in-depth against any path that could reach submit.
+ let isValid = amountNum > 0 && !isDepositBlocked
+ let validationError = isDepositBlocked ? (vaultCapability.unavailableReason ?? "") : ""
- if (amountNum > 0) {
+ if (amountNum > 0 && !isDepositBlocked) {
if (mode === "add") {
if (amountNum > walletBalance) {
isValid = false
@@ -150,6 +161,17 @@ export function CollateralDialog({ position, mode, open, onClose, onSubmit }: Pr
+ {/* OB-089: Inform the user when collateral transfer is unavailable because
+ the OrderVault contract is not deployed on this network. */}
+ {isDepositBlocked && (
+
+ {vaultCapability.unavailableReason}
+
+ )}
{/* Market Status details */}
diff --git a/apps/web/src/features/trade/components/positions/FundingActivityList.test.tsx b/apps/web/src/features/trade/components/positions/FundingActivityList.test.tsx
new file mode 100644
index 00000000..57702edd
--- /dev/null
+++ b/apps/web/src/features/trade/components/positions/FundingActivityList.test.tsx
@@ -0,0 +1,189 @@
+/**
+ * apps/web/src/features/trade/components/positions/FundingActivityList.test.tsx
+ *
+ * OB-088: Regression tests ensuring funding and fee activity is displayed
+ * with explicit units, correct accrual/settlement status labels, and that
+ * claim actions are only available for settled funding-type rows.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
+import { cleanup, render, screen } from "@testing-library/react"
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import React from "react"
+
+// ── Mocks ──────────────────────────────────────────────────────────────────────
+
+const mockFeeClaimsData = {
+ data: [] as Array<{
+ id: string
+ key: string
+ account: string
+ feeType: string
+ amount: string
+ amountUsd: string | null
+ status: string
+ ledger: number
+ timestamp: Date
+ transactionHash: string
+ market: { id: string; key: string; name: string } | null
+ token: { address: string; symbol: string | null; decimals: number | null } | null
+ }>,
+ isLoading: false,
+ isFetchingNextPage: false,
+ hasNextPage: false,
+ error: null,
+ isDisabled: false,
+ fetchNextPage: vi.fn(),
+ pageCount: 1,
+}
+
+vi.mock("../../hooks/useAccountFeeClaims", () => ({
+ useAccountFeeClaims: vi.fn(() => mockFeeClaimsData),
+}))
+
+vi.mock("../../lib/stellar", () => ({
+ claimFundingFees: vi.fn(),
+}))
+
+vi.mock("@/features/wallet/store/wallet-store", () => ({
+ useWalletStore: (selector: (s: { address: string | null }) => unknown) =>
+ selector({ address: "GABCDEF123456" }),
+}))
+
+vi.mock("@/lib/graphql/query-keys", () => ({
+ indexerQueryKeys: {
+ fees: {
+ pagesAll: (a: string) => ["fees", "pages", a],
+ },
+ },
+}))
+
+vi.mock("../../lib/query-keys", () => ({
+ activeQueryNetwork: () => "testnet",
+ queryKeys: {
+ wallet: {
+ tokenBalances: (a: string, n: string) => ["wallet", n, "token-balances", a],
+ },
+ },
+}))
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+
+function buildWrapper() {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ return function W({ children }: { children: React.ReactNode }) {
+ return React.createElement(QueryClientProvider, { client: qc }, children)
+ }
+}
+
+function makeClaim(overrides: Partial = {}): typeof mockFeeClaimsData.data[0] {
+ return {
+ id: "fee-1",
+ key: "fee-key-1",
+ account: "GABCDEF123456",
+ feeType: "funding",
+ amount: "10000000", // 1.0 in 7-decimal Stellar format
+ amountUsd: "1.23",
+ status: "settled",
+ ledger: 100,
+ timestamp: new Date("2024-01-15T10:00:00Z"),
+ transactionHash: "abc123",
+ market: { id: "market-1", key: "BTC-USD", name: "BTC/USD" },
+ token: { address: "CUSDC...", symbol: "USDC", decimals: 7 },
+ ...overrides,
+ }
+}
+
+// ── Tests ──────────────────────────────────────────────────────────────────────
+
+// Dynamically import to respect vi.mock hoisting.
+// eslint-disable-next-line @typescript-eslint/consistent-type-imports
+let FundingActivityList: typeof import("./FundingActivityList").FundingActivityList
+
+describe("FundingActivityList (OB-088)", () => {
+ beforeEach(async () => {
+ const module = await import("./FundingActivityList")
+ FundingActivityList = module.FundingActivityList
+ mockFeeClaimsData.data = []
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.clearAllMocks()
+ })
+
+ it("shows empty state when there is no fee activity", () => {
+ render(React.createElement(FundingActivityList, { feeType: "funding" }), {
+ wrapper: buildWrapper(),
+ })
+ expect(screen.getByText(/No funding activity/i)).toBeInTheDocument()
+ })
+
+ it("renders token symbol explicitly next to the amount (OB-088 unit requirement)", () => {
+ mockFeeClaimsData.data = [makeClaim()]
+ render(React.createElement(FundingActivityList, {}), { wrapper: buildWrapper() })
+ // The token symbol 'USDC' must appear alongside the amount.
+ expect(screen.getByText("USDC")).toBeInTheDocument()
+ })
+
+ it("shows 'Settled' status badge for a settled fee row", () => {
+ mockFeeClaimsData.data = [makeClaim({ status: "settled" })]
+ render(React.createElement(FundingActivityList, {}), { wrapper: buildWrapper() })
+ expect(screen.getByText("Settled")).toBeInTheDocument()
+ })
+
+ it("shows 'Accruing' status badge for an accrued row", () => {
+ mockFeeClaimsData.data = [makeClaim({ status: "accrued" })]
+ render(React.createElement(FundingActivityList, {}), { wrapper: buildWrapper() })
+ expect(screen.getByText("Accruing")).toBeInTheDocument()
+ })
+
+ it("shows 'Claimed' status badge for an already-claimed row", () => {
+ mockFeeClaimsData.data = [makeClaim({ status: "claimed" })]
+ render(React.createElement(FundingActivityList, {}), { wrapper: buildWrapper() })
+ expect(screen.getByText("Claimed")).toBeInTheDocument()
+ })
+
+ it("shows Claim button only for settled funding rows", () => {
+ mockFeeClaimsData.data = [
+ makeClaim({ id: "fee-settled", feeType: "funding", status: "settled" }),
+ makeClaim({ id: "fee-accrued", feeType: "funding", status: "accrued" }),
+ makeClaim({ id: "fee-claimed", feeType: "funding", status: "claimed" }),
+ ]
+ render(React.createElement(FundingActivityList, {}), { wrapper: buildWrapper() })
+
+ // Only one Claim button should be rendered (for the settled row).
+ const claimButtons = screen.queryAllByRole("button", { name: /claim/i })
+ expect(claimButtons).toHaveLength(1)
+ })
+
+ it("shows Unavailable for non-claimable accruing rows instead of hiding the action", () => {
+ mockFeeClaimsData.data = [
+ makeClaim({ id: "fee-accrued", feeType: "funding", status: "accrued" }),
+ ]
+ render(React.createElement(FundingActivityList, {}), { wrapper: buildWrapper() })
+ expect(screen.getByText("Unavailable")).toBeInTheDocument()
+ })
+
+ it("does not show Claim for non-funding fee types (position fees are auto-settled)", () => {
+ mockFeeClaimsData.data = [
+ makeClaim({ id: "fee-position", feeType: "position_fee", status: "settled" }),
+ ]
+ render(React.createElement(FundingActivityList, {}), { wrapper: buildWrapper() })
+ const claimButton = screen.queryByRole("button", { name: /claim/i })
+ expect(claimButton).toBeNull()
+ })
+
+ it("displays market name in the row", () => {
+ mockFeeClaimsData.data = [makeClaim()]
+ render(React.createElement(FundingActivityList, {}), { wrapper: buildWrapper() })
+ expect(screen.getByText("BTC/USD")).toBeInTheDocument()
+ })
+
+ it("shows indexer-disabled message when isDisabled=true", () => {
+ mockFeeClaimsData.isDisabled = true
+ render(React.createElement(FundingActivityList, {}), { wrapper: buildWrapper() })
+ expect(screen.getByText(/indexer is disabled/i)).toBeInTheDocument()
+ mockFeeClaimsData.isDisabled = false
+ })
+})
diff --git a/apps/web/src/features/trade/components/positions/FundingActivityList.tsx b/apps/web/src/features/trade/components/positions/FundingActivityList.tsx
new file mode 100644
index 00000000..22b2bb35
--- /dev/null
+++ b/apps/web/src/features/trade/components/positions/FundingActivityList.tsx
@@ -0,0 +1,292 @@
+/**
+ * apps/web/src/features/trade/components/positions/FundingActivityList.tsx
+ *
+ * OB-088: Show funding and fee activity with explicit units.
+ *
+ * Acceptance criteria:
+ * - Sign, asset units, timestamps, and accrual/settlement status must match
+ * source fixtures and cannot be confused with trading PnL.
+ * - A claim refreshes the relevant activity and balances through the shared flow;
+ * unsupported claims remain clearly unavailable.
+ *
+ * The data source is `useAccountFeeClaims` (paginated) for settled/claimed
+ * records and `useAccountFundingHistory` for funding-specific rows (feeType=
+ * "funding"). Both share the same `FeeClaim` shape from the indexer.
+ */
+
+import { useState } from "react"
+import { Button } from "@workspace/ui/components/button"
+import { DataTable } from "@workspace/ui/components/data-table"
+import { Numeric } from "@workspace/ui/components/numeric"
+import { StatusBadge } from "@workspace/ui/components/status-badge"
+import { useQueryClient } from "@tanstack/react-query"
+import { useAccountFeeClaims } from "../../hooks/useAccountFeeClaims"
+import { claimFundingFees } from "../../lib/stellar"
+import { indexerQueryKeys } from "@/lib/graphql/query-keys"
+import { activeQueryNetwork, queryKeys } from "../../lib/query-keys"
+import { useWalletStore } from "@/features/wallet/store/wallet-store"
+import type { FeeClaim } from "@/lib/graphql/types"
+import type { Column } from "@workspace/ui/components/data-table"
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+function formatTimestamp(ts: Date | string | number): string {
+ return new Date(ts).toLocaleString(undefined, {
+ dateStyle: "short",
+ timeStyle: "short",
+ })
+}
+
+/**
+ * Parse the raw `amount` string from the indexer into a JS number.
+ * The amount is stored as an integer string in the token's native decimals.
+ * We use the `token.decimals` field (defaults to 7 for Stellar assets) to
+ * convert to a human-readable fractional value.
+ */
+function parseAmount(amount: string, decimals: number | null): number {
+ const dec = decimals ?? 7
+ return parseInt(amount, 10) / 10 ** dec
+}
+
+/**
+ * OB-088: Map the indexer `status` field to a UI label that distinguishes
+ * accrual (not yet settled) from settlement and claim stages.
+ *
+ * Known status values from the indexer schema:
+ * - "accrued" → fee has accrued but is not yet settled to the position
+ * - "settled" → fee has been settled; claimable if positive
+ * - "claimed" → user has already claimed this fee
+ * - "cancelled" → fee was reversed / cancelled
+ */
+type FeeStatus = "accrued" | "settled" | "claimed" | "cancelled" | string
+
+function feeStatusLabel(status: FeeStatus): string {
+ switch (status) {
+ case "accrued": return "Accruing"
+ case "settled": return "Settled"
+ case "claimed": return "Claimed"
+ case "cancelled": return "Cancelled"
+ default: return status
+ }
+}
+
+function feeStatusVariant(status: FeeStatus): "success" | "warning" | "neutral" | "danger" {
+ switch (status) {
+ case "claimed": return "success"
+ case "settled": return "warning"
+ case "accrued": return "neutral"
+ case "cancelled": return "danger"
+ default: return "neutral"
+ }
+}
+
+/**
+ * OB-088: Whether a `feeType` value represents a funding-specific row.
+ * Funding fees are the ones that can be claimed via claimFundingFees().
+ */
+function isFundingClaim(feeType: string): boolean {
+ return feeType === "funding"
+}
+
+// ── Component ─────────────────────────────────────────────────────────────────
+
+type Props = {
+ /** When provided, filter to a specific fee type (e.g. "funding"). */
+ feeType?: string | null
+}
+
+export function FundingActivityList({ feeType = null }: Props) {
+ const account = useWalletStore((state) => state.address)
+ const queryClient = useQueryClient()
+ const network = activeQueryNetwork()
+
+ const feeHistory = useAccountFeeClaims(account, feeType)
+
+ const [claiming, setClaiming] = useState(null)
+ const [claimError, setClaimError] = useState(null)
+
+ /** Claim a single settled funding-fee row. */
+ async function handleClaim(row: FeeClaim) {
+ if (!account || !row.market?.id) return
+ const claimKey = row.id
+ setClaiming(claimKey)
+ setClaimError(null)
+ try {
+ await claimFundingFees(
+ account,
+ [row.market.id],
+ row.token?.address ? [row.token.address] : [],
+ )
+ // Refresh fee history and balances after the claim transaction confirms.
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: indexerQueryKeys.fees.pagesAll(account),
+ refetchType: "active",
+ }),
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.wallet.tokenBalances(account, network),
+ refetchType: "active",
+ }),
+ ])
+ } catch (e) {
+ setClaimError(e instanceof Error ? e.message : "Claim failed")
+ } finally {
+ setClaiming(null)
+ }
+ }
+
+ const columns: Array> = [
+ {
+ id: "market",
+ header: "Market",
+ accessor: (row) => (
+ {row.market?.name ?? row.market?.key ?? "—"}
+ ),
+ },
+ {
+ id: "type",
+ header: "Type",
+ accessor: (row) => (
+ {row.feeType.replace(/_/g, " ")}
+ ),
+ },
+ {
+ id: "status",
+ header: "Status",
+ accessor: (row) => (
+
+ {/* OB-088: Accrual/settlement status must be unambiguous from trading PnL. */}
+ {feeStatusLabel(row.status)}
+
+ ),
+ },
+ {
+ id: "amount",
+ header: "Amount",
+ accessor: (row) => {
+ const symbol = row.token?.symbol ?? "?"
+ const decimals = row.token?.decimals ?? null
+ const amount = parseAmount(row.amount, decimals)
+ // OB-088: Show the asset symbol explicitly — never omit units to avoid
+ // confusion with a USD PnL figure.
+ return (
+
+
+ {" "}
+ {symbol}
+
+ )
+ },
+ },
+ {
+ id: "amountUsd",
+ header: "Value",
+ accessor: (row) =>
+ row.amountUsd ? (
+
+ ) : (
+ —
+ ),
+ },
+ {
+ id: "timestamp",
+ header: "Time",
+ accessor: (row) => (
+ {formatTimestamp(row.timestamp)}
+ ),
+ },
+ {
+ id: "actions",
+ header: "",
+ accessor: (row) => {
+ // OB-088: Only settled funding-type rows support a claim action. All
+ // other types (position fees, UI fees) are settled automatically and
+ // have no claim path — they must remain clearly unavailable.
+ const canClaim =
+ isFundingClaim(row.feeType) &&
+ row.status === "settled" &&
+ !!row.market?.id
+
+ if (!canClaim) {
+ // Explicitly show that claim is not applicable rather than hiding it,
+ // so the user can tell this is intentional and not a bug.
+ if (row.status === "accrued" || row.status === "settled") {
+ return (
+
+ Unavailable
+
+ )
+ }
+ return null
+ }
+
+ return (
+
+ )
+ },
+ },
+ ]
+
+ if (feeHistory.isDisabled) {
+ return (
+
+ Fee activity is unavailable while the indexer is disabled.
+