diff --git a/features/ledger-protocol-transition-map/README.md b/features/ledger-protocol-transition-map/README.md
new file mode 100644
index 0000000..085fae9
--- /dev/null
+++ b/features/ledger-protocol-transition-map/README.md
@@ -0,0 +1,22 @@
+# Ledger Protocol Transition Map
+
+Scans a bounded Horizon ledger range and maps where reported protocol versions change.
+
+## Behavior
+
+1. Validate start ledger and bounded count (2–200).
+2. Fetch consecutive `GET /ledgers?order=asc` pages until the count is filled or history ends.
+3. Group adjacent same-version ledgers into runs.
+4. Mark exact transitions only when both neighboring sequences were fetched; gaps yield uncertain boundaries.
+
+## Data boundaries
+
+- Configured testnet/mainnet Horizon only.
+- No unbounded history scan.
+- Sequences and protocol versions are strings / `BigInt`; no secret keys.
+
+## Design decisions
+
+- A missing ledger between fetched records never invents the upgrade sequence.
+- Partial pages flag the scan as incomplete so trailing inferences stay conservative.
+- The copyable summary is plain text for pasting into reviews or tickets.
diff --git a/features/ledger-protocol-transition-map/__tests__/LedgerProtocolTransitionMapPanel.test.tsx b/features/ledger-protocol-transition-map/__tests__/LedgerProtocolTransitionMapPanel.test.tsx
new file mode 100644
index 0000000..b91c430
--- /dev/null
+++ b/features/ledger-protocol-transition-map/__tests__/LedgerProtocolTransitionMapPanel.test.tsx
@@ -0,0 +1,50 @@
+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 { LedgerProtocolTransitionMapPanel } from "@/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapPanel";
+import { copy, errorCopy } from "@/features/ledger-protocol-transition-map/copy";
+import { handlers, rateLimitedHandler } from "@/features/ledger-protocol-transition-map/msw/handlers";
+
+const server = withMswHandlers(...handlers);
+
+describe("LedgerProtocolTransitionMapPanel", () => {
+ it("shows the empty state", () => {
+ renderFeature();
+ expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument();
+ });
+
+ it("renders runs and transitions after a successful scan", async () => {
+ resetHorizonClients();
+ const { user } = renderFeature(, { network: "testnet" });
+
+ await user.clear(screen.getByLabelText(copy.startLabel));
+ await user.type(screen.getByLabelText(copy.startLabel), "1000");
+ await user.clear(screen.getByLabelText(copy.countLabel));
+ await user.type(screen.getByLabelText(copy.countLabel), "4");
+ await user.click(screen.getByRole("button", { name: copy.submit }));
+
+ expect(await screen.findByText(copy.runsTitle)).toBeInTheDocument();
+ expect(screen.getByText(copy.transitionsTitle)).toBeInTheDocument();
+ expect(screen.getByText(copy.exactBadge)).toBeInTheDocument();
+ });
+
+ it("explains invalid count", async () => {
+ const { user } = renderFeature(, { network: "testnet" });
+
+ await user.clear(screen.getByLabelText(copy.countLabel));
+ await user.type(screen.getByLabelText(copy.countLabel), "1");
+ await user.click(screen.getByRole("button", { name: copy.submit }));
+
+ expect(await screen.findByText(errorCopy.invalid_count.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-protocol-transition-map/__tests__/a11y.test.tsx b/features/ledger-protocol-transition-map/__tests__/a11y.test.tsx
new file mode 100644
index 0000000..aec8dbe
--- /dev/null
+++ b/features/ledger-protocol-transition-map/__tests__/a11y.test.tsx
@@ -0,0 +1,33 @@
+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 { LedgerProtocolTransitionMapPanel } from "@/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapPanel";
+import { copy } from "@/features/ledger-protocol-transition-map/copy";
+import { handlers } from "@/features/ledger-protocol-transition-map/msw/handlers";
+
+withMswHandlers(...handlers);
+
+describe("LedgerProtocolTransitionMapPanel 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 scan", async () => {
+ resetHorizonClients();
+ const { container, user } = renderFeature(, {
+ network: "testnet"
+ });
+
+ await user.clear(screen.getByLabelText(copy.startLabel));
+ await user.type(screen.getByLabelText(copy.startLabel), "1000");
+ await user.clear(screen.getByLabelText(copy.countLabel));
+ await user.type(screen.getByLabelText(copy.countLabel), "4");
+ await user.click(screen.getByRole("button", { name: copy.submit }));
+ await screen.findByText(copy.runsTitle);
+
+ await expectNoAxeViolations(container);
+ });
+});
diff --git a/features/ledger-protocol-transition-map/__tests__/format.test.ts b/features/ledger-protocol-transition-map/__tests__/format.test.ts
new file mode 100644
index 0000000..9ba9573
--- /dev/null
+++ b/features/ledger-protocol-transition-map/__tests__/format.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildSummaryText,
+ formatNetworkLabel,
+ formatRange,
+ formatRunLabel,
+ formatTransitionLabel
+} from "@/features/ledger-protocol-transition-map/lib/format";
+
+describe("format helpers", () => {
+ it("labels networks and ranges", () => {
+ expect(formatNetworkLabel("testnet")).toBe("Testnet");
+ expect(formatRange("1000", "1003")).toBe("#1000 → #1003");
+ });
+
+ it("formats runs and transitions", () => {
+ expect(
+ formatRunLabel({
+ protocolVersion: "21",
+ startSequence: "1002",
+ endSequence: "1003",
+ ledgerCount: "2"
+ })
+ ).toBe("v21 · #1002 → #1003 (2)");
+
+ expect(
+ formatTransitionLabel({
+ fromVersion: "20",
+ toVersion: "21",
+ beforeSequence: "1001",
+ afterSequence: "1002",
+ certainty: "exact",
+ reason: "adjacent"
+ })
+ ).toBe("v20 → v21 at #1001/#1002");
+ });
+
+ it("builds a copyable summary", () => {
+ expect(
+ buildSummaryText({
+ network: "testnet",
+ start: "1000",
+ end: "1003",
+ observed: 4,
+ transitions: [
+ {
+ fromVersion: "20",
+ toVersion: "21",
+ beforeSequence: "1001",
+ afterSequence: "1002",
+ certainty: "exact",
+ reason: "adjacent"
+ }
+ ]
+ })
+ ).toContain("exact:20->21@1001/1002");
+ });
+});
diff --git a/features/ledger-protocol-transition-map/__tests__/ledger-pages.test.ts b/features/ledger-protocol-transition-map/__tests__/ledger-pages.test.ts
new file mode 100644
index 0000000..478d898
--- /dev/null
+++ b/features/ledger-protocol-transition-map/__tests__/ledger-pages.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import {
+ filterToRequestedRange,
+ normalizeFetchedLedgers,
+ parseProtocolLedger
+} from "@/features/ledger-protocol-transition-map/lib/ledger-pages";
+import {
+ malformedProtocolRecords,
+ protocolLedgersFixture
+} from "@/features/ledger-protocol-transition-map/fixtures/ledgerProtocolTransitionMap.fixture";
+
+describe("parseProtocolLedger", () => {
+ it("accepts sequence and protocol version", () => {
+ expect(parseProtocolLedger(protocolLedgersFixture[0]!)).toMatchObject({
+ sequence: "1000",
+ protocolVersion: "20"
+ });
+ });
+
+ it("rejects malformed records", () => {
+ expect(parseProtocolLedger(malformedProtocolRecords[0]!)).toBeNull();
+ });
+});
+
+describe("normalizeFetchedLedgers", () => {
+ it("sorts and keeps valid ledgers", () => {
+ const result = normalizeFetchedLedgers(protocolLedgersFixture);
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.ledgers.map((ledger) => ledger.sequence)).toEqual([
+ "1000",
+ "1001",
+ "1002",
+ "1003"
+ ]);
+ });
+
+ it("fails when every record is malformed", () => {
+ expect(normalizeFetchedLedgers(malformedProtocolRecords)).toEqual({
+ ok: false,
+ code: "malformed_ledger"
+ });
+ });
+});
+
+describe("filterToRequestedRange", () => {
+ it("keeps only ledgers inside the requested window", () => {
+ const normalized = normalizeFetchedLedgers(protocolLedgersFixture);
+ expect(normalized.ok).toBe(true);
+ if (!normalized.ok) return;
+ expect(
+ filterToRequestedRange(normalized.value.ledgers, 1001n, 2).map((ledger) => ledger.sequence)
+ ).toEqual(["1001", "1002"]);
+ });
+});
diff --git a/features/ledger-protocol-transition-map/__tests__/ledgerProtocolTransitionMap.test.ts b/features/ledger-protocol-transition-map/__tests__/ledgerProtocolTransitionMap.test.ts
new file mode 100644
index 0000000..cfb8859
--- /dev/null
+++ b/features/ledger-protocol-transition-map/__tests__/ledgerProtocolTransitionMap.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from "vitest";
+import { withMswHandlers } from "@/core/testing/msw";
+import { resetHorizonClients } from "@/core/horizon/client";
+import {
+ analyzeProtocolLedgers,
+ runLedgerProtocolTransitionMap
+} from "@/features/ledger-protocol-transition-map/lib/ledgerProtocolTransitionMap";
+import {
+ emptyHistoryHandler,
+ handlers,
+ malformedOnlyHandler,
+ rateLimitedHandler,
+ serverErrorHandler
+} from "@/features/ledger-protocol-transition-map/msw/handlers";
+import {
+ missingLedgersFixtureRecords,
+ protocolLedgersFixture
+} from "@/features/ledger-protocol-transition-map/fixtures/ledgerProtocolTransitionMap.fixture";
+
+const server = withMswHandlers(...handlers);
+
+describe("analyzeProtocolLedgers", () => {
+ it("maps an exact protocol upgrade", () => {
+ const result = analyzeProtocolLedgers(
+ protocolLedgersFixture,
+ { startLedger: 1000n, count: 4 },
+ "testnet",
+ false
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.transitions[0]?.certainty).toBe("exact");
+ expect(result.value.runs).toHaveLength(2);
+ });
+
+ it("keeps uncertain transitions across missing ledgers", () => {
+ const result = analyzeProtocolLedgers(
+ missingLedgersFixtureRecords,
+ { startLedger: 2000n, count: 4 },
+ "testnet",
+ true
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.transitions[0]?.certainty).toBe("uncertain");
+ expect(result.value.gaps).toHaveLength(1);
+ });
+});
+
+describe("runLedgerProtocolTransitionMap", () => {
+ it("loads a Horizon page for the selected network", async () => {
+ resetHorizonClients();
+ const result = await runLedgerProtocolTransitionMap(
+ { startLedger: 1000n, count: 4 },
+ "testnet"
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.value.observedCount).toBe(4);
+ });
+
+ it("maps empty history", async () => {
+ server.use(emptyHistoryHandler);
+ resetHorizonClients();
+ expect(
+ await runLedgerProtocolTransitionMap({ startLedger: 1000n, count: 4 }, "testnet")
+ ).toEqual({ ok: false, code: "history_unavailable" });
+ });
+
+ it("maps malformed pages", async () => {
+ server.use(malformedOnlyHandler);
+ resetHorizonClients();
+ expect(
+ await runLedgerProtocolTransitionMap({ startLedger: 1000n, count: 4 }, "testnet")
+ ).toEqual({ ok: false, code: "malformed_ledger" });
+ });
+
+ it("maps rate limiting", async () => {
+ server.use(rateLimitedHandler);
+ resetHorizonClients();
+ expect(
+ await runLedgerProtocolTransitionMap({ startLedger: 1000n, count: 4 }, "testnet")
+ ).toEqual({ ok: false, code: "rate_limited" });
+ });
+
+ it("maps transport failures", async () => {
+ server.use(serverErrorHandler);
+ resetHorizonClients();
+ expect(
+ await runLedgerProtocolTransitionMap({ startLedger: 1000n, count: 4 }, "testnet")
+ ).toEqual({ ok: false, code: "request_failed" });
+ });
+});
diff --git a/features/ledger-protocol-transition-map/__tests__/protocol-runs.test.ts b/features/ledger-protocol-transition-map/__tests__/protocol-runs.test.ts
new file mode 100644
index 0000000..d3af9e1
--- /dev/null
+++ b/features/ledger-protocol-transition-map/__tests__/protocol-runs.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from "vitest";
+import { groupProtocolRuns } from "@/features/ledger-protocol-transition-map/lib/protocol-runs";
+import { normalizeFetchedLedgers } from "@/features/ledger-protocol-transition-map/lib/ledger-pages";
+import {
+ missingLedgersFixtureRecords,
+ protocolLedgersFixture
+} from "@/features/ledger-protocol-transition-map/fixtures/ledgerProtocolTransitionMap.fixture";
+
+describe("groupProtocolRuns", () => {
+ it("groups contiguous same-version ledgers", () => {
+ const sample = normalizeFetchedLedgers(protocolLedgersFixture);
+ expect(sample.ok).toBe(true);
+ if (!sample.ok) return;
+ expect(groupProtocolRuns(sample.value.ledgers)).toEqual([
+ {
+ protocolVersion: "20",
+ startSequence: "1000",
+ endSequence: "1001",
+ ledgerCount: "2"
+ },
+ {
+ protocolVersion: "21",
+ startSequence: "1002",
+ endSequence: "1003",
+ ledgerCount: "2"
+ }
+ ]);
+ });
+
+ it("starts a new run after a sequence gap even with the same version", () => {
+ const sample = normalizeFetchedLedgers(missingLedgersFixtureRecords);
+ expect(sample.ok).toBe(true);
+ if (!sample.ok) return;
+ expect(groupProtocolRuns(sample.value.ledgers)).toEqual([
+ {
+ protocolVersion: "19",
+ startSequence: "2000",
+ endSequence: "2000",
+ ledgerCount: "1"
+ },
+ {
+ protocolVersion: "20",
+ startSequence: "2002",
+ endSequence: "2003",
+ ledgerCount: "2"
+ }
+ ]);
+ });
+});
diff --git a/features/ledger-protocol-transition-map/__tests__/schema.test.ts b/features/ledger-protocol-transition-map/__tests__/schema.test.ts
new file mode 100644
index 0000000..8f4ca68
--- /dev/null
+++ b/features/ledger-protocol-transition-map/__tests__/schema.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import { parseLedgerProtocolTransitionMapInput } from "@/features/ledger-protocol-transition-map/schema";
+import { COUNT_MAX, COUNT_MIN } from "@/features/ledger-protocol-transition-map/types";
+
+describe("parseLedgerProtocolTransitionMapInput", () => {
+ it("accepts a valid start ledger and count", () => {
+ expect(
+ parseLedgerProtocolTransitionMapInput({ startLedger: "1000", count: "20" })
+ ).toEqual({ ok: true, value: { startLedger: 1000n, count: 20 } });
+ });
+
+ it.each(["", "0", "-1", "abc"])("rejects invalid start ledger %j", (startLedger) => {
+ expect(parseLedgerProtocolTransitionMapInput({ startLedger, count: "20" })).toEqual({
+ ok: false,
+ code: "invalid_start_ledger"
+ });
+ });
+
+ it.each(["", "1", String(COUNT_MAX + 1), "2.5", "nope"])(
+ "rejects invalid count %j",
+ (count) => {
+ expect(parseLedgerProtocolTransitionMapInput({ startLedger: "1000", count })).toEqual({
+ ok: false,
+ code: "invalid_count"
+ });
+ }
+ );
+
+ it("accepts count boundaries", () => {
+ expect(
+ parseLedgerProtocolTransitionMapInput({
+ startLedger: "1",
+ count: String(COUNT_MIN)
+ }).ok
+ ).toBe(true);
+ expect(
+ parseLedgerProtocolTransitionMapInput({
+ startLedger: "1",
+ count: String(COUNT_MAX)
+ }).ok
+ ).toBe(true);
+ });
+});
diff --git a/features/ledger-protocol-transition-map/__tests__/transition-evidence.test.ts b/features/ledger-protocol-transition-map/__tests__/transition-evidence.test.ts
new file mode 100644
index 0000000..8385377
--- /dev/null
+++ b/features/ledger-protocol-transition-map/__tests__/transition-evidence.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import {
+ classifyTransitions,
+ detectSequenceGaps
+} from "@/features/ledger-protocol-transition-map/lib/transition-evidence";
+import { normalizeFetchedLedgers } from "@/features/ledger-protocol-transition-map/lib/ledger-pages";
+import {
+ missingLedgersFixtureRecords,
+ protocolLedgersFixture
+} from "@/features/ledger-protocol-transition-map/fixtures/ledgerProtocolTransitionMap.fixture";
+
+describe("detectSequenceGaps", () => {
+ it("finds missing sequences", () => {
+ const sample = normalizeFetchedLedgers(missingLedgersFixtureRecords);
+ expect(sample.ok).toBe(true);
+ if (!sample.ok) return;
+ expect(detectSequenceGaps(sample.value.ledgers)).toEqual([
+ { afterSequence: "2000", beforeSequence: "2002", missingCount: "1" }
+ ]);
+ });
+});
+
+describe("classifyTransitions", () => {
+ it("marks adjacent version changes as exact", () => {
+ const sample = normalizeFetchedLedgers(protocolLedgersFixture);
+ expect(sample.ok).toBe(true);
+ if (!sample.ok) return;
+ expect(classifyTransitions(sample.value.ledgers)).toEqual([
+ {
+ fromVersion: "20",
+ toVersion: "21",
+ beforeSequence: "1001",
+ afterSequence: "1002",
+ certainty: "exact",
+ reason: "adjacent"
+ }
+ ]);
+ });
+
+ it("marks version changes across gaps as uncertain", () => {
+ const sample = normalizeFetchedLedgers(missingLedgersFixtureRecords);
+ expect(sample.ok).toBe(true);
+ if (!sample.ok) return;
+ expect(classifyTransitions(sample.value.ledgers)).toEqual([
+ {
+ fromVersion: "19",
+ toVersion: "20",
+ beforeSequence: "2000",
+ afterSequence: "2002",
+ certainty: "uncertain",
+ reason: "sequence_gap"
+ }
+ ]);
+ });
+});
diff --git a/features/ledger-protocol-transition-map/__tests__/useLedgerProtocolTransitionMap.test.tsx b/features/ledger-protocol-transition-map/__tests__/useLedgerProtocolTransitionMap.test.tsx
new file mode 100644
index 0000000..f9ebca2
--- /dev/null
+++ b/features/ledger-protocol-transition-map/__tests__/useLedgerProtocolTransitionMap.test.tsx
@@ -0,0 +1,95 @@
+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 { useLedgerProtocolTransitionMap } from "@/features/ledger-protocol-transition-map/hooks/useLedgerProtocolTransitionMap";
+import { handlers, rateLimitedHandler } from "@/features/ledger-protocol-transition-map/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("useLedgerProtocolTransitionMap", () => {
+ it("starts idle", () => {
+ const { result } = renderHook(() => useLedgerProtocolTransitionMap(), {
+ wrapper: wrapperFor("testnet")
+ });
+ expect(result.current.state).toEqual({ status: "idle" });
+ });
+
+ it("loads a protocol map", async () => {
+ resetHorizonClients();
+ const { result } = renderHook(() => useLedgerProtocolTransitionMap(), {
+ wrapper: wrapperFor("testnet")
+ });
+
+ await act(async () => {
+ await result.current.submit({ startLedger: "1000", count: "4" });
+ });
+
+ await waitFor(() => expect(result.current.state.status).toBe("success"));
+ });
+
+ it("loads uncertain mainnet fixtures", async () => {
+ resetHorizonClients();
+ const { result } = renderHook(() => useLedgerProtocolTransitionMap(), {
+ wrapper: wrapperFor("mainnet")
+ });
+
+ await act(async () => {
+ await result.current.submit({ startLedger: "2000", count: "4" });
+ });
+
+ await waitFor(() => expect(result.current.state.status).toBe("success"));
+ if (result.current.state.status !== "success") return;
+ expect(result.current.state.result.transitions[0]?.certainty).toBe("uncertain");
+ });
+
+ it("surfaces invalid start ledger", async () => {
+ const { result } = renderHook(() => useLedgerProtocolTransitionMap(), {
+ wrapper: wrapperFor("testnet")
+ });
+
+ await act(async () => {
+ await result.current.submit({ startLedger: "0", count: "4" });
+ });
+
+ expect(result.current.state).toEqual({ status: "error", code: "invalid_start_ledger" });
+ });
+
+ it("surfaces rate limiting", async () => {
+ server.use(rateLimitedHandler);
+ resetHorizonClients();
+ const { result } = renderHook(() => useLedgerProtocolTransitionMap(), {
+ wrapper: wrapperFor("testnet")
+ });
+
+ await act(async () => {
+ await result.current.submit({ startLedger: "1000", count: "4" });
+ });
+
+ await waitFor(() =>
+ expect(result.current.state).toEqual({ status: "error", code: "rate_limited" })
+ );
+ });
+
+ it("resets to idle", async () => {
+ resetHorizonClients();
+ const { result } = renderHook(() => useLedgerProtocolTransitionMap(), {
+ wrapper: wrapperFor("testnet")
+ });
+
+ await act(async () => {
+ await result.current.submit({ startLedger: "1000", count: "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-protocol-transition-map/components/GapWarnings.tsx b/features/ledger-protocol-transition-map/components/GapWarnings.tsx
new file mode 100644
index 0000000..b869806
--- /dev/null
+++ b/features/ledger-protocol-transition-map/components/GapWarnings.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { StatusMessage } from "@/core/ui/StatusMessage";
+import { copy } from "@/features/ledger-protocol-transition-map/copy";
+import type { LedgerProtocolTransitionMapResult } from "@/features/ledger-protocol-transition-map/types";
+
+export function GapWarnings({ result }: { result: LedgerProtocolTransitionMapResult }) {
+ return (
+
+ {result.partialPage ? (
+
+ ) : (
+
+ )}
+
+ {result.gaps.length > 0 ? (
+
+ {result.gaps.map((gap) => (
+ -
+ #{gap.afterSequence} → #{gap.beforeSequence} · missing {gap.missingCount}
+
+ ))}
+
+ ) : (
+
{copy.noGaps}
+ )}
+
+ );
+}
diff --git a/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapEmptyState.tsx b/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapEmptyState.tsx
new file mode 100644
index 0000000..d05b7ef
--- /dev/null
+++ b/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapEmptyState.tsx
@@ -0,0 +1,15 @@
+"use client";
+
+import { Card, CardHeader, CardTitle } from "@/core/ui/Card";
+import { copy } from "@/features/ledger-protocol-transition-map/copy";
+
+export function LedgerProtocolTransitionMapEmptyState() {
+ return (
+
+
+ {copy.emptyTitle}
+
+ {copy.emptyDescription}
+
+ );
+}
diff --git a/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapForm.tsx b/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapForm.tsx
new file mode 100644
index 0000000..c56d29d
--- /dev/null
+++ b/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapForm.tsx
@@ -0,0 +1,65 @@
+"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-protocol-transition-map/copy";
+import type { RawLedgerProtocolTransitionMapForm } from "@/features/ledger-protocol-transition-map/types";
+
+export function LedgerProtocolTransitionMapForm({
+ onSubmit,
+ pending
+}: {
+ onSubmit: (values: RawLedgerProtocolTransitionMapForm) => void;
+ pending: boolean;
+}) {
+ const [startLedger, setStartLedger] = useState("1000");
+ const [count, setCount] = useState("20");
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ onSubmit({ startLedger, count });
+ }
+
+ return (
+
+ );
+}
diff --git a/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapPanel.tsx b/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapPanel.tsx
new file mode 100644
index 0000000..ca94b5c
--- /dev/null
+++ b/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapPanel.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { Card } from "@/core/ui/Card";
+import { SkeletonRows } from "@/core/ui/Skeleton";
+import { StatusMessage } from "@/core/ui/StatusMessage";
+import { LedgerProtocolTransitionMapEmptyState } from "@/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapEmptyState";
+import { LedgerProtocolTransitionMapForm } from "@/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapForm";
+import { LedgerProtocolTransitionMapResultView } from "@/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapResult";
+import { copy, errorCopy } from "@/features/ledger-protocol-transition-map/copy";
+import { useLedgerProtocolTransitionMap } from "@/features/ledger-protocol-transition-map/hooks/useLedgerProtocolTransitionMap";
+
+export function LedgerProtocolTransitionMapPanel() {
+ const { state, submit } = useLedgerProtocolTransitionMap();
+
+ return (
+
+
+
+
+
+ {state.status === "loading" ? (
+
+
+ {copy.loading}
+
+
+
+ ) : null}
+
+ {state.status === "error" ? (
+
+ ) : null}
+
+ {state.status === "success" ? (
+
+ ) : null}
+
+ {state.status === "idle" ?
: null}
+
+ );
+}
diff --git a/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapResult.tsx b/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapResult.tsx
new file mode 100644
index 0000000..5e68fff
--- /dev/null
+++ b/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapResult.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/core/ui/Button";
+import { Card, CardHeader, CardTitle } from "@/core/ui/Card";
+import { DataList } from "@/core/ui/DataList";
+import { GapWarnings } from "@/features/ledger-protocol-transition-map/components/GapWarnings";
+import { ProtocolRuns } from "@/features/ledger-protocol-transition-map/components/ProtocolRuns";
+import { TransitionRows } from "@/features/ledger-protocol-transition-map/components/TransitionRows";
+import { copy } from "@/features/ledger-protocol-transition-map/copy";
+import {
+ formatNetworkLabel,
+ formatRange
+} from "@/features/ledger-protocol-transition-map/lib/format";
+import type { LedgerProtocolTransitionMapResult } from "@/features/ledger-protocol-transition-map/types";
+
+export function LedgerProtocolTransitionMapResultView({
+ result
+}: {
+ result: LedgerProtocolTransitionMapResult;
+}) {
+ const [copied, setCopied] = useState(false);
+
+ async function copySummary() {
+ try {
+ await navigator.clipboard.writeText(result.summaryText);
+ setCopied(true);
+ } catch {
+ setCopied(false);
+ }
+ }
+
+ return (
+
+
+
+ {copy.summaryTitle}
+
+
+ )
+ }
+ ]}
+ />
+
+
+
+
+ {copy.runsTitle}
+
+
+
+
+
+
+ {copy.transitionsTitle}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/features/ledger-protocol-transition-map/components/ProtocolRuns.tsx b/features/ledger-protocol-transition-map/components/ProtocolRuns.tsx
new file mode 100644
index 0000000..2f34c94
--- /dev/null
+++ b/features/ledger-protocol-transition-map/components/ProtocolRuns.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { formatRunLabel } from "@/features/ledger-protocol-transition-map/lib/format";
+import type { ProtocolRun } from "@/features/ledger-protocol-transition-map/types";
+
+export function ProtocolRuns({ runs }: { runs: ProtocolRun[] }) {
+ return (
+
+ {runs.map((run) => (
+ -
+ {formatRunLabel(run)}
+
+ ))}
+
+ );
+}
diff --git a/features/ledger-protocol-transition-map/components/TransitionRows.tsx b/features/ledger-protocol-transition-map/components/TransitionRows.tsx
new file mode 100644
index 0000000..827bd68
--- /dev/null
+++ b/features/ledger-protocol-transition-map/components/TransitionRows.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import { copy } from "@/features/ledger-protocol-transition-map/copy";
+import { formatTransitionLabel } from "@/features/ledger-protocol-transition-map/lib/format";
+import type { ProtocolTransition } from "@/features/ledger-protocol-transition-map/types";
+
+export function TransitionRows({ transitions }: { transitions: ProtocolTransition[] }) {
+ if (transitions.length === 0) {
+ return {copy.noTransitions}
;
+ }
+
+ return (
+
+ {transitions.map((transition) => (
+ -
+
+ {formatTransitionLabel(transition)}
+
+ {transition.certainty === "exact" ? copy.exactBadge : copy.uncertainBadge}
+
+
+
+ ))}
+
+ );
+}
diff --git a/features/ledger-protocol-transition-map/copy.ts b/features/ledger-protocol-transition-map/copy.ts
new file mode 100644
index 0000000..4aef777
--- /dev/null
+++ b/features/ledger-protocol-transition-map/copy.ts
@@ -0,0 +1,67 @@
+import type { LedgerProtocolTransitionMapErrorCode } from "@/features/ledger-protocol-transition-map/types";
+import { COUNT_MAX, COUNT_MIN } from "@/features/ledger-protocol-transition-map/types";
+
+export const copy = {
+ startLabel: "Start ledger",
+ startHint: "Inclusive sequence number where the scan begins.",
+ countLabel: "Ledger count",
+ countHint: `Whole number from ${COUNT_MIN} to ${COUNT_MAX}. Bounded history only.`,
+ submit: "Map protocol transitions",
+ loading: "Scanning ledger protocol versions...",
+ emptyTitle: "No protocol map yet",
+ emptyDescription:
+ "Scan a bounded ledger range and group adjacent protocol versions. Exact transitions are marked only when both neighboring sequences were fetched.",
+ summaryTitle: "Scan summary",
+ runsTitle: "Protocol version runs",
+ transitionsTitle: "Transitions",
+ gapsTitle: "Gaps and uncertain boundaries",
+ networkLabel: "Network",
+ rangeLabel: "Scanned range",
+ requestedLabel: "Requested count",
+ observedLabel: "Observed count",
+ summaryLabel: "Copyable summary",
+ copySummary: "Copy summary",
+ copiedSummary: "Summary copied",
+ partialPageNotice:
+ "Horizon returned a partial page before the requested count was filled. Transitions at the trailing edge are uncertain.",
+ gapNotice:
+ "A missing ledger between two fetched records prevents an exact transition boundary.",
+ noTransitions: "No protocol version changes in this scan.",
+ noGaps: "No sequence gaps in the fetched sample.",
+ exactBadge: "Exact",
+ uncertainBadge: "Uncertain",
+ runLabel: "Protocol",
+ ledgersLabel: "Ledgers"
+} as const;
+
+export const errorCopy: Record<
+ LedgerProtocolTransitionMapErrorCode,
+ { title: string; description: string }
+> = {
+ invalid_start_ledger: {
+ title: "Start ledger is not valid",
+ description: "Enter a positive whole-number ledger sequence where the scan should begin."
+ },
+ invalid_count: {
+ title: "Ledger count is not valid",
+ description: `Enter a whole number from ${COUNT_MIN} to ${COUNT_MAX}. Larger scans are out of scope.`
+ },
+ history_unavailable: {
+ title: "Ledger history is unavailable",
+ description:
+ "Horizon returned no usable ledgers for this range. The endpoint may be catching up or missing history."
+ },
+ malformed_ledger: {
+ title: "Ledger records could not be used",
+ description:
+ "Horizon returned records without usable sequence numbers or protocol versions. Try another range."
+ },
+ rate_limited: {
+ title: "Horizon is rate limiting this request",
+ description: "Ledger history was requested too often. Wait a moment before scanning 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-protocol-transition-map/e2e/ledger-protocol-transition-map.spec.ts b/features/ledger-protocol-transition-map/e2e/ledger-protocol-transition-map.spec.ts
new file mode 100644
index 0000000..4dfe944
--- /dev/null
+++ b/features/ledger-protocol-transition-map/e2e/ledger-protocol-transition-map.spec.ts
@@ -0,0 +1,15 @@
+export const spec = {
+ route: "/tools/ledger-protocol-transition-map",
+ steps: [
+ { action: "visit", target: "/tools/ledger-protocol-transition-map" },
+ { action: "expect", target: "heading", value: "Ledger Protocol Transition Map" },
+ { action: "expect", target: "text", value: "No protocol map yet" },
+ { action: "fill", target: "Start ledger", value: "1000" },
+ { action: "fill", target: "Ledger count", value: "4" },
+ { action: "click", target: "Map protocol transitions" },
+ { action: "expect", target: "text", value: "Protocol version runs" },
+ { action: "expect", target: "text", value: "Exact" },
+ { action: "switchNetwork", value: "mainnet" },
+ { action: "expect", target: "text", value: "No protocol map yet" }
+ ]
+} as const;
diff --git a/features/ledger-protocol-transition-map/e2e/uncertain-boundary.spec.ts b/features/ledger-protocol-transition-map/e2e/uncertain-boundary.spec.ts
new file mode 100644
index 0000000..6b60e48
--- /dev/null
+++ b/features/ledger-protocol-transition-map/e2e/uncertain-boundary.spec.ts
@@ -0,0 +1,12 @@
+export const spec = {
+ route: "/tools/ledger-protocol-transition-map",
+ steps: [
+ { action: "visit", target: "/tools/ledger-protocol-transition-map" },
+ { action: "switchNetwork", value: "mainnet" },
+ { action: "fill", target: "Start ledger", value: "2000" },
+ { action: "fill", target: "Ledger count", value: "4" },
+ { action: "click", target: "Map protocol transitions" },
+ { action: "expect", target: "text", value: "Uncertain" },
+ { action: "expect", target: "text", value: "Gaps and uncertain boundaries" }
+ ]
+} as const;
diff --git a/features/ledger-protocol-transition-map/fixtures/ledgerProtocolTransitionMap.fixture.ts b/features/ledger-protocol-transition-map/fixtures/ledgerProtocolTransitionMap.fixture.ts
new file mode 100644
index 0000000..f43c56d
--- /dev/null
+++ b/features/ledger-protocol-transition-map/fixtures/ledgerProtocolTransitionMap.fixture.ts
@@ -0,0 +1,35 @@
+import type { HorizonProtocolLedger } from "@/features/ledger-protocol-transition-map/types";
+
+const base = Date.parse("2026-09-29T12:00:00.000Z");
+
+/** Contiguous ledgers with a single exact protocol upgrade. */
+export const protocolLedgersFixture: HorizonProtocolLedger[] = [
+ { sequence: 1000, protocol_version: 20, closed_at: new Date(base).toISOString(), paging_token: "1000" },
+ { sequence: 1001, protocol_version: 20, closed_at: new Date(base + 5_000).toISOString(), paging_token: "1001" },
+ { sequence: 1002, protocol_version: 21, closed_at: new Date(base + 10_000).toISOString(), paging_token: "1002" },
+ { sequence: 1003, protocol_version: 21, closed_at: new Date(base + 15_000).toISOString(), paging_token: "1003" }
+];
+
+export const ledgerProtocolTransitionMapFixture = {
+ _embedded: { records: protocolLedgersFixture }
+};
+
+/** Same data under the edge-case fixture filename required by the issue. */
+export { protocolLedgersFixture as protocolLedgers };
+
+/** Missing ledger between protocol versions — uncertain boundary. */
+export const missingLedgersFixtureRecords: HorizonProtocolLedger[] = [
+ { sequence: 2000, protocol_version: 19, closed_at: new Date(base).toISOString(), paging_token: "2000" },
+ { sequence: 2002, protocol_version: 20, closed_at: new Date(base + 10_000).toISOString(), paging_token: "2002" },
+ { sequence: 2003, protocol_version: 20, closed_at: new Date(base + 15_000).toISOString(), paging_token: "2003" }
+];
+
+export const missingLedgersFixture = {
+ _embedded: { records: missingLedgersFixtureRecords }
+};
+
+export const malformedProtocolRecords: HorizonProtocolLedger[] = [
+ { sequence: "x", protocol_version: 20 },
+ { sequence: 1, protocol_version: "nope" },
+ { closed_at: new Date(base).toISOString() }
+];
diff --git a/features/ledger-protocol-transition-map/fixtures/missing-ledgers.fixture.ts b/features/ledger-protocol-transition-map/fixtures/missing-ledgers.fixture.ts
new file mode 100644
index 0000000..f5fb69b
--- /dev/null
+++ b/features/ledger-protocol-transition-map/fixtures/missing-ledgers.fixture.ts
@@ -0,0 +1,4 @@
+export {
+ missingLedgersFixture,
+ missingLedgersFixtureRecords
+} from "@/features/ledger-protocol-transition-map/fixtures/ledgerProtocolTransitionMap.fixture";
diff --git a/features/ledger-protocol-transition-map/fixtures/protocol-ledgers.fixture.ts b/features/ledger-protocol-transition-map/fixtures/protocol-ledgers.fixture.ts
new file mode 100644
index 0000000..5a21ca7
--- /dev/null
+++ b/features/ledger-protocol-transition-map/fixtures/protocol-ledgers.fixture.ts
@@ -0,0 +1,4 @@
+export {
+ protocolLedgersFixture,
+ ledgerProtocolTransitionMapFixture
+} from "@/features/ledger-protocol-transition-map/fixtures/ledgerProtocolTransitionMap.fixture";
diff --git a/features/ledger-protocol-transition-map/hooks/useLedgerProtocolTransitionMap.ts b/features/ledger-protocol-transition-map/hooks/useLedgerProtocolTransitionMap.ts
new file mode 100644
index 0000000..083ea90
--- /dev/null
+++ b/features/ledger-protocol-transition-map/hooks/useLedgerProtocolTransitionMap.ts
@@ -0,0 +1,82 @@
+"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 { parseLedgerProtocolTransitionMapInput } from "@/features/ledger-protocol-transition-map/schema";
+import { runLedgerProtocolTransitionMap } from "@/features/ledger-protocol-transition-map/lib/ledgerProtocolTransitionMap";
+import { toLedgerProtocolTransitionMapErrorCode } from "@/features/ledger-protocol-transition-map/lib/ledgerProtocolTransitionMap.errors";
+import type {
+ LedgerProtocolTransitionMapErrorCode,
+ LedgerProtocolTransitionMapResult,
+ RawLedgerProtocolTransitionMapForm
+} from "@/features/ledger-protocol-transition-map/types";
+
+export type LedgerProtocolTransitionMapState =
+ | { status: "idle" }
+ | { status: "loading" }
+ | { status: "success"; result: LedgerProtocolTransitionMapResult }
+ | { status: "error"; code: LedgerProtocolTransitionMapErrorCode };
+
+const IDLE: LedgerProtocolTransitionMapState = { status: "idle" };
+
+interface Held {
+ state: LedgerProtocolTransitionMapState;
+ network: StellarNetwork;
+}
+
+export function useLedgerProtocolTransitionMap() {
+ 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: RawLedgerProtocolTransitionMapForm) => {
+ controller.current?.abort();
+ const parsed = parseLedgerProtocolTransitionMapInput(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<
+ LedgerProtocolTransitionMapResult,
+ LedgerProtocolTransitionMapErrorCode
+ > = await runLedgerProtocolTransitionMap(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: toLedgerProtocolTransitionMapErrorCode(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-protocol-transition-map/lib/format.ts b/features/ledger-protocol-transition-map/lib/format.ts
new file mode 100644
index 0000000..a7c61be
--- /dev/null
+++ b/features/ledger-protocol-transition-map/lib/format.ts
@@ -0,0 +1,54 @@
+import type { StellarNetwork } from "@/core/network/types";
+import type {
+ ProtocolLedger,
+ ProtocolRun,
+ ProtocolTransition
+} from "@/features/ledger-protocol-transition-map/types";
+
+const NETWORK_LABELS: Record = {
+ testnet: "Testnet",
+ mainnet: "Mainnet"
+};
+
+export function formatNetworkLabel(network: StellarNetwork): string {
+ return NETWORK_LABELS[network];
+}
+
+export function formatRange(start: string, end: string): string {
+ return `#${start} → #${end}`;
+}
+
+export function formatRunLabel(run: ProtocolRun): string {
+ return `v${run.protocolVersion} · ${formatRange(run.startSequence, run.endSequence)} (${run.ledgerCount})`;
+}
+
+export function formatTransitionLabel(transition: ProtocolTransition): string {
+ return `v${transition.fromVersion} → v${transition.toVersion} at #${transition.beforeSequence}/#${transition.afterSequence}`;
+}
+
+export function buildSummaryText(args: {
+ network: StellarNetwork;
+ start: string;
+ end: string;
+ observed: number;
+ transitions: ProtocolTransition[];
+}): string {
+ const lines = [
+ `network=${args.network}`,
+ `range=#${args.start}-#${args.end}`,
+ `observed=${args.observed}`,
+ `transitions=${args.transitions.length}`
+ ];
+
+ for (const transition of args.transitions) {
+ lines.push(
+ `${transition.certainty}:${transition.fromVersion}->${transition.toVersion}@${transition.beforeSequence}/${transition.afterSequence}`
+ );
+ }
+
+ return lines.join("\n");
+}
+
+export function formatLedgerRow(ledger: ProtocolLedger): string {
+ return `#${ledger.sequence} · protocol ${ledger.protocolVersion}`;
+}
diff --git a/features/ledger-protocol-transition-map/lib/ledger-pages.ts b/features/ledger-protocol-transition-map/lib/ledger-pages.ts
new file mode 100644
index 0000000..3cce39a
--- /dev/null
+++ b/features/ledger-protocol-transition-map/lib/ledger-pages.ts
@@ -0,0 +1,79 @@
+import { err, ok, type Result } from "@/core/result/result";
+import type {
+ HorizonProtocolLedger,
+ LedgerProtocolTransitionMapErrorCode,
+ ProtocolLedger
+} from "@/features/ledger-protocol-transition-map/types";
+
+export function parseProtocolLedger(record: HorizonProtocolLedger): ProtocolLedger | null {
+ if (!record || typeof record !== "object") return null;
+
+ const sequenceText =
+ record.sequence === undefined || record.sequence === null
+ ? ""
+ : String(record.sequence).trim();
+ const versionText =
+ record.protocol_version === undefined || record.protocol_version === null
+ ? ""
+ : String(record.protocol_version).trim();
+
+ if (!/^\d+$/.test(sequenceText) || !/^\d+$/.test(versionText)) return null;
+
+ const closedAt =
+ typeof record.closed_at === "string" && Number.isFinite(Date.parse(record.closed_at))
+ ? new Date(Date.parse(record.closed_at)).toISOString()
+ : null;
+
+ return {
+ sequence: sequenceText,
+ protocolVersion: versionText,
+ closedAt
+ };
+}
+
+export function normalizeFetchedLedgers(
+ records: HorizonProtocolLedger[]
+): Result<{ ledgers: ProtocolLedger[]; malformedCount: number }, LedgerProtocolTransitionMapErrorCode> {
+ 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 = parseProtocolLedger(record);
+ if (!parsed) {
+ malformedCount += 1;
+ continue;
+ }
+ if (!bySequence.has(parsed.sequence)) {
+ bySequence.set(parsed.sequence, parsed);
+ }
+ }
+
+ const ledgers = [...bySequence.values()].sort((a, b) => compareSequences(a.sequence, b.sequence));
+
+ if (ledgers.length === 0) {
+ return err("malformed_ledger");
+ }
+
+ return ok({ ledgers, malformedCount });
+}
+
+export function compareSequences(a: string, b: string): number {
+ if (a.length !== b.length) return a.length - b.length;
+ return a < b ? -1 : a > b ? 1 : 0;
+}
+
+export function filterToRequestedRange(
+ ledgers: ProtocolLedger[],
+ startLedger: bigint,
+ count: number
+): ProtocolLedger[] {
+ const endLedger = startLedger + BigInt(count) - 1n;
+ return ledgers.filter((ledger) => {
+ const sequence = BigInt(ledger.sequence);
+ return sequence >= startLedger && sequence <= endLedger;
+ });
+}
diff --git a/features/ledger-protocol-transition-map/lib/ledgerProtocolTransitionMap.errors.ts b/features/ledger-protocol-transition-map/lib/ledgerProtocolTransitionMap.errors.ts
new file mode 100644
index 0000000..aee9df5
--- /dev/null
+++ b/features/ledger-protocol-transition-map/lib/ledgerProtocolTransitionMap.errors.ts
@@ -0,0 +1,21 @@
+import { classifyHorizonError } from "@/core/horizon/errors";
+import type { LedgerProtocolTransitionMapErrorCode } from "@/features/ledger-protocol-transition-map/types";
+
+export function toLedgerProtocolTransitionMapErrorCode(
+ error: unknown
+): LedgerProtocolTransitionMapErrorCode {
+ 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-protocol-transition-map/lib/ledgerProtocolTransitionMap.ts b/features/ledger-protocol-transition-map/lib/ledgerProtocolTransitionMap.ts
new file mode 100644
index 0000000..7c640e2
--- /dev/null
+++ b/features/ledger-protocol-transition-map/lib/ledgerProtocolTransitionMap.ts
@@ -0,0 +1,165 @@
+import { err, ok, type Result } from "@/core/result/result";
+import { horizonUrl } from "@/core/horizon/client";
+import type { StellarNetwork } from "@/core/network/types";
+import { buildSummaryText } from "@/features/ledger-protocol-transition-map/lib/format";
+import {
+ HorizonStatusError,
+ toLedgerProtocolTransitionMapErrorCode
+} from "@/features/ledger-protocol-transition-map/lib/ledgerProtocolTransitionMap.errors";
+import {
+ filterToRequestedRange,
+ normalizeFetchedLedgers
+} from "@/features/ledger-protocol-transition-map/lib/ledger-pages";
+import { groupProtocolRuns } from "@/features/ledger-protocol-transition-map/lib/protocol-runs";
+import {
+ classifyTransitions,
+ detectSequenceGaps
+} from "@/features/ledger-protocol-transition-map/lib/transition-evidence";
+import {
+ PAGE_LIMIT,
+ type HorizonProtocolLedger,
+ type LedgerProtocolTransitionMapErrorCode,
+ type LedgerProtocolTransitionMapInput,
+ type LedgerProtocolTransitionMapResult
+} from "@/features/ledger-protocol-transition-map/types";
+
+const REQUEST_TIMEOUT_MS = 20_000;
+
+interface HorizonLedgerPage {
+ _embedded?: { records?: HorizonProtocolLedger[] };
+ records?: HorizonProtocolLedger[];
+}
+
+function recordsFromPage(page: HorizonLedgerPage): HorizonProtocolLedger[] {
+ if (Array.isArray(page._embedded?.records)) return page._embedded.records;
+ if (Array.isArray(page.records)) return page.records;
+ return [];
+}
+
+export async function fetchBoundedLedgerPages(
+ input: LedgerProtocolTransitionMapInput,
+ network: StellarNetwork,
+ signal?: AbortSignal
+): Promise<
+ Result<{ records: HorizonProtocolLedger[]; partialPage: boolean }, LedgerProtocolTransitionMapErrorCode>
+> {
+ const collected: HorizonProtocolLedger[] = [];
+ let cursor = (input.startLedger - 1n).toString();
+ let remaining = input.count;
+ let partialPage = false;
+
+ while (remaining > 0) {
+ const limit = Math.min(PAGE_LIMIT, remaining);
+ 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: "asc",
+ limit,
+ cursor
+ }),
+ {
+ signal: controller.signal,
+ headers: { Accept: "application/json" }
+ }
+ );
+
+ if (!response.ok) throw new HorizonStatusError(response.status);
+
+ const page = (await response.json()) as HorizonLedgerPage;
+ const records = recordsFromPage(page);
+
+ if (records.length === 0) {
+ if (collected.length === 0) return err("history_unavailable");
+ partialPage = true;
+ break;
+ }
+
+ collected.push(...records);
+ const last = records[records.length - 1]!;
+ cursor =
+ typeof last.paging_token === "string" && last.paging_token
+ ? last.paging_token
+ : String(last.sequence ?? cursor);
+
+ if (records.length < limit) {
+ partialPage = true;
+ break;
+ }
+
+ remaining = input.count - collected.length;
+ } catch (error) {
+ return err(toLedgerProtocolTransitionMapErrorCode(error));
+ } finally {
+ clearTimeout(timeout);
+ signal?.removeEventListener("abort", abortFromCaller);
+ }
+ }
+
+ if (collected.length < input.count) partialPage = true;
+
+ return ok({ records: collected, partialPage });
+}
+
+export function analyzeProtocolLedgers(
+ records: HorizonProtocolLedger[],
+ input: LedgerProtocolTransitionMapInput,
+ network: StellarNetwork,
+ partialPage: boolean
+): Result {
+ const normalized = normalizeFetchedLedgers(records);
+ if (!normalized.ok) return normalized;
+
+ const ledgers = filterToRequestedRange(
+ normalized.value.ledgers,
+ input.startLedger,
+ input.count
+ );
+
+ if (ledgers.length === 0) return err("history_unavailable");
+
+ const runs = groupProtocolRuns(ledgers);
+ const gaps = detectSequenceGaps(ledgers);
+ const transitions = classifyTransitions(ledgers).map((transition) =>
+ partialPage && transition.certainty === "uncertain"
+ ? { ...transition, reason: "partial_page" as const }
+ : transition
+ );
+
+ const start = ledgers[0]!.sequence;
+ const end = ledgers[ledgers.length - 1]!.sequence;
+
+ return ok({
+ network,
+ startLedger: start,
+ requestedCount: input.count,
+ observedCount: ledgers.length,
+ endLedger: end,
+ runs,
+ transitions,
+ gaps,
+ partialPage: partialPage || ledgers.length < input.count,
+ summaryText: buildSummaryText({
+ network,
+ start,
+ end,
+ observed: ledgers.length,
+ transitions
+ })
+ });
+}
+
+export async function runLedgerProtocolTransitionMap(
+ input: LedgerProtocolTransitionMapInput,
+ network: StellarNetwork,
+ signal?: AbortSignal
+): Promise> {
+ const fetched = await fetchBoundedLedgerPages(input, network, signal);
+ if (!fetched.ok) return fetched;
+ return analyzeProtocolLedgers(fetched.value.records, input, network, fetched.value.partialPage);
+}
diff --git a/features/ledger-protocol-transition-map/lib/protocol-runs.ts b/features/ledger-protocol-transition-map/lib/protocol-runs.ts
new file mode 100644
index 0000000..8461ffd
--- /dev/null
+++ b/features/ledger-protocol-transition-map/lib/protocol-runs.ts
@@ -0,0 +1,49 @@
+import type { ProtocolLedger, ProtocolRun } from "@/features/ledger-protocol-transition-map/types";
+
+/** Groups adjacent ledgers that share the same reported protocol version. */
+export function groupProtocolRuns(ledgers: ProtocolLedger[]): ProtocolRun[] {
+ if (ledgers.length === 0) return [];
+
+ const runs: ProtocolRun[] = [];
+ let current = {
+ protocolVersion: ledgers[0]!.protocolVersion,
+ startSequence: ledgers[0]!.sequence,
+ endSequence: ledgers[0]!.sequence,
+ count: 1n
+ };
+
+ for (let index = 1; index < ledgers.length; index += 1) {
+ const ledger = ledgers[index]!;
+ const previous = ledgers[index - 1]!;
+ const contiguous = BigInt(ledger.sequence) === BigInt(previous.sequence) + 1n;
+
+ if (contiguous && ledger.protocolVersion === current.protocolVersion) {
+ current.endSequence = ledger.sequence;
+ current.count += 1n;
+ continue;
+ }
+
+ runs.push({
+ protocolVersion: current.protocolVersion,
+ startSequence: current.startSequence,
+ endSequence: current.endSequence,
+ ledgerCount: current.count.toString()
+ });
+
+ current = {
+ protocolVersion: ledger.protocolVersion,
+ startSequence: ledger.sequence,
+ endSequence: ledger.sequence,
+ count: 1n
+ };
+ }
+
+ runs.push({
+ protocolVersion: current.protocolVersion,
+ startSequence: current.startSequence,
+ endSequence: current.endSequence,
+ ledgerCount: current.count.toString()
+ });
+
+ return runs;
+}
diff --git a/features/ledger-protocol-transition-map/lib/transition-evidence.ts b/features/ledger-protocol-transition-map/lib/transition-evidence.ts
new file mode 100644
index 0000000..ebbb835
--- /dev/null
+++ b/features/ledger-protocol-transition-map/lib/transition-evidence.ts
@@ -0,0 +1,54 @@
+import type {
+ ProtocolLedger,
+ ProtocolTransition,
+ SequenceGap
+} from "@/features/ledger-protocol-transition-map/types";
+
+export function detectSequenceGaps(ledgers: ProtocolLedger[]): SequenceGap[] {
+ const gaps: SequenceGap[] = [];
+
+ for (let index = 1; index < ledgers.length; index += 1) {
+ const previous = ledgers[index - 1]!;
+ const current = ledgers[index]!;
+ const expected = BigInt(previous.sequence) + 1n;
+ const actual = BigInt(current.sequence);
+
+ if (actual > expected) {
+ gaps.push({
+ afterSequence: previous.sequence,
+ beforeSequence: current.sequence,
+ missingCount: (actual - expected).toString()
+ });
+ }
+ }
+
+ return gaps;
+}
+
+/**
+ * Exact transitions require both adjacent sequence numbers to have been fetched.
+ * A gap makes the boundary uncertain instead of inventing the upgrade ledger.
+ */
+export function classifyTransitions(ledgers: ProtocolLedger[]): ProtocolTransition[] {
+ const transitions: ProtocolTransition[] = [];
+
+ for (let index = 1; index < ledgers.length; index += 1) {
+ const previous = ledgers[index - 1]!;
+ const current = ledgers[index]!;
+
+ if (previous.protocolVersion === current.protocolVersion) continue;
+
+ const adjacent = BigInt(current.sequence) === BigInt(previous.sequence) + 1n;
+
+ transitions.push({
+ fromVersion: previous.protocolVersion,
+ toVersion: current.protocolVersion,
+ beforeSequence: previous.sequence,
+ afterSequence: current.sequence,
+ certainty: adjacent ? "exact" : "uncertain",
+ reason: adjacent ? "adjacent" : "sequence_gap"
+ });
+ }
+
+ return transitions;
+}
diff --git a/features/ledger-protocol-transition-map/manifest.ts b/features/ledger-protocol-transition-map/manifest.ts
new file mode 100644
index 0000000..0209cf8
--- /dev/null
+++ b/features/ledger-protocol-transition-map/manifest.ts
@@ -0,0 +1,15 @@
+import { Map as MapIcon } from "lucide-react";
+import type { FeatureManifest } from "@/core/registry/types";
+
+export const manifest: FeatureManifest = {
+ slug: "ledger-protocol-transition-map",
+ title: "Ledger Protocol Transition Map",
+ description:
+ "Scan a bounded ledger range and map where Horizon-reported protocol versions change.",
+ character: "A cartographer marks every protocol upgrade on a short stretch of ledger road.",
+ category: "network",
+ status: "working",
+ icon: MapIcon,
+ networks: ["testnet", "mainnet"],
+ keywords: ["ledger", "protocol", "upgrade", "transition", "horizon", "version"]
+};
diff --git a/features/ledger-protocol-transition-map/msw/handlers.ts b/features/ledger-protocol-transition-map/msw/handlers.ts
new file mode 100644
index 0000000..69f9e4e
--- /dev/null
+++ b/features/ledger-protocol-transition-map/msw/handlers.ts
@@ -0,0 +1,35 @@
+import { http, HttpResponse } from "msw";
+import { horizonUrl } from "@/core/horizon/client";
+import {
+ ledgerProtocolTransitionMapFixture,
+ missingLedgersFixture
+} from "@/features/ledger-protocol-transition-map/fixtures/ledgerProtocolTransitionMap.fixture";
+
+export const handlers = [
+ http.get(horizonUrl("testnet", "/ledgers"), () =>
+ HttpResponse.json(ledgerProtocolTransitionMapFixture)
+ ),
+ http.get(horizonUrl("mainnet", "/ledgers"), () => HttpResponse.json(missingLedgersFixture))
+];
+
+export const missingHandler = http.get(horizonUrl("testnet", "/ledgers"), () =>
+ HttpResponse.json(missingLedgersFixture)
+);
+
+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" }, { protocol_version: 20 }] }
+ })
+);
diff --git a/features/ledger-protocol-transition-map/panel.tsx b/features/ledger-protocol-transition-map/panel.tsx
new file mode 100644
index 0000000..32f2e6c
--- /dev/null
+++ b/features/ledger-protocol-transition-map/panel.tsx
@@ -0,0 +1 @@
+export { LedgerProtocolTransitionMapPanel as default } from "@/features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapPanel";
diff --git a/features/ledger-protocol-transition-map/schema.ts b/features/ledger-protocol-transition-map/schema.ts
new file mode 100644
index 0000000..90f5663
--- /dev/null
+++ b/features/ledger-protocol-transition-map/schema.ts
@@ -0,0 +1,39 @@
+import { err, ok, type Result } from "@/core/result/result";
+import {
+ COUNT_MAX,
+ COUNT_MIN,
+ type LedgerProtocolTransitionMapErrorCode,
+ type LedgerProtocolTransitionMapInput,
+ type RawLedgerProtocolTransitionMapForm
+} from "@/features/ledger-protocol-transition-map/types";
+
+export function parseLedgerProtocolTransitionMapInput(
+ raw: RawLedgerProtocolTransitionMapForm
+): Result {
+ const startText = raw.startLedger.trim();
+ const countText = raw.count.trim();
+
+ if (!startText || !/^\d+$/.test(startText) || startText === "0") {
+ return err("invalid_start_ledger");
+ }
+
+ let startLedger: bigint;
+ try {
+ startLedger = BigInt(startText);
+ } catch {
+ return err("invalid_start_ledger");
+ }
+
+ if (startLedger < 1n) return err("invalid_start_ledger");
+
+ if (!countText || !/^\d+$/.test(countText)) {
+ return err("invalid_count");
+ }
+
+ const count = Number(countText);
+ if (!Number.isSafeInteger(count) || count < COUNT_MIN || count > COUNT_MAX) {
+ return err("invalid_count");
+ }
+
+ return ok({ startLedger, count });
+}
diff --git a/features/ledger-protocol-transition-map/types.ts b/features/ledger-protocol-transition-map/types.ts
new file mode 100644
index 0000000..4eee772
--- /dev/null
+++ b/features/ledger-protocol-transition-map/types.ts
@@ -0,0 +1,73 @@
+import type { StellarNetwork } from "@/core/network/types";
+
+export interface RawLedgerProtocolTransitionMapForm {
+ startLedger: string;
+ count: string;
+}
+
+export interface LedgerProtocolTransitionMapInput {
+ startLedger: bigint;
+ count: number;
+}
+
+export interface HorizonProtocolLedger {
+ sequence?: number | string;
+ protocol_version?: number | string;
+ closed_at?: string;
+ paging_token?: string;
+}
+
+export interface ProtocolLedger {
+ sequence: string;
+ protocolVersion: string;
+ closedAt: string | null;
+}
+
+export interface ProtocolRun {
+ protocolVersion: string;
+ startSequence: string;
+ endSequence: string;
+ ledgerCount: string;
+}
+
+export type TransitionCertainty = "exact" | "uncertain";
+
+export interface ProtocolTransition {
+ fromVersion: string;
+ toVersion: string;
+ beforeSequence: string;
+ afterSequence: string;
+ certainty: TransitionCertainty;
+ reason: "adjacent" | "sequence_gap" | "partial_page";
+}
+
+export interface SequenceGap {
+ afterSequence: string;
+ beforeSequence: string;
+ missingCount: string;
+}
+
+export interface LedgerProtocolTransitionMapResult {
+ network: StellarNetwork;
+ startLedger: string;
+ requestedCount: number;
+ observedCount: number;
+ endLedger: string;
+ runs: ProtocolRun[];
+ transitions: ProtocolTransition[];
+ gaps: SequenceGap[];
+ partialPage: boolean;
+ summaryText: string;
+}
+
+export type LedgerProtocolTransitionMapErrorCode =
+ | "invalid_start_ledger"
+ | "invalid_count"
+ | "history_unavailable"
+ | "malformed_ledger"
+ | "rate_limited"
+ | "request_failed";
+
+export const COUNT_MIN = 2;
+export const COUNT_MAX = 200;
+export const PAGE_LIMIT = 200;