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) => ( +
  1. +

    {formatAssetLabel(row.asset)}

    +
    +
    + {copy.balanceLabel}: {row.balance} +
    +
    + {copy.sellingLiabilitiesLabel}:{" "} + {row.sellingLiabilities} +
    +
    + {copy.buyingLiabilitiesLabel}:{" "} + {row.buyingLiabilities} +
    +
    + {copy.limitLabel}:{" "} + {row.limit ?? "—"} +
    +
    + {copy.availableLabel}:{" "} + {row.availableToSellEstimate ?? "—"} +
    +
    +
  2. + ))} +
+ ); +} 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 ( +
+ + {({ inputId, describedBy, invalid, required }) => ( + setAccountId(event.target.value)} + placeholder="G…" + autoComplete="off" + spellCheck={false} + className="font-mono" + /> + )} + + +
+ ); +} 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) => ( +
  1. +

    {formatOfferLine(offer)}

    +

    + {copy.priceLabel}: {formatPrice(offer.price)} + {!offer.matchedBalance ? " · unmatched balance row" : ""} +

    +
  2. + ))} +
+ ); +} 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";