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 (
+
+ );
+}
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) => (
+ -
+
+
+ #{interval.fromSequence} → #{interval.toSequence}
+
+
+ {formatDurationMs(interval.durationMs)}
+
+ {interval.unusual ? (
+
+ {copy.unusualBadge}
+
+ ) : null}
+ {interval.repeatedTimestamp ? (
+
+ {copy.repeatedBadge}
+
+ ) : null}
+
+
+ {interval.fromClosedAt} → {interval.toClosedAt}
+
+
+ ))}
+
+ );
+}
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;