diff --git a/dashboard/src/components/IndexingHealthPanel.test.tsx b/dashboard/src/components/IndexingHealthPanel.test.tsx new file mode 100644 index 0000000..f3c2555 --- /dev/null +++ b/dashboard/src/components/IndexingHealthPanel.test.tsx @@ -0,0 +1,72 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { IndexingHealthPanel } from './IndexingHealthPanel'; + +function mockFetchOnce(payload: unknown) { + const fetchMock = global.fetch as unknown as jest.Mock; + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => payload, + }); +} + +describe('IndexingHealthPanel', () => { + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + (global.fetch as unknown as jest.Mock).mockReset(); + }); + + it('renders the synced state with core metrics', async () => { + mockFetchOnce({ + status: 'synced', + timestamp: '2026-01-01T00:00:00.000Z', + indexedLedger: 100, + networkTipLedger: 100, + ledgerLag: 0, + processingDelayMs: 10_000, + lastIngestedAt: '2026-01-01T00:00:00.000Z', + }); + + render( + + ); + + expect(await screen.findByText('Indexing Health')).toBeInTheDocument(); + expect(await screen.findByText('Synced')).toBeInTheDocument(); + expect(await screen.findByText('100 / 100')).toBeInTheDocument(); + expect(await screen.findByText('0 block(s)')).toBeInTheDocument(); + expect(await screen.findByText('10s')).toBeInTheDocument(); + }); + + it('renders the degraded state for lagging indexers', async () => { + mockFetchOnce({ + status: 'degraded', + timestamp: '2026-01-01T00:00:00.000Z', + indexedLedger: 80, + networkTipLedger: 100, + ledgerLag: 20, + processingDelayMs: 300_000, + detail: 'Behind by 20 ledger(s).', + }); + + render( + + ); + + expect(await screen.findByText('Degraded')).toBeInTheDocument(); + expect(await screen.findByText('80 / 100')).toBeInTheDocument(); + expect(await screen.findByText('20 block(s)')).toBeInTheDocument(); + expect(await screen.findByText('5m 0s')).toBeInTheDocument(); + expect(await screen.findByText('Behind by 20 ledger(s).')).toBeInTheDocument(); + }); +}); + diff --git a/dashboard/src/components/IndexingHealthPanel.tsx b/dashboard/src/components/IndexingHealthPanel.tsx new file mode 100644 index 0000000..8ef6434 --- /dev/null +++ b/dashboard/src/components/IndexingHealthPanel.tsx @@ -0,0 +1,154 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { fetchIndexingHealth } from '../services/indexingHealthApi'; +import type { IndexingHealth, IndexingSyncStatus } from '../types/indexingHealth'; +import { formatTimestampShort } from '../utils/formatTime'; +import { formatDuration } from '../utils/formatDuration'; + +const DEFAULT_POLL_INTERVAL_MS = 5000; + +function statusLabel(status: IndexingSyncStatus): string { + switch (status) { + case 'synced': + return 'Synced'; + case 'syncing': + return 'Syncing'; + case 'degraded': + default: + return 'Degraded'; + } +} + +function statusClass(status: IndexingSyncStatus): string { + switch (status) { + case 'synced': + return 'indexing-health__status--synced'; + case 'syncing': + return 'indexing-health__status--syncing'; + case 'degraded': + default: + return 'indexing-health__status--degraded'; + } +} + +export function IndexingHealthPanel(props: { healthUrl: string; pollIntervalMs?: number }) { + const pollIntervalMs = props.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const [health, setHealth] = useState(null); + const [error, setError] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + const abortRef = useRef(null); + + const effectivePollIntervalMs = useMemo(() => { + if (typeof document === 'undefined') return pollIntervalMs; + return document.visibilityState === 'hidden' ? pollIntervalMs * 3 : pollIntervalMs; + }, [pollIntervalMs]); + + const refresh = useCallback(async () => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setIsRefreshing(true); + try { + const next = await fetchIndexingHealth(props.healthUrl, { signal: controller.signal }); + setHealth(next); + setError(null); + } catch (err) { + if ((err as any)?.name === 'AbortError') return; + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsRefreshing(false); + } + }, [props.healthUrl]); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType | null = null; + + const schedule = (ms: number) => { + if (cancelled) return; + timer = setTimeout(async () => { + await refresh(); + schedule(effectivePollIntervalMs); + }, ms); + }; + + void refresh(); + schedule(effectivePollIntervalMs); + + const onVisibilityChange = () => { + if (document.visibilityState === 'visible') { + void refresh(); + } + }; + document.addEventListener('visibilitychange', onVisibilityChange); + + return () => { + cancelled = true; + abortRef.current?.abort(); + if (timer) clearTimeout(timer); + document.removeEventListener('visibilitychange', onVisibilityChange); + }; + }, [effectivePollIntervalMs, refresh]); + + const status: IndexingSyncStatus = health?.status ?? 'degraded'; + const indexedLedger = health?.indexedLedger ?? null; + const tipLedger = health?.networkTipLedger ?? null; + + const indexedVsTip = + indexedLedger === null || tipLedger === null + ? '—' + : `${indexedLedger.toLocaleString()} / ${tipLedger.toLocaleString()}`; + + const ledgerLag = + health?.ledgerLag === null || health?.ledgerLag === undefined + ? '—' + : `${health.ledgerLag.toLocaleString()} block(s)`; + + const updatedAt = health ? formatTimestampShort(health.timestampMs) : '—'; + const processingDelay = health ? formatDuration(health.processingDelayMs) : '—'; + const detail = health?.detail ?? null; + + return ( +
+
+
+

Maintainer

+

Indexing Health

+
+ +
+ + {statusLabel(status)} + + + {isRefreshing ? 'Updating…' : `Updated ${updatedAt}`} + +
+
+ + {error && ( +

+ {error} +

+ )} + +
+
+
Indexed Blocks
+
{indexedVsTip}
+
+
+
Ledger Lag
+
{ledgerLag}
+
+
+
Processing Delay
+
{processingDelay}
+
+
+ + {detail &&

{detail}

} +
+ ); +} + diff --git a/dashboard/src/index.css b/dashboard/src/index.css index f3b7ac7..39f265e 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -460,6 +460,117 @@ body { padding: 24px 0 12px; } +.indexing-health { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + padding: 18px 18px 16px; + background: rgba(255, 255, 255, 0.02); + display: grid; + gap: 14px; +} + +.indexing-health__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + flex-wrap: wrap; +} + +.indexing-health__eyebrow { + margin: 0 0 6px; + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #a78bfa; +} + +.indexing-health__title { + margin: 0; + font-size: 1.05rem; +} + +.indexing-health__meta { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.indexing-health__status { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 12px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.indexing-health__status--synced { + color: #34d399; + background: rgba(52, 211, 153, 0.14); +} + +.indexing-health__status--syncing { + color: #f4b400; + background: rgba(244, 180, 0, 0.14); +} + +.indexing-health__status--degraded { + color: #f87171; + background: rgba(248, 113, 113, 0.14); +} + +.indexing-health__updated { + color: #9aa0a6; + font-size: 0.85rem; +} + +.indexing-health__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + margin: 0; +} + +.indexing-health__metric { + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 14px; + padding: 12px 12px 10px; + background: rgba(255, 255, 255, 0.02); +} + +.indexing-health__metric dt { + font-size: 0.75rem; + color: #9aa0a6; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 6px; +} + +.indexing-health__metric dd { + margin: 0; + font-size: 1rem; + font-weight: 650; + font-family: 'Courier New', Courier, monospace; +} + +.indexing-health__detail { + margin: 0; + color: #9aa0a6; + font-size: 0.9rem; +} + +.indexing-health__error { + margin: 0; + color: #f87171; + font-size: 0.9rem; +} + .event-explorer__eyebrow { margin: 0 0 10px; font-size: 0.85rem; diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx index 724938b..2e0a171 100644 --- a/dashboard/src/pages/EventExplorerPage.tsx +++ b/dashboard/src/pages/EventExplorerPage.tsx @@ -4,15 +4,19 @@ import { WalletConnectButton } from '../components/WalletConnectButton'; import { EventExplorerTable } from '../components/EventExplorerTable'; import { EventExplorerSkeleton } from '../components/EventExplorerSkeleton'; import { PaginationControls } from '../components/PaginationControls'; +import { IndexingHealthPanel } from '../components/IndexingHealthPanel'; import { useEventFilters, useEventLoadingState, useFilteredEvents } from '../hooks/useEventSelectors'; import { useEventStore } from '../store/eventStore'; import { fetchEvents } from '../services/eventsApi'; +import { resolveIndexingHealthUrl } from '../services/indexingHealthApi'; import { generateMockEvents } from '../utils/eventData'; import { restoreWalletSession } from '../services/wallet'; const DEFAULT_EVENT_COUNT = 5000; const DEFAULT_LIMIT = 12; const API_URL = import.meta.env.VITE_EVENTS_API_URL ?? 'http://localhost:8787/api/events'; +const INDEXING_HEALTH_URL = + import.meta.env.VITE_INDEXING_HEALTH_URL ?? resolveIndexingHealthUrl(API_URL); function parsePageParam(search: string) { const params = new URLSearchParams(search); @@ -137,6 +141,8 @@ export function EventExplorerPage() { + + {error && ( diff --git a/dashboard/src/services/indexingHealthApi.ts b/dashboard/src/services/indexingHealthApi.ts new file mode 100644 index 0000000..4ca835d --- /dev/null +++ b/dashboard/src/services/indexingHealthApi.ts @@ -0,0 +1,29 @@ +import type { IndexingHealth } from '../types/indexingHealth'; +import { parseIndexingHealth } from '../types/indexingHealth'; + +export async function fetchIndexingHealth( + healthUrl: string, + options?: { signal?: AbortSignal } +): Promise { + const response = await fetch(healthUrl, { signal: options?.signal }); + if (!response.ok) { + throw new Error(`Failed to fetch indexing health: ${response.status}`); + } + + const json = (await response.json()) as unknown; + return parseIndexingHealth(json); +} + +export function resolveIndexingHealthUrl(eventsApiUrl: string): string { + // Most deployments use `{base}/api/events` for the event feed. + // Derive the health endpoint from that in a resilient way. + try { + const url = new URL(eventsApiUrl); + url.pathname = '/api/indexing/health'; + url.search = ''; + return url.toString(); + } catch { + return 'http://localhost:8787/api/indexing/health'; + } +} + diff --git a/dashboard/src/types/indexingHealth.test.ts b/dashboard/src/types/indexingHealth.test.ts new file mode 100644 index 0000000..0be04e6 --- /dev/null +++ b/dashboard/src/types/indexingHealth.test.ts @@ -0,0 +1,30 @@ +import { parseIndexingHealth } from './indexingHealth'; + +describe('parseIndexingHealth', () => { + it('parses numeric fields and timestamps defensively', () => { + const parsed = parseIndexingHealth({ + status: 'synced', + timestamp: '2026-01-01T00:00:00.000Z', + indexedLedger: '123', + networkTipLedger: 124, + ledgerLag: '1', + processingDelayMs: '2500', + lastIngestedAt: '2026-01-01T00:00:02.500Z', + detail: 'ok', + }); + + expect(parsed.status).toBe('synced'); + expect(parsed.indexedLedger).toBe(123); + expect(parsed.networkTipLedger).toBe(124); + expect(parsed.ledgerLag).toBe(1); + expect(parsed.processingDelayMs).toBe(2500); + expect(parsed.lastIngestedAtMs).toBe(Date.parse('2026-01-01T00:00:02.500Z')); + expect(parsed.detail).toBe('ok'); + }); + + it('falls back to degraded for unknown status', () => { + const parsed = parseIndexingHealth({ status: 'UNKNOWN_STATUS' }); + expect(parsed.status).toBe('degraded'); + }); +}); + diff --git a/dashboard/src/types/indexingHealth.ts b/dashboard/src/types/indexingHealth.ts new file mode 100644 index 0000000..df16201 --- /dev/null +++ b/dashboard/src/types/indexingHealth.ts @@ -0,0 +1,69 @@ +export type IndexingSyncStatus = 'synced' | 'syncing' | 'degraded'; + +export interface IndexingHealthDto { + status?: unknown; + timestamp?: unknown; + indexedLedger?: unknown; + networkTipLedger?: unknown; + ledgerLag?: unknown; + processingDelayMs?: unknown; + lastIngestedAt?: unknown; + detail?: unknown; +} + +export interface IndexingHealth { + status: IndexingSyncStatus; + timestampMs: number; + indexedLedger: number | null; + networkTipLedger: number | null; + ledgerLag: number | null; + processingDelayMs: number | null; + lastIngestedAtMs: number | null; + detail: string | null; +} + +function parseNumberOrNull(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function parseTimestampMs(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const ms = Date.parse(value); + return Number.isFinite(ms) ? ms : null; + } + return null; +} + +export function parseIndexingHealth(payload: unknown): IndexingHealth { + const dto = (payload ?? {}) as IndexingHealthDto; + const status = + dto.status === 'synced' || dto.status === 'syncing' || dto.status === 'degraded' + ? (dto.status as IndexingSyncStatus) + : 'degraded'; + + const timestampMs = parseTimestampMs(dto.timestamp) ?? Date.now(); + const indexedLedger = parseNumberOrNull(dto.indexedLedger); + const networkTipLedger = parseNumberOrNull(dto.networkTipLedger); + const ledgerLag = parseNumberOrNull(dto.ledgerLag); + const processingDelayMs = parseNumberOrNull(dto.processingDelayMs); + const lastIngestedAtMs = parseTimestampMs(dto.lastIngestedAt); + const detail = typeof dto.detail === 'string' && dto.detail.trim() ? dto.detail.trim() : null; + + return { + status, + timestampMs, + indexedLedger, + networkTipLedger, + ledgerLag, + processingDelayMs, + lastIngestedAtMs, + detail, + }; +} + diff --git a/dashboard/src/utils/formatDuration.ts b/dashboard/src/utils/formatDuration.ts new file mode 100644 index 0000000..cf71397 --- /dev/null +++ b/dashboard/src/utils/formatDuration.ts @@ -0,0 +1,18 @@ +export function formatDuration(ms: number | null): string { + if (ms === null || !Number.isFinite(ms)) return '—'; + + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const seconds = totalSeconds % 60; + const totalMinutes = Math.floor(totalSeconds / 60); + const minutes = totalMinutes % 60; + const hours = Math.floor(totalMinutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } + return `${seconds}s`; +} + diff --git a/listener/src/api/events-server.ts b/listener/src/api/events-server.ts index c1153c5..8e5f4db 100644 --- a/listener/src/api/events-server.ts +++ b/listener/src/api/events-server.ts @@ -70,6 +70,28 @@ interface HealthResponse { } const HEALTH_TIMEOUT_MS = 5000; +const NETWORK_TIP_CACHE_TTL_MS = 2000; + +type IndexingStatus = 'synced' | 'syncing' | 'degraded'; + +interface IndexingHealthResponse { + status: IndexingStatus; + timestamp: string; + indexedLedger: number | null; + networkTipLedger: number | null; + ledgerLag: number | null; + /** + * Time since the last event was ingested into the in-memory registry. + * This serves as a lightweight proxy for ingestion latency / pipeline stalls. + */ + processingDelayMs: number | null; + lastIngestedAt: string | null; + detail?: string; +} + +let cachedNetworkTip: + | { fetchedAt: number; ledger: number | null; errorDetail?: string } + | null = null; function withTimeout(promise: Promise, ms: number): Promise { return Promise.race([ @@ -119,6 +141,84 @@ export async function checkDiscord(webhookUrl: string): Promise { } } +async function fetchNetworkTipLedger(rpcUrl: string): Promise<{ + ledger: number | null; + errorDetail?: string; +}> { + if ( + cachedNetworkTip && + Date.now() - cachedNetworkTip.fetchedAt < NETWORK_TIP_CACHE_TTL_MS + ) { + return { ledger: cachedNetworkTip.ledger, errorDetail: cachedNetworkTip.errorDetail }; + } + + const start = Date.now(); + try { + const server = new StellarSDK.rpc.Server(rpcUrl); + + // `getLatestLedger` is the most direct source of the current ledger/tip for Soroban RPC. + // We keep extraction defensive to avoid hard-coupling to the SDK response shape. + const latest: any = await withTimeout( + (server as any).getLatestLedger(), + HEALTH_TIMEOUT_MS + ); + const ledger = + typeof latest?.sequence === 'number' + ? latest.sequence + : typeof latest?.ledger === 'number' + ? latest.ledger + : typeof latest?.latestLedger === 'number' + ? latest.latestLedger + : null; + + cachedNetworkTip = { fetchedAt: Date.now(), ledger }; + return { ledger }; + } catch (err) { + const errorDetail = err instanceof Error ? err.message : String(err); + cachedNetworkTip = { fetchedAt: Date.now(), ledger: null, errorDetail }; + logger.warn('Failed to fetch network tip ledger', { + rpcUrl, + durationMs: Date.now() - start, + errorDetail, + }); + return { ledger: null, errorDetail }; + } +} + +function deriveIndexingStatus(args: { + indexedLedger: number | null; + networkTipLedger: number | null; + processingDelayMs: number | null; +}): { status: IndexingStatus; detail?: string } { + const { indexedLedger, networkTipLedger, processingDelayMs } = args; + + if (networkTipLedger === null) { + return { status: 'degraded', detail: 'Unable to resolve network tip ledger.' }; + } + + if (indexedLedger === null) { + return { status: 'syncing', detail: 'No events ingested yet.' }; + } + + const ledgerLag = Math.max(0, networkTipLedger - indexedLedger); + const delay = processingDelayMs ?? Number.POSITIVE_INFINITY; + + if (ledgerLag === 0 && delay <= 60_000) { + return { status: 'synced' }; + } + + if (ledgerLag <= 5 && delay <= 5 * 60_000) { + return { status: 'syncing', detail: `Behind by ${ledgerLag} ledger(s).` }; + } + + return { + status: 'degraded', + detail: `Behind by ${ledgerLag} ledger(s) and last ingestion was ${Math.round( + delay / 1000 + )}s ago.`, + }; +} + async function buildHealthResponse(options: EventsServerOptions): Promise { const [stellarRpc, discord] = await Promise.all([ checkStellarRpc(options.stellarRpcUrl), @@ -228,6 +328,40 @@ export function createEventsServer(options: EventsServerOptions): http.Server { return; } + // GET /api/indexing/health + if (req.method === 'GET' && url.pathname === '/api/indexing/health') { + const networkTip = await fetchNetworkTipLedger(options.stellarRpcUrl); + const ingestion = eventRegistry.getIngestionSnapshot(); + + const now = Date.now(); + const processingDelayMs = + ingestion.lastIngestedAt === null ? null : Math.max(0, now - ingestion.lastIngestedAt); + + const indexedLedger = ingestion.lastIngestedLedger; + const networkTipLedger = networkTip.ledger; + const ledgerLag = + indexedLedger === null || networkTipLedger === null + ? null + : Math.max(0, networkTipLedger - indexedLedger); + + const derived = deriveIndexingStatus({ indexedLedger, networkTipLedger, processingDelayMs }); + + const response: IndexingHealthResponse = { + status: derived.status, + timestamp: new Date().toISOString(), + indexedLedger, + networkTipLedger, + ledgerLag, + processingDelayMs, + lastIngestedAt: ingestion.lastIngestedAt ? new Date(ingestion.lastIngestedAt).toISOString() : null, + detail: derived.detail ?? networkTip.errorDetail, + }; + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + return; + } + // GET /api/rate-limit/metrics if (req.method === 'GET' && url.pathname === '/api/rate-limit/metrics') { if (!rateLimiter) { @@ -726,4 +860,4 @@ export function startEventsServer(options: EventsServerOptions): http.Server { logger.info('Events API server listening', { port: options.port }); }); return server; -} \ No newline at end of file +} diff --git a/listener/src/index.ts b/listener/src/index.ts index c6397cb..47ce887 100644 --- a/listener/src/index.ts +++ b/listener/src/index.ts @@ -32,7 +32,7 @@ async function main() { // Rebuild registry with configured event TTL if (config.cleanup) { - eventRegistry['ttlMs'] = config.cleanup.eventRetentionMs; + eventRegistry.setTtlMs(config.cleanup.eventRetentionMs); } cleanupService = new CleanupService(db, eventRegistry, config.cleanup); diff --git a/listener/src/store/event-registry.ts b/listener/src/store/event-registry.ts index 1595155..24d5c3f 100644 --- a/listener/src/store/event-registry.ts +++ b/listener/src/store/event-registry.ts @@ -9,14 +9,21 @@ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours export class EventRegistry { private events: DisplayEvent[] = []; private readonly maxEvents: number; - private readonly ttlMs: number; + private ttlMs: number; private cleanupTimer: ReturnType | null = null; + private lastIngestedLedger: number | null = null; + private lastIngestedAt: number | null = null; + private maxLedgerSeen: number | null = null; constructor(maxEvents = DEFAULT_MAX_EVENTS, ttlMs = DEFAULT_TTL_MS) { this.maxEvents = maxEvents; this.ttlMs = ttlMs; } + setTtlMs(ttlMs: number): void { + this.ttlMs = ttlMs; + } + startCleanup(intervalMs = 60_000): void { if (this.cleanupTimer) return; this.cleanupTimer = setInterval(() => this.pruneExpired(), intervalMs); @@ -55,6 +62,10 @@ export class EventRegistry { }; this.events.push(displayEvent); + this.lastIngestedLedger = displayEvent.ledger; + this.lastIngestedAt = displayEvent.receivedAt; + this.maxLedgerSeen = + this.maxLedgerSeen === null ? displayEvent.ledger : Math.max(this.maxLedgerSeen, displayEvent.ledger); if (this.events.length > this.maxEvents) { const evicted = this.events.length - this.maxEvents; @@ -79,8 +90,27 @@ export class EventRegistry { return this.events.length; } + /** + * Returns ingestion metadata for the most recently ingested event. + * Used by observability endpoints (e.g. indexing health). + */ + getIngestionSnapshot(): { + lastIngestedLedger: number | null; + lastIngestedAt: number | null; + maxLedgerSeen: number | null; + } { + return { + lastIngestedLedger: this.lastIngestedLedger, + lastIngestedAt: this.lastIngestedAt, + maxLedgerSeen: this.maxLedgerSeen, + }; + } + clear(): void { this.events = []; + this.lastIngestedLedger = null; + this.lastIngestedAt = null; + this.maxLedgerSeen = null; } }