From 6455913ef37e2c03d376532fe47264e54b75cbd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caner=20Yakupo=C4=9Flu?= Date: Tue, 29 Sep 2026 15:44:18 +0300 Subject: [PATCH] feat(ledger-protocol-transition-map): map protocol version changes in ledger ranges --- .../ledger-protocol-transition-map/README.md | 22 +++ .../LedgerProtocolTransitionMapPanel.test.tsx | 50 ++++++ .../__tests__/a11y.test.tsx | 33 ++++ .../__tests__/format.test.ts | 58 ++++++ .../__tests__/ledger-pages.test.ts | 55 ++++++ .../ledgerProtocolTransitionMap.test.ts | 93 ++++++++++ .../__tests__/protocol-runs.test.ts | 49 ++++++ .../__tests__/schema.test.ts | 43 +++++ .../__tests__/transition-evidence.test.ts | 55 ++++++ .../useLedgerProtocolTransitionMap.test.tsx | 95 ++++++++++ .../components/GapWarnings.tsx | 32 ++++ .../LedgerProtocolTransitionMapEmptyState.tsx | 15 ++ .../LedgerProtocolTransitionMapForm.tsx | 65 +++++++ .../LedgerProtocolTransitionMapPanel.tsx | 45 +++++ .../LedgerProtocolTransitionMapResult.tsx | 85 +++++++++ .../components/ProtocolRuns.tsx | 19 ++ .../components/TransitionRows.tsx | 35 ++++ .../ledger-protocol-transition-map/copy.ts | 67 +++++++ .../ledger-protocol-transition-map.spec.ts | 15 ++ .../e2e/uncertain-boundary.spec.ts | 12 ++ .../ledgerProtocolTransitionMap.fixture.ts | 35 ++++ .../fixtures/missing-ledgers.fixture.ts | 4 + .../fixtures/protocol-ledgers.fixture.ts | 4 + .../hooks/useLedgerProtocolTransitionMap.ts | 82 +++++++++ .../lib/format.ts | 54 ++++++ .../lib/ledger-pages.ts | 79 +++++++++ .../lib/ledgerProtocolTransitionMap.errors.ts | 21 +++ .../lib/ledgerProtocolTransitionMap.ts | 165 ++++++++++++++++++ .../lib/protocol-runs.ts | 49 ++++++ .../lib/transition-evidence.ts | 54 ++++++ .../manifest.ts | 15 ++ .../msw/handlers.ts | 35 ++++ .../ledger-protocol-transition-map/panel.tsx | 1 + .../ledger-protocol-transition-map/schema.ts | 39 +++++ .../ledger-protocol-transition-map/types.ts | 73 ++++++++ 35 files changed, 1653 insertions(+) create mode 100644 features/ledger-protocol-transition-map/README.md create mode 100644 features/ledger-protocol-transition-map/__tests__/LedgerProtocolTransitionMapPanel.test.tsx create mode 100644 features/ledger-protocol-transition-map/__tests__/a11y.test.tsx create mode 100644 features/ledger-protocol-transition-map/__tests__/format.test.ts create mode 100644 features/ledger-protocol-transition-map/__tests__/ledger-pages.test.ts create mode 100644 features/ledger-protocol-transition-map/__tests__/ledgerProtocolTransitionMap.test.ts create mode 100644 features/ledger-protocol-transition-map/__tests__/protocol-runs.test.ts create mode 100644 features/ledger-protocol-transition-map/__tests__/schema.test.ts create mode 100644 features/ledger-protocol-transition-map/__tests__/transition-evidence.test.ts create mode 100644 features/ledger-protocol-transition-map/__tests__/useLedgerProtocolTransitionMap.test.tsx create mode 100644 features/ledger-protocol-transition-map/components/GapWarnings.tsx create mode 100644 features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapEmptyState.tsx create mode 100644 features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapForm.tsx create mode 100644 features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapPanel.tsx create mode 100644 features/ledger-protocol-transition-map/components/LedgerProtocolTransitionMapResult.tsx create mode 100644 features/ledger-protocol-transition-map/components/ProtocolRuns.tsx create mode 100644 features/ledger-protocol-transition-map/components/TransitionRows.tsx create mode 100644 features/ledger-protocol-transition-map/copy.ts create mode 100644 features/ledger-protocol-transition-map/e2e/ledger-protocol-transition-map.spec.ts create mode 100644 features/ledger-protocol-transition-map/e2e/uncertain-boundary.spec.ts create mode 100644 features/ledger-protocol-transition-map/fixtures/ledgerProtocolTransitionMap.fixture.ts create mode 100644 features/ledger-protocol-transition-map/fixtures/missing-ledgers.fixture.ts create mode 100644 features/ledger-protocol-transition-map/fixtures/protocol-ledgers.fixture.ts create mode 100644 features/ledger-protocol-transition-map/hooks/useLedgerProtocolTransitionMap.ts create mode 100644 features/ledger-protocol-transition-map/lib/format.ts create mode 100644 features/ledger-protocol-transition-map/lib/ledger-pages.ts create mode 100644 features/ledger-protocol-transition-map/lib/ledgerProtocolTransitionMap.errors.ts create mode 100644 features/ledger-protocol-transition-map/lib/ledgerProtocolTransitionMap.ts create mode 100644 features/ledger-protocol-transition-map/lib/protocol-runs.ts create mode 100644 features/ledger-protocol-transition-map/lib/transition-evidence.ts create mode 100644 features/ledger-protocol-transition-map/manifest.ts create mode 100644 features/ledger-protocol-transition-map/msw/handlers.ts create mode 100644 features/ledger-protocol-transition-map/panel.tsx create mode 100644 features/ledger-protocol-transition-map/schema.ts create mode 100644 features/ledger-protocol-transition-map/types.ts 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 ( +
+
+ + {({ inputId, describedBy, invalid, required }) => ( + setStartLedger(event.target.value)} + inputMode="numeric" + autoComplete="off" + className="font-mono" + /> + )} + + + + {({ inputId, describedBy, invalid, required }) => ( + setCount(event.target.value)} + inputMode="numeric" + autoComplete="off" + /> + )} + +
+ + +
+ ); +} 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} + + +
+                    {result.summaryText}
+                  
+ +
+ ) + } + ]} + /> + + + + + {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) => ( +
  1. + {formatRunLabel(run)} +
  2. + ))} +
+ ); +} 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) => ( +
  1. +
    + {formatTransitionLabel(transition)} + + {transition.certainty === "exact" ? copy.exactBadge : copy.uncertainBadge} + +
    +
  2. + ))} +
+ ); +} 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;