diff --git a/features/ledger-close-cadence/README.md b/features/ledger-close-cadence/README.md new file mode 100644 index 0000000..6be0d28 --- /dev/null +++ b/features/ledger-close-cadence/README.md @@ -0,0 +1,24 @@ +# Ledger Close Cadence Explorer + +Measures observed spacing between recent Horizon ledger close timestamps for the selected network. + +## Behavior + +1. Validate a sample size between 2 and 200. +2. Fetch `GET /ledgers?order=desc&limit=N` from the selected Horizon endpoint. +3. Drop malformed records, sort the remainder by sequence ascending, and compute adjacent close-time intervals. +4. Report median / min / max interval, sequence gaps, repeated timestamps, and unusually long intervals. + +A long interval in the sample is an observation, not proof of consensus failure or an endpoint outage. Sequence gaps are listed separately from time gaps. + +## Data boundaries + +- Network: configured testnet or mainnet Horizon only. +- History is bounded by the requested sample size; there is no unbounded scan. +- Amounts and ledger sequences are handled as strings / `BigInt`; no secret keys are accepted. + +## Design decisions + +- Malformed records are skipped when at least two valid ledgers remain, so one bad row cannot erase an otherwise useful sample. +- Unusual spacing is flagged relative to the sample median (3×), not a fixed SLA. +- Repeated timestamps are kept as zero-duration intervals so they remain visible. diff --git a/features/ledger-close-cadence/__tests__/LedgerCloseCadencePanel.test.tsx b/features/ledger-close-cadence/__tests__/LedgerCloseCadencePanel.test.tsx new file mode 100644 index 0000000..b3aca1f --- /dev/null +++ b/features/ledger-close-cadence/__tests__/LedgerCloseCadencePanel.test.tsx @@ -0,0 +1,49 @@ +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 { LedgerCloseCadencePanel } from "@/features/ledger-close-cadence/components/LedgerCloseCadencePanel"; +import { copy, errorCopy } from "@/features/ledger-close-cadence/copy"; +import { handlers, rateLimitedHandler } from "@/features/ledger-close-cadence/msw/handlers"; + +const server = withMswHandlers(...handlers); + +describe("LedgerCloseCadencePanel", () => { + it("shows the empty state before anything is loaded", () => { + renderFeature(); + expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument(); + }); + + it("renders cadence summary and intervals after a successful load", async () => { + resetHorizonClients(); + const { user } = renderFeature(, { network: "testnet" }); + + await user.clear(screen.getByLabelText(copy.formLabel)); + await user.type(screen.getByLabelText(copy.formLabel), "4"); + await user.click(screen.getByRole("button", { name: copy.submit })); + + expect(await screen.findByText(copy.summaryTitle)).toBeInTheDocument(); + expect(screen.getByText(copy.intervalsTitle)).toBeInTheDocument(); + expect(screen.getByText(copy.medianLabel)).toBeInTheDocument(); + }); + + it("explains invalid sample size", async () => { + const { user } = renderFeature(, { network: "testnet" }); + + await user.clear(screen.getByLabelText(copy.formLabel)); + await user.type(screen.getByLabelText(copy.formLabel), "1"); + await user.click(screen.getByRole("button", { name: copy.submit })); + + expect(await screen.findByText(errorCopy.invalid_sample_size.title)).toBeInTheDocument(); + }); + + it("explains rate limiting", async () => { + server.use(rateLimitedHandler); + resetHorizonClients(); + const { user } = renderFeature(, { network: "testnet" }); + + await user.click(screen.getByRole("button", { name: copy.submit })); + + expect(await screen.findByText(errorCopy.rate_limited.title)).toBeInTheDocument(); + }); +}); diff --git a/features/ledger-close-cadence/__tests__/a11y.test.tsx b/features/ledger-close-cadence/__tests__/a11y.test.tsx new file mode 100644 index 0000000..aa5f980 --- /dev/null +++ b/features/ledger-close-cadence/__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 { LedgerCloseCadencePanel } from "@/features/ledger-close-cadence/components/LedgerCloseCadencePanel"; +import { copy } from "@/features/ledger-close-cadence/copy"; +import { handlers } from "@/features/ledger-close-cadence/msw/handlers"; + +withMswHandlers(...handlers); + +describe("LedgerCloseCadencePanel accessibility", () => { + it("has no WCAG A/AA violations in its initial state", async () => { + const { container } = renderFeature(); + await expectNoAxeViolations(container); + }); + + it("has no WCAG A/AA violations after a successful measurement", async () => { + resetHorizonClients(); + const { container, user } = renderFeature(, { network: "testnet" }); + + await user.clear(screen.getByLabelText(copy.formLabel)); + await user.type(screen.getByLabelText(copy.formLabel), "4"); + await user.click(screen.getByRole("button", { name: copy.submit })); + await screen.findByText(copy.summaryTitle); + + await expectNoAxeViolations(container); + }); +}); diff --git a/features/ledger-close-cadence/__tests__/cadence-stats.test.ts b/features/ledger-close-cadence/__tests__/cadence-stats.test.ts new file mode 100644 index 0000000..67c186a --- /dev/null +++ b/features/ledger-close-cadence/__tests__/cadence-stats.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + calculateCadenceStats, + medianIntervalMs +} from "@/features/ledger-close-cadence/lib/cadence-stats"; + +describe("medianIntervalMs", () => { + it("returns the middle value for an odd sample", () => { + expect(medianIntervalMs([1000, 5000, 3000])).toBe(3000); + }); + + it("averages the two middle values for an even sample", () => { + expect(medianIntervalMs([1000, 2000, 4000, 8000])).toBe(3000); + }); + + it("returns null for an empty list", () => { + expect(medianIntervalMs([])).toBeNull(); + }); +}); + +describe("calculateCadenceStats", () => { + it("reports min, max, median and count", () => { + expect(calculateCadenceStats([5000, 5000, 5000])).toEqual({ + medianMs: 5000, + minMs: 5000, + maxMs: 5000, + intervalCount: 3 + }); + }); + + it("returns null when there are no intervals", () => { + expect(calculateCadenceStats([])).toBeNull(); + }); +}); diff --git a/features/ledger-close-cadence/__tests__/format.test.ts b/features/ledger-close-cadence/__tests__/format.test.ts new file mode 100644 index 0000000..de4e889 --- /dev/null +++ b/features/ledger-close-cadence/__tests__/format.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + formatCount, + formatDurationMs, + formatLedgerLabel, + formatNetworkLabel +} from "@/features/ledger-close-cadence/lib/format"; + +describe("formatDurationMs", () => { + it("formats sub-second durations in milliseconds", () => { + expect(formatDurationMs(250)).toBe("250 ms"); + }); + + it("formats whole seconds without trailing decimals", () => { + expect(formatDurationMs(5000)).toBe("5 s"); + }); + + it("formats fractional seconds with three digits", () => { + expect(formatDurationMs(5125)).toBe("5.125 s"); + }); + + it("formats minutes and leftover seconds", () => { + expect(formatDurationMs(125_000)).toBe("2 m 5 s"); + }); +}); + +describe("formatNetworkLabel", () => { + it("labels configured networks", () => { + expect(formatNetworkLabel("testnet")).toBe("Testnet"); + expect(formatNetworkLabel("mainnet")).toBe("Mainnet"); + }); +}); + +describe("formatLedgerLabel", () => { + it("joins sequence and close time", () => { + expect(formatLedgerLabel("1001", "2026-09-29T12:00:00.000Z")).toBe( + "#1001 · 2026-09-29T12:00:00.000Z" + ); + }); +}); + +describe("formatCount", () => { + it("stringifies counts", () => { + expect(formatCount(4)).toBe("4"); + }); +}); diff --git a/features/ledger-close-cadence/__tests__/ledger-sample.test.ts b/features/ledger-close-cadence/__tests__/ledger-sample.test.ts new file mode 100644 index 0000000..56a38ce --- /dev/null +++ b/features/ledger-close-cadence/__tests__/ledger-sample.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + parseLedgerRecord, + validateAndSortLedgerSample +} from "@/features/ledger-close-cadence/lib/ledger-sample"; +import { + allMalformedRecords, + malformedMixedRecords, + recentLedgersFixture +} from "@/features/ledger-close-cadence/fixtures/ledgerCloseCadence.fixture"; + +describe("parseLedgerRecord", () => { + it("accepts a Horizon ledger with sequence and UTC close time", () => { + expect(parseLedgerRecord(recentLedgersFixture[0]!)).toMatchObject({ + sequence: "1004" + }); + }); + + it("rejects missing or invalid fields", () => { + expect(parseLedgerRecord({ sequence: 1 })).toBeNull(); + expect(parseLedgerRecord({ closed_at: "2026-01-01T00:00:00Z" })).toBeNull(); + expect(parseLedgerRecord({ sequence: "x", closed_at: "2026-01-01T00:00:00Z" })).toBeNull(); + expect(parseLedgerRecord({ sequence: 1, closed_at: "not-a-date" })).toBeNull(); + }); +}); + +describe("validateAndSortLedgerSample", () => { + it("sorts descending Horizon pages into ascending sequence order", () => { + const result = validateAndSortLedgerSample(recentLedgersFixture); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.ledgers.map((ledger) => ledger.sequence)).toEqual([ + "1001", + "1002", + "1003", + "1004" + ]); + expect(result.value.malformedCount).toBe(0); + }); + + it("skips malformed records without discarding the rest", () => { + const result = validateAndSortLedgerSample(malformedMixedRecords); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.ledgers.map((ledger) => ledger.sequence)).toEqual(["4000", "4002"]); + expect(result.value.malformedCount).toBe(2); + }); + + it("fails when every record is malformed", () => { + expect(validateAndSortLedgerSample(allMalformedRecords)).toEqual({ + ok: false, + code: "malformed_ledger" + }); + }); + + it("fails on an empty page", () => { + expect(validateAndSortLedgerSample([])).toEqual({ + ok: false, + code: "history_unavailable" + }); + }); +}); diff --git a/features/ledger-close-cadence/__tests__/ledgerCloseCadence.test.ts b/features/ledger-close-cadence/__tests__/ledgerCloseCadence.test.ts new file mode 100644 index 0000000..74c5b93 --- /dev/null +++ b/features/ledger-close-cadence/__tests__/ledgerCloseCadence.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { withMswHandlers } from "@/core/testing/msw"; +import { resetHorizonClients } from "@/core/horizon/client"; +import { + analyzeLedgerSample, + runLedgerCloseCadence +} from "@/features/ledger-close-cadence/lib/ledgerCloseCadence"; +import { + emptyHistoryHandler, + handlers, + malformedOnlyHandler, + rateLimitedHandler, + serverErrorHandler +} from "@/features/ledger-close-cadence/msw/handlers"; +import { + gapsFixtureRecords, + recentLedgersFixture, + repeatedTimestampRecords +} from "@/features/ledger-close-cadence/fixtures/ledgerCloseCadence.fixture"; + +const server = withMswHandlers(...handlers); + +describe("analyzeLedgerSample", () => { + it("computes median/min/max intervals for a contiguous sample", () => { + const result = analyzeLedgerSample(recentLedgersFixture, { sampleSize: 4 }, "testnet"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.stats).toEqual({ + medianMs: 5000, + minMs: 5000, + maxMs: 5000, + intervalCount: 3 + }); + expect(result.value.firstLedger.sequence).toBe("1001"); + expect(result.value.lastLedger.sequence).toBe("1004"); + expect(result.value.sequenceGaps).toEqual([]); + }); + + it("flags sequence gaps separately from long close intervals", () => { + const result = analyzeLedgerSample(gapsFixtureRecords, { sampleSize: 4 }, "testnet"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.sequenceGaps).toHaveLength(1); + expect(result.value.intervals.some((interval) => interval.unusual)).toBe(true); + }); + + it("keeps repeated timestamps without corrupting the sample", () => { + const result = analyzeLedgerSample(repeatedTimestampRecords, { sampleSize: 4 }, "testnet"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.repeatedTimestampCount).toBe(1); + expect(result.value.observedSampleSize).toBe(4); + }); +}); + +describe("runLedgerCloseCadence", () => { + it("loads a Horizon ledger page for the selected network", async () => { + resetHorizonClients(); + const result = await runLedgerCloseCadence({ sampleSize: 4 }, "testnet"); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.network).toBe("testnet"); + expect(result.value.observedSampleSize).toBe(4); + }); + + it("maps an empty history page", async () => { + server.use(emptyHistoryHandler); + resetHorizonClients(); + expect(await runLedgerCloseCadence({ sampleSize: 4 }, "testnet")).toEqual({ + ok: false, + code: "history_unavailable" + }); + }); + + it("maps a fully malformed page", async () => { + server.use(malformedOnlyHandler); + resetHorizonClients(); + expect(await runLedgerCloseCadence({ sampleSize: 4 }, "testnet")).toEqual({ + ok: false, + code: "malformed_ledger" + }); + }); + + it("maps rate limiting", async () => { + server.use(rateLimitedHandler); + resetHorizonClients(); + expect(await runLedgerCloseCadence({ sampleSize: 4 }, "testnet")).toEqual({ + ok: false, + code: "rate_limited" + }); + }); + + it("maps transport failures", async () => { + server.use(serverErrorHandler); + resetHorizonClients(); + expect(await runLedgerCloseCadence({ sampleSize: 4 }, "testnet")).toEqual({ + ok: false, + code: "request_failed" + }); + }); +}); diff --git a/features/ledger-close-cadence/__tests__/schema.test.ts b/features/ledger-close-cadence/__tests__/schema.test.ts new file mode 100644 index 0000000..4d02e54 --- /dev/null +++ b/features/ledger-close-cadence/__tests__/schema.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { parseLedgerCloseCadenceInput } from "@/features/ledger-close-cadence/schema"; +import { SAMPLE_SIZE_MAX, SAMPLE_SIZE_MIN } from "@/features/ledger-close-cadence/types"; + +describe("parseLedgerCloseCadenceInput", () => { + it("accepts a whole number inside the supported range", () => { + expect(parseLedgerCloseCadenceInput({ sampleSize: "20" })).toEqual({ + ok: true, + value: { sampleSize: 20 } + }); + }); + + it("accepts the inclusive boundaries", () => { + expect(parseLedgerCloseCadenceInput({ sampleSize: String(SAMPLE_SIZE_MIN) })).toEqual({ + ok: true, + value: { sampleSize: SAMPLE_SIZE_MIN } + }); + expect(parseLedgerCloseCadenceInput({ sampleSize: String(SAMPLE_SIZE_MAX) })).toEqual({ + ok: true, + value: { sampleSize: SAMPLE_SIZE_MAX } + }); + }); + + it.each(["", " ", "abc", "2.5", "-1", "0", "1", String(SAMPLE_SIZE_MAX + 1)])( + "rejects invalid sample size %j", + (sampleSize) => { + expect(parseLedgerCloseCadenceInput({ sampleSize })).toEqual({ + ok: false, + code: "invalid_sample_size" + }); + } + ); +}); diff --git a/features/ledger-close-cadence/__tests__/sequence-gaps.test.ts b/features/ledger-close-cadence/__tests__/sequence-gaps.test.ts new file mode 100644 index 0000000..f6c4ffc --- /dev/null +++ b/features/ledger-close-cadence/__tests__/sequence-gaps.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { detectSequenceGaps } from "@/features/ledger-close-cadence/lib/sequence-gaps"; +import { validateAndSortLedgerSample } from "@/features/ledger-close-cadence/lib/ledger-sample"; +import { + gapsFixtureRecords, + recentLedgersFixture +} from "@/features/ledger-close-cadence/fixtures/ledgerCloseCadence.fixture"; + +describe("detectSequenceGaps", () => { + it("reports no gaps for a contiguous sample", () => { + const sample = validateAndSortLedgerSample(recentLedgersFixture); + expect(sample.ok).toBe(true); + if (!sample.ok) return; + expect(detectSequenceGaps(sample.value.ledgers)).toEqual([]); + }); + + it("counts missing sequences between fetched neighbors", () => { + const sample = validateAndSortLedgerSample(gapsFixtureRecords); + expect(sample.ok).toBe(true); + if (!sample.ok) return; + expect(detectSequenceGaps(sample.value.ledgers)).toEqual([ + { + afterSequence: "2008", + beforeSequence: "2010", + missingCount: "1" + } + ]); + }); +}); diff --git a/features/ledger-close-cadence/__tests__/useLedgerCloseCadence.test.tsx b/features/ledger-close-cadence/__tests__/useLedgerCloseCadence.test.tsx new file mode 100644 index 0000000..dd29576 --- /dev/null +++ b/features/ledger-close-cadence/__tests__/useLedgerCloseCadence.test.tsx @@ -0,0 +1,96 @@ +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 { useLedgerCloseCadence } from "@/features/ledger-close-cadence/hooks/useLedgerCloseCadence"; +import { handlers, rateLimitedHandler } from "@/features/ledger-close-cadence/msw/handlers"; +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("useLedgerCloseCadence", () => { + it("starts idle", () => { + const { result } = renderHook(() => useLedgerCloseCadence(), { + wrapper: wrapperFor("testnet") + }); + expect(result.current.state).toEqual({ status: "idle" }); + }); + + it("loads cadence for the selected network", async () => { + resetHorizonClients(); + const { result } = renderHook(() => useLedgerCloseCadence(), { + wrapper: wrapperFor("testnet") + }); + + await act(async () => { + await result.current.submit({ sampleSize: "4" }); + }); + + await waitFor(() => expect(result.current.state.status).toBe("success")); + }); + + it("reads mainnet fixtures when mainnet is selected", async () => { + resetHorizonClients(); + const { result } = renderHook(() => useLedgerCloseCadence(), { + wrapper: wrapperFor("mainnet") + }); + + await act(async () => { + await result.current.submit({ sampleSize: "4" }); + }); + + await waitFor(() => expect(result.current.state.status).toBe("success")); + if (result.current.state.status !== "success") return; + expect(result.current.state.result.sequenceGaps.length).toBeGreaterThan(0); + }); + + it("surfaces invalid sample size without fetching", async () => { + const { result } = renderHook(() => useLedgerCloseCadence(), { + wrapper: wrapperFor("testnet") + }); + + await act(async () => { + await result.current.submit({ sampleSize: "1" }); + }); + + expect(result.current.state).toEqual({ status: "error", code: "invalid_sample_size" }); + }); + + it("surfaces rate limiting", async () => { + server.use(rateLimitedHandler); + resetHorizonClients(); + const { result } = renderHook(() => useLedgerCloseCadence(), { + wrapper: wrapperFor("testnet") + }); + + await act(async () => { + await result.current.submit({ sampleSize: "4" }); + }); + + await waitFor(() => + expect(result.current.state).toEqual({ status: "error", code: "rate_limited" }) + ); + }); + + it("clears the reading on reset", async () => { + resetHorizonClients(); + const { result } = renderHook(() => useLedgerCloseCadence(), { + wrapper: wrapperFor("testnet") + }); + + await act(async () => { + await result.current.submit({ sampleSize: "4" }); + }); + await waitFor(() => expect(result.current.state.status).toBe("success")); + + act(() => result.current.reset()); + expect(result.current.state).toEqual({ status: "idle" }); + }); +}); diff --git a/features/ledger-close-cadence/components/CadenceSummary.tsx b/features/ledger-close-cadence/components/CadenceSummary.tsx new file mode 100644 index 0000000..a5362a3 --- /dev/null +++ b/features/ledger-close-cadence/components/CadenceSummary.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { DataList } from "@/core/ui/DataList"; +import { copy } from "@/features/ledger-close-cadence/copy"; +import { + formatCount, + formatDurationMs, + formatLedgerLabel, + formatNetworkLabel +} from "@/features/ledger-close-cadence/lib/format"; +import type { LedgerCloseCadenceResult } from "@/features/ledger-close-cadence/types"; + +export function CadenceSummary({ result }: { result: LedgerCloseCadenceResult }) { + return ( + + ); +} diff --git a/features/ledger-close-cadence/components/GapNotice.tsx b/features/ledger-close-cadence/components/GapNotice.tsx new file mode 100644 index 0000000..55f87de --- /dev/null +++ b/features/ledger-close-cadence/components/GapNotice.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { StatusMessage } from "@/core/ui/StatusMessage"; +import { copy } from "@/features/ledger-close-cadence/copy"; +import type { LedgerCloseCadenceResult } from "@/features/ledger-close-cadence/types"; + +export function GapNotice({ result }: { result: LedgerCloseCadenceResult }) { + const hasGaps = result.sequenceGaps.length > 0; + const hasUnusual = result.intervals.some((interval) => interval.unusual); + + return ( +
+ + + {hasUnusual ?

{copy.unusualNotice}

: null} + + {hasGaps ? ( +
    + {result.sequenceGaps.map((gap) => ( +
  • + + #{gap.afterSequence} → #{gap.beforeSequence} + + + {copy.missingCountLabel}: {gap.missingCount} + +
  • + ))} +
+ ) : ( +

{copy.noGaps}

+ )} +
+ ); +} diff --git a/features/ledger-close-cadence/components/LedgerCloseCadenceEmptyState.tsx b/features/ledger-close-cadence/components/LedgerCloseCadenceEmptyState.tsx new file mode 100644 index 0000000..6309455 --- /dev/null +++ b/features/ledger-close-cadence/components/LedgerCloseCadenceEmptyState.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { Card, CardHeader, CardTitle } from "@/core/ui/Card"; +import { copy } from "@/features/ledger-close-cadence/copy"; + +export function LedgerCloseCadenceEmptyState() { + return ( + + + {copy.emptyTitle} + +

{copy.emptyDescription}

+
+ ); +} diff --git a/features/ledger-close-cadence/components/LedgerCloseCadenceForm.tsx b/features/ledger-close-cadence/components/LedgerCloseCadenceForm.tsx new file mode 100644 index 0000000..9036386 --- /dev/null +++ b/features/ledger-close-cadence/components/LedgerCloseCadenceForm.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/ledger-close-cadence/copy"; +import type { RawLedgerCloseCadenceForm } from "@/features/ledger-close-cadence/types"; + +export function LedgerCloseCadenceForm({ + onSubmit, + pending +}: { + onSubmit: (values: RawLedgerCloseCadenceForm) => void; + pending: boolean; +}) { + const [sampleSize, setSampleSize] = useState("20"); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + onSubmit({ sampleSize }); + } + + return ( +
+ + {({ inputId, describedBy, invalid, required }) => ( + setSampleSize(event.target.value)} + inputMode="numeric" + autoComplete="off" + /> + )} + + + +
+ ); +} diff --git a/features/ledger-close-cadence/components/LedgerCloseCadencePanel.tsx b/features/ledger-close-cadence/components/LedgerCloseCadencePanel.tsx new file mode 100644 index 0000000..96118d4 --- /dev/null +++ b/features/ledger-close-cadence/components/LedgerCloseCadencePanel.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { Card } from "@/core/ui/Card"; +import { SkeletonRows } from "@/core/ui/Skeleton"; +import { StatusMessage } from "@/core/ui/StatusMessage"; +import { LedgerCloseCadenceEmptyState } from "@/features/ledger-close-cadence/components/LedgerCloseCadenceEmptyState"; +import { LedgerCloseCadenceForm } from "@/features/ledger-close-cadence/components/LedgerCloseCadenceForm"; +import { LedgerCloseCadenceResultView } from "@/features/ledger-close-cadence/components/LedgerCloseCadenceResult"; +import { copy, errorCopy } from "@/features/ledger-close-cadence/copy"; +import { useLedgerCloseCadence } from "@/features/ledger-close-cadence/hooks/useLedgerCloseCadence"; + +export function LedgerCloseCadencePanel() { + const { state, submit } = useLedgerCloseCadence(); + + return ( +
+ + + + + {state.status === "loading" ? ( + +

+ {copy.loading} +

+ +
+ ) : null} + + {state.status === "error" ? ( + + ) : null} + + {state.status === "success" ? : null} + + {state.status === "idle" ? : null} +
+ ); +} diff --git a/features/ledger-close-cadence/components/LedgerCloseCadenceResult.tsx b/features/ledger-close-cadence/components/LedgerCloseCadenceResult.tsx new file mode 100644 index 0000000..f10940a --- /dev/null +++ b/features/ledger-close-cadence/components/LedgerCloseCadenceResult.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { Card, CardHeader, CardTitle } from "@/core/ui/Card"; +import { CadenceSummary } from "@/features/ledger-close-cadence/components/CadenceSummary"; +import { GapNotice } from "@/features/ledger-close-cadence/components/GapNotice"; +import { LedgerIntervals } from "@/features/ledger-close-cadence/components/LedgerIntervals"; +import { copy } from "@/features/ledger-close-cadence/copy"; +import type { LedgerCloseCadenceResult } from "@/features/ledger-close-cadence/types"; + +export function LedgerCloseCadenceResultView({ result }: { result: LedgerCloseCadenceResult }) { + return ( +
+ + + {copy.summaryTitle} + + + + + + + {copy.intervalsTitle} + + + + + + + {copy.gapsTitle} + + + +
+ ); +} diff --git a/features/ledger-close-cadence/components/LedgerIntervals.tsx b/features/ledger-close-cadence/components/LedgerIntervals.tsx new file mode 100644 index 0000000..d71fbdc --- /dev/null +++ b/features/ledger-close-cadence/components/LedgerIntervals.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { copy } from "@/features/ledger-close-cadence/copy"; +import { formatDurationMs } from "@/features/ledger-close-cadence/lib/format"; +import type { LedgerInterval } from "@/features/ledger-close-cadence/types"; + +export function LedgerIntervals({ intervals }: { intervals: LedgerInterval[] }) { + if (intervals.length === 0) { + return

{copy.noIntervals}

; + } + + return ( +
    + {intervals.map((interval) => ( +
  1. +
    + + #{interval.fromSequence} → #{interval.toSequence} + + + {formatDurationMs(interval.durationMs)} + + {interval.unusual ? ( + + {copy.unusualBadge} + + ) : null} + {interval.repeatedTimestamp ? ( + + {copy.repeatedBadge} + + ) : null} +
    +

    + {interval.fromClosedAt} → {interval.toClosedAt} +

    +
  2. + ))} +
+ ); +} diff --git a/features/ledger-close-cadence/copy.ts b/features/ledger-close-cadence/copy.ts new file mode 100644 index 0000000..912f8fb --- /dev/null +++ b/features/ledger-close-cadence/copy.ts @@ -0,0 +1,68 @@ +import type { LedgerCloseCadenceErrorCode } from "@/features/ledger-close-cadence/types"; +import { SAMPLE_SIZE_MAX, SAMPLE_SIZE_MIN } from "@/features/ledger-close-cadence/types"; + +export const copy = { + formLabel: "Sample size", + formHint: `Whole number from ${SAMPLE_SIZE_MIN} to ${SAMPLE_SIZE_MAX}. Horizon returns the newest ledgers first.`, + submit: "Measure close cadence", + refresh: "Measure again", + loading: "Reading recent ledger closes...", + emptyTitle: "No cadence sample yet", + emptyDescription: + "Fetch a bounded window of recent ledgers and measure the spacing between their close timestamps. A long gap is an observation in the sample — not proof of an outage.", + resultTitle: "Observed close cadence", + summaryTitle: "Sample summary", + intervalsTitle: "Adjacent close intervals", + gapsTitle: "Sequence gaps", + noticesTitle: "Sample notices", + networkLabel: "Network", + sampleSizeLabel: "Requested sample size", + observedSizeLabel: "Observed sample size", + firstLedgerLabel: "Oldest ledger in sample", + lastLedgerLabel: "Newest ledger in sample", + medianLabel: "Median interval", + minLabel: "Minimum interval", + maxLabel: "Maximum interval", + intervalCountLabel: "Interval count", + malformedLabel: "Malformed records skipped", + repeatedLabel: "Repeated timestamps", + gapNotice: + "Missing sequence numbers are listed separately from long close-time gaps. A gap means those ledgers were not in this Horizon page.", + unusualNotice: "Intervals marked unusual are much longer than the sample median.", + noGaps: "No sequence discontinuities in this sample.", + noIntervals: "Need at least two valid ledgers to compute intervals.", + fromLabel: "From", + toLabel: "To", + durationLabel: "Duration", + unusualBadge: "Unusual spacing", + repeatedBadge: "Repeated timestamp", + missingCountLabel: "Missing sequences" +} as const; + +export const errorCopy: Record< + LedgerCloseCadenceErrorCode, + { title: string; description: string } +> = { + invalid_sample_size: { + title: "Sample size is not valid", + description: `Enter a whole number from ${SAMPLE_SIZE_MIN} to ${SAMPLE_SIZE_MAX}. Smaller samples cannot measure adjacent closes.` + }, + malformed_ledger: { + title: "Ledger records could not be used", + description: + "Horizon returned records without usable sequence numbers or UTC close times. Try a different sample size or network." + }, + history_unavailable: { + title: "Ledger history is unavailable", + description: + "Horizon returned no usable ledgers for this request. The endpoint may be catching up or missing history." + }, + rate_limited: { + title: "Horizon is rate limiting this request", + description: "Ledger history was requested too often. Wait a moment before measuring again." + }, + request_failed: { + title: "The request did not complete", + description: "Horizon responded with an error or the connection failed. Try again in a moment." + } +}; diff --git a/features/ledger-close-cadence/e2e/ledger-close-cadence.spec.ts b/features/ledger-close-cadence/e2e/ledger-close-cadence.spec.ts new file mode 100644 index 0000000..3d6edc0 --- /dev/null +++ b/features/ledger-close-cadence/e2e/ledger-close-cadence.spec.ts @@ -0,0 +1,14 @@ +export const spec = { + route: "/tools/ledger-close-cadence", + steps: [ + { action: "visit", target: "/tools/ledger-close-cadence" }, + { action: "expect", target: "heading", value: "Ledger Close Cadence Explorer" }, + { action: "expect", target: "text", value: "No cadence sample yet" }, + { action: "fill", target: "Sample size", value: "4" }, + { action: "click", target: "Measure close cadence" }, + { action: "expect", target: "text", value: "Sample summary" }, + { action: "expect", target: "text", value: "Adjacent close intervals" }, + { action: "switchNetwork", value: "mainnet" }, + { action: "expect", target: "text", value: "No cadence sample yet" } + ] +} as const; diff --git a/features/ledger-close-cadence/e2e/ledger-gap.spec.ts b/features/ledger-close-cadence/e2e/ledger-gap.spec.ts new file mode 100644 index 0000000..f4a57fe --- /dev/null +++ b/features/ledger-close-cadence/e2e/ledger-gap.spec.ts @@ -0,0 +1,11 @@ +export const spec = { + route: "/tools/ledger-close-cadence", + steps: [ + { action: "visit", target: "/tools/ledger-close-cadence" }, + { action: "switchNetwork", value: "mainnet" }, + { action: "fill", target: "Sample size", value: "4" }, + { action: "click", target: "Measure close cadence" }, + { action: "expect", target: "text", value: "Sequence gaps" }, + { action: "expect", target: "text", value: "Missing sequences" } + ] +} as const; diff --git a/features/ledger-close-cadence/fixtures/gaps.fixture.ts b/features/ledger-close-cadence/fixtures/gaps.fixture.ts new file mode 100644 index 0000000..c152c5c --- /dev/null +++ b/features/ledger-close-cadence/fixtures/gaps.fixture.ts @@ -0,0 +1,4 @@ +export { + gapsFixture, + gapsFixtureRecords +} from "@/features/ledger-close-cadence/fixtures/ledgerCloseCadence.fixture"; diff --git a/features/ledger-close-cadence/fixtures/ledgerCloseCadence.fixture.ts b/features/ledger-close-cadence/fixtures/ledgerCloseCadence.fixture.ts new file mode 100644 index 0000000..693a23a --- /dev/null +++ b/features/ledger-close-cadence/fixtures/ledgerCloseCadence.fixture.ts @@ -0,0 +1,50 @@ +import type { HorizonLedgerRecord } from "@/features/ledger-close-cadence/types"; + +const base = Date.parse("2026-09-29T12:00:00.000Z"); + +/** Contiguous recent ledgers with ~5s closes — normal case. */ +export const recentLedgersFixture: HorizonLedgerRecord[] = [ + { sequence: 1004, closed_at: new Date(base + 15_000).toISOString() }, + { sequence: 1003, closed_at: new Date(base + 10_000).toISOString() }, + { sequence: 1002, closed_at: new Date(base + 5_000).toISOString() }, + { sequence: 1001, closed_at: new Date(base).toISOString() } +]; + +export const ledgerCloseCadenceFixture = { + _embedded: { records: recentLedgersFixture } +}; + +/** Same as recent, exported for edge-case naming required by the issue. */ +export { recentLedgersFixture as recentLedgers }; + +/** Sample with a sequence gap and one unusually long interval. */ +export const gapsFixtureRecords: HorizonLedgerRecord[] = [ + { sequence: 2010, closed_at: new Date(base + 40_000).toISOString() }, + { sequence: 2008, closed_at: new Date(base + 35_000).toISOString() }, + { sequence: 2007, closed_at: new Date(base + 5_000).toISOString() }, + { sequence: 2006, closed_at: new Date(base).toISOString() } +]; + +export const gapsFixture = { + _embedded: { records: gapsFixtureRecords } +}; + +export const repeatedTimestampRecords: HorizonLedgerRecord[] = [ + { sequence: 3003, closed_at: new Date(base + 10_000).toISOString() }, + { sequence: 3002, closed_at: new Date(base + 5_000).toISOString() }, + { sequence: 3001, closed_at: new Date(base + 5_000).toISOString() }, + { sequence: 3000, closed_at: new Date(base).toISOString() } +]; + +export const malformedMixedRecords: HorizonLedgerRecord[] = [ + { sequence: 4002, closed_at: new Date(base + 5_000).toISOString() }, + { sequence: "not-a-number", closed_at: new Date(base).toISOString() }, + { sequence: 4001, closed_at: "yesterday" }, + { sequence: 4000, closed_at: new Date(base).toISOString() } +]; + +export const allMalformedRecords: HorizonLedgerRecord[] = [ + { sequence: "x", closed_at: "nope" }, + { closed_at: new Date(base).toISOString() }, + { sequence: 1 } +]; diff --git a/features/ledger-close-cadence/fixtures/recent-ledgers.fixture.ts b/features/ledger-close-cadence/fixtures/recent-ledgers.fixture.ts new file mode 100644 index 0000000..119260c --- /dev/null +++ b/features/ledger-close-cadence/fixtures/recent-ledgers.fixture.ts @@ -0,0 +1,5 @@ +export { + gapsFixture, + gapsFixtureRecords, + recentLedgersFixture +} from "@/features/ledger-close-cadence/fixtures/ledgerCloseCadence.fixture"; diff --git a/features/ledger-close-cadence/hooks/useLedgerCloseCadence.ts b/features/ledger-close-cadence/hooks/useLedgerCloseCadence.ts new file mode 100644 index 0000000..79be237 --- /dev/null +++ b/features/ledger-close-cadence/hooks/useLedgerCloseCadence.ts @@ -0,0 +1,80 @@ +"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 { parseLedgerCloseCadenceInput } from "@/features/ledger-close-cadence/schema"; +import { runLedgerCloseCadence } from "@/features/ledger-close-cadence/lib/ledgerCloseCadence"; +import { toLedgerCloseCadenceErrorCode } from "@/features/ledger-close-cadence/lib/ledgerCloseCadence.errors"; +import type { + LedgerCloseCadenceErrorCode, + LedgerCloseCadenceResult, + RawLedgerCloseCadenceForm +} from "@/features/ledger-close-cadence/types"; + +export type LedgerCloseCadenceState = + | { status: "idle" } + | { status: "loading" } + | { status: "success"; result: LedgerCloseCadenceResult } + | { status: "error"; code: LedgerCloseCadenceErrorCode }; + +const IDLE: LedgerCloseCadenceState = { status: "idle" }; + +interface Held { + state: LedgerCloseCadenceState; + network: StellarNetwork; +} + +export function useLedgerCloseCadence() { + 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: RawLedgerCloseCadenceForm) => { + controller.current?.abort(); + const parsed = parseLedgerCloseCadenceInput(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 runLedgerCloseCadence(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: toLedgerCloseCadenceErrorCode(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/ledger-close-cadence/lib/cadence-stats.ts b/features/ledger-close-cadence/lib/cadence-stats.ts new file mode 100644 index 0000000..8b9b191 --- /dev/null +++ b/features/ledger-close-cadence/lib/cadence-stats.ts @@ -0,0 +1,37 @@ +import type { CadenceStats } from "@/features/ledger-close-cadence/types"; + +/** Exact median of integer millisecond durations (average of two middle values when even). */ +export function medianIntervalMs(durations: number[]): number | null { + if (durations.length === 0) return null; + + const sorted = [...durations].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + + if (sorted.length % 2 === 1) { + return sorted[mid]!; + } + + return Math.floor((sorted[mid - 1]! + sorted[mid]!) / 2); +} + +export function calculateCadenceStats(durations: number[]): CadenceStats | null { + if (durations.length === 0) return null; + + let minMs = durations[0]!; + let maxMs = durations[0]!; + + for (const duration of durations) { + if (duration < minMs) minMs = duration; + if (duration > maxMs) maxMs = duration; + } + + const medianMs = medianIntervalMs(durations); + if (medianMs === null) return null; + + return { + medianMs, + minMs, + maxMs, + intervalCount: durations.length + }; +} diff --git a/features/ledger-close-cadence/lib/format.ts b/features/ledger-close-cadence/lib/format.ts new file mode 100644 index 0000000..02580db --- /dev/null +++ b/features/ledger-close-cadence/lib/format.ts @@ -0,0 +1,42 @@ +import type { StellarNetwork } from "@/core/network/types"; + +const NETWORK_LABELS: Record = { + testnet: "Testnet", + mainnet: "Mainnet" +}; + +export function formatNetworkLabel(network: StellarNetwork): string { + return NETWORK_LABELS[network]; +} + +export function formatDurationMs(durationMs: number): string { + if (!Number.isFinite(durationMs)) return "—"; + + const absolute = Math.abs(Math.trunc(durationMs)); + const sign = durationMs < 0 ? "-" : ""; + + if (absolute < 1000) { + return `${sign}${absolute} ms`; + } + + const wholeSeconds = Math.floor(absolute / 1000); + const millis = absolute % 1000; + + if (wholeSeconds < 60) { + return millis === 0 + ? `${sign}${wholeSeconds} s` + : `${sign}${wholeSeconds}.${String(millis).padStart(3, "0")} s`; + } + + const minutes = Math.floor(wholeSeconds / 60); + const seconds = wholeSeconds % 60; + return `${sign}${minutes} m ${seconds} s`; +} + +export function formatLedgerLabel(sequence: string, closedAt: string): string { + return `#${sequence} · ${closedAt}`; +} + +export function formatCount(value: number): string { + return String(value); +} diff --git a/features/ledger-close-cadence/lib/ledger-sample.ts b/features/ledger-close-cadence/lib/ledger-sample.ts new file mode 100644 index 0000000..01df41f --- /dev/null +++ b/features/ledger-close-cadence/lib/ledger-sample.ts @@ -0,0 +1,77 @@ +import { err, ok, type Result } from "@/core/result/result"; +import type { + HorizonLedgerRecord, + LedgerCloseCadenceErrorCode, + ValidatedLedger +} from "@/features/ledger-close-cadence/types"; + +/** + * Parses a Horizon ledger record into a validated close observation. + * Malformed records return null so callers can skip without corrupting the sample. + */ +export function parseLedgerRecord(record: HorizonLedgerRecord): ValidatedLedger | null { + if (!record || typeof record !== "object") return null; + + const sequenceRaw = record.sequence; + const closedAtRaw = record.closed_at; + + if (sequenceRaw === undefined || sequenceRaw === null) return null; + if (typeof closedAtRaw !== "string" || !closedAtRaw.trim()) return null; + + const sequenceText = String(sequenceRaw).trim(); + if (!/^\d+$/.test(sequenceText)) return null; + + const closedAtMs = Date.parse(closedAtRaw); + if (!Number.isFinite(closedAtMs)) return null; + + return { + sequence: sequenceText, + closedAt: new Date(closedAtMs).toISOString(), + closedAtMs + }; +} + +/** + * Validates records, drops malformed ones, and sorts ascending by sequence. + * Duplicate sequences keep the first valid observation. + */ +export function validateAndSortLedgerSample( + records: HorizonLedgerRecord[] +): Result< + { ledgers: ValidatedLedger[]; malformedCount: number }, + LedgerCloseCadenceErrorCode +> { + if (!Array.isArray(records) || records.length === 0) { + return err("history_unavailable"); + } + + const bySequence = new Map(); + let malformedCount = 0; + + for (const record of records) { + const parsed = parseLedgerRecord(record); + if (!parsed) { + malformedCount += 1; + continue; + } + if (!bySequence.has(parsed.sequence)) { + bySequence.set(parsed.sequence, parsed); + } + } + + const ledgers = [...bySequence.values()].sort((a, b) => + a.sequence.length === b.sequence.length + ? a.sequence < b.sequence + ? -1 + : a.sequence > b.sequence + ? 1 + : 0 + : a.sequence.length - b.sequence.length + ); + + if (ledgers.length === 0) { + return err("malformed_ledger"); + } + + return ok({ ledgers, malformedCount }); +} diff --git a/features/ledger-close-cadence/lib/ledgerCloseCadence.errors.ts b/features/ledger-close-cadence/lib/ledgerCloseCadence.errors.ts new file mode 100644 index 0000000..6f9fbcb --- /dev/null +++ b/features/ledger-close-cadence/lib/ledgerCloseCadence.errors.ts @@ -0,0 +1,20 @@ +import { classifyHorizonError } from "@/core/horizon/errors"; +import type { LedgerCloseCadenceErrorCode } from "@/features/ledger-close-cadence/types"; + +export function toLedgerCloseCadenceErrorCode(error: unknown): LedgerCloseCadenceErrorCode { + const { code } = classifyHorizonError(error); + + if (code === "rate_limited") return "rate_limited"; + if (code === "not_found") return "history_unavailable"; + 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/ledger-close-cadence/lib/ledgerCloseCadence.ts b/features/ledger-close-cadence/lib/ledgerCloseCadence.ts new file mode 100644 index 0000000..18b83d5 --- /dev/null +++ b/features/ledger-close-cadence/lib/ledgerCloseCadence.ts @@ -0,0 +1,134 @@ +import { err, ok, type Result } from "@/core/result/result"; +import { horizonUrl } from "@/core/horizon/client"; +import type { StellarNetwork } from "@/core/network/types"; +import { calculateCadenceStats } from "@/features/ledger-close-cadence/lib/cadence-stats"; +import { + HorizonStatusError, + toLedgerCloseCadenceErrorCode +} from "@/features/ledger-close-cadence/lib/ledgerCloseCadence.errors"; +import { validateAndSortLedgerSample } from "@/features/ledger-close-cadence/lib/ledger-sample"; +import { detectSequenceGaps } from "@/features/ledger-close-cadence/lib/sequence-gaps"; +import { + UNUSUAL_INTERVAL_MEDIAN_MULTIPLE, + type HorizonLedgerRecord, + type LedgerCloseCadenceErrorCode, + type LedgerCloseCadenceInput, + type LedgerCloseCadenceResult, + type LedgerInterval, + type ValidatedLedger +} from "@/features/ledger-close-cadence/types"; + +const REQUEST_TIMEOUT_MS = 15_000; + +interface HorizonLedgerPage { + _embedded?: { records?: HorizonLedgerRecord[] }; + records?: HorizonLedgerRecord[]; +} + +function recordsFromPage(page: HorizonLedgerPage): HorizonLedgerRecord[] { + if (Array.isArray(page._embedded?.records)) return page._embedded.records; + if (Array.isArray(page.records)) return page.records; + return []; +} + +export function buildIntervals( + ledgers: ValidatedLedger[], + medianMs: number | null +): { intervals: LedgerInterval[]; repeatedTimestampCount: number } { + const intervals: LedgerInterval[] = []; + let repeatedTimestampCount = 0; + const unusualThreshold = + medianMs === null ? Number.POSITIVE_INFINITY : medianMs * UNUSUAL_INTERVAL_MEDIAN_MULTIPLE; + + for (let index = 1; index < ledgers.length; index += 1) { + const from = ledgers[index - 1]!; + const to = ledgers[index]!; + const durationMs = to.closedAtMs - from.closedAtMs; + const repeatedTimestamp = durationMs === 0; + + if (repeatedTimestamp) repeatedTimestampCount += 1; + + intervals.push({ + fromSequence: from.sequence, + toSequence: to.sequence, + fromClosedAt: from.closedAt, + toClosedAt: to.closedAt, + durationMs, + unusual: durationMs > unusualThreshold, + repeatedTimestamp + }); + } + + return { intervals, repeatedTimestampCount }; +} + +export function analyzeLedgerSample( + records: HorizonLedgerRecord[], + input: LedgerCloseCadenceInput, + network: StellarNetwork +): Result { + const validated = validateAndSortLedgerSample(records); + if (!validated.ok) return validated; + + const { ledgers, malformedCount } = validated.value; + if (ledgers.length < 2) { + return err("malformed_ledger"); + } + + const preliminaryDurations: number[] = []; + for (let index = 1; index < ledgers.length; index += 1) { + preliminaryDurations.push(ledgers[index]!.closedAtMs - ledgers[index - 1]!.closedAtMs); + } + + const stats = calculateCadenceStats(preliminaryDurations); + if (!stats) return err("malformed_ledger"); + + const { intervals, repeatedTimestampCount } = buildIntervals(ledgers, stats.medianMs); + + return ok({ + network, + requestedSampleSize: input.sampleSize, + observedSampleSize: ledgers.length, + firstLedger: ledgers[0]!, + lastLedger: ledgers[ledgers.length - 1]!, + intervals, + stats, + sequenceGaps: detectSequenceGaps(ledgers), + malformedCount, + repeatedTimestampCount + }); +} + +export async function runLedgerCloseCadence( + input: LedgerCloseCadenceInput, + network: StellarNetwork, + 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( + horizonUrl(network, "/ledgers", { order: "desc", limit: input.sampleSize }), + { + signal: controller.signal, + headers: { Accept: "application/json" } + } + ); + + if (!response.ok) { + throw new HorizonStatusError(response.status); + } + + const page = (await response.json()) as HorizonLedgerPage; + return analyzeLedgerSample(recordsFromPage(page), input, network); + } catch (error) { + return err(toLedgerCloseCadenceErrorCode(error)); + } finally { + clearTimeout(timeout); + signal?.removeEventListener("abort", abortFromCaller); + } +} diff --git a/features/ledger-close-cadence/lib/sequence-gaps.ts b/features/ledger-close-cadence/lib/sequence-gaps.ts new file mode 100644 index 0000000..dd4c1b5 --- /dev/null +++ b/features/ledger-close-cadence/lib/sequence-gaps.ts @@ -0,0 +1,27 @@ +import type { SequenceGap, ValidatedLedger } from "@/features/ledger-close-cadence/types"; + +/** + * Detects missing sequence numbers between adjacent validated ledgers. + * Does not infer continuity across pages that were never fetched. + */ +export function detectSequenceGaps(ledgers: ValidatedLedger[]): SequenceGap[] { + const gaps: SequenceGap[] = []; + + for (let index = 1; index < ledgers.length; index += 1) { + const previous = ledgers[index - 1]!; + const current = ledgers[index]!; + const previousSeq = BigInt(previous.sequence); + const currentSeq = BigInt(current.sequence); + const expected = previousSeq + 1n; + + if (currentSeq > expected) { + gaps.push({ + afterSequence: previous.sequence, + beforeSequence: current.sequence, + missingCount: (currentSeq - previousSeq - 1n).toString() + }); + } + } + + return gaps; +} diff --git a/features/ledger-close-cadence/manifest.ts b/features/ledger-close-cadence/manifest.ts new file mode 100644 index 0000000..d0afc97 --- /dev/null +++ b/features/ledger-close-cadence/manifest.ts @@ -0,0 +1,23 @@ +import { Timer } from "lucide-react"; +import type { FeatureManifest } from "@/core/registry/types"; + +export const manifest: FeatureManifest = { + slug: "ledger-close-cadence", + title: "Ledger Close Cadence Explorer", + description: + "Measure observed spacing between recent ledger close timestamps and separate sequence gaps from long intervals.", + character: "A metronome clerk times each ledger close and notes when a beat goes missing.", + category: "network", + status: "working", + icon: Timer, + networks: ["testnet", "mainnet"], + keywords: [ + "ledger", + "close time", + "cadence", + "interval", + "sequence gap", + "horizon", + "network" + ] +}; diff --git a/features/ledger-close-cadence/msw/handlers.ts b/features/ledger-close-cadence/msw/handlers.ts new file mode 100644 index 0000000..5849f37 --- /dev/null +++ b/features/ledger-close-cadence/msw/handlers.ts @@ -0,0 +1,35 @@ +import { http, HttpResponse } from "msw"; +import { horizonUrl } from "@/core/horizon/client"; +import { + gapsFixture, + ledgerCloseCadenceFixture +} from "@/features/ledger-close-cadence/fixtures/ledgerCloseCadence.fixture"; + +export const handlers = [ + http.get(horizonUrl("testnet", "/ledgers"), () => HttpResponse.json(ledgerCloseCadenceFixture)), + http.get(horizonUrl("mainnet", "/ledgers"), () => HttpResponse.json(gapsFixture)) +]; + +export const gapsHandler = http.get(horizonUrl("testnet", "/ledgers"), () => + HttpResponse.json(gapsFixture) +); + +export const emptyHistoryHandler = http.get(horizonUrl("testnet", "/ledgers"), () => + HttpResponse.json({ _embedded: { records: [] } }) +); + +export const rateLimitedHandler = http.get(horizonUrl("testnet", "/ledgers"), () => + HttpResponse.json({ title: "Rate limit exceeded", status: 429 }, { status: 429 }) +); + +export const serverErrorHandler = http.get(horizonUrl("testnet", "/ledgers"), () => + HttpResponse.json({ title: "Server Error", status: 503 }, { status: 503 }) +); + +export const malformedOnlyHandler = http.get(horizonUrl("testnet", "/ledgers"), () => + HttpResponse.json({ + _embedded: { + records: [{ sequence: "bad", closed_at: "nope" }, { sequence: 1 }] + } + }) +); diff --git a/features/ledger-close-cadence/panel.tsx b/features/ledger-close-cadence/panel.tsx new file mode 100644 index 0000000..243d7e1 --- /dev/null +++ b/features/ledger-close-cadence/panel.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { LedgerCloseCadencePanel as default } from "@/features/ledger-close-cadence/components/LedgerCloseCadencePanel"; diff --git a/features/ledger-close-cadence/schema.ts b/features/ledger-close-cadence/schema.ts new file mode 100644 index 0000000..0bc7cc2 --- /dev/null +++ b/features/ledger-close-cadence/schema.ts @@ -0,0 +1,34 @@ +import { err, ok, type Result } from "@/core/result/result"; +import { + SAMPLE_SIZE_MAX, + SAMPLE_SIZE_MIN, + type LedgerCloseCadenceErrorCode, + type LedgerCloseCadenceInput, + type RawLedgerCloseCadenceForm +} from "@/features/ledger-close-cadence/types"; + +/** + * Validates sample size without talking to Horizon. + * Accepts whole numbers from SAMPLE_SIZE_MIN to SAMPLE_SIZE_MAX inclusive. + */ +export function parseLedgerCloseCadenceInput( + raw: RawLedgerCloseCadenceForm +): Result { + const trimmed = raw.sampleSize.trim(); + + if (!trimmed || !/^\d+$/.test(trimmed)) { + return err("invalid_sample_size"); + } + + const sampleSize = Number(trimmed); + + if ( + !Number.isSafeInteger(sampleSize) || + sampleSize < SAMPLE_SIZE_MIN || + sampleSize > SAMPLE_SIZE_MAX + ) { + return err("invalid_sample_size"); + } + + return ok({ sampleSize }); +} diff --git a/features/ledger-close-cadence/types.ts b/features/ledger-close-cadence/types.ts new file mode 100644 index 0000000..828fe73 --- /dev/null +++ b/features/ledger-close-cadence/types.ts @@ -0,0 +1,70 @@ +import type { StellarNetwork } from "@/core/network/types"; + +export interface RawLedgerCloseCadenceForm { + sampleSize: string; +} + +export interface LedgerCloseCadenceInput { + sampleSize: number; +} + +export interface HorizonLedgerRecord { + sequence?: number | string; + closed_at?: string; +} + +export interface ValidatedLedger { + sequence: string; + closedAt: string; + closedAtMs: number; +} + +export interface LedgerInterval { + fromSequence: string; + toSequence: string; + fromClosedAt: string; + toClosedAt: string; + durationMs: number; + unusual: boolean; + repeatedTimestamp: boolean; +} + +export interface SequenceGap { + afterSequence: string; + beforeSequence: string; + missingCount: string; +} + +export interface CadenceStats { + medianMs: number; + minMs: number; + maxMs: number; + intervalCount: number; +} + +export interface LedgerCloseCadenceResult { + network: StellarNetwork; + requestedSampleSize: number; + observedSampleSize: number; + firstLedger: ValidatedLedger; + lastLedger: ValidatedLedger; + intervals: LedgerInterval[]; + stats: CadenceStats; + sequenceGaps: SequenceGap[]; + malformedCount: number; + repeatedTimestampCount: number; +} + +export type LedgerCloseCadenceErrorCode = + | "invalid_sample_size" + | "malformed_ledger" + | "history_unavailable" + | "rate_limited" + | "request_failed"; + +/** Inclusive bounds for the sample-size field. */ +export const SAMPLE_SIZE_MIN = 2; +export const SAMPLE_SIZE_MAX = 200; + +/** Intervals longer than this multiple of the median are flagged as unusual. */ +export const UNUSUAL_INTERVAL_MEDIAN_MULTIPLE = 3;