diff --git a/features/offer-liability-headroom/README.md b/features/offer-liability-headroom/README.md
new file mode 100644
index 00000000..bd493ded
--- /dev/null
+++ b/features/offer-liability-headroom/README.md
@@ -0,0 +1,23 @@
+# Offer Liability and Capacity Audit
+
+Diagnostic snapshot of an account's Horizon offers next to balance and liability fields.
+
+## Behavior
+
+1. Validate a G-address (reject secret keys).
+2. Fetch `/accounts/{id}` and `/accounts/{id}/offers` on the selected network.
+3. Normalize buying/selling asset identities and exact `price_r` rationals.
+4. Pair offers with balance rows; warn on unmatched assets or ledger skew.
+5. Never imply that estimates guarantee execution.
+
+## Data boundaries
+
+- Configured Horizon networks only.
+- Amounts and prices stay in strings / `BigInt` rationals.
+- No offer mutation.
+
+## Design decisions
+
+- Large ledger skew between the two reads fails as `inconsistent_snapshot`.
+- Small skew is shown as a warning so the UI stays usable.
+- Approximate decimals are display-only; calculations keep the fraction.
diff --git a/features/offer-liability-headroom/__tests__/OfferLiabilityHeadroomPanel.test.tsx b/features/offer-liability-headroom/__tests__/OfferLiabilityHeadroomPanel.test.tsx
new file mode 100644
index 00000000..dea7aacc
--- /dev/null
+++ b/features/offer-liability-headroom/__tests__/OfferLiabilityHeadroomPanel.test.tsx
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+import { renderFeature, screen } from "@/core/testing/render";
+import { withMswHandlers } from "@/core/testing/msw";
+import { resetHorizonClients } from "@/core/horizon/client";
+import { OfferLiabilityHeadroomPanel } from "@/features/offer-liability-headroom/components/OfferLiabilityHeadroomPanel";
+import { copy, errorCopy } from "@/features/offer-liability-headroom/copy";
+import { handlers, rateLimitedHandler } from "@/features/offer-liability-headroom/msw/handlers";
+import { accountId } from "@/features/offer-liability-headroom/fixtures/offerLiabilityHeadroom.fixture";
+
+const server = withMswHandlers(...handlers);
+
+describe("OfferLiabilityHeadroomPanel", () => {
+ it("shows empty state", () => {
+ renderFeature();
+ expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument();
+ });
+
+ it("renders offers and liabilities", async () => {
+ resetHorizonClients();
+ const { user } = renderFeature(, { network: "testnet" });
+ await user.type(screen.getByLabelText(copy.formLabel), accountId);
+ await user.click(screen.getByRole("button", { name: copy.submit }));
+ expect(await screen.findByText(copy.offersTitle)).toBeInTheDocument();
+ expect(screen.getByText(copy.liabilitiesTitle)).toBeInTheDocument();
+ });
+
+ it("explains invalid accounts", async () => {
+ const { user } = renderFeature();
+ await user.type(screen.getByLabelText(copy.formLabel), "nope");
+ await user.click(screen.getByRole("button", { name: copy.submit }));
+ expect(await screen.findByText(errorCopy.invalid_account.title)).toBeInTheDocument();
+ });
+
+ it("explains rate limiting", async () => {
+ server.use(rateLimitedHandler);
+ resetHorizonClients();
+ const { user } = renderFeature(, { network: "testnet" });
+ await user.type(screen.getByLabelText(copy.formLabel), accountId);
+ await user.click(screen.getByRole("button", { name: copy.submit }));
+ expect(await screen.findByText(errorCopy.rate_limited.title)).toBeInTheDocument();
+ });
+});
diff --git a/features/offer-liability-headroom/__tests__/a11y.test.tsx b/features/offer-liability-headroom/__tests__/a11y.test.tsx
new file mode 100644
index 00000000..8b28c7c1
--- /dev/null
+++ b/features/offer-liability-headroom/__tests__/a11y.test.tsx
@@ -0,0 +1,29 @@
+import { describe, it } from "vitest";
+import { renderFeature, screen } from "@/core/testing/render";
+import { expectNoAxeViolations } from "@/core/testing/axe";
+import { withMswHandlers } from "@/core/testing/msw";
+import { resetHorizonClients } from "@/core/horizon/client";
+import { OfferLiabilityHeadroomPanel } from "@/features/offer-liability-headroom/components/OfferLiabilityHeadroomPanel";
+import { copy } from "@/features/offer-liability-headroom/copy";
+import { handlers } from "@/features/offer-liability-headroom/msw/handlers";
+import { accountId } from "@/features/offer-liability-headroom/fixtures/offerLiabilityHeadroom.fixture";
+
+withMswHandlers(...handlers);
+
+describe("OfferLiabilityHeadroomPanel accessibility", () => {
+ it("has no WCAG A/AA violations idle", async () => {
+ const { container } = renderFeature();
+ await expectNoAxeViolations(container);
+ });
+
+ it("has no WCAG A/AA violations after success", async () => {
+ resetHorizonClients();
+ const { container, user } = renderFeature(, {
+ network: "testnet"
+ });
+ await user.type(screen.getByLabelText(copy.formLabel), accountId);
+ await user.click(screen.getByRole("button", { name: copy.submit }));
+ await screen.findByText(copy.offersTitle);
+ await expectNoAxeViolations(container);
+ });
+});
diff --git a/features/offer-liability-headroom/__tests__/format.test.ts b/features/offer-liability-headroom/__tests__/format.test.ts
new file mode 100644
index 00000000..e3289e45
--- /dev/null
+++ b/features/offer-liability-headroom/__tests__/format.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import {
+ formatAssetLabel,
+ formatNetworkLabel,
+ formatOfferLine,
+ formatPrice
+} from "@/features/offer-liability-headroom/lib/format";
+
+describe("format helpers", () => {
+ it("labels networks and assets", () => {
+ expect(formatNetworkLabel("testnet")).toBe("Testnet");
+ expect(formatAssetLabel({ kind: "native", key: "native", label: "XLM (native)" })).toBe(
+ "XLM (native)"
+ );
+ });
+
+ it("formats exact prices with approximate preview", () => {
+ expect(
+ formatPrice({
+ numerator: "1",
+ denominator: "2",
+ display: "1/2",
+ approximate: "0.5"
+ })
+ ).toBe("1/2 (≈ 0.5)");
+ });
+
+ it("formats offer lines", () => {
+ expect(
+ formatOfferLine({
+ id: "1",
+ selling: { kind: "native", key: "native", label: "XLM (native)" },
+ buying: {
+ kind: "credit",
+ key: "USDC:G",
+ code: "USDC",
+ issuer: "G",
+ label: "USDC"
+ },
+ amount: "25.0000000",
+ price: {
+ numerator: "1",
+ denominator: "2",
+ display: "1/2",
+ approximate: "0.5"
+ },
+ lastModifiedLedger: "1",
+ matchedBalance: true
+ })
+ ).toContain("25.0000000");
+ });
+});
diff --git a/features/offer-liability-headroom/__tests__/liability-audit.test.ts b/features/offer-liability-headroom/__tests__/liability-audit.test.ts
new file mode 100644
index 00000000..336b7a7e
--- /dev/null
+++ b/features/offer-liability-headroom/__tests__/liability-audit.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildLiabilityRows,
+ buildOfferRows
+} from "@/features/offer-liability-headroom/lib/liability-audit";
+import {
+ accountResponse,
+ offersResponse,
+ unmatchedOffersFixture
+} from "@/features/offer-liability-headroom/fixtures/offerLiabilityHeadroom.fixture";
+
+describe("liability-audit", () => {
+ it("builds balance rows with headroom estimates", () => {
+ const rows = buildLiabilityRows(accountResponse.balances);
+ expect(rows[0]).toMatchObject({
+ asset: { key: "native" },
+ availableToSellEstimate: "75"
+ });
+ });
+
+ it("matches offers to selling balance rows", () => {
+ const liabilities = buildLiabilityRows(accountResponse.balances);
+ const keys = new Set(liabilities.map((row) => row.asset.key));
+ const built = buildOfferRows(offersResponse._embedded.records, keys);
+ expect(built.malformed).toBe(false);
+ expect(built.offers[0]?.matchedBalance).toBe(true);
+ expect(built.offers[0]?.price.display).toBe("1/2");
+ });
+
+ it("flags unmatched selling assets", () => {
+ const liabilities = buildLiabilityRows(accountResponse.balances);
+ const keys = new Set(liabilities.map((row) => row.asset.key));
+ const built = buildOfferRows(unmatchedOffersFixture._embedded.records, keys);
+ expect(built.offers[0]?.matchedBalance).toBe(false);
+ });
+});
diff --git a/features/offer-liability-headroom/__tests__/offer-assets.test.ts b/features/offer-liability-headroom/__tests__/offer-assets.test.ts
new file mode 100644
index 00000000..f624b516
--- /dev/null
+++ b/features/offer-liability-headroom/__tests__/offer-assets.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+import { normalizeOfferAsset } from "@/features/offer-liability-headroom/lib/offer-assets";
+import { issuerId } from "@/features/offer-liability-headroom/fixtures/offerLiabilityHeadroom.fixture";
+
+describe("normalizeOfferAsset", () => {
+ it("normalizes native assets", () => {
+ expect(normalizeOfferAsset({ asset_type: "native" })).toEqual({
+ kind: "native",
+ key: "native",
+ label: "XLM (native)"
+ });
+ });
+
+ it("normalizes credit assets with uppercase codes", () => {
+ expect(
+ normalizeOfferAsset({
+ asset_type: "credit_alphanum4",
+ asset_code: "usdc",
+ asset_issuer: issuerId
+ })
+ ).toMatchObject({
+ kind: "credit",
+ code: "USDC",
+ key: `USDC:${issuerId}`
+ });
+ });
+
+ it("rejects incomplete credit assets", () => {
+ expect(
+ normalizeOfferAsset({ asset_type: "credit_alphanum4", asset_code: "USDC" })
+ ).toBeNull();
+ });
+});
diff --git a/features/offer-liability-headroom/__tests__/offerLiabilityHeadroom.test.ts b/features/offer-liability-headroom/__tests__/offerLiabilityHeadroom.test.ts
new file mode 100644
index 00000000..8eba3239
--- /dev/null
+++ b/features/offer-liability-headroom/__tests__/offerLiabilityHeadroom.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest";
+import { withMswHandlers } from "@/core/testing/msw";
+import { resetHorizonClients } from "@/core/horizon/client";
+import { runOfferLiabilityHeadroom } from "@/features/offer-liability-headroom/lib/offerLiabilityHeadroom";
+import {
+ handlers,
+ inconsistentHandlerAccount,
+ inconsistentHandlerOffers,
+ malformedHandler,
+ rateLimitedHandler
+} from "@/features/offer-liability-headroom/msw/handlers";
+import {
+ accountId,
+ unknownAccountId
+} from "@/features/offer-liability-headroom/fixtures/offerLiabilityHeadroom.fixture";
+
+const server = withMswHandlers(...handlers);
+
+describe("runOfferLiabilityHeadroom", () => {
+ it("loads account balances and offers", async () => {
+ resetHorizonClients();
+ const result = await runOfferLiabilityHeadroom({ accountId }, "testnet");
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.offers).toHaveLength(1);
+ expect(result.value.liabilities.length).toBeGreaterThan(0);
+ });
+
+ it("maps account not found", async () => {
+ resetHorizonClients();
+ expect(await runOfferLiabilityHeadroom({ accountId: unknownAccountId }, "testnet")).toEqual({
+ ok: false,
+ code: "account_not_found"
+ });
+ });
+
+ it("maps malformed offers", async () => {
+ server.use(malformedHandler);
+ resetHorizonClients();
+ expect(await runOfferLiabilityHeadroom({ accountId }, "testnet")).toEqual({
+ ok: false,
+ code: "malformed_offer"
+ });
+ });
+
+ it("maps inconsistent snapshots", async () => {
+ server.use(inconsistentHandlerAccount, inconsistentHandlerOffers);
+ resetHorizonClients();
+ expect(await runOfferLiabilityHeadroom({ accountId }, "testnet")).toEqual({
+ ok: false,
+ code: "inconsistent_snapshot"
+ });
+ });
+
+ it("maps rate limiting", async () => {
+ server.use(rateLimitedHandler);
+ resetHorizonClients();
+ expect(await runOfferLiabilityHeadroom({ accountId }, "testnet")).toEqual({
+ ok: false,
+ code: "rate_limited"
+ });
+ });
+});
diff --git a/features/offer-liability-headroom/__tests__/rational-price.test.ts b/features/offer-liability-headroom/__tests__/rational-price.test.ts
new file mode 100644
index 00000000..20024185
--- /dev/null
+++ b/features/offer-liability-headroom/__tests__/rational-price.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import {
+ formatAmountString,
+ formatRationalApproximate,
+ formatRationalDisplay,
+ parseAmountString,
+ parseRationalPrice,
+ subtractAmounts
+} from "@/features/offer-liability-headroom/lib/rational-price";
+
+describe("rational-price", () => {
+ it("reduces and displays rationals exactly", () => {
+ const price = parseRationalPrice({ n: 2, d: 4 });
+ expect(price).toEqual({ n: 1n, d: 2n });
+ expect(formatRationalDisplay(1n, 2n)).toBe("1/2");
+ expect(formatRationalApproximate(1n, 2n)).toBe("0.5");
+ });
+
+ it("parses and subtracts amount strings without float drift", () => {
+ expect(parseAmountString("25.0000000")).toBe(250_000_000n);
+ expect(formatAmountString(250_000_000n)).toBe("25");
+ expect(subtractAmounts("100.0000000", "25.0000000")).toBe("75");
+ });
+
+ it("rejects invalid rationals and amounts", () => {
+ expect(parseRationalPrice({ n: 0, d: 1 })).toBeNull();
+ expect(parseAmountString("1.12345678")).toBeNull();
+ });
+});
diff --git a/features/offer-liability-headroom/__tests__/schema.test.ts b/features/offer-liability-headroom/__tests__/schema.test.ts
new file mode 100644
index 00000000..904e59c3
--- /dev/null
+++ b/features/offer-liability-headroom/__tests__/schema.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { parseOfferLiabilityHeadroomInput } from "@/features/offer-liability-headroom/schema";
+import { accountId } from "@/features/offer-liability-headroom/fixtures/offerLiabilityHeadroom.fixture";
+
+describe("parseOfferLiabilityHeadroomInput", () => {
+ it("accepts a G-address", () => {
+ expect(parseOfferLiabilityHeadroomInput(accountId)).toEqual({
+ ok: true,
+ value: { accountId }
+ });
+ });
+
+ it("rejects empty, secret keys and malformed addresses", () => {
+ expect(parseOfferLiabilityHeadroomInput("")).toEqual({
+ ok: false,
+ code: "invalid_account"
+ });
+ expect(parseOfferLiabilityHeadroomInput("SAGH")).toEqual({
+ ok: false,
+ code: "invalid_account"
+ });
+ expect(parseOfferLiabilityHeadroomInput("not-an-address")).toEqual({
+ ok: false,
+ code: "invalid_account"
+ });
+ });
+});
diff --git a/features/offer-liability-headroom/__tests__/useOfferLiabilityHeadroom.test.tsx b/features/offer-liability-headroom/__tests__/useOfferLiabilityHeadroom.test.tsx
new file mode 100644
index 00000000..27921acd
--- /dev/null
+++ b/features/offer-liability-headroom/__tests__/useOfferLiabilityHeadroom.test.tsx
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { NetworkProvider } from "@/core/network/NetworkProvider";
+import { withMswHandlers } from "@/core/testing/msw";
+import { resetHorizonClients } from "@/core/horizon/client";
+import { useOfferLiabilityHeadroom } from "@/features/offer-liability-headroom/hooks/useOfferLiabilityHeadroom";
+import { handlers, rateLimitedHandler } from "@/features/offer-liability-headroom/msw/handlers";
+import { accountId } from "@/features/offer-liability-headroom/fixtures/offerLiabilityHeadroom.fixture";
+import type { StellarNetwork } from "@/core/network/types";
+
+const server = withMswHandlers(...handlers);
+
+function wrapperFor(network: StellarNetwork) {
+ return function Wrapper({ children }: { children: React.ReactNode }) {
+ return {children};
+ };
+}
+
+describe("useOfferLiabilityHeadroom", () => {
+ it("starts idle", () => {
+ const { result } = renderHook(() => useOfferLiabilityHeadroom(), {
+ wrapper: wrapperFor("testnet")
+ });
+ expect(result.current.state).toEqual({ status: "idle" });
+ });
+
+ it("loads a snapshot", async () => {
+ resetHorizonClients();
+ const { result } = renderHook(() => useOfferLiabilityHeadroom(), {
+ wrapper: wrapperFor("testnet")
+ });
+ await act(async () => {
+ await result.current.submit(accountId);
+ });
+ await waitFor(() => expect(result.current.state.status).toBe("success"));
+ });
+
+ it("surfaces invalid accounts", async () => {
+ const { result } = renderHook(() => useOfferLiabilityHeadroom(), {
+ wrapper: wrapperFor("testnet")
+ });
+ await act(async () => {
+ await result.current.submit("nope");
+ });
+ expect(result.current.state).toEqual({ status: "error", code: "invalid_account" });
+ });
+
+ it("surfaces rate limiting", async () => {
+ server.use(rateLimitedHandler);
+ resetHorizonClients();
+ const { result } = renderHook(() => useOfferLiabilityHeadroom(), {
+ wrapper: wrapperFor("testnet")
+ });
+ await act(async () => {
+ await result.current.submit(accountId);
+ });
+ await waitFor(() =>
+ expect(result.current.state).toEqual({ status: "error", code: "rate_limited" })
+ );
+ });
+
+ it("resets", async () => {
+ resetHorizonClients();
+ const { result } = renderHook(() => useOfferLiabilityHeadroom(), {
+ wrapper: wrapperFor("testnet")
+ });
+ await act(async () => {
+ await result.current.submit(accountId);
+ });
+ await waitFor(() => expect(result.current.state.status).toBe("success"));
+ act(() => result.current.reset());
+ expect(result.current.state).toEqual({ status: "idle" });
+ });
+});
diff --git a/features/offer-liability-headroom/components/LiabilityRows.tsx b/features/offer-liability-headroom/components/LiabilityRows.tsx
new file mode 100644
index 00000000..d9e82c82
--- /dev/null
+++ b/features/offer-liability-headroom/components/LiabilityRows.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { copy } from "@/features/offer-liability-headroom/copy";
+import { formatAssetLabel } from "@/features/offer-liability-headroom/lib/format";
+import type { BalanceLiabilityRow } from "@/features/offer-liability-headroom/types";
+
+export function LiabilityRows({ rows }: { rows: BalanceLiabilityRow[] }) {
+ if (rows.length === 0) {
+ return
{copy.noBalances}
;
+ }
+
+ return (
+
+ {rows.map((row) => (
+ -
+
{formatAssetLabel(row.asset)}
+
+
+ {copy.balanceLabel}: {row.balance}
+
+
+ {copy.sellingLiabilitiesLabel}:{" "}
+ {row.sellingLiabilities}
+
+
+ {copy.buyingLiabilitiesLabel}:{" "}
+ {row.buyingLiabilities}
+
+
+ {copy.limitLabel}:{" "}
+ {row.limit ?? "—"}
+
+
+ {copy.availableLabel}:{" "}
+ {row.availableToSellEstimate ?? "—"}
+
+
+
+ ))}
+
+ );
+}
diff --git a/features/offer-liability-headroom/components/OfferLiabilityHeadroomEmptyState.tsx b/features/offer-liability-headroom/components/OfferLiabilityHeadroomEmptyState.tsx
new file mode 100644
index 00000000..e24018a8
--- /dev/null
+++ b/features/offer-liability-headroom/components/OfferLiabilityHeadroomEmptyState.tsx
@@ -0,0 +1,15 @@
+"use client";
+
+import { Card, CardHeader, CardTitle } from "@/core/ui/Card";
+import { copy } from "@/features/offer-liability-headroom/copy";
+
+export function OfferLiabilityHeadroomEmptyState() {
+ return (
+
+
+ {copy.emptyTitle}
+
+ {copy.emptyDescription}
+
+ );
+}
diff --git a/features/offer-liability-headroom/components/OfferLiabilityHeadroomForm.tsx b/features/offer-liability-headroom/components/OfferLiabilityHeadroomForm.tsx
new file mode 100644
index 00000000..1bf3ad3f
--- /dev/null
+++ b/features/offer-liability-headroom/components/OfferLiabilityHeadroomForm.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { useState, type FormEvent } from "react";
+import { Button } from "@/core/ui/Button";
+import { Field } from "@/core/ui/Field";
+import { Input } from "@/core/ui/Input";
+import { copy } from "@/features/offer-liability-headroom/copy";
+
+export function OfferLiabilityHeadroomForm({
+ onSubmit,
+ pending
+}: {
+ onSubmit: (value: string) => void;
+ pending: boolean;
+}) {
+ const [accountId, setAccountId] = useState("");
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ onSubmit(accountId);
+ }
+
+ return (
+
+ );
+}
diff --git a/features/offer-liability-headroom/components/OfferLiabilityHeadroomPanel.tsx b/features/offer-liability-headroom/components/OfferLiabilityHeadroomPanel.tsx
new file mode 100644
index 00000000..a5770bb0
--- /dev/null
+++ b/features/offer-liability-headroom/components/OfferLiabilityHeadroomPanel.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { Card } from "@/core/ui/Card";
+import { SkeletonRows } from "@/core/ui/Skeleton";
+import { StatusMessage } from "@/core/ui/StatusMessage";
+import { OfferLiabilityHeadroomEmptyState } from "@/features/offer-liability-headroom/components/OfferLiabilityHeadroomEmptyState";
+import { OfferLiabilityHeadroomForm } from "@/features/offer-liability-headroom/components/OfferLiabilityHeadroomForm";
+import { OfferLiabilityHeadroomResultView } from "@/features/offer-liability-headroom/components/OfferLiabilityHeadroomResult";
+import { copy, errorCopy } from "@/features/offer-liability-headroom/copy";
+import { useOfferLiabilityHeadroom } from "@/features/offer-liability-headroom/hooks/useOfferLiabilityHeadroom";
+
+export function OfferLiabilityHeadroomPanel() {
+ const { state, submit } = useOfferLiabilityHeadroom();
+
+ return (
+
+
+
+
+
+ {state.status === "loading" ? (
+
+
+ {copy.loading}
+
+
+
+ ) : null}
+
+ {state.status === "error" ? (
+
+ ) : null}
+
+ {state.status === "success" ? (
+
+ ) : null}
+
+ {state.status === "idle" ?
: null}
+
+ );
+}
diff --git a/features/offer-liability-headroom/components/OfferLiabilityHeadroomResult.tsx b/features/offer-liability-headroom/components/OfferLiabilityHeadroomResult.tsx
new file mode 100644
index 00000000..e821ae52
--- /dev/null
+++ b/features/offer-liability-headroom/components/OfferLiabilityHeadroomResult.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { Card, CardHeader, CardTitle } from "@/core/ui/Card";
+import { DataList } from "@/core/ui/DataList";
+import { LiabilityRows } from "@/features/offer-liability-headroom/components/LiabilityRows";
+import { OfferRows } from "@/features/offer-liability-headroom/components/OfferRows";
+import { SnapshotWarning } from "@/features/offer-liability-headroom/components/SnapshotWarning";
+import { copy } from "@/features/offer-liability-headroom/copy";
+import { formatNetworkLabel } from "@/features/offer-liability-headroom/lib/format";
+import type { OfferLiabilityHeadroomResult } from "@/features/offer-liability-headroom/types";
+
+export function OfferLiabilityHeadroomResultView({
+ result
+}: {
+ result: OfferLiabilityHeadroomResult;
+}) {
+ return (
+
+
+
+ {copy.summaryTitle}
+
+
+
+
+
+
+ {copy.offersTitle}
+
+
+
+
+
+
+ {copy.liabilitiesTitle}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/features/offer-liability-headroom/components/OfferRows.tsx b/features/offer-liability-headroom/components/OfferRows.tsx
new file mode 100644
index 00000000..30a2492d
--- /dev/null
+++ b/features/offer-liability-headroom/components/OfferRows.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { copy } from "@/features/offer-liability-headroom/copy";
+import { formatOfferLine, formatPrice } from "@/features/offer-liability-headroom/lib/format";
+import type { OfferRow } from "@/features/offer-liability-headroom/types";
+
+export function OfferRows({ offers }: { offers: OfferRow[] }) {
+ if (offers.length === 0) {
+ return {copy.noOffers}
;
+ }
+
+ return (
+
+ {offers.map((offer) => (
+ -
+
{formatOfferLine(offer)}
+
+ {copy.priceLabel}: {formatPrice(offer.price)}
+ {!offer.matchedBalance ? " · unmatched balance row" : ""}
+
+
+ ))}
+
+ );
+}
diff --git a/features/offer-liability-headroom/components/SnapshotWarning.tsx b/features/offer-liability-headroom/components/SnapshotWarning.tsx
new file mode 100644
index 00000000..ee00b266
--- /dev/null
+++ b/features/offer-liability-headroom/components/SnapshotWarning.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import { StatusMessage } from "@/core/ui/StatusMessage";
+import { copy } from "@/features/offer-liability-headroom/copy";
+import type { OfferLiabilityHeadroomResult } from "@/features/offer-liability-headroom/types";
+
+export function SnapshotWarning({ result }: { result: OfferLiabilityHeadroomResult }) {
+ return (
+
+
+ {result.ledgerSkew ? (
+
+ ) : null}
+ {result.unmatchedOfferCount > 0 ? (
+
+ ) : null}
+
+ );
+}
diff --git a/features/offer-liability-headroom/copy.ts b/features/offer-liability-headroom/copy.ts
new file mode 100644
index 00000000..a24a55aa
--- /dev/null
+++ b/features/offer-liability-headroom/copy.ts
@@ -0,0 +1,69 @@
+import type { OfferLiabilityHeadroomErrorCode } from "@/features/offer-liability-headroom/types";
+
+export const copy = {
+ formLabel: "Account address",
+ formHint: "G-address on the selected network. Secret keys are rejected.",
+ submit: "Audit offer liabilities",
+ loading: "Reading account balances and offers...",
+ emptyTitle: "No liability snapshot yet",
+ emptyDescription:
+ "Inspect an account's offers next to balance and liability fields. Estimates never guarantee that an offer will execute.",
+ summaryTitle: "Snapshot",
+ offersTitle: "Active offers",
+ liabilitiesTitle: "Balances and liabilities",
+ warningsTitle: "Snapshot warnings",
+ networkLabel: "Network",
+ accountLabel: "Account",
+ accountLedgerLabel: "Account ledger",
+ offersLedgerLabel: "Offers ledger",
+ unmatchedLabel: "Offers without a matching balance row",
+ disclaimer:
+ "Displayed estimates are diagnostic only. They do not guarantee order book execution or future fill capacity.",
+ skewWarning:
+ "Account and offers were read from different ledger cursors. Treat liability pairing as approximate.",
+ unmatchedWarning:
+ "Some offers reference assets with no matching current balance row on the account payload.",
+ noOffers: "No open offers on this account.",
+ noBalances: "No balances returned for this account.",
+ sellingLabel: "Selling",
+ buyingLabel: "Buying",
+ amountLabel: "Amount",
+ priceLabel: "Price (exact)",
+ balanceLabel: "Balance",
+ sellingLiabilitiesLabel: "Selling liabilities",
+ buyingLiabilitiesLabel: "Buying liabilities",
+ limitLabel: "Trustline limit",
+ availableLabel: "Estimated headroom to sell"
+} as const;
+
+export const errorCopy: Record<
+ OfferLiabilityHeadroomErrorCode,
+ { title: string; description: string }
+> = {
+ invalid_account: {
+ title: "Account address is not valid",
+ description: "Paste a G-address. Secret keys (S…) are never accepted."
+ },
+ account_not_found: {
+ title: "Account was not found",
+ description: "Horizon has no record for this address on the selected network."
+ },
+ malformed_offer: {
+ title: "Offer records could not be used",
+ description:
+ "Horizon returned offers without usable asset identities, amounts or rational prices."
+ },
+ inconsistent_snapshot: {
+ title: "Account and offers could not be paired safely",
+ description:
+ "The two Horizon reads disagree too strongly to present a reliable liability snapshot. Try again."
+ },
+ rate_limited: {
+ title: "Horizon is rate limiting this request",
+ description: "Wait a moment before auditing again."
+ },
+ request_failed: {
+ title: "The request did not complete",
+ description: "Horizon responded with an error or the connection failed."
+ }
+};
diff --git a/features/offer-liability-headroom/e2e/offer-liability-headroom.spec.ts b/features/offer-liability-headroom/e2e/offer-liability-headroom.spec.ts
new file mode 100644
index 00000000..f1873af1
--- /dev/null
+++ b/features/offer-liability-headroom/e2e/offer-liability-headroom.spec.ts
@@ -0,0 +1,14 @@
+import { accountId } from "@/features/offer-liability-headroom/fixtures/offerLiabilityHeadroom.fixture";
+
+export const spec = {
+ route: "/tools/offer-liability-headroom",
+ steps: [
+ { action: "visit", target: "/tools/offer-liability-headroom" },
+ { action: "expect", target: "heading", value: "Offer Liability and Capacity Audit" },
+ { action: "expect", target: "text", value: "No liability snapshot yet" },
+ { action: "fill", target: "Account address", value: accountId },
+ { action: "click", target: "Audit offer liabilities" },
+ { action: "expect", target: "text", value: "Active offers" },
+ { action: "expect", target: "text", value: "Balances and liabilities" }
+ ]
+} as const;
diff --git a/features/offer-liability-headroom/e2e/unmatched-offer.spec.ts b/features/offer-liability-headroom/e2e/unmatched-offer.spec.ts
new file mode 100644
index 00000000..f7e0c32e
--- /dev/null
+++ b/features/offer-liability-headroom/e2e/unmatched-offer.spec.ts
@@ -0,0 +1,12 @@
+import { accountId } from "@/features/offer-liability-headroom/fixtures/offerLiabilityHeadroom.fixture";
+
+export const spec = {
+ route: "/tools/offer-liability-headroom",
+ steps: [
+ { action: "visit", target: "/tools/offer-liability-headroom" },
+ { action: "switchNetwork", value: "mainnet" },
+ { action: "fill", target: "Account address", value: accountId },
+ { action: "click", target: "Audit offer liabilities" },
+ { action: "expect", target: "text", value: "Some offers reference assets with no matching current balance row" }
+ ]
+} as const;
diff --git a/features/offer-liability-headroom/fixtures/offerLiabilityHeadroom.fixture.ts b/features/offer-liability-headroom/fixtures/offerLiabilityHeadroom.fixture.ts
new file mode 100644
index 00000000..959d292a
--- /dev/null
+++ b/features/offer-liability-headroom/fixtures/offerLiabilityHeadroom.fixture.ts
@@ -0,0 +1,97 @@
+import { Keypair } from "@stellar/stellar-sdk";
+
+const seed = (byte: number) => Keypair.fromRawEd25519Seed(Buffer.alloc(32, byte));
+
+export const accountId = seed(11).publicKey();
+export const issuerId = seed(12).publicKey();
+export const unknownAccountId = seed(13).publicKey();
+
+export const accountResponse = {
+ id: accountId,
+ account_id: accountId,
+ last_modified_ledger: 5000,
+ balances: [
+ {
+ balance: "100.0000000",
+ asset_type: "native",
+ buying_liabilities: "0.0000000",
+ selling_liabilities: "25.0000000"
+ },
+ {
+ balance: "50.0000000",
+ limit: "1000.0000000",
+ asset_type: "credit_alphanum4",
+ asset_code: "USDC",
+ asset_issuer: issuerId,
+ buying_liabilities: "5.0000000",
+ selling_liabilities: "0.0000000",
+ is_authorized: true
+ }
+ ]
+};
+
+export const offersResponse = {
+ _embedded: {
+ records: [
+ {
+ id: "1",
+ amount: "25.0000000",
+ price_r: { n: 1, d: 2 },
+ last_modified_ledger: 4999,
+ selling: { asset_type: "native" },
+ buying: {
+ asset_type: "credit_alphanum4",
+ asset_code: "USDC",
+ asset_issuer: issuerId
+ }
+ }
+ ]
+ }
+};
+
+export const offerLiabilityHeadroomFixture = {
+ account: accountResponse,
+ offers: offersResponse
+};
+
+/** Edge fixture: offer sells an asset with no matching balance row. */
+export const unmatchedOffersFixture = {
+ _embedded: {
+ records: [
+ {
+ id: "9",
+ amount: "1.0000000",
+ price_r: { n: 1, d: 1 },
+ last_modified_ledger: 5000,
+ selling: {
+ asset_type: "credit_alphanum4",
+ asset_code: "EURC",
+ asset_issuer: issuerId
+ },
+ buying: { asset_type: "native" }
+ }
+ ]
+ }
+};
+
+export const skewedAccountResponse = {
+ ...accountResponse,
+ last_modified_ledger: 6000
+};
+
+export const skewedOffersResponse = {
+ _embedded: {
+ records: [
+ {
+ ...offersResponse._embedded.records[0],
+ last_modified_ledger: 1000
+ }
+ ]
+ }
+};
+
+export const malformedOffersResponse = {
+ _embedded: {
+ records: [{ id: "bad", amount: "x", selling: { asset_type: "native" } }]
+ }
+};
diff --git a/features/offer-liability-headroom/fixtures/offers.fixture.ts b/features/offer-liability-headroom/fixtures/offers.fixture.ts
new file mode 100644
index 00000000..b4f89e26
--- /dev/null
+++ b/features/offer-liability-headroom/fixtures/offers.fixture.ts
@@ -0,0 +1,4 @@
+export {
+ offersResponse as offersFixture,
+ offerLiabilityHeadroomFixture
+} from "@/features/offer-liability-headroom/fixtures/offerLiabilityHeadroom.fixture";
diff --git a/features/offer-liability-headroom/fixtures/unmatched-assets.fixture.ts b/features/offer-liability-headroom/fixtures/unmatched-assets.fixture.ts
new file mode 100644
index 00000000..92ffd2ab
--- /dev/null
+++ b/features/offer-liability-headroom/fixtures/unmatched-assets.fixture.ts
@@ -0,0 +1 @@
+export { unmatchedOffersFixture } from "@/features/offer-liability-headroom/fixtures/offerLiabilityHeadroom.fixture";
diff --git a/features/offer-liability-headroom/hooks/useOfferLiabilityHeadroom.ts b/features/offer-liability-headroom/hooks/useOfferLiabilityHeadroom.ts
new file mode 100644
index 00000000..6063e4ff
--- /dev/null
+++ b/features/offer-liability-headroom/hooks/useOfferLiabilityHeadroom.ts
@@ -0,0 +1,78 @@
+"use client";
+
+import { useCallback, useRef, useState } from "react";
+import { useNetwork } from "@/core/network/NetworkProvider";
+import type { StellarNetwork } from "@/core/network/types";
+import { isErr, type Result } from "@/core/result/result";
+import { parseOfferLiabilityHeadroomInput } from "@/features/offer-liability-headroom/schema";
+import { runOfferLiabilityHeadroom } from "@/features/offer-liability-headroom/lib/offerLiabilityHeadroom";
+import { toOfferLiabilityHeadroomErrorCode } from "@/features/offer-liability-headroom/lib/offerLiabilityHeadroom.errors";
+import type {
+ OfferLiabilityHeadroomErrorCode,
+ OfferLiabilityHeadroomResult
+} from "@/features/offer-liability-headroom/types";
+
+export type OfferLiabilityHeadroomState =
+ | { status: "idle" }
+ | { status: "loading" }
+ | { status: "success"; result: OfferLiabilityHeadroomResult }
+ | { status: "error"; code: OfferLiabilityHeadroomErrorCode };
+
+const IDLE: OfferLiabilityHeadroomState = { status: "idle" };
+
+interface Held {
+ state: OfferLiabilityHeadroomState;
+ network: StellarNetwork;
+}
+
+export function useOfferLiabilityHeadroom() {
+ const { network } = useNetwork();
+ const [held, setHeld] = useState({ state: IDLE, network });
+ const controller = useRef(null);
+ const requestId = useRef(0);
+ const state = held.network === network ? held.state : IDLE;
+
+ const submit = useCallback(
+ async (raw: string) => {
+ controller.current?.abort();
+ const parsed = parseOfferLiabilityHeadroomInput(raw);
+ if (isErr(parsed)) {
+ setHeld({ state: { status: "error", code: parsed.code }, network });
+ return;
+ }
+
+ requestId.current += 1;
+ const id = requestId.current;
+ const next = new AbortController();
+ controller.current = next;
+ setHeld({ state: { status: "loading" }, network });
+
+ try {
+ const result: Result =
+ await runOfferLiabilityHeadroom(parsed.value, network, next.signal);
+ if (id !== requestId.current || next.signal.aborted) return;
+ setHeld({
+ state: result.ok
+ ? { status: "success", result: result.value }
+ : { status: "error", code: result.code },
+ network
+ });
+ } catch (error) {
+ if (id !== requestId.current || next.signal.aborted) return;
+ setHeld({
+ state: { status: "error", code: toOfferLiabilityHeadroomErrorCode(error) },
+ network
+ });
+ }
+ },
+ [network]
+ );
+
+ const reset = useCallback(() => {
+ controller.current?.abort();
+ requestId.current += 1;
+ setHeld({ state: IDLE, network });
+ }, [network]);
+
+ return { state, submit, reset };
+}
diff --git a/features/offer-liability-headroom/lib/format.ts b/features/offer-liability-headroom/lib/format.ts
new file mode 100644
index 00000000..2e0f8c4c
--- /dev/null
+++ b/features/offer-liability-headroom/lib/format.ts
@@ -0,0 +1,23 @@
+import type { StellarNetwork } from "@/core/network/types";
+import type { AssetIdentity, OfferRow, RationalPrice } from "@/features/offer-liability-headroom/types";
+
+const NETWORK_LABELS: Record = {
+ testnet: "Testnet",
+ mainnet: "Mainnet"
+};
+
+export function formatNetworkLabel(network: StellarNetwork): string {
+ return NETWORK_LABELS[network];
+}
+
+export function formatAssetLabel(asset: AssetIdentity): string {
+ return asset.label;
+}
+
+export function formatPrice(price: RationalPrice): string {
+ return `${price.display} (≈ ${price.approximate})`;
+}
+
+export function formatOfferLine(offer: OfferRow): string {
+ return `${offer.amount} ${offer.selling.label} → ${offer.buying.label} @ ${offer.price.display}`;
+}
diff --git a/features/offer-liability-headroom/lib/liability-audit.ts b/features/offer-liability-headroom/lib/liability-audit.ts
new file mode 100644
index 00000000..5df3eace
--- /dev/null
+++ b/features/offer-liability-headroom/lib/liability-audit.ts
@@ -0,0 +1,115 @@
+import { normalizeOfferAsset } from "@/features/offer-liability-headroom/lib/offer-assets";
+import {
+ formatRationalApproximate,
+ formatRationalDisplay,
+ parseAmountString,
+ parseRationalPrice,
+ subtractAmounts
+} from "@/features/offer-liability-headroom/lib/rational-price";
+import type {
+ BalanceLiabilityRow,
+ OfferRow
+} from "@/features/offer-liability-headroom/types";
+
+export interface RawHorizonBalance {
+ asset_type?: string;
+ asset_code?: string;
+ asset_issuer?: string;
+ balance?: string;
+ limit?: string;
+ buying_liabilities?: string;
+ selling_liabilities?: string;
+}
+
+export interface RawHorizonOffer {
+ id?: string;
+ amount?: string;
+ price_r?: { n?: string | number; d?: string | number };
+ last_modified_ledger?: number | string;
+ selling?: RawHorizonBalance;
+ buying?: RawHorizonBalance;
+}
+
+export function buildLiabilityRows(balances: RawHorizonBalance[]): BalanceLiabilityRow[] {
+ const rows: BalanceLiabilityRow[] = [];
+
+ for (const balance of balances) {
+ const asset = normalizeOfferAsset(balance);
+ if (!asset) continue;
+ if (
+ typeof balance.balance !== "string" ||
+ parseAmountString(balance.balance) === null
+ ) {
+ continue;
+ }
+
+ const sellingLiabilities = balance.selling_liabilities ?? "0.0000000";
+ const buyingLiabilities = balance.buying_liabilities ?? "0.0000000";
+
+ rows.push({
+ asset,
+ balance: balance.balance,
+ sellingLiabilities,
+ buyingLiabilities,
+ limit: typeof balance.limit === "string" ? balance.limit : null,
+ availableToSellEstimate: subtractAmounts(balance.balance, sellingLiabilities)
+ });
+ }
+
+ return rows;
+}
+
+export function buildOfferRows(
+ offers: RawHorizonOffer[],
+ liabilityKeys: Set
+): { offers: OfferRow[]; malformed: boolean } {
+ const rows: OfferRow[] = [];
+
+ for (const offer of offers) {
+ if (!offer.id || typeof offer.amount !== "string" || parseAmountString(offer.amount) === null) {
+ return { offers: [], malformed: true };
+ }
+
+ const selling = offer.selling ? normalizeOfferAsset(offer.selling) : null;
+ const buying = offer.buying ? normalizeOfferAsset(offer.buying) : null;
+ const price = parseRationalPrice(offer.price_r);
+
+ if (!selling || !buying || !price) {
+ return { offers: [], malformed: true };
+ }
+
+ rows.push({
+ id: String(offer.id),
+ selling,
+ buying,
+ amount: offer.amount,
+ price: {
+ numerator: price.n.toString(),
+ denominator: price.d.toString(),
+ display: formatRationalDisplay(price.n, price.d),
+ approximate: formatRationalApproximate(price.n, price.d)
+ },
+ lastModifiedLedger:
+ offer.last_modified_ledger === undefined || offer.last_modified_ledger === null
+ ? null
+ : String(offer.last_modified_ledger),
+ matchedBalance: liabilityKeys.has(selling.key)
+ });
+ }
+
+ return { offers: rows, malformed: false };
+}
+
+export function maxOfferLedger(offers: OfferRow[]): string | null {
+ let max: bigint | null = null;
+ for (const offer of offers) {
+ if (!offer.lastModifiedLedger) continue;
+ try {
+ const value = BigInt(offer.lastModifiedLedger);
+ if (max === null || value > max) max = value;
+ } catch {
+ continue;
+ }
+ }
+ return max === null ? null : max.toString();
+}
diff --git a/features/offer-liability-headroom/lib/offer-assets.ts b/features/offer-liability-headroom/lib/offer-assets.ts
new file mode 100644
index 00000000..4e852aed
--- /dev/null
+++ b/features/offer-liability-headroom/lib/offer-assets.ts
@@ -0,0 +1,46 @@
+import type { AssetIdentity } from "@/features/offer-liability-headroom/types";
+
+interface HorizonAssetFields {
+ asset_type?: string;
+ asset_code?: string;
+ asset_issuer?: string;
+}
+
+/** Normalize Horizon offer/balance asset fields into a stable identity key. */
+export function normalizeOfferAsset(fields: HorizonAssetFields): AssetIdentity | null {
+ const assetType = fields.asset_type?.trim();
+ if (!assetType) return null;
+
+ if (assetType === "native") {
+ return { kind: "native", key: "native", label: "XLM (native)" };
+ }
+
+ if (assetType === "liquidity_pool_shares") return null;
+
+ const code = fields.asset_code?.trim();
+ const issuer = fields.asset_issuer?.trim();
+ if (!code || !issuer || !/^[A-Za-z0-9]{1,12}$/.test(code) || !/^G[A-Z0-9]{55}$/.test(issuer)) {
+ return null;
+ }
+
+ const normalizedCode = code.toUpperCase();
+ return {
+ kind: "credit",
+ key: `${normalizedCode}:${issuer}`,
+ code: normalizedCode,
+ issuer,
+ label: `${normalizedCode}:${issuer.slice(0, 4)}…${issuer.slice(-4)}`
+ };
+}
+
+export function assetKeyFromSellingBuying(
+ assetType: string | undefined,
+ assetCode: string | undefined,
+ assetIssuer: string | undefined
+): AssetIdentity | null {
+ return normalizeOfferAsset({
+ asset_type: assetType,
+ asset_code: assetCode,
+ asset_issuer: assetIssuer
+ });
+}
diff --git a/features/offer-liability-headroom/lib/offerLiabilityHeadroom.errors.ts b/features/offer-liability-headroom/lib/offerLiabilityHeadroom.errors.ts
new file mode 100644
index 00000000..90777059
--- /dev/null
+++ b/features/offer-liability-headroom/lib/offerLiabilityHeadroom.errors.ts
@@ -0,0 +1,18 @@
+import { classifyHorizonError } from "@/core/horizon/errors";
+import type { OfferLiabilityHeadroomErrorCode } from "@/features/offer-liability-headroom/types";
+
+export function toOfferLiabilityHeadroomErrorCode(error: unknown): OfferLiabilityHeadroomErrorCode {
+ const { code } = classifyHorizonError(error);
+ if (code === "not_found") return "account_not_found";
+ if (code === "rate_limited") return "rate_limited";
+ return "request_failed";
+}
+
+export class HorizonStatusError extends Error {
+ readonly status: number;
+ constructor(status: number) {
+ super(`Horizon responded with ${status}`);
+ this.name = "HorizonStatusError";
+ this.status = status;
+ }
+}
diff --git a/features/offer-liability-headroom/lib/offerLiabilityHeadroom.ts b/features/offer-liability-headroom/lib/offerLiabilityHeadroom.ts
new file mode 100644
index 00000000..ae8066fb
--- /dev/null
+++ b/features/offer-liability-headroom/lib/offerLiabilityHeadroom.ts
@@ -0,0 +1,137 @@
+import { err, ok, type Result } from "@/core/result/result";
+import { horizonUrl } from "@/core/horizon/client";
+import type { StellarNetwork } from "@/core/network/types";
+import { copy } from "@/features/offer-liability-headroom/copy";
+import {
+ buildLiabilityRows,
+ buildOfferRows,
+ maxOfferLedger,
+ type RawHorizonBalance,
+ type RawHorizonOffer
+} from "@/features/offer-liability-headroom/lib/liability-audit";
+import {
+ HorizonStatusError,
+ toOfferLiabilityHeadroomErrorCode
+} from "@/features/offer-liability-headroom/lib/offerLiabilityHeadroom.errors";
+import type {
+ OfferLiabilityHeadroomErrorCode,
+ OfferLiabilityHeadroomInput,
+ OfferLiabilityHeadroomResult
+} from "@/features/offer-liability-headroom/types";
+
+const REQUEST_TIMEOUT_MS = 15_000;
+
+interface HorizonAccount {
+ account_id?: string;
+ id?: string;
+ last_modified_ledger?: number | string;
+ balances?: RawHorizonBalance[];
+}
+
+interface HorizonOffersPage {
+ _embedded?: { records?: RawHorizonOffer[] };
+ records?: RawHorizonOffer[];
+}
+
+async function fetchJson(
+ url: string,
+ signal?: AbortSignal
+): Promise {
+ const controller = new AbortController();
+ const abortFromCaller = () => controller.abort();
+ if (signal?.aborted) abortFromCaller();
+ else signal?.addEventListener("abort", abortFromCaller, { once: true });
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
+
+ try {
+ const response = await fetch(url, {
+ signal: controller.signal,
+ headers: { Accept: "application/json" }
+ });
+ return response;
+ } finally {
+ clearTimeout(timeout);
+ signal?.removeEventListener("abort", abortFromCaller);
+ }
+}
+
+export function analyzeOfferLiabilitySnapshot(
+ account: HorizonAccount,
+ offersPage: HorizonOffersPage,
+ network: StellarNetwork,
+ accountId: string
+): Result {
+ const balances = Array.isArray(account.balances) ? account.balances : [];
+ const offersRaw = Array.isArray(offersPage._embedded?.records)
+ ? offersPage._embedded.records
+ : Array.isArray(offersPage.records)
+ ? offersPage.records
+ : [];
+
+ const liabilities = buildLiabilityRows(balances);
+ const liabilityKeys = new Set(liabilities.map((row) => row.asset.key));
+ const built = buildOfferRows(offersRaw, liabilityKeys);
+ if (built.malformed) return err("malformed_offer");
+
+ const accountLedger =
+ account.last_modified_ledger === undefined || account.last_modified_ledger === null
+ ? null
+ : String(account.last_modified_ledger);
+ const offersLedger = maxOfferLedger(built.offers);
+
+ let ledgerSkew = false;
+ if (accountLedger && offersLedger) {
+ try {
+ const delta =
+ accountLedger > offersLedger
+ ? BigInt(accountLedger) - BigInt(offersLedger)
+ : BigInt(offersLedger) - BigInt(accountLedger);
+ // Large skew means the two reads are not a coherent snapshot.
+ if (delta > 50n) return err("inconsistent_snapshot");
+ ledgerSkew = delta > 0n;
+ } catch {
+ return err("inconsistent_snapshot");
+ }
+ }
+
+ return ok({
+ network,
+ accountId: account.account_id ?? account.id ?? accountId,
+ accountLedger,
+ offersLedger,
+ ledgerSkew,
+ offers: built.offers,
+ liabilities,
+ unmatchedOfferCount: built.offers.filter((offer) => !offer.matchedBalance).length,
+ disclaimer: copy.disclaimer
+ });
+}
+
+export async function runOfferLiabilityHeadroom(
+ input: OfferLiabilityHeadroomInput,
+ network: StellarNetwork,
+ signal?: AbortSignal
+): Promise> {
+ try {
+ const accountUrl = horizonUrl(network, `/accounts/${input.accountId}`);
+ const accountResponse = await fetchJson(accountUrl, signal);
+
+ if (accountResponse.status === 404) return err("account_not_found");
+ if (!accountResponse.ok) throw new HorizonStatusError(accountResponse.status);
+
+ const offersUrl = horizonUrl(network, `/accounts/${input.accountId}/offers`, {
+ limit: 200,
+ order: "desc"
+ });
+ const offersResponse = await fetchJson(offersUrl, signal);
+
+ if (offersResponse.status === 404) return err("account_not_found");
+ if (!offersResponse.ok) throw new HorizonStatusError(offersResponse.status);
+
+ const account = (await accountResponse.json()) as HorizonAccount;
+ const offersPage = (await offersResponse.json()) as HorizonOffersPage;
+ return analyzeOfferLiabilitySnapshot(account, offersPage, network, input.accountId);
+ } catch (error) {
+ return err(toOfferLiabilityHeadroomErrorCode(error));
+ }
+}
diff --git a/features/offer-liability-headroom/lib/rational-price.ts b/features/offer-liability-headroom/lib/rational-price.ts
new file mode 100644
index 00000000..114fc89c
--- /dev/null
+++ b/features/offer-liability-headroom/lib/rational-price.ts
@@ -0,0 +1,61 @@
+const AMOUNT_SCALE = 10_000_000n;
+
+function gcd(a: bigint, b: bigint): bigint {
+ let x = a < 0n ? -a : a;
+ let y = b < 0n ? -b : b;
+ while (y) [x, y] = [y, x % y];
+ return x || 1n;
+}
+
+export function reduceRational(n: bigint, d: bigint): { n: bigint; d: bigint } {
+ if (d <= 0n || n < 0n) throw new Error("invalid rational");
+ const g = gcd(n, d);
+ return { n: n / g, d: d / g };
+}
+
+export function parseRationalPrice(raw: {
+ n?: string | number;
+ d?: string | number;
+} | null | undefined): { n: bigint; d: bigint } | null {
+ if (!raw || raw.n === undefined || raw.d === undefined) return null;
+ try {
+ const n = BigInt(raw.n);
+ const d = BigInt(raw.d);
+ if (n <= 0n || d <= 0n) return null;
+ return reduceRational(n, d);
+ } catch {
+ return null;
+ }
+}
+
+export function formatRationalDisplay(n: bigint, d: bigint): string {
+ return `${n}/${d}`;
+}
+
+/** Non-authoritative decimal preview for humans; calculations keep the fraction. */
+export function formatRationalApproximate(n: bigint, d: bigint): string {
+ const scaled = (n * 1_000_000n) / d;
+ const whole = scaled / 1_000_000n;
+ const fraction = (scaled % 1_000_000n).toString().padStart(6, "0").replace(/0+$/, "");
+ return fraction ? `${whole}.${fraction}` : `${whole}`;
+}
+
+export function parseAmountString(value: string | undefined): bigint | null {
+ if (!value || !/^\d+(?:\.\d{1,7})?$/.test(value)) return null;
+ const [whole, fraction = ""] = value.split(".");
+ return BigInt(whole) * AMOUNT_SCALE + BigInt(fraction.padEnd(7, "0"));
+}
+
+export function formatAmountString(value: bigint): string {
+ const whole = value / AMOUNT_SCALE;
+ const fraction = (value % AMOUNT_SCALE).toString().padStart(7, "0").replace(/0+$/, "");
+ return fraction ? `${whole}.${fraction}` : `${whole}`;
+}
+
+export function subtractAmounts(balance: string, liabilities: string): string | null {
+ const left = parseAmountString(balance);
+ const right = parseAmountString(liabilities);
+ if (left === null || right === null) return null;
+ if (left < right) return formatAmountString(0n);
+ return formatAmountString(left - right);
+}
diff --git a/features/offer-liability-headroom/manifest.ts b/features/offer-liability-headroom/manifest.ts
new file mode 100644
index 00000000..15e76629
--- /dev/null
+++ b/features/offer-liability-headroom/manifest.ts
@@ -0,0 +1,15 @@
+import { Scale } from "lucide-react";
+import type { FeatureManifest } from "@/core/registry/types";
+
+export const manifest: FeatureManifest = {
+ slug: "offer-liability-headroom",
+ title: "Offer Liability and Capacity Audit",
+ description:
+ "Inspect an account's offers alongside balances and liabilities to explain which obligations constrain further offers.",
+ character: "A desk auditor lines up offers against the ledger sheet and circles the tight spots.",
+ category: "developer",
+ status: "working",
+ icon: Scale,
+ networks: ["testnet", "mainnet"],
+ keywords: ["offer", "liability", "trustline", "DEX", "capacity", "price_r"]
+};
diff --git a/features/offer-liability-headroom/msw/handlers.ts b/features/offer-liability-headroom/msw/handlers.ts
new file mode 100644
index 00000000..e2e1f1e5
--- /dev/null
+++ b/features/offer-liability-headroom/msw/handlers.ts
@@ -0,0 +1,49 @@
+import { http, HttpResponse } from "msw";
+import { horizonUrl } from "@/core/horizon/client";
+import {
+ accountId,
+ accountResponse,
+ malformedOffersResponse,
+ offersResponse,
+ skewedAccountResponse,
+ skewedOffersResponse,
+ unmatchedOffersFixture,
+ unknownAccountId
+} from "@/features/offer-liability-headroom/fixtures/offerLiabilityHeadroom.fixture";
+
+export const handlers = [
+ http.get(horizonUrl("testnet", `/accounts/${accountId}`), () =>
+ HttpResponse.json(accountResponse)
+ ),
+ http.get(horizonUrl("testnet", `/accounts/${accountId}/offers`), () =>
+ HttpResponse.json(offersResponse)
+ ),
+ http.get(horizonUrl("mainnet", `/accounts/${accountId}`), () =>
+ HttpResponse.json(accountResponse)
+ ),
+ http.get(horizonUrl("mainnet", `/accounts/${accountId}/offers`), () =>
+ HttpResponse.json(unmatchedOffersFixture)
+ ),
+ http.get(horizonUrl("testnet", `/accounts/${unknownAccountId}`), () =>
+ HttpResponse.json({ title: "Not Found", status: 404 }, { status: 404 })
+ )
+];
+
+export const rateLimitedHandler = http.get(horizonUrl("testnet", `/accounts/${accountId}`), () =>
+ HttpResponse.json({ title: "Rate limit exceeded", status: 429 }, { status: 429 })
+);
+
+export const malformedHandler = http.get(
+ horizonUrl("testnet", `/accounts/${accountId}/offers`),
+ () => HttpResponse.json(malformedOffersResponse)
+);
+
+export const inconsistentHandlerAccount = http.get(
+ horizonUrl("testnet", `/accounts/${accountId}`),
+ () => HttpResponse.json(skewedAccountResponse)
+);
+
+export const inconsistentHandlerOffers = http.get(
+ horizonUrl("testnet", `/accounts/${accountId}/offers`),
+ () => HttpResponse.json(skewedOffersResponse)
+);
diff --git a/features/offer-liability-headroom/panel.tsx b/features/offer-liability-headroom/panel.tsx
new file mode 100644
index 00000000..85d0aaea
--- /dev/null
+++ b/features/offer-liability-headroom/panel.tsx
@@ -0,0 +1 @@
+export { OfferLiabilityHeadroomPanel as default } from "@/features/offer-liability-headroom/components/OfferLiabilityHeadroomPanel";
diff --git a/features/offer-liability-headroom/schema.ts b/features/offer-liability-headroom/schema.ts
new file mode 100644
index 00000000..2e192add
--- /dev/null
+++ b/features/offer-liability-headroom/schema.ts
@@ -0,0 +1,17 @@
+import { StrKey } from "@stellar/stellar-sdk";
+import { err, ok, type Result } from "@/core/result/result";
+import { normalizeInput } from "@/core/lib/strings";
+import type {
+ OfferLiabilityHeadroomErrorCode,
+ OfferLiabilityHeadroomInput
+} from "@/features/offer-liability-headroom/types";
+
+export function parseOfferLiabilityHeadroomInput(
+ raw: string
+): Result {
+ const accountId = normalizeInput(raw);
+ if (!accountId || accountId.startsWith("S") || !StrKey.isValidEd25519PublicKey(accountId)) {
+ return err("invalid_account");
+ }
+ return ok({ accountId });
+}
diff --git a/features/offer-liability-headroom/types.ts b/features/offer-liability-headroom/types.ts
new file mode 100644
index 00000000..e7f1d512
--- /dev/null
+++ b/features/offer-liability-headroom/types.ts
@@ -0,0 +1,55 @@
+import type { StellarNetwork } from "@/core/network/types";
+
+export interface OfferLiabilityHeadroomInput {
+ accountId: string;
+}
+
+export type AssetIdentity =
+ | { kind: "native"; key: "native"; label: string }
+ | { kind: "credit"; key: string; code: string; issuer: string; label: string };
+
+export interface RationalPrice {
+ numerator: string;
+ denominator: string;
+ display: string;
+ approximate: string;
+}
+
+export interface BalanceLiabilityRow {
+ asset: AssetIdentity;
+ balance: string;
+ sellingLiabilities: string;
+ buyingLiabilities: string;
+ limit: string | null;
+ availableToSellEstimate: string | null;
+}
+
+export interface OfferRow {
+ id: string;
+ selling: AssetIdentity;
+ buying: AssetIdentity;
+ amount: string;
+ price: RationalPrice;
+ lastModifiedLedger: string | null;
+ matchedBalance: boolean;
+}
+
+export interface OfferLiabilityHeadroomResult {
+ network: StellarNetwork;
+ accountId: string;
+ accountLedger: string | null;
+ offersLedger: string | null;
+ ledgerSkew: boolean;
+ offers: OfferRow[];
+ liabilities: BalanceLiabilityRow[];
+ unmatchedOfferCount: number;
+ disclaimer: string;
+}
+
+export type OfferLiabilityHeadroomErrorCode =
+ | "invalid_account"
+ | "account_not_found"
+ | "malformed_offer"
+ | "inconsistent_snapshot"
+ | "rate_limited"
+ | "request_failed";